Skip to content

Commit 97a2fc8

Browse files
authored
e2e: add USDC balance verification to multicast settlement QA test (#3456)
## Summary of Changes - Adds `GetWalletPubkey` QA agent RPC to read a Solana keypair file and return the base58-encoded public key - Adds `GetUSDCBalance` to the QA client, deriving the ATA from the wallet pubkey and querying the Solana RPC - Adds `GetEffectiveSeatPrice` to the QA client, which respects per-seat price overrides vs. epoch price - Extends `TestQA_MulticastSettlement` with balance snapshot/assertion subtests before pay, after pay, and after withdrawal ## Diff Breakdown | Category | Files | Lines (+/-) | Net | |-------------|-------|-------------|------| | Core logic | 3 | +172 / -6 | +166 | | Scaffolding | 3 | +131 / -22 | +109 | | Tests | 1 | +54 / -11 | +43 | Mostly new test infrastructure (client helpers + proto RPC) to support richer settlement assertions. <details> <summary>Key files (click to expand)</summary> - `e2e/internal/qa/client_settlement.go` — added `GetEffectiveSeatPrice`, `GetWalletPubkey`, and `GetUSDCBalance` client methods - `e2e/qa_multicast_settlement_test.go` — added `record_balance_before_pay`, `validate_balance_after_pay`, `query_effective_seat_price`, and `validate_balance_after_withdraw` subtests - `e2e/internal/rpc/agent.go` — implemented `GetWalletPubkey` gRPC handler (reads keypair JSON, returns base58 pubkey) - `e2e/proto/qa/agent.proto` — added `GetWalletPubkey` RPC and request/response messages </details> ## Testing Verification - Ran `TestQA_MulticastSettlement` against testnet and mainnet QA environments; all subtests passed including the new balance assertions
1 parent 836b2f3 commit 97a2fc8

7 files changed

Lines changed: 356 additions & 42 deletions

File tree

config/constants.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const (
1616
MainnetRevenueDistributionProgramID = "dzrevZC94tBLwuHw1dyynZxaXTWyp7yocsinyEVPtt4"
1717
MainnetGeolocationProgramID = "8H7nS6eZiuf7rGQtz3PPz2q9m4eJRL37PPM678KHnspG"
1818
MainnetShredSubscriptionProgramID = "dzshrr3yL57SB13sJPYHYo3TV8Bo1i1FxkyrZr3bKNE"
19-
MainnetUSDCMint = "" // CLI defaults to real USDC on mainnet
19+
MainnetUSDCMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
2020

2121
// Testnet constants.
2222
TestnetLedgerPublicRPCURL = "https://doublezerolocalnet.rpcpool.com/8a4fd3f4-0977-449f-88c7-63d4b0f10f16"

e2e/internal/qa/client_settlement.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package qa
22

33
import (
44
"context"
5+
"encoding/binary"
56
"fmt"
67
"math"
8+
"strconv"
79

10+
"github.com/gagliardetto/solana-go"
11+
"github.com/gagliardetto/solana-go/rpc"
812
pb "github.com/malbeclabs/doublezero/e2e/proto/qa/gen/pb-go"
13+
shreds "github.com/malbeclabs/doublezero/sdk/shreds/go"
914
"google.golang.org/protobuf/types/known/emptypb"
1015
)
1116

@@ -120,3 +125,88 @@ func (c *Client) FeedSeatWithdraw(ctx context.Context, devicePubkey string) erro
120125
c.log.Debug("Seat withdrawal successful", "host", c.Host, "device", devicePubkey)
121126
return nil
122127
}
128+
129+
// GetEffectiveSeatPrice returns the effective per-epoch price for the client's
130+
// seat on the given device, in raw USDC (6 decimals). If the client seat has a
131+
// price override, the override is returned; otherwise the epoch price (in whole
132+
// dollars, converted to micro-USDC) is used.
133+
func (c *Client) GetEffectiveSeatPrice(ctx context.Context, devicePubkey string, epochPrice uint64) (uint64, error) {
134+
deviceKey, err := solana.PublicKeyFromBase58(devicePubkey)
135+
if err != nil {
136+
return 0, fmt.Errorf("failed to parse device pubkey %q: %w", devicePubkey, err)
137+
}
138+
139+
programID, err := solana.PublicKeyFromBase58(c.ShredSubscriptionProgramID)
140+
if err != nil {
141+
return 0, fmt.Errorf("failed to parse shred subscription program ID %q: %w", c.ShredSubscriptionProgramID, err)
142+
}
143+
144+
clientIPBits := binary.BigEndian.Uint32(c.publicIP.To4())
145+
shredsClient := shreds.New(shreds.NewRPCClient(c.SolanaRPCURL), programID)
146+
seat, err := shredsClient.FetchClientSeat(ctx, deviceKey, clientIPBits)
147+
if err != nil {
148+
return 0, fmt.Errorf("failed to fetch client seat on host %s: %w", c.Host, err)
149+
}
150+
151+
if seat.HasPriceOverride() {
152+
price := uint64(seat.OverrideUSDCPriceDollars) * 1_000_000
153+
c.log.Debug("Seat has price override", "host", c.Host, "override_dollars", seat.OverrideUSDCPriceDollars, "price_usdc", price)
154+
return price, nil
155+
}
156+
157+
price := epochPrice * 1_000_000
158+
c.log.Debug("Seat using epoch price", "host", c.Host, "epoch_price_dollars", epochPrice, "price_usdc", price)
159+
return price, nil
160+
}
161+
162+
// GetWalletPubkey calls the GetWalletPubkey RPC to read the keypair file on the
163+
// remote host and return the base58-encoded public key.
164+
func (c *Client) GetWalletPubkey(ctx context.Context) (solana.PublicKey, error) {
165+
resp, err := c.grpcClient.GetWalletPubkey(ctx, &pb.GetWalletPubkeyRequest{
166+
Keypair: c.Keypair,
167+
})
168+
if err != nil {
169+
return solana.PublicKey{}, fmt.Errorf("failed to get wallet pubkey on host %s: %w", c.Host, err)
170+
}
171+
pubkey, err := solana.PublicKeyFromBase58(resp.GetPubkey())
172+
if err != nil {
173+
return solana.PublicKey{}, fmt.Errorf("failed to parse wallet pubkey %q: %w", resp.GetPubkey(), err)
174+
}
175+
c.log.Debug("Wallet pubkey retrieved", "host", c.Host, "pubkey", pubkey)
176+
return pubkey, nil
177+
}
178+
179+
// GetUSDCBalance queries the USDC token balance for the client's wallet.
180+
// It derives the associated token account from the wallet pubkey and USDC mint,
181+
// then queries the balance via the Solana RPC (which points to the DZ ledger
182+
// on testnet/devnet and Solana proper on mainnet).
183+
func (c *Client) GetUSDCBalance(ctx context.Context) (uint64, error) {
184+
ownerPubkey, err := c.GetWalletPubkey(ctx)
185+
if err != nil {
186+
return 0, fmt.Errorf("failed to get wallet pubkey on host %s: %w", c.Host, err)
187+
}
188+
189+
usdcMint, err := solana.PublicKeyFromBase58(c.USDCMint)
190+
if err != nil {
191+
return 0, fmt.Errorf("failed to parse USDC mint %q: %w", c.USDCMint, err)
192+
}
193+
194+
ata, _, err := solana.FindAssociatedTokenAddress(ownerPubkey, usdcMint)
195+
if err != nil {
196+
return 0, fmt.Errorf("failed to derive ATA for owner %s and mint %s: %w", ownerPubkey, usdcMint, err)
197+
}
198+
199+
solanaClient := rpc.New(c.SolanaRPCURL)
200+
result, err := solanaClient.GetTokenAccountBalance(ctx, ata, rpc.CommitmentConfirmed)
201+
if err != nil {
202+
return 0, fmt.Errorf("failed to get token account balance for ATA %s on host %s: %w", ata, c.Host, err)
203+
}
204+
205+
balance, err := strconv.ParseUint(result.Value.Amount, 10, 64)
206+
if err != nil {
207+
return 0, fmt.Errorf("failed to parse balance %q: %w", result.Value.Amount, err)
208+
}
209+
210+
c.log.Debug("USDC balance retrieved", "host", c.Host, "owner", ownerPubkey, "ata", ata, "balance", balance)
211+
return balance, nil
212+
}

e2e/internal/rpc/agent.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/malbeclabs/doublezero/e2e/internal/netutil"
1919
pb "github.com/malbeclabs/doublezero/e2e/proto/qa/gen/pb-go"
20+
"github.com/mr-tron/base58"
2021
probing "github.com/prometheus-community/pro-bing"
2122
"golang.org/x/net/ipv4"
2223
"golang.org/x/sys/unix"
@@ -503,6 +504,33 @@ func (q *QAAgent) FeedSeatWithdraw(ctx context.Context, req *pb.FeedSeatWithdraw
503504
return res, nil
504505
}
505506

507+
// GetWalletPubkey reads a Solana keypair JSON file and returns the base58-encoded
508+
// public key. The keypair format is a JSON array of 64 bytes: the first 32 are the
509+
// private key, the last 32 are the public key.
510+
func (q *QAAgent) GetWalletPubkey(_ context.Context, req *pb.GetWalletPubkeyRequest) (*pb.GetWalletPubkeyResponse, error) {
511+
keypairPath := os.ExpandEnv(req.GetKeypair())
512+
if keypairPath == "" {
513+
return nil, fmt.Errorf("keypair path is required")
514+
}
515+
516+
data, err := os.ReadFile(keypairPath)
517+
if err != nil {
518+
return nil, fmt.Errorf("failed to read keypair file %s: %w", keypairPath, err)
519+
}
520+
521+
var keyBytes []byte
522+
if err := json.Unmarshal(data, &keyBytes); err != nil {
523+
return nil, fmt.Errorf("failed to parse keypair JSON from %s: %w", keypairPath, err)
524+
}
525+
if len(keyBytes) != 64 {
526+
return nil, fmt.Errorf("invalid keypair length %d (expected 64) in %s", len(keyBytes), keypairPath)
527+
}
528+
529+
pubkey := base58.Encode(keyBytes[32:])
530+
q.log.Debug("Wallet pubkey retrieved", "keypair", keypairPath, "pubkey", pubkey)
531+
return &pb.GetWalletPubkeyResponse{Pubkey: pubkey}, nil
532+
}
533+
506534
type StatusResponse struct {
507535
Response struct {
508536
TunnelName string `json:"tunnel_name"`

e2e/proto/qa/agent.proto

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ service QAAgentService {
2828
rpc FeedSeatPrice(FeedSeatPriceRequest) returns (FeedSeatPriceResponse);
2929
rpc FeedSeatPay(FeedSeatPayRequest) returns (Result);
3030
rpc FeedSeatWithdraw(FeedSeatWithdrawRequest) returns (Result);
31+
rpc GetWalletPubkey(GetWalletPubkeyRequest) returns (GetWalletPubkeyResponse);
3132
}
3233

3334
message ConnectUnicastRequest {
@@ -255,3 +256,11 @@ message DevicePrice {
255256
message FeedSeatPriceResponse {
256257
repeated DevicePrice prices = 1;
257258
}
259+
260+
message GetWalletPubkeyRequest {
261+
string keypair = 1;
262+
}
263+
264+
message GetWalletPubkeyResponse {
265+
string pubkey = 1;
266+
}

0 commit comments

Comments
 (0)