diff --git a/go/mechanisms/svm/constants.go b/go/mechanisms/svm/constants.go index b0ed5d8f21..441c65f73f 100644 --- a/go/mechanisms/svm/constants.go +++ b/go/mechanisms/svm/constants.go @@ -35,6 +35,20 @@ const ( // MemoProgramAddress is the SPL Memo program address MemoProgramAddress = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + // SwigProgramAddress is the Swig smart wallet program address. + // Swig wraps inner instructions (e.g. SPL transferChecked) inside a signV2 + // instruction and executes them via CPI using the Swig PDA as authority. + // See: https://github.com/anagram-xyz/swig + SwigProgramAddress = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB" + + // SwigSignV2Discriminator is the U16 LE discriminator for Swig signV2 instructions + SwigSignV2Discriminator uint16 = 11 + + // Secp256r1PrecompileAddress is the secp256r1 precompile program address. + // Swig passkey wallets include secp256r1 signature verification instructions + // before the SignV2 instruction. These are filtered out during transaction flattening. + Secp256r1PrecompileAddress = "Secp256r1SigVerify1111111111111111111111111" + // DefaultCommitment is the default commitment level for transactions DefaultCommitment = rpc.CommitmentConfirmed diff --git a/go/mechanisms/svm/exact/facilitator/scheme.go b/go/mechanisms/svm/exact/facilitator/scheme.go index 8ba6a02181..d8e9c5232d 100644 --- a/go/mechanisms/svm/exact/facilitator/scheme.go +++ b/go/mechanisms/svm/exact/facilitator/scheme.go @@ -121,35 +121,34 @@ func (f *ExactSvmScheme) Verify( return nil, x402.NewVerifyError(ErrTransactionCouldNotBeDecoded, "", err.Error()) } - // Allow 3-6 instructions: + // Normalize the transaction (handles Swig, regular, and future wallet types) + normalized, err := svm.NormalizeTransaction(tx) + if err != nil { + return nil, x402.NewVerifyError(ErrNoTransferInstruction, "", err.Error()) + } + instructions := normalized.Instructions + payer := normalized.Payer + + // Instruction count check AFTER flattening (3-6) // - 3 instructions: ComputeLimit + ComputePrice + TransferChecked // - 4 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse or Memo // - 5 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse + Lighthouse or Memo // - 6 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse + Lighthouse + Memo // See: https://github.com/coinbase/x402/issues/828 - numInstructions := len(tx.Message.Instructions) + numInstructions := len(instructions) if numInstructions < 3 || numInstructions > 6 { return nil, x402.NewVerifyError(ErrTransactionInstructionsLength, "", fmt.Sprintf("transaction instructions length mismatch: %d < 3 or %d > 6", numInstructions, numInstructions)) } // Step 3: Verify Compute Budget Instructions - if err := f.verifyComputeLimitInstruction(tx, tx.Message.Instructions[0]); err != nil { + if err := f.verifyComputeLimitInstruction(tx, instructions[0]); err != nil { return nil, x402.NewVerifyError(err.Error(), "", err.Error()) } - if err := f.verifyComputePriceInstruction(tx, tx.Message.Instructions[1]); err != nil { + if err := f.verifyComputePriceInstruction(tx, instructions[1]); err != nil { return nil, x402.NewVerifyError(err.Error(), "", err.Error()) } - // Extract payer from transaction - payer, err := svm.GetTokenPayerFromTransaction(tx) - if err != nil { - return nil, x402.NewVerifyError(ErrNoTransferInstruction, payer, err.Error()) - } - - // V2: payload.Accepted.Network is already validated by scheme lookup - // Network matching is implicit - facilitator was selected based on requirements.Network - // Convert requirements to old struct format for helper methods reqStruct := x402.PaymentRequirements{ Scheme: requirements.Scheme, @@ -160,9 +159,9 @@ func (f *ExactSvmScheme) Verify( Extra: requirements.Extra, } - // Step 4: Verify Transfer Instruction - if err := f.verifyTransferInstruction(tx, tx.Message.Instructions[2], reqStruct, signerAddressStrs); err != nil { - return nil, x402.NewVerifyError(err.Error(), payer, err.Error()) + // Step 4: Verify Transfer Instruction (unified — works for both regular and Swig) + if verifyErr := f.verifyTransferInstruction(tx, instructions[2], reqStruct, signerAddressStrs); verifyErr != nil { + return nil, x402.NewVerifyError(verifyErr.Error(), payer, verifyErr.Error()) } // Step 5: Verify optional instructions (if present) @@ -170,7 +169,7 @@ func (f *ExactSvmScheme) Verify( if numInstructions >= 4 { lighthousePubkey := solana.MustPublicKeyFromBase58(svm.LighthouseProgramAddress) memoPubkey := solana.MustPublicKeyFromBase58(svm.MemoProgramAddress) - optionalInstructions := tx.Message.Instructions[3:] + optionalInstructions := instructions[3:] invalidReasons := []string{ ErrUnknownFourthInstruction, ErrUnknownFifthInstruction, diff --git a/go/mechanisms/svm/normalizer.go b/go/mechanisms/svm/normalizer.go new file mode 100644 index 0000000000..40551fb738 --- /dev/null +++ b/go/mechanisms/svm/normalizer.go @@ -0,0 +1,60 @@ +package svm + +import ( + "errors" + + solana "github.com/gagliardetto/solana-go" +) + +// NormalizedTransaction is the output of a TransactionNormalizer: a flat +// instruction list and the address of the entity paying the token transfer. +type NormalizedTransaction struct { + Instructions []solana.CompiledInstruction + Payer string +} + +// TransactionNormalizer knows how to detect and flatten a particular wallet +// type's transaction layout into a NormalizedTransaction. +type TransactionNormalizer interface { + CanHandle(tx *solana.Transaction) bool + Normalize(tx *solana.Transaction) (*NormalizedTransaction, error) +} + +// RegularNormalizer is the fallback normalizer for standard (non-smart-wallet) +// transactions. It returns the transaction's instructions as-is and derives the +// payer from the first TransferChecked instruction. +type RegularNormalizer struct{} + +func (r *RegularNormalizer) CanHandle(_ *solana.Transaction) bool { + return true +} + +func (r *RegularNormalizer) Normalize(tx *solana.Transaction) (*NormalizedTransaction, error) { + payer, err := GetTokenPayerFromTransaction(tx) + if err != nil { + return nil, err + } + return &NormalizedTransaction{ + Instructions: tx.Message.Instructions, + Payer: payer, + }, nil +} + +// DefaultNormalizers is the ordered list of normalizers tried by +// NormalizeTransaction. Specific wallet types come first; RegularNormalizer is +// the catch-all fallback. +var DefaultNormalizers = []TransactionNormalizer{ + &SwigNormalizer{}, + &RegularNormalizer{}, +} + +// NormalizeTransaction runs the default normalizer chain against tx and returns +// the first successful result. +func NormalizeTransaction(tx *solana.Transaction) (*NormalizedTransaction, error) { + for _, n := range DefaultNormalizers { + if n.CanHandle(tx) { + return n.Normalize(tx) + } + } + return nil, errors.New("no normalizer found for transaction") +} diff --git a/go/mechanisms/svm/swig.go b/go/mechanisms/svm/swig.go new file mode 100644 index 0000000000..a777f9ac84 --- /dev/null +++ b/go/mechanisms/svm/swig.go @@ -0,0 +1,284 @@ +package svm + +import ( + "encoding/binary" + "errors" + "fmt" + "sort" + + solana "github.com/gagliardetto/solana-go" +) + +// SwigNormalizer detects and flattens Swig smart-wallet transactions into the +// same NormalizedTransaction shape used by regular transactions. +type SwigNormalizer struct{} + +func (s *SwigNormalizer) CanHandle(tx *solana.Transaction) bool { + return IsSwigTransaction(tx) +} + +func (s *SwigNormalizer) Normalize(tx *solana.Transaction) (*NormalizedTransaction, error) { + result, err := ParseSwigTransaction(tx) + if err != nil { + return nil, err + } + return &NormalizedTransaction{ + Instructions: result.Instructions, + Payer: result.SwigPDA, + }, nil +} + +// SwigCompactInstruction is a decoded compact instruction embedded in a Swig +// SignV2 instruction payload. Indices reference the SignV2 instruction's +// own account list, not the outer transaction's global account keys. +type SwigCompactInstruction struct { + ProgramIDIndex uint8 + Accounts []uint8 + Data []byte +} + +// DecodeSwigCompactInstructions parses the compact instructions embedded in the +// data payload of a Swig SignV2 instruction. +// +// Outer instruction data layout: +// +// [0..1] discriminator U16 LE +// [2..3] instructionPayloadLen U16 LE +// [4..7] roleId U32 LE +// [8..] compact instructions (instructionPayloadLen bytes) +// +// Compact instructions payload: +// +// [0] numInstructions U8 +// [1..] compact instruction entries... +// +// Each CompactInstruction: +// +// [0] programIDIndex U8 +// [1] numAccounts U8 +// [2..N+1] accounts []U8 +// [N+2..N+3] dataLen U16 LE +// [N+4..] data raw bytes +func DecodeSwigCompactInstructions(data []byte) ([]SwigCompactInstruction, error) { + if len(data) < 4 { + return nil, fmt.Errorf("swig instruction data too short: need ≥4 bytes, got %d", len(data)) + } + + instructionPayloadLen := int(binary.LittleEndian.Uint16(data[2:4])) + startOffset := 8 + if len(data) < startOffset+instructionPayloadLen { + return nil, fmt.Errorf("swig instruction data truncated: payload needs %d bytes but only %d available after offset %d", + instructionPayloadLen, len(data)-startOffset, startOffset) + } + + var results []SwigCompactInstruction + offset := startOffset + 1 // skip numInstructions count byte + endOffset := startOffset + instructionPayloadLen + + for offset < endOffset { + if offset >= len(data) { + break + } + + // programIDIndex: U8 + programIDIndex := data[offset] + offset++ + + // numAccounts: U8 + if offset >= endOffset { + break + } + numAccounts := int(data[offset]) + offset++ + + // accounts: []U8 + if offset+numAccounts > endOffset { + break + } + accounts := make([]uint8, numAccounts) + copy(accounts, data[offset:offset+numAccounts]) + offset += numAccounts + + // dataLen: U16 LE + if offset+2 > endOffset { + break + } + dataLen := int(binary.LittleEndian.Uint16(data[offset : offset+2])) + offset += 2 + + // instruction data + if offset+dataLen > endOffset { + break + } + instrData := make([]byte, dataLen) + copy(instrData, data[offset:offset+dataLen]) + offset += dataLen + + results = append(results, SwigCompactInstruction{ + ProgramIDIndex: programIDIndex, + Accounts: accounts, + Data: instrData, + }) + } + + return results, nil +} + +// IsSwigTransaction returns true when the transaction has a Swig layout: +// - Every instruction is one of: compute budget, secp256r1 precompile, or Swig SignV2 +// - At least one Swig SignV2 instruction is present +func IsSwigTransaction(tx *solana.Transaction) bool { + instructions := tx.Message.Instructions + if len(instructions) == 0 { + return false + } + + secp256r1Pubkey := solana.MustPublicKeyFromBase58(Secp256r1PrecompileAddress) + swigPubkey := solana.MustPublicKeyFromBase58(SwigProgramAddress) + + hasSignV2 := false + for _, inst := range instructions { + if int(inst.ProgramIDIndex) >= len(tx.Message.AccountKeys) { + return false + } + progID := tx.Message.AccountKeys[inst.ProgramIDIndex] + if progID.Equals(solana.ComputeBudget) || progID.Equals(secp256r1Pubkey) { + continue + } + if progID.Equals(swigPubkey) { + if len(inst.Data) < 2 { + return false + } + discriminator := binary.LittleEndian.Uint16(inst.Data[0:2]) + if discriminator != SwigSignV2Discriminator { + return false + } + hasSignV2 = true + continue + } + return false // unrecognized instruction + } + return hasSignV2 +} + +// ParseSwigResult holds the flattened instructions and the Swig PDA address. +type ParseSwigResult struct { + Instructions []solana.CompiledInstruction + SwigPDA string +} + +// ParseSwigTransaction flattens a Swig transaction into the same instruction +// layout as a regular one. It collects non-precompile, non-SignV2 outer +// instructions (compute budgets) and resolves the compact instructions embedded +// in each SignV2 instruction. +// +// A transaction may contain multiple SignV2 instructions. All must reference +// the same Swig PDA (accounts[0]). +func ParseSwigTransaction(tx *solana.Transaction) (*ParseSwigResult, error) { + instructions := tx.Message.Instructions + if len(instructions) == 0 { + return nil, errors.New("no instructions") + } + + secp256r1Pubkey := solana.MustPublicKeyFromBase58(Secp256r1PrecompileAddress) + swigPubkey := solana.MustPublicKeyFromBase58(SwigProgramAddress) + + // 1. Single pass: separate SignV2 instructions from the rest + var result []solana.CompiledInstruction + var signV2Instructions []solana.CompiledInstruction + for _, inst := range instructions { + progID := tx.Message.AccountKeys[inst.ProgramIDIndex] + if progID.Equals(secp256r1Pubkey) { + continue // skip precompile + } + if progID.Equals(swigPubkey) { + signV2Instructions = append(signV2Instructions, inst) + } else { + result = append(result, inst) // compute budget and other non-precompile instructions + } + } + + // Sort compute budget instructions so SetComputeUnitLimit (disc=2) precedes + // SetComputeUnitPrice (disc=3), matching the order the facilitator expects. + sort.Slice(result, func(i, j int) bool { + iIsComputeBudget := tx.Message.AccountKeys[result[i].ProgramIDIndex].Equals(solana.ComputeBudget) + jIsComputeBudget := tx.Message.AccountKeys[result[j].ProgramIDIndex].Equals(solana.ComputeBudget) + if iIsComputeBudget && jIsComputeBudget && len(result[i].Data) > 0 && len(result[j].Data) > 0 { + return result[i].Data[0] < result[j].Data[0] + } + return false + }) + + if len(signV2Instructions) == 0 { + return nil, errors.New("invalid_exact_svm_payload_no_transfer_instruction") + } + + // 2. Process each SignV2 instruction + swigPDA := "" + for _, signV2 := range signV2Instructions { + // Extract Swig PDA from SignV2's first account + if len(signV2.Accounts) < 2 { + return nil, errors.New("invalid_exact_svm_payload_no_transfer_instruction") + } + if int(signV2.Accounts[0]) >= len(tx.Message.AccountKeys) { + return nil, errors.New("invalid_exact_svm_payload_no_transfer_instruction") + } + swigConfigKey := tx.Message.AccountKeys[signV2.Accounts[0]] + pda := swigConfigKey.String() + + // Enforce all SignV2 instructions share the same PDA + if swigPDA == "" { + swigPDA = pda + } else if pda != swigPDA { + return nil, errors.New("swig_pda_mismatch: all SignV2 instructions must reference the same Swig PDA") + } + + // Validate Swig wallet address derivation (cross-check accounts[0] and accounts[1]) + if int(signV2.Accounts[1]) >= len(tx.Message.AccountKeys) { + return nil, errors.New("invalid_swig_wallet_address_derivation") + } + actualWalletAddress := tx.Message.AccountKeys[signV2.Accounts[1]] + expectedWalletAddress, _, err := solana.FindProgramAddress( + [][]byte{[]byte("swig-wallet-address"), swigConfigKey.Bytes()}, + swigPubkey, + ) + if err != nil { + return nil, fmt.Errorf("failed to derive swig wallet address: %w", err) + } + if !actualWalletAddress.Equals(expectedWalletAddress) { + return nil, errors.New("invalid_swig_wallet_address_derivation") + } + + // Decode compact instructions from SignV2 data + compactInstructions, err := DecodeSwigCompactInstructions(signV2.Data) + if err != nil { + return nil, err + } + + // Resolve compact instructions: remap local indices through signV2.Accounts + for _, ci := range compactInstructions { + if int(ci.ProgramIDIndex) >= len(signV2.Accounts) { + return nil, fmt.Errorf("compact instruction programIDIndex %d out of range (signV2 has %d accounts)", + ci.ProgramIDIndex, len(signV2.Accounts)) + } + accounts := make([]uint16, len(ci.Accounts)) + for j, a := range ci.Accounts { + if int(a) >= len(signV2.Accounts) { + return nil, fmt.Errorf("compact instruction account index %d out of range (signV2 has %d accounts)", + a, len(signV2.Accounts)) + } + accounts[j] = signV2.Accounts[a] + } + result = append(result, solana.CompiledInstruction{ + ProgramIDIndex: signV2.Accounts[ci.ProgramIDIndex], + Accounts: accounts, + Data: ci.Data, + }) + } + } + + return &ParseSwigResult{ + Instructions: result, + SwigPDA: swigPDA, + }, nil +} diff --git a/go/mechanisms/svm/swig_real_tx_test.go b/go/mechanisms/svm/swig_real_tx_test.go new file mode 100644 index 0000000000..5db7b59f65 --- /dev/null +++ b/go/mechanisms/svm/swig_real_tx_test.go @@ -0,0 +1,169 @@ +package svm_test + +import ( + "context" + "encoding/binary" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coinbase/x402/go/mechanisms/svm" + "github.com/coinbase/x402/go/mechanisms/svm/exact/facilitator" + "github.com/coinbase/x402/go/types" +) + +// Real confirmed Swig smart wallet USDC transfer on devnet. +// tx: 2TAkeCETVcsbtmK1UMdgk2BZVdWQjnv7s2s7QUYv3Ynaqh36iXVdwM1ong8hmRw4Za3Yw8CkjgVwiyUpGR6SQP1g +const realSwigTxBase64 = "AkiVWpmnwCMi7VKkTgzdR2vqY1fOSr14KPzUnzCNQpeOMif5NskDc4uS+gOp8RgsErjrnGLEYL1N" + + "268w+qF+dge3oCdndWRM1K0yufH+fFvkZZ4Bs3zo54vRPaX9frRvVfnjAvIaF+LrUcesSgDzelLub" + + "NZgz/xTZpMF+M73W2QBgAIBBAqZaoBA6PatAWpRvzksIlZIPBdwhETOtNqkgD0atmy0InVOnwjWNA" + + "xK9dVi7s3ExZUKIESvFVgLxy2EuifanfHXNuKlxHOPekji0xlP2QWZWAXWe2Waz6nHvKl8rEzDOBW" + + "YZE9jRaDJ3Di+pFN1xwc5xnR4DB9Ie84lQHbJaXPMB+psglirF8mTyZ49SOemjo+02LMohN2jyoK" + + "VBiPYUEFBZOwM3pq0f7lZsDDur9i+ue/ujyUjQwUnXvJRe7/+3hMDBkZv5SEXMv/srbpyw5vnvIz" + + "lu8X3EmssQ5s6QAAAAA0M6ULh58UG4hjfDX3xxS+v3DUp5I1nTR2yTHW1TMy+Bt324ddloZPZy+F" + + "Gzut5rBy0he1fWzeROoz1hX7/AKk7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAup78zWM" + + "i4yXOAY9JrFqsFFkNRq8dONAlh9AOdsB99f/m6AwYACQNkAAAAAAAAAAYABQKAGgYABwcCAwEIBA" + + "kFHAsAEwAAAAAAAQMEBAUGAQoADAEAAAAAAAAABgIA" + +const ( + feePayer = "BKsZvzPUY6VT2GpLMxx6fA6fuC8MK3hVxwdjK8yqmqSR" + swigPDA = "4hFTuZxrMbZciAxA9DcLYYC9vupNuw89v527ys6PvRo2" + payTo = "EkkpfzUdwwgeqWb25hWcSi2c5gquELLUB3Z2asr1Xroo" +) + +func decodeTx(t *testing.T) *solana.Transaction { + t.Helper() + tx, err := svm.DecodeTransaction(realSwigTxBase64) + require.NoError(t, err) + return tx +} + +// --- Test 1: IsSwigTransaction detection --- + +func TestRealSwigTx_IsSwigTransaction(t *testing.T) { + tx := decodeTx(t) + assert.True(t, svm.IsSwigTransaction(tx), "expected real devnet tx to be detected as Swig") +} + +// --- Test 2: ParseSwigTransaction --- + +func TestRealSwigTx_ParseSwigTransaction(t *testing.T) { + tx := decodeTx(t) + result, err := svm.ParseSwigTransaction(tx) + require.NoError(t, err) + + t.Run("flattened instruction count", func(t *testing.T) { + assert.Len(t, result.Instructions, 3) + }) + + t.Run("swig PDA matches", func(t *testing.T) { + assert.Equal(t, swigPDA, result.SwigPDA) + }) + + t.Run("transfer checked discriminator", func(t *testing.T) { + transferIx := result.Instructions[2] + require.True(t, len(transferIx.Data) > 0, "transfer instruction data should not be empty") + assert.Equal(t, byte(12), transferIx.Data[0], "expected TransferChecked discriminator (12)") + }) + + t.Run("transfer checked amount and decimals", func(t *testing.T) { + transferIx := result.Instructions[2] + require.True(t, len(transferIx.Data) >= 10, "transfer instruction data too short") + amount := binary.LittleEndian.Uint64(transferIx.Data[1:9]) + decimals := transferIx.Data[9] + assert.Equal(t, uint64(1), amount) + assert.Equal(t, byte(6), decimals) + }) + + t.Run("compute budget instructions sorted correctly", func(t *testing.T) { + // First instruction should be SetComputeUnitLimit (disc=2) + assert.Equal(t, byte(2), result.Instructions[0].Data[0], "expected SetComputeUnitLimit at index 0") + // Second instruction should be SetComputeUnitPrice (disc=3) + assert.Equal(t, byte(3), result.Instructions[1].Data[0], "expected SetComputeUnitPrice at index 1") + }) +} + +// --- Test 3: NormalizeTransaction --- + +func TestRealSwigTx_NormalizeTransaction(t *testing.T) { + tx := decodeTx(t) + normalized, err := svm.NormalizeTransaction(tx) + require.NoError(t, err) + + t.Run("payer is swig PDA", func(t *testing.T) { + assert.Equal(t, swigPDA, normalized.Payer) + }) + + t.Run("three instructions", func(t *testing.T) { + assert.Len(t, normalized.Instructions, 3) + }) +} + +// --- Test 4: Full verify pipeline --- + +type mockFacilitatorSigner struct{} + +func (m *mockFacilitatorSigner) GetAddresses(_ context.Context, _ string) []solana.PublicKey { + return []solana.PublicKey{solana.MustPublicKeyFromBase58(feePayer)} +} + +func (m *mockFacilitatorSigner) SignTransaction(_ context.Context, _ *solana.Transaction, _ solana.PublicKey, _ string) error { + return nil +} + +func (m *mockFacilitatorSigner) SimulateTransaction(_ context.Context, _ *solana.Transaction, _ string) error { + return nil +} + +func (m *mockFacilitatorSigner) SendTransaction(_ context.Context, _ *solana.Transaction, _ string) (solana.Signature, error) { + return solana.Signature{}, nil +} + +func (m *mockFacilitatorSigner) ConfirmTransaction(_ context.Context, _ solana.Signature, _ string) error { + return nil +} + +func TestRealSwigTx_VerifyPipeline(t *testing.T) { + signer := &mockFacilitatorSigner{} + scheme := facilitator.NewExactSvmScheme(signer) + + requirements := types.PaymentRequirements{ + Scheme: "exact", + Network: svm.SolanaDevnetCAIP2, + Asset: svm.USDCDevnetAddress, + Amount: "1", + PayTo: payTo, + Extra: map[string]interface{}{"feePayer": feePayer}, + } + + payload := types.PaymentPayload{ + X402Version: 2, + Resource: &types.ResourceInfo{ + URL: "http://example.com/protected", + Description: "Test resource", + MimeType: "application/json", + }, + Accepted: requirements, + Payload: map[string]interface{}{"transaction": realSwigTxBase64}, + } + + ctx := context.Background() + result, err := scheme.Verify(ctx, payload, requirements, nil) + + t.Run("verify returns no error", func(t *testing.T) { + assert.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("verify is valid", func(t *testing.T) { + require.NotNil(t, result) + assert.True(t, result.IsValid) + }) + + t.Run("payer is swig PDA", func(t *testing.T) { + require.NotNil(t, result) + assert.Equal(t, swigPDA, result.Payer) + }) +} diff --git a/go/test/unit/svm_test.go b/go/test/unit/svm_test.go index 3496eff5d4..aa1cc40f7e 100644 --- a/go/test/unit/svm_test.go +++ b/go/test/unit/svm_test.go @@ -2,6 +2,7 @@ package unit_test import ( + "encoding/binary" "testing" solana "github.com/gagliardetto/solana-go" @@ -292,6 +293,508 @@ func TestSolanaMessageVersioning(t *testing.T) { }) } +// ─── Swig wallet tests ──────────────────────────────────────────────────────── + +// buildSwigInstructionData constructs synthetic Swig signV2 instruction bytes +// containing a single compact instruction entry. +func buildSwigInstructionData( + programIDIndex uint8, + accounts []uint8, + instrData []byte, +) []byte { + // Compact instruction entry: + // [0] programIDIndex U8 + // [1] numAccounts U8 + // [2..N+1] accounts []U8 + // [N+2..N+3] dataLen U16 LE + // [N+4..] data + entry := []byte{programIDIndex, uint8(len(accounts))} + entry = append(entry, accounts...) + dl := make([]byte, 2) + binary.LittleEndian.PutUint16(dl, uint16(len(instrData))) + entry = append(entry, dl...) + entry = append(entry, instrData...) + + // Prepend numInstructions count byte (1 instruction) + payload := append([]byte{1}, entry...) + + // Outer Swig instruction: + // [0..1] discriminator = 11 (signV2, U16 LE) + // [2..3] instructionPayloadLen = len(payload) (U16 LE) + // [4..7] roleId = 0 + // [8..] payload (count byte + entry) + outer := make([]byte, 8+len(payload)) + binary.LittleEndian.PutUint16(outer[0:], svm.SwigSignV2Discriminator) + binary.LittleEndian.PutUint16(outer[2:], uint16(len(payload))) + copy(outer[8:], payload) + return outer +} + +// buildTransferCheckedData constructs the 10-byte data payload for SPL TransferChecked. +// Layout: [0]=12 (discriminator), [1..8]=amount U64 LE, [9]=decimals +func buildTransferCheckedData(amount uint64, decimals uint8) []byte { + data := make([]byte, 10) + data[0] = 12 // transferChecked discriminator + binary.LittleEndian.PutUint64(data[1:], amount) + data[9] = decimals + return data +} + +// TestDecodeSwigCompactInstructions tests DecodeSwigCompactInstructions with crafted data. +func TestDecodeSwigCompactInstructions(t *testing.T) { + t.Run("error on data shorter than 4 bytes", func(t *testing.T) { + _, err := svm.DecodeSwigCompactInstructions([]byte{1, 2, 3}) + if err == nil { + t.Fatal("expected error for data < 4 bytes") + } + }) + + t.Run("error when instructionPayloadLen exceeds available data", func(t *testing.T) { + // header claims payloadLen=100 but only 8 bytes total + data := make([]byte, 8) + binary.LittleEndian.PutUint16(data[2:], 100) + _, err := svm.DecodeSwigCompactInstructions(data) + if err == nil { + t.Fatal("expected error for truncated payload") + } + }) + + t.Run("decodes a single TransferChecked compact instruction", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + // programIDIndex=5, accounts=[1,2,3,0] + outer := buildSwigInstructionData(5, []uint8{1, 2, 3, 0}, instrData) + + result, err := svm.DecodeSwigCompactInstructions(outer) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 compact instruction, got %d", len(result)) + } + ci := result[0] + if ci.ProgramIDIndex != 5 { + t.Errorf("expected ProgramIDIndex=5, got %d", ci.ProgramIDIndex) + } + if len(ci.Accounts) != 4 || ci.Accounts[0] != 1 || ci.Accounts[1] != 2 || ci.Accounts[2] != 3 || ci.Accounts[3] != 0 { + t.Errorf("unexpected accounts: %v", ci.Accounts) + } + if len(ci.Data) < 9 { + t.Fatalf("expected ≥9 data bytes, got %d", len(ci.Data)) + } + if ci.Data[0] != 12 { + t.Errorf("expected discriminator 12, got %d", ci.Data[0]) + } + amount := binary.LittleEndian.Uint64(ci.Data[1:9]) + if amount != 100000 { + t.Errorf("expected amount 100000, got %d", amount) + } + }) + + t.Run("returns empty slice when payload is zero length", func(t *testing.T) { + data := make([]byte, 8) // payloadLen=0 → no compact instructions + result, err := svm.DecodeSwigCompactInstructions(data) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result) != 0 { + t.Errorf("expected 0 instructions, got %d", len(result)) + } + }) +} + +// TestIsSwigTransaction tests the IsSwigTransaction helper. +func TestIsSwigTransaction(t *testing.T) { + swigKey := solana.MustPublicKeyFromBase58(svm.SwigProgramAddress) + secp256r1Key := solana.MustPublicKeyFromBase58(svm.Secp256r1PrecompileAddress) + tokenKey := solana.TokenProgramID + + mkSignV2Data := func() []byte { + data := make([]byte, 4) + binary.LittleEndian.PutUint16(data, svm.SwigSignV2Discriminator) + return data + } + + t.Run("true for 2 compute budgets + SignV2", func(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{solana.ComputeBudget, swigKey}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 0, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Data: mkSignV2Data()}, + }, + }, + } + if !svm.IsSwigTransaction(tx) { + t.Error("expected true") + } + }) + + t.Run("true with secp256r1 precompile instructions", func(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{solana.ComputeBudget, secp256r1Key, swigKey}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 0, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Data: []byte{}}, // secp256r1 + {ProgramIDIndex: 2, Data: mkSignV2Data()}, + }, + }, + } + if !svm.IsSwigTransaction(tx) { + t.Error("expected true") + } + }) + + t.Run("false when last instruction is not Swig program", func(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{solana.ComputeBudget, tokenKey}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 0, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Data: []byte{12, 0, 0, 0}}, // TOKEN_PROGRAM, not swig + }, + }, + } + if svm.IsSwigTransaction(tx) { + t.Error("expected false") + } + }) + + t.Run("false when a non-allowed instruction precedes the last", func(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{solana.ComputeBudget, tokenKey, swigKey}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Data: []byte{12, 0, 0, 0}}, // token program — not allowed + {ProgramIDIndex: 2, Data: mkSignV2Data()}, + }, + }, + } + if svm.IsSwigTransaction(tx) { + t.Error("expected false") + } + }) + + t.Run("false for unknown discriminator", func(t *testing.T) { + data := make([]byte, 4) + binary.LittleEndian.PutUint16(data, 99) // unknown + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{solana.ComputeBudget, swigKey}, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 0, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Data: data}, + }, + }, + } + if svm.IsSwigTransaction(tx) { + t.Error("expected false") + } + }) + + t.Run("false for empty instructions", func(t *testing.T) { + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{}, + Instructions: []solana.CompiledInstruction{}, + }, + } + if svm.IsSwigTransaction(tx) { + t.Error("expected false") + } + }) +} + +// TestParseSwigTransaction tests the ParseSwigTransaction helper. +func TestParseSwigTransaction(t *testing.T) { + swigPDA := solana.MustPublicKeyFromBase58(svm.SwigProgramAddress) // reuse as a PDA for test + swigKey := solana.MustPublicKeyFromBase58(svm.SwigProgramAddress) + secp256r1Key := solana.MustPublicKeyFromBase58(svm.Secp256r1PrecompileAddress) + mintPubkey := solana.MustPublicKeyFromBase58(svm.USDCDevnetAddress) + sourcePubkey := solana.MustPublicKeyFromBase58("11111111111111111111111111111112") + destPubkey := solana.MustPublicKeyFromBase58("11111111111111111111111111111111") + + // Account keys: [0]=swigPDA, [1]=TOKEN_PROGRAM, [2]=source, [3]=mint, [4]=dest, [5]=swigProgram, [6]=computeBudget, [7]=secp256r1 + accountKeys := []solana.PublicKey{ + swigPDA, + solana.TokenProgramID, + sourcePubkey, + mintPubkey, + destPubkey, + swigKey, + solana.ComputeBudget, + secp256r1Key, + } + + // Non-identity SignV2 account mapping to exercise index remapping: + // signV2 pos → global index: [0→0(swigPDA), 1→4(dest), 2→1(TOKEN_PROGRAM), 3→3(mint), 4→2(source)] + signV2Accounts := []uint16{0, 4, 1, 3, 2} + + t.Run("flattens Swig transaction with embedded TransferChecked", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + // compact indices reference signV2's account list, not global: + // programIDIndex=2 → signV2[2]=global 1 (TOKEN_PROGRAM) + // accounts=[4,3,1,0] → signV2[4,3,1,0] = global [2,3,4,0] (source, mint, dest, swigPDA) + signV2Data := buildSwigInstructionData(2, []uint8{4, 3, 1, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, // compute limit + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, // compute price + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, // SignV2 + }, + }, + } + + result, err := svm.ParseSwigTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have 3 instructions: 2 compute budgets + 1 TransferChecked + if len(result.Instructions) != 3 { + t.Fatalf("expected 3 instructions, got %d", len(result.Instructions)) + } + if result.SwigPDA != swigPDA.String() { + t.Errorf("expected SwigPDA=%s, got %s", swigPDA.String(), result.SwigPDA) + } + + // First two are compute budget (unchanged) + if result.Instructions[0].ProgramIDIndex != 6 { + t.Errorf("expected compute budget at index 0") + } + if result.Instructions[1].ProgramIDIndex != 6 { + t.Errorf("expected compute budget at index 1") + } + + // Third is the resolved TransferChecked + transferIx := result.Instructions[2] + if transferIx.ProgramIDIndex != 1 { + t.Errorf("expected ProgramIDIndex=1 (TOKEN_PROGRAM), got %d", transferIx.ProgramIDIndex) + } + if len(transferIx.Accounts) != 4 { + t.Fatalf("expected 4 accounts, got %d", len(transferIx.Accounts)) + } + if transferIx.Accounts[0] != 2 || transferIx.Accounts[1] != 3 || transferIx.Accounts[2] != 4 || transferIx.Accounts[3] != 0 { + t.Errorf("unexpected accounts: %v", transferIx.Accounts) + } + if transferIx.Data[0] != 12 { + t.Errorf("expected transferChecked discriminator 12, got %d", transferIx.Data[0]) + } + }) + + t.Run("filters out secp256r1 precompile instructions", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + signV2Data := buildSwigInstructionData(2, []uint8{4, 3, 1, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, // compute limit + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, // compute price + {ProgramIDIndex: 7, Data: []byte{}}, // secp256r1 precompile + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, // SignV2 + }, + }, + } + + result, err := svm.ParseSwigTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should have 3 instructions: 2 compute budgets + 1 TransferChecked (precompile filtered) + if len(result.Instructions) != 3 { + t.Fatalf("expected 3 instructions, got %d", len(result.Instructions)) + } + }) + + t.Run("extracts SwigPDA from first account of SignV2", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + signV2Data := buildSwigInstructionData(2, []uint8{4, 3, 1, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, + }, + }, + } + + result, err := svm.ParseSwigTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.SwigPDA != swigPDA.String() { + t.Errorf("expected SwigPDA=%s, got %s", swigPDA.String(), result.SwigPDA) + } + }) + + t.Run("error when SignV2 has no accounts", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + signV2Data := buildSwigInstructionData(1, []uint8{2, 3, 4, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Accounts: []uint16{}, Data: signV2Data}, // no accounts + }, + }, + } + + _, err := svm.ParseSwigTransaction(tx) + if err == nil { + t.Fatal("expected error for SignV2 with no accounts") + } + }) + + t.Run("error when compact instruction index exceeds signV2 accounts", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + // programIDIndex=5 is out of range for signV2Accounts (len 5, valid 0-4) + signV2Data := buildSwigInstructionData(5, []uint8{0, 1, 2, 3}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, + }, + }, + } + + _, err := svm.ParseSwigTransaction(tx) + if err == nil { + t.Fatal("expected error for out-of-range compact instruction index") + } + }) +} + +// TestNormalizeTransaction tests the NormalizeTransaction dispatch function. +func TestNormalizeTransaction(t *testing.T) { + swigPDA := solana.MustPublicKeyFromBase58(svm.SwigProgramAddress) + swigKey := solana.MustPublicKeyFromBase58(svm.SwigProgramAddress) + mintPubkey := solana.MustPublicKeyFromBase58(svm.USDCDevnetAddress) + sourcePubkey := solana.MustPublicKeyFromBase58("11111111111111111111111111111112") + destPubkey := solana.MustPublicKeyFromBase58("11111111111111111111111111111111") + + accountKeys := []solana.PublicKey{ + swigPDA, + solana.TokenProgramID, + sourcePubkey, + mintPubkey, + destPubkey, + swigKey, + solana.ComputeBudget, + } + + signV2Accounts := []uint16{0, 4, 1, 3, 2} + + t.Run("dispatches to SwigNormalizer for Swig transactions", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + signV2Data := buildSwigInstructionData(2, []uint8{4, 3, 1, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, + }, + }, + } + + normalized, err := svm.NormalizeTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if normalized.Payer != swigPDA.String() { + t.Errorf("expected payer=%s, got %s", swigPDA.String(), normalized.Payer) + } + if len(normalized.Instructions) != 3 { + t.Fatalf("expected 3 instructions, got %d", len(normalized.Instructions)) + } + }) + + t.Run("dispatches to RegularNormalizer for regular transactions", func(t *testing.T) { + transferData := buildTransferCheckedData(100000, 6) + // Regular transaction: compute budget + compute price + TransferChecked + // authority is the 4th account (index 3 in the token instruction accounts) + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: []solana.PublicKey{ + solana.ComputeBudget, + solana.TokenProgramID, + sourcePubkey, // source + mintPubkey, // mint + destPubkey, // dest + solana.MustPublicKeyFromBase58("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), // authority + }, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 0, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 0, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 1, Accounts: []uint16{2, 3, 4, 5}, Data: transferData}, + }, + }, + } + + normalized, err := svm.NormalizeTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Payer should be the authority from TransferChecked (index 5 = EPjFWd...) + if normalized.Payer != "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" { + t.Errorf("expected payer=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v, got %s", normalized.Payer) + } + if len(normalized.Instructions) != 3 { + t.Fatalf("expected 3 instructions, got %d", len(normalized.Instructions)) + } + }) + + t.Run("SwigNormalizer takes priority over RegularNormalizer", func(t *testing.T) { + instrData := buildTransferCheckedData(100000, 6) + signV2Data := buildSwigInstructionData(2, []uint8{4, 3, 1, 0}, instrData) + + tx := &solana.Transaction{ + Message: solana.Message{ + AccountKeys: accountKeys, + Instructions: []solana.CompiledInstruction{ + {ProgramIDIndex: 6, Data: []byte{2, 0, 0, 0, 0}}, + {ProgramIDIndex: 6, Data: []byte{3, 0, 0, 0, 0, 0, 0, 0, 0}}, + {ProgramIDIndex: 5, Accounts: signV2Accounts, Data: signV2Data}, + }, + }, + } + + // For a Swig transaction, the payer should be the SwigPDA, not the + // token authority (which is what RegularNormalizer would return). + normalized, err := svm.NormalizeTransaction(tx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if normalized.Payer != swigPDA.String() { + t.Errorf("SwigNormalizer should take priority: expected payer=%s, got %s", swigPDA.String(), normalized.Payer) + } + }) +} + // TestSolanaGetAssetInfo tests asset info retrieval func TestSolanaGetAssetInfo(t *testing.T) { t.Run("By symbol", func(t *testing.T) { diff --git a/python/x402/mechanisms/svm/__init__.py b/python/x402/mechanisms/svm/__init__.py index fb9c6c0d31..aec34603de 100644 --- a/python/x402/mechanisms/svm/__init__.py +++ b/python/x402/mechanisms/svm/__init__.py @@ -35,10 +35,13 @@ MEMO_PROGRAM_ADDRESS, NETWORK_CONFIGS, SCHEME_EXACT, + SECP256R1_PRECOMPILE_ADDRESS, SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2, SOLANA_TESTNET_CAIP2, SVM_ADDRESS_REGEX, + SWIG_PROGRAM_ADDRESS, + SWIG_SIGN_V2_DISCRIMINATOR, TESTNET_RPC_URL, TESTNET_WS_URL, TOKEN_2022_PROGRAM_ADDRESS, @@ -52,6 +55,9 @@ NetworkConfig, ) +# Normalizer +from .normalizer import NormalizedTransaction, normalize_transaction + # Signer protocols from .signer import ClientSvmSigner, FacilitatorSvmSigner @@ -66,6 +72,13 @@ TransactionInfo, ) +# Swig +from .swig import ( + decode_swig_compact_instructions, + is_swig_transaction, + parse_swig_transaction, +) + # Utilities from .utils import ( convert_to_token_amount, @@ -92,6 +105,9 @@ "COMPUTE_BUDGET_PROGRAM_ADDRESS", "MEMO_PROGRAM_ADDRESS", "LIGHTHOUSE_PROGRAM_ADDRESS", + "SWIG_PROGRAM_ADDRESS", + "SWIG_SIGN_V2_DISCRIMINATOR", + "SECP256R1_PRECOMPILE_ADDRESS", "DEFAULT_COMPUTE_UNIT_LIMIT", "DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS", "MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS", @@ -145,6 +161,13 @@ # Signer implementations "KeypairSigner", "FacilitatorKeypairSigner", + # Swig + "is_swig_transaction", + "parse_swig_transaction", + "decode_swig_compact_instructions", + # Normalizer + "normalize_transaction", + "NormalizedTransaction", # Utilities "normalize_network", "get_network_config", diff --git a/python/x402/mechanisms/svm/constants.py b/python/x402/mechanisms/svm/constants.py index f82443afcb..b4a7abecb7 100644 --- a/python/x402/mechanisms/svm/constants.py +++ b/python/x402/mechanisms/svm/constants.py @@ -14,6 +14,9 @@ COMPUTE_BUDGET_PROGRAM_ADDRESS = "ComputeBudget111111111111111111111111111111" MEMO_PROGRAM_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" LIGHTHOUSE_PROGRAM_ADDRESS = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" +SWIG_PROGRAM_ADDRESS = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB" +SWIG_SIGN_V2_DISCRIMINATOR = 11 # U16 LE +SECP256R1_PRECOMPILE_ADDRESS = "Secp256r1SigVerify1111111111111111111111111" # Default RPC URLs for Solana networks DEVNET_RPC_URL = "https://api.devnet.solana.com" diff --git a/python/x402/mechanisms/svm/exact/facilitator.py b/python/x402/mechanisms/svm/exact/facilitator.py index cc0e0cecd4..7c569479b9 100644 --- a/python/x402/mechanisms/svm/exact/facilitator.py +++ b/python/x402/mechanisms/svm/exact/facilitator.py @@ -44,12 +44,12 @@ TOKEN_2022_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS, ) +from ..normalizer import normalize_transaction from ..signer import FacilitatorSvmSigner from ..types import ExactSvmPayload from ..utils import ( decode_transaction_from_payload, derive_ata, - get_token_payer_from_transaction, ) @@ -163,10 +163,20 @@ def verify( ) message = tx.message - instructions = message.instructions static_accounts = list(message.account_keys) - # 3-6 instructions: ComputeLimit + ComputePrice + TransferChecked + optional Lighthouse/Memo + # Normalize the transaction (handles Swig, regular, and future wallet types) + try: + normalized = normalize_transaction(tx) + except Exception: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer="" + ) + + instructions = normalized.instructions + payer = normalized.payer + + # Instruction count check AFTER flattening (3-6) if len(instructions) < 3 or len(instructions) > 6: return VerifyResponse( is_valid=False, invalid_reason=ERR_INVALID_INSTRUCTION_COUNT, payer="" @@ -212,13 +222,6 @@ def verify( payer="", ) - # Get token payer - payer = get_token_payer_from_transaction(tx) - if not payer: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer="" - ) - # Step 4: Verify Transfer Instruction transfer_ix = instructions[2] transfer_program = static_accounts[transfer_ix.program_id_index] diff --git a/python/x402/mechanisms/svm/normalizer.py b/python/x402/mechanisms/svm/normalizer.py new file mode 100644 index 0000000000..d194ba6246 --- /dev/null +++ b/python/x402/mechanisms/svm/normalizer.py @@ -0,0 +1,67 @@ +"""Transaction normalization for different wallet types (regular, Swig, etc.).""" + +from dataclasses import dataclass + +try: + from solders.transaction import VersionedTransaction +except ImportError as e: + raise ImportError( + "SVM mechanism requires solana packages. Install with: pip install x402[svm]" + ) from e + +from .swig import NormalizedInstruction, is_swig_transaction, parse_swig_transaction +from .utils import get_token_payer_from_transaction + + +@dataclass +class NormalizedTransaction: + """A flat instruction list and the address of the entity paying the token transfer.""" + + instructions: list[NormalizedInstruction] + payer: str + + +class SwigNormalizer: + """Detects and flattens Swig smart-wallet transactions.""" + + def can_handle(self, tx: VersionedTransaction) -> bool: + return is_swig_transaction(tx) + + def normalize(self, tx: VersionedTransaction) -> NormalizedTransaction: + result = parse_swig_transaction(tx) + return NormalizedTransaction( + instructions=result.instructions, + payer=result.swig_pda, + ) + + +class RegularNormalizer: + """Fallback normalizer for standard (non-smart-wallet) transactions.""" + + def can_handle(self, tx: VersionedTransaction) -> bool: + return True + + def normalize(self, tx: VersionedTransaction) -> NormalizedTransaction: + payer = get_token_payer_from_transaction(tx) + if not payer: + raise ValueError("invalid_exact_svm_payload_no_transfer_instruction") + instructions = [ + NormalizedInstruction( + program_id_index=ix.program_id_index, + accounts=list(ix.accounts), + data=bytes(ix.data), + ) + for ix in tx.message.instructions + ] + return NormalizedTransaction(instructions=instructions, payer=payer) + + +_DEFAULT_NORMALIZERS = [SwigNormalizer(), RegularNormalizer()] + + +def normalize_transaction(tx: VersionedTransaction) -> NormalizedTransaction: + """Run the default normalizer chain and return the first successful result.""" + for normalizer in _DEFAULT_NORMALIZERS: + if normalizer.can_handle(tx): + return normalizer.normalize(tx) + raise ValueError("no normalizer found for transaction") diff --git a/python/x402/mechanisms/svm/swig.py b/python/x402/mechanisms/svm/swig.py new file mode 100644 index 0000000000..f5ac658fb8 --- /dev/null +++ b/python/x402/mechanisms/svm/swig.py @@ -0,0 +1,277 @@ +"""Swig smart wallet transaction detection, parsing, and flattening.""" + +from dataclasses import dataclass + +try: + from solders.pubkey import Pubkey + from solders.transaction import VersionedTransaction +except ImportError as e: + raise ImportError( + "SVM mechanism requires solana packages. Install with: pip install x402[svm]" + ) from e + +from .constants import ( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + SECP256R1_PRECOMPILE_ADDRESS, + SWIG_PROGRAM_ADDRESS, + SWIG_SIGN_V2_DISCRIMINATOR, +) + + +@dataclass +class SwigCompactInstruction: + """A decoded compact instruction embedded in a Swig SignV2 payload. + + Indices reference the SignV2 instruction's own account list, + not the outer transaction's global account keys. + """ + + program_id_index: int + accounts: list[int] + data: bytes + + +@dataclass +class NormalizedInstruction: + """An instruction with global indices into tx.message.account_keys. + + Mimics the solders CompiledInstruction interface so the facilitator + can use it interchangeably. + """ + + program_id_index: int + accounts: list[int] + data: bytes + + +@dataclass +class ParseSwigResult: + """Result of parsing/flattening a Swig transaction.""" + + instructions: list[NormalizedInstruction] + swig_pda: str + + +def is_swig_transaction(tx: VersionedTransaction) -> bool: + """Check if a transaction has a Swig smart wallet layout. + + A Swig transaction contains only compute budget, secp256r1 precompile, + and Swig SignV2 instructions, with at least one SignV2 present. + """ + instructions = tx.message.instructions + if len(instructions) == 0: + return False + + account_keys = list(tx.message.account_keys) + compute_budget = Pubkey.from_string(COMPUTE_BUDGET_PROGRAM_ADDRESS) + secp256r1 = Pubkey.from_string(SECP256R1_PRECOMPILE_ADDRESS) + swig = Pubkey.from_string(SWIG_PROGRAM_ADDRESS) + + has_sign_v2 = False + for ix in instructions: + if ix.program_id_index >= len(account_keys): + return False + prog_id = account_keys[ix.program_id_index] + if prog_id == compute_budget or prog_id == secp256r1: + continue + if prog_id == swig: + ix_data = bytes(ix.data) + if len(ix_data) < 2: + return False + discriminator = int.from_bytes(ix_data[0:2], "little") + if discriminator != SWIG_SIGN_V2_DISCRIMINATOR: + return False + has_sign_v2 = True + continue + return False # unrecognized instruction + + return has_sign_v2 + + +def decode_swig_compact_instructions(data: bytes) -> list[SwigCompactInstruction]: + """Decode compact instructions from a Swig SignV2 instruction payload. + + Data layout: + [0..1] discriminator U16 LE + [2..3] instructionPayloadLen U16 LE + [4..7] roleId U32 LE + [8..] compact instructions (instructionPayloadLen bytes) + + Compact instructions payload: + [0] numInstructions U8 + [1..] compact instruction entries... + + Each compact instruction: + [0] programIDIndex U8 + [1] numAccounts U8 + [2..N+1] accounts []U8 + [N+2..N+3] dataLen U16 LE + [N+4..] data raw bytes + """ + if len(data) < 4: + raise ValueError( + f"swig instruction data too short: need \u22654 bytes, got {len(data)}" + ) + + instruction_payload_len = int.from_bytes(data[2:4], "little") + start_offset = 8 + if len(data) < start_offset + instruction_payload_len: + raise ValueError( + f"swig instruction data truncated: payload needs {instruction_payload_len} bytes " + f"but only {len(data) - start_offset} available after offset {start_offset}" + ) + + results: list[SwigCompactInstruction] = [] + offset = start_offset + 1 # skip numInstructions count byte + end_offset = start_offset + instruction_payload_len + + while offset < end_offset: + if offset >= len(data): + break + + # programIDIndex: U8 + program_id_index = data[offset] + offset += 1 + + # numAccounts: U8 + if offset >= end_offset: + break + num_accounts = data[offset] + offset += 1 + + # accounts: []U8 + if offset + num_accounts > end_offset: + break + accounts = list(data[offset : offset + num_accounts]) + offset += num_accounts + + # dataLen: U16 LE + if offset + 2 > end_offset: + break + data_len = int.from_bytes(data[offset : offset + 2], "little") + offset += 2 + + # instruction data + if offset + data_len > end_offset: + break + instr_data = bytes(data[offset : offset + data_len]) + offset += data_len + + results.append( + SwigCompactInstruction( + program_id_index=program_id_index, + accounts=accounts, + data=instr_data, + ) + ) + + return results + + +def parse_swig_transaction(tx: VersionedTransaction) -> ParseSwigResult: + """Flatten a Swig transaction into a regular instruction layout. + + Collects non-precompile, non-SignV2 outer instructions (compute budgets) + and resolves the compact instructions embedded in each SignV2 instruction. + + All SignV2 instructions must reference the same Swig PDA (accounts[0]). + """ + instructions = tx.message.instructions + if len(instructions) == 0: + raise ValueError("no instructions") + + account_keys = list(tx.message.account_keys) + secp256r1 = Pubkey.from_string(SECP256R1_PRECOMPILE_ADDRESS) + swig_pubkey = Pubkey.from_string(SWIG_PROGRAM_ADDRESS) + + # 1. Single pass: separate SignV2 from the rest + result: list[NormalizedInstruction] = [] + sign_v2_instructions = [] + for ix in instructions: + prog_id = account_keys[ix.program_id_index] + if prog_id == secp256r1: + continue # skip precompile + if prog_id == swig_pubkey: + sign_v2_instructions.append(ix) + else: + # compute budget and other non-precompile instructions (pass through) + result.append( + NormalizedInstruction( + program_id_index=ix.program_id_index, + accounts=list(ix.accounts), + data=bytes(ix.data), + ) + ) + + if len(sign_v2_instructions) == 0: + raise ValueError("invalid_exact_svm_payload_no_transfer_instruction") + + # Sort compute budget instructions so SetComputeUnitLimit (disc=2) precedes + # SetComputeUnitPrice (disc=3), matching the order the facilitator expects. + compute_budget = Pubkey.from_string(COMPUTE_BUDGET_PROGRAM_ADDRESS) + result.sort( + key=lambda ix: ix.data[0] + if account_keys[ix.program_id_index] == compute_budget and len(ix.data) > 0 + else 0 + ) + + # 2. Process each SignV2 instruction + swig_pda = "" + for sign_v2 in sign_v2_instructions: + sign_v2_accounts = list(sign_v2.accounts) + + # Extract Swig PDA from SignV2's first account + if len(sign_v2_accounts) < 2: + raise ValueError("invalid_exact_svm_payload_no_transfer_instruction") + if sign_v2_accounts[0] >= len(account_keys): + raise ValueError("invalid_exact_svm_payload_no_transfer_instruction") + + swig_config_key = account_keys[sign_v2_accounts[0]] + pda = str(swig_config_key) + + # Enforce all SignV2 instructions share the same PDA + if swig_pda == "": + swig_pda = pda + elif pda != swig_pda: + raise ValueError( + "swig_pda_mismatch: all SignV2 instructions must reference the same Swig PDA" + ) + + # Validate Swig wallet address derivation (cross-check accounts[0] and accounts[1]) + if sign_v2_accounts[1] >= len(account_keys): + raise ValueError("invalid_swig_wallet_address_derivation") + actual_wallet_address = account_keys[sign_v2_accounts[1]] + expected_wallet_address, _ = Pubkey.find_program_address( + [b"swig-wallet-address", bytes(swig_config_key)], + swig_pubkey, + ) + if actual_wallet_address != expected_wallet_address: + raise ValueError("invalid_swig_wallet_address_derivation") + + # Decode compact instructions from SignV2 data + compact_instructions = decode_swig_compact_instructions(bytes(sign_v2.data)) + + # Resolve compact instructions: remap local indices through sign_v2.accounts + for ci in compact_instructions: + if ci.program_id_index >= len(sign_v2_accounts): + raise ValueError( + f"compact instruction programIDIndex {ci.program_id_index} " + f"out of range (signV2 has {len(sign_v2_accounts)} accounts)" + ) + remapped_accounts = [] + for a in ci.accounts: + if a >= len(sign_v2_accounts): + raise ValueError( + f"compact instruction account index {a} " + f"out of range (signV2 has {len(sign_v2_accounts)} accounts)" + ) + remapped_accounts.append(sign_v2_accounts[a]) + result.append( + NormalizedInstruction( + program_id_index=sign_v2_accounts[ci.program_id_index], + accounts=remapped_accounts, + data=ci.data, + ) + ) + + return ParseSwigResult(instructions=result, swig_pda=swig_pda) diff --git a/python/x402/tests/unit/mechanisms/svm/test_swig.py b/python/x402/tests/unit/mechanisms/svm/test_swig.py new file mode 100644 index 0000000000..2bfe51415b --- /dev/null +++ b/python/x402/tests/unit/mechanisms/svm/test_swig.py @@ -0,0 +1,504 @@ +"""Tests for Swig smart wallet transaction detection, parsing, and normalization.""" + +import struct + +import pytest +from solders.hash import Hash +from solders.instruction import CompiledInstruction +from solders.message import MessageV0, MessageAddressTableLookup +from solders.pubkey import Pubkey +from solders.transaction import VersionedTransaction + +from x402.mechanisms.svm.constants import ( + COMPUTE_BUDGET_PROGRAM_ADDRESS, + SECP256R1_PRECOMPILE_ADDRESS, + SWIG_PROGRAM_ADDRESS, + SWIG_SIGN_V2_DISCRIMINATOR, + TOKEN_PROGRAM_ADDRESS, + USDC_DEVNET_ADDRESS, +) +from x402.mechanisms.svm.normalizer import normalize_transaction +from x402.mechanisms.svm.swig import ( + decode_swig_compact_instructions, + is_swig_transaction, + parse_swig_transaction, +) + +# Test constants +SWIG_PDA = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" +SWIG_PDA_PUBKEY = Pubkey.from_string(SWIG_PDA) +SWIG_PROGRAM_PUBKEY = Pubkey.from_string(SWIG_PROGRAM_ADDRESS) + +# Derive the SwigWalletAddress PDA from the test SWIG_PDA +SWIG_WALLET_ADDRESS_PUBKEY, _ = Pubkey.find_program_address( + [b"swig-wallet-address", bytes(SWIG_PDA_PUBKEY)], + SWIG_PROGRAM_PUBKEY, +) +SWIG_WALLET_ADDRESS = str(SWIG_WALLET_ADDRESS_PUBKEY) + +COMPUTE_BUDGET_PUBKEY = Pubkey.from_string(COMPUTE_BUDGET_PROGRAM_ADDRESS) +SECP256R1_PUBKEY = Pubkey.from_string(SECP256R1_PRECOMPILE_ADDRESS) +TOKEN_PROGRAM_PUBKEY = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) +USDC_PUBKEY = Pubkey.from_string(USDC_DEVNET_ADDRESS) + +# Dummy accounts for testing +SOURCE_ACCOUNT = Pubkey.from_string("3Js7k6xkFRBwhiGfnFbSadMgmBHnFDAMLTwEBfvCedeR") +DEST_ATA = Pubkey.from_string("CiDwVBFgWV9E5MvXWoLgnEgn2hK7rJikbvfWavzAQz3") +FEE_PAYER = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + +def _build_swig_data(payload: bytes, num_instructions: int = 1) -> bytes: + """Build a Swig SignV2 instruction data buffer with the given compact instruction payload.""" + with_count = bytes([num_instructions]) + payload + buf = bytearray(8 + len(with_count)) + struct.pack_into(" bytes: + """Build a TransferChecked compact instruction entry.""" + instr_data = bytearray(10) + instr_data[0] = 12 # transferChecked discriminator + struct.pack_into(" VersionedTransaction: + """Build a VersionedTransaction from account keys and compiled instructions.""" + msg = MessageV0( + header=MessageV0.default().header, + account_keys=account_keys, + recent_blockhash=Hash.default(), + instructions=instructions, + address_table_lookups=[], + ) + return VersionedTransaction.populate(msg, []) + + +# ─── isSwigTransaction ───────────────────────────────────────────────────── + + +class TestIsSwigTransaction: + def test_valid_swig_returns_true(self): + """Valid Swig transaction (2 compute budgets + SignV2) returns true.""" + account_keys = [COMPUTE_BUDGET_PUBKEY, SWIG_PROGRAM_PUBKEY] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(0, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction( + 1, bytes([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]), bytes([]) + ), + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is True + + def test_valid_with_secp256r1_returns_true(self): + """Valid with secp256r1 precompile returns true.""" + account_keys = [ + COMPUTE_BUDGET_PUBKEY, + SECP256R1_PUBKEY, + SWIG_PROGRAM_PUBKEY, + ] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(0, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(1, bytes([]), bytes([])), + CompiledInstruction( + 2, bytes([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]), bytes([]) + ), + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is True + + def test_non_swig_instruction_returns_false(self): + """Returns false when non-Swig instruction present.""" + account_keys = [COMPUTE_BUDGET_PUBKEY, TOKEN_PROGRAM_PUBKEY] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(0, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(1, bytes([12, 0, 0, 0]), bytes([])), + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is False + + def test_unknown_discriminator_returns_false(self): + """Returns false for unknown discriminator.""" + account_keys = [COMPUTE_BUDGET_PUBKEY, SWIG_PROGRAM_PUBKEY] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(0, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(1, bytes([4, 0, 0, 0]), bytes([])), # V1 discriminator + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is False + + def test_empty_instructions_returns_false(self): + """Returns false for empty instructions.""" + account_keys = [COMPUTE_BUDGET_PUBKEY] + tx = _make_tx(account_keys, []) + assert is_swig_transaction(tx) is False + + def test_data_too_short_returns_false(self): + """Returns false when data too short.""" + account_keys = [COMPUTE_BUDGET_PUBKEY, SWIG_PROGRAM_PUBKEY] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(1, bytes([11]), bytes([])), # only 1 byte + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is False + + def test_two_sign_v2_returns_true(self): + """Returns true for 2 SignV2 instructions.""" + account_keys = [COMPUTE_BUDGET_PUBKEY, SWIG_PROGRAM_PUBKEY] + instructions = [ + CompiledInstruction(0, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction( + 1, bytes([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]), bytes([]) + ), + CompiledInstruction( + 1, bytes([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]), bytes([]) + ), + ] + tx = _make_tx(account_keys, instructions) + assert is_swig_transaction(tx) is True + + +# ─── decodeSwigCompactInstructions ───────────────────────────────────────── + + +class TestDecodeSwigCompactInstructions: + def test_throws_when_data_less_than_4_bytes(self): + """Throws when data < 4 bytes.""" + with pytest.raises(ValueError, match="swig instruction data too short"): + decode_swig_compact_instructions(bytes([1, 2, 3])) + + def test_throws_when_payload_truncated(self): + """Throws when instructionPayloadLen exceeds available data.""" + # instructionPayloadLen = 100 but only 8 bytes total + data = bytes([4, 0, 100, 0, 0, 0, 0, 0]) + with pytest.raises(ValueError, match="swig instruction data truncated"): + decode_swig_compact_instructions(data) + + def test_decodes_single_compact_instruction(self): + """Correctly decodes single TransferChecked compact instruction.""" + compact = _build_transfer_checked_compact(5, [1, 2, 3, 0], 100000) + data = _build_swig_data(compact) + + result = decode_swig_compact_instructions(data) + assert len(result) == 1 + assert result[0].program_id_index == 5 + assert result[0].accounts == [1, 2, 3, 0] + assert result[0].data[0] == 12 # transferChecked discriminator + # Check amount (U64 LE at bytes 1-8) + amount = int.from_bytes(result[0].data[1:9], "little") + assert amount == 100000 + + def test_throws_on_truncated_compact_data(self): + """Throws on truncated compact instruction data.""" + # payload length = 5 in header but no payload bytes + data = bytes([4, 0, 5, 0, 0, 0, 0, 0]) + with pytest.raises(ValueError, match="swig instruction data truncated"): + decode_swig_compact_instructions(data) + + +# ─── parseSwigTransaction ────────────────────────────────────────────────── + + +class TestParseSwigTransaction: + # Account keys for test transactions: + # [0] SWIG_PDA_PUBKEY + # [1] TOKEN_PROGRAM_PUBKEY + # [2] SOURCE_ACCOUNT + # [3] USDC_PUBKEY + # [4] DEST_ATA + # [5] SWIG_PROGRAM_PUBKEY + # [6] COMPUTE_BUDGET_PUBKEY + # [7] SWIG_WALLET_ADDRESS_PUBKEY + + @property + def _account_keys(self) -> list[Pubkey]: + return [ + SWIG_PDA_PUBKEY, + TOKEN_PROGRAM_PUBKEY, + SOURCE_ACCOUNT, + USDC_PUBKEY, + DEST_ATA, + SWIG_PROGRAM_PUBKEY, + COMPUTE_BUDGET_PUBKEY, + SWIG_WALLET_ADDRESS_PUBKEY, + ] + + def _sign_v2_account_indices(self) -> bytes: + """SignV2 account list as global indices into _account_keys. + + pos 0 -> _account_keys[0] = SWIG_PDA + pos 1 -> _account_keys[7] = SWIG_WALLET_ADDRESS + pos 2 -> _account_keys[4] = DEST_ATA + pos 3 -> _account_keys[1] = TOKEN_PROGRAM + pos 4 -> _account_keys[3] = USDC + pos 5 -> _account_keys[2] = SOURCE + """ + return bytes([0, 7, 4, 1, 3, 2]) + + def test_flattens_swig_with_transfer_checked(self): + """Flattens Swig transaction with embedded TransferChecked.""" + # compact indices reference signV2's local account list: + # programIdIndex=3 -> signV2[3]=TOKEN_PROGRAM (global idx 1) + # accounts=[5,4,2,0] -> signV2[5,4,2,0] = source, usdc, dest, swigPda + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(self._account_keys, instructions) + + result = parse_swig_transaction(tx) + + # Should have 3 instructions: 2 compute budgets + 1 TransferChecked + assert len(result.instructions) == 3 + assert result.swig_pda == SWIG_PDA + + # First two are compute budget (unchanged) + assert result.instructions[0].program_id_index == 6 + assert result.instructions[1].program_id_index == 6 + + # Third is the resolved TransferChecked + assert result.instructions[2].program_id_index == 1 # TOKEN_PROGRAM global idx + assert result.instructions[2].data[0] == 12 + + def test_filters_secp256r1_precompile(self): + """Filters out secp256r1 precompile instructions.""" + account_keys = self._account_keys + [SECP256R1_PUBKEY] # index 8 + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(8, bytes([]), bytes([])), # secp256r1 + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(account_keys, instructions) + + result = parse_swig_transaction(tx) + + # Should have 3 instructions: 2 compute budgets + 1 TransferChecked (precompile filtered) + assert len(result.instructions) == 3 + assert result.swig_pda == SWIG_PDA + + def test_extracts_swig_pda(self): + """Extracts swigPda from first account of SignV2.""" + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(self._account_keys, instructions) + + result = parse_swig_transaction(tx) + assert result.swig_pda == SWIG_PDA + + def test_throws_on_index_out_of_range(self): + """Throws when compact instruction programIDIndex exceeds signV2 accounts.""" + # programIdIndex=6 out of range for signV2 accounts (len 6, valid 0-5) + compact = _build_transfer_checked_compact(6, [0, 1, 2, 3], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(self._account_keys, instructions) + + with pytest.raises(ValueError, match="out of range"): + parse_swig_transaction(tx) + + def test_flattens_two_sign_v2_instructions(self): + """Flattens 2 SignV2 instructions.""" + compact1 = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + compact2 = _build_transfer_checked_compact(3, [5, 4, 2, 0], 200000) + sign_v2_data1 = _build_swig_data(compact1) + sign_v2_data2 = _build_swig_data(compact2) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data1, self._sign_v2_account_indices()), + CompiledInstruction(5, sign_v2_data2, self._sign_v2_account_indices()), + ] + tx = _make_tx(self._account_keys, instructions) + + result = parse_swig_transaction(tx) + + # Should have 3: 1 compute budget + 2 TransferChecked + assert len(result.instructions) == 3 + assert result.swig_pda == SWIG_PDA + + # First is compute budget + assert result.instructions[0].program_id_index == 6 + + # Second and third are TransferChecked + assert result.instructions[1].data[0] == 12 + assert result.instructions[2].data[0] == 12 + + # Verify different amounts + amount1 = int.from_bytes(result.instructions[1].data[1:9], "little") + amount2 = int.from_bytes(result.instructions[2].data[1:9], "little") + assert amount1 == 100000 + assert amount2 == 200000 + + def test_throws_on_pda_mismatch(self): + """Throws on PDA mismatch between two SignV2 instructions.""" + different_pda = Pubkey.from_string("11111111111111111111111111111111") + # Add the different PDA to the account keys + account_keys = self._account_keys + [different_pda] # index 8 + + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data1 = _build_swig_data(compact) + sign_v2_data2 = _build_swig_data(compact) + + # Second SignV2 references different PDA at global index 8 + sign_v2_accounts_2 = bytes([8, 7, 4, 1, 3, 2]) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data1, self._sign_v2_account_indices()), + CompiledInstruction(5, sign_v2_data2, sign_v2_accounts_2), + ] + tx = _make_tx(account_keys, instructions) + + with pytest.raises(ValueError, match="swig_pda_mismatch"): + parse_swig_transaction(tx) + + def test_throws_on_invalid_wallet_address_derivation(self): + """Throws on invalid wallet address derivation.""" + wrong_wallet = Pubkey.from_string("3Js7k6xkFRBwhiGfnFbSadMgmBHnFDAMLTwEBfvCedeR") + # Replace SWIG_WALLET_ADDRESS at index 7 with wrong address + account_keys = list(self._account_keys) + account_keys[7] = wrong_wallet + + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(account_keys, instructions) + + with pytest.raises(ValueError, match="invalid_swig_wallet_address_derivation"): + parse_swig_transaction(tx) + + +# ─── normalizeTransaction ────────────────────────────────────────────────── + + +class TestNormalizeTransaction: + @property + def _account_keys(self) -> list[Pubkey]: + return [ + SWIG_PDA_PUBKEY, + TOKEN_PROGRAM_PUBKEY, + SOURCE_ACCOUNT, + USDC_PUBKEY, + DEST_ATA, + SWIG_PROGRAM_PUBKEY, + COMPUTE_BUDGET_PUBKEY, + SWIG_WALLET_ADDRESS_PUBKEY, + ] + + def _sign_v2_account_indices(self) -> bytes: + return bytes([0, 7, 4, 1, 3, 2]) + + def test_swig_normalizer_for_swig_tx(self): + """SwigNormalizer dispatches for Swig transactions.""" + compact = _build_transfer_checked_compact(3, [5, 4, 2, 0], 100000) + sign_v2_data = _build_swig_data(compact) + + instructions = [ + CompiledInstruction(6, bytes([2, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(6, bytes([3, 0, 0, 0, 0, 0, 0, 0, 0]), bytes([])), + CompiledInstruction(5, sign_v2_data, self._sign_v2_account_indices()), + ] + tx = _make_tx(self._account_keys, instructions) + + result = normalize_transaction(tx) + assert result.payer == SWIG_PDA + assert len(result.instructions) == 3 + + def test_regular_normalizer_for_regular_tx(self): + """RegularNormalizer dispatches for non-Swig transactions.""" + # A regular transaction: compute budgets + TransferChecked + # account keys: [fee_payer, source, token_program, usdc, dest, compute_budget] + account_keys = [ + FEE_PAYER, + SOURCE_ACCOUNT, + TOKEN_PROGRAM_PUBKEY, + USDC_PUBKEY, + DEST_ATA, + COMPUTE_BUDGET_PUBKEY, + ] + + # TransferChecked data: discriminator 12 + amount 100000 + decimals 6 + transfer_data = bytearray(10) + transfer_data[0] = 12 + struct.pack_into(" VersionedTransaction: + raw = base64.b64decode(REAL_SWIG_TX_BASE64) + return VersionedTransaction.from_bytes(raw) + + +class MockFacilitatorSigner: + """Mock facilitator signer for testing with real fee payer address.""" + + def __init__(self, addresses: list[str] | None = None): + self._addresses = addresses or [FEE_PAYER] + + def get_addresses(self) -> list[str]: + return self._addresses + + def sign_transaction(self, tx_base64: str, fee_payer: str, network: str) -> str: + return tx_base64 + + def simulate_transaction(self, tx_base64: str, network: str) -> None: + pass + + def send_transaction(self, tx_base64: str, network: str) -> str: + return "mockSignature123" + + def confirm_transaction(self, signature: str, network: str) -> None: + pass + + +class TestIsSwigTransaction: + """Detect that the real devnet tx is a Swig transaction.""" + + def test_real_tx_detected_as_swig(self): + tx = _decode_tx() + assert is_swig_transaction(tx) is True + + +class TestParseSwigTransaction: + """Parse and flatten the real devnet Swig transaction.""" + + def test_flattened_instruction_count(self): + tx = _decode_tx() + result = parse_swig_transaction(tx) + assert len(result.instructions) == 3 + + def test_swig_pda(self): + tx = _decode_tx() + result = parse_swig_transaction(tx) + assert result.swig_pda == SWIG_PDA + + def test_transfer_checked_discriminator(self): + tx = _decode_tx() + result = parse_swig_transaction(tx) + transfer_ix = result.instructions[2] + assert transfer_ix.data[0] == 12 + + def test_transfer_checked_amount_and_decimals(self): + tx = _decode_tx() + result = parse_swig_transaction(tx) + transfer_ix = result.instructions[2] + amount = int.from_bytes(transfer_ix.data[1:9], "little") + decimals = transfer_ix.data[9] + assert amount == 1 + assert decimals == 6 + + +class TestNormalizeTransaction: + """Verify normalizer picks SwigNormalizer and returns correct payer.""" + + def test_normalizer_returns_swig_pda_as_payer(self): + tx = _decode_tx() + normalized = normalize_transaction(tx) + assert normalized.payer == SWIG_PDA + + def test_normalizer_returns_three_instructions(self): + tx = _decode_tx() + normalized = normalize_transaction(tx) + assert len(normalized.instructions) == 3 + + +class TestVerifyPipeline: + """Full verify() pipeline via ExactSvmFacilitatorScheme.""" + + def _make_payload_and_requirements(self): + requirements = PaymentRequirements( + scheme="exact", + network=SOLANA_DEVNET_CAIP2, + asset=USDC_DEVNET_ADDRESS, + amount="1", + pay_to=PAY_TO, + max_timeout_seconds=3600, + extra={"feePayer": FEE_PAYER}, + ) + payload = PaymentPayload( + x402_version=2, + resource=ResourceInfo( + url="http://example.com/protected", + description="Test resource", + mime_type="application/json", + ), + accepted=requirements, + payload={"transaction": REAL_SWIG_TX_BASE64}, + ) + return payload, requirements + + def test_verify_is_valid(self): + signer = MockFacilitatorSigner() + facilitator = ExactSvmFacilitatorScheme(signer) + payload, requirements = self._make_payload_and_requirements() + + result = facilitator.verify(payload, requirements) + + assert result.is_valid is True + + def test_verify_payer_is_swig_pda(self): + signer = MockFacilitatorSigner() + facilitator = ExactSvmFacilitatorScheme(signer) + payload, requirements = self._make_payload_and_requirements() + + result = facilitator.verify(payload, requirements) + + assert result.payer == SWIG_PDA diff --git a/typescript/packages/mechanisms/svm/src/constants.ts b/typescript/packages/mechanisms/svm/src/constants.ts index 15a5463e8a..54a9cef149 100644 --- a/typescript/packages/mechanisms/svm/src/constants.ts +++ b/typescript/packages/mechanisms/svm/src/constants.ts @@ -7,6 +7,22 @@ export const TOKEN_2022_PROGRAM_ADDRESS = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXE export const COMPUTE_BUDGET_PROGRAM_ADDRESS = "ComputeBudget111111111111111111111111111111"; export const MEMO_PROGRAM_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; +/** + * Swig smart wallet program address and instruction discriminators. + * Swig wraps inner instructions (e.g. SPL transferChecked) inside a SignV2 + * instruction and executes them via CPI using the Swig PDA as authority. + * See: https://github.com/anagram-xyz/swig + */ +export const SWIG_PROGRAM_ADDRESS = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB"; +export const SWIG_SIGN_V2_DISCRIMINATOR = 11; // U16 LE + +/** + * Secp256r1 precompile program address (used by Swig passkey wallets) + * Swig transactions may include secp256r1 signature verification instructions + * before the SignV2 instruction. These are filtered out during transaction flattening. + */ +export const SECP256R1_PRECOMPILE_ADDRESS = "Secp256r1SigVerify1111111111111111111111111"; + /** * Phantom/Solflare Lighthouse program address * Phantom and Solflare wallets inject Lighthouse instructions for user protection on mainnet transactions. diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts index 0aa54064db..0d1d57dbab 100644 --- a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts @@ -31,7 +31,8 @@ import { } from "../../constants"; import type { FacilitatorSvmSigner } from "../../signer"; import type { ExactSvmPayloadV2 } from "../../types"; -import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "../../utils"; +import { decodeTransactionFromPayload } from "../../utils"; +import { normalizeTransaction, type NormalizedTransaction } from "../../normalizer"; /** * SVM facilitator implementation for the Exact payment scheme. @@ -139,9 +140,27 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); const decompiled = decompileTransactionMessage(compiled); - const instructions = decompiled.instructions ?? []; + let instructions = decompiled.instructions ?? []; - // Allow 3-6 instructions: + // Normalize the transaction (handles Swig, regular, and future wallet types) + let normalized: NormalizedTransaction; + try { + normalized = await normalizeTransaction( + instructions, + compiled.staticAccounts ?? [], + transaction, + ); + } catch { + return { + isValid: false, + invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", + payer: "", + }; + } + instructions = normalized.instructions; + const payer = normalized.payer; + + // Instruction count check AFTER flattening (3-6) // - 3 instructions: ComputeLimit + ComputePrice + TransferChecked // - 4 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse or Memo // - 5 instructions: ComputeLimit + ComputePrice + TransferChecked + Lighthouse + Lighthouse or Memo @@ -168,22 +187,13 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { }; } - const payer = getTokenPayerFromTransaction(transaction); - if (!payer) { - return { - isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", - payer: "", - }; - } - - // Step 4: Verify Transfer Instruction + // Step 4: Verify Transfer Instruction (unified — works for both regular and Swig) const transferIx = instructions[2]; - const programAddress = transferIx.programAddress.toString(); + const transferProgramAddress = transferIx.programAddress.toString(); if ( - programAddress !== TOKEN_PROGRAM_ADDRESS.toString() && - programAddress !== TOKEN_2022_PROGRAM_ADDRESS.toString() + transferProgramAddress !== TOKEN_PROGRAM_ADDRESS.toString() && + transferProgramAddress !== TOKEN_2022_PROGRAM_ADDRESS.toString() ) { return { isValid: false, @@ -195,7 +205,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // Parse the transfer instruction using the appropriate library helper let parsedTransfer; try { - if (programAddress === TOKEN_PROGRAM_ADDRESS.toString()) { + if (transferProgramAddress === TOKEN_PROGRAM_ADDRESS.toString()) { parsedTransfer = parseTransferCheckedInstructionToken(transferIx as never); } else { parsedTransfer = parseTransferCheckedInstruction2022(transferIx as never); @@ -210,6 +220,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { // Verify that the facilitator's signers are not transferring their own funds // SECURITY: Prevent facilitator from signing away their own tokens + // For Swig txs, the authority is the Swig PDA (signs via CPI) const authorityAddress = parsedTransfer.accounts.authority.address.toString(); if (signerAddresses.includes(authorityAddress)) { return { @@ -236,7 +247,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { mint: requirements.asset as Address, owner: requirements.payTo as Address, tokenProgram: - programAddress === TOKEN_PROGRAM_ADDRESS.toString() + transferProgramAddress === TOKEN_PROGRAM_ADDRESS.toString() ? (TOKEN_PROGRAM_ADDRESS as Address) : (TOKEN_2022_PROGRAM_ADDRESS as Address), }); diff --git a/typescript/packages/mechanisms/svm/src/index.ts b/typescript/packages/mechanisms/svm/src/index.ts index 3f1e736fd9..f521dbf59e 100644 --- a/typescript/packages/mechanisms/svm/src/index.ts +++ b/typescript/packages/mechanisms/svm/src/index.ts @@ -25,3 +25,6 @@ export * from "./constants"; // Export utilities export * from "./utils"; + +// Export normalizer +export * from "./normalizer"; diff --git a/typescript/packages/mechanisms/svm/src/normalizer.ts b/typescript/packages/mechanisms/svm/src/normalizer.ts new file mode 100644 index 0000000000..a5a94897b7 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/normalizer.ts @@ -0,0 +1,83 @@ +import type { Address, Instruction, Transaction } from "@solana/kit"; +import { + isSwigTransaction, + parseSwigTransaction, + getTokenPayerFromTransaction, +} from "./utils"; + +export interface NormalizedTransaction { + instructions: Array<{ + programAddress: Address; + accounts: Array<{ address: Address; role: number }>; + data: Uint8Array; + }>; + payer: string; +} + +export interface TransactionNormalizer { + canHandle(instructions: ReadonlyArray): boolean; + normalize( + instructions: ReadonlyArray, + staticAccounts: ReadonlyArray
, + transaction: Transaction, + ): Promise; +} + +class SwigNormalizer implements TransactionNormalizer { + canHandle(instructions: ReadonlyArray): boolean { + return isSwigTransaction(instructions); + } + + async normalize( + instructions: ReadonlyArray, + staticAccounts: ReadonlyArray
, + ): Promise { + const result = await parseSwigTransaction(instructions, staticAccounts); + return { + instructions: result.instructions, + payer: result.swigPda, + }; + } +} + +class RegularNormalizer implements TransactionNormalizer { + canHandle(): boolean { + return true; + } + + async normalize( + instructions: ReadonlyArray, + _staticAccounts: ReadonlyArray
, + transaction: Transaction, + ): Promise { + const payer = getTokenPayerFromTransaction(transaction); + if (!payer) { + throw new Error("invalid_exact_svm_payload_no_transfer_instruction"); + } + + return { + // The caller already decompiled the instructions; pass them through. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instructions: instructions as any, + payer, + }; + } +} + +const defaultNormalizers: TransactionNormalizer[] = [ + new SwigNormalizer(), + new RegularNormalizer(), +]; + +export async function normalizeTransaction( + instructions: ReadonlyArray, + staticAccounts: ReadonlyArray
, + transaction: Transaction, +): Promise { + for (const n of defaultNormalizers) { + if (n.canHandle(instructions)) { + return await n.normalize(instructions, staticAccounts, transaction); + } + } + throw new Error("no normalizer found for transaction"); +} diff --git a/typescript/packages/mechanisms/svm/src/utils.ts b/typescript/packages/mechanisms/svm/src/utils.ts index c613a3034f..3d8ed02518 100644 --- a/typescript/packages/mechanisms/svm/src/utils.ts +++ b/typescript/packages/mechanisms/svm/src/utils.ts @@ -2,7 +2,11 @@ import { getBase64Encoder, getTransactionDecoder, getCompiledTransactionMessageDecoder, + getProgramDerivedAddress, + getAddressEncoder, type Transaction, + type Address, + type Instruction, createSolanaRpc, devnet, testnet, @@ -29,6 +33,10 @@ import { SOLANA_DEVNET_CAIP2, SOLANA_TESTNET_CAIP2, V1_TO_V2_NETWORK_MAP, + COMPUTE_BUDGET_PROGRAM_ADDRESS, + SWIG_PROGRAM_ADDRESS, + SWIG_SIGN_V2_DISCRIMINATOR, + SECP256R1_PRECOMPILE_ADDRESS, } from "./constants"; import type { ExactSvmPayloadV1 } from "./types"; @@ -191,3 +199,230 @@ export function convertToTokenAmount(decimalAmount: string, decimals: number): s const tokenAmount = (intPart + paddedDec).replace(/^0+/, "") || "0"; return tokenAmount; } + +// ─── Swig wallet support ────────────────────────────────────────────────────── + +/** + * A decoded compact instruction extracted from a Swig SignV2 payload. + * Indices reference the SignV2 instruction's own account list, not the outer + * transaction's static account keys. + */ +export interface SwigCompactInstruction { + programIdIndex: number; + accounts: number[]; + data: Uint8Array; +} + +/** + * Returns true when the transaction has a Swig layout: + * - Every instruction is one of: compute budget, secp256r1 precompile, or Swig SignV2 + * - At least one Swig SignV2 instruction is present + * + * @param instructions - Decompiled instruction array + */ +export function isSwigTransaction( + instructions: ReadonlyArray, +): boolean { + if (instructions.length === 0) return false; + + let hasSignV2 = false; + for (const ix of instructions) { + const addr = ix.programAddress.toString(); + if (addr === COMPUTE_BUDGET_PROGRAM_ADDRESS || addr === SECP256R1_PRECOMPILE_ADDRESS) continue; + if (addr === SWIG_PROGRAM_ADDRESS) { + const data = ix.data; + if (!data || data.length < 2) return false; + const discriminator = data[0] | (data[1] << 8); // U16 LE + if (discriminator !== SWIG_SIGN_V2_DISCRIMINATOR) return false; + hasSignV2 = true; + continue; + } + return false; // unrecognized instruction + } + return hasSignV2; +} + +/** + * Flatten a Swig transaction into the same instruction layout as a regular one. + * Collects non-precompile, non-SignV2 outer instructions (compute budgets) and + * resolves the compact instructions embedded in each SignV2 instruction. + * + * A transaction may contain multiple SignV2 instructions. All must reference + * the same Swig PDA (accounts[0]). + * + * @param instructions - Decompiled instruction array (from a Swig transaction) + * @param staticAccounts - Ordered account list from the compiled transaction message + * @returns Object with flattened `instructions` array and `swigPda` address + */ +export async function parseSwigTransaction( + instructions: ReadonlyArray, + staticAccounts: ReadonlyArray
, +): Promise<{ instructions: Array<{ programAddress: Address; accounts: Array<{ address: Address; role: number }>; data: Uint8Array }>; swigPda: string }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any[] = []; + const signV2Instructions: Instruction[] = []; + + // 1. Single pass: separate SignV2 instructions from the rest + for (const ix of instructions) { + const addr = ix.programAddress.toString(); + if (addr === SECP256R1_PRECOMPILE_ADDRESS) continue; // skip precompile + if (addr === SWIG_PROGRAM_ADDRESS) { + signV2Instructions.push(ix); + } else { + result.push(ix); // compute budget and other non-precompile instructions + } + } + + // Sort compute budget instructions so SetComputeUnitLimit (disc=2) precedes + // SetComputeUnitPrice (disc=3), matching the order the facilitator expects. + result.sort((a, b) => { + const aIsCB = a.programAddress.toString() === COMPUTE_BUDGET_PROGRAM_ADDRESS; + const bIsCB = b.programAddress.toString() === COMPUTE_BUDGET_PROGRAM_ADDRESS; + if (aIsCB && bIsCB && a.data?.length > 0 && b.data?.length > 0) { + return a.data[0] - b.data[0]; + } + return 0; + }); + + if (signV2Instructions.length === 0) { + throw new Error("invalid_exact_svm_payload_no_transfer_instruction"); + } + + // 2. Process each SignV2 instruction + let swigPda = ""; + const addressEncoder = getAddressEncoder(); + + for (const signV2Ix of signV2Instructions) { + // Extract Swig PDA from SignV2's first account + const pda = signV2Ix.accounts?.[0]?.address?.toString() ?? ""; + if (!pda) throw new Error("invalid_exact_svm_payload_no_transfer_instruction"); + + // Enforce all SignV2 instructions share the same PDA + if (swigPda === "") { + swigPda = pda; + } else if (pda !== swigPda) { + throw new Error("swig_pda_mismatch: all SignV2 instructions must reference the same Swig PDA"); + } + + // Validate Swig wallet address derivation (cross-check accounts[0] and accounts[1]) + const swigWalletAddress = signV2Ix.accounts?.[1]?.address?.toString() ?? ""; + if (!swigWalletAddress) throw new Error("invalid_swig_wallet_address_derivation"); + + const [expectedWalletAddress] = await getProgramDerivedAddress({ + programAddress: SWIG_PROGRAM_ADDRESS as Address, + seeds: [ + new TextEncoder().encode("swig-wallet-address"), + addressEncoder.encode(pda as Address), + ], + }); + + if (swigWalletAddress !== expectedWalletAddress.toString()) { + throw new Error("invalid_swig_wallet_address_derivation"); + } + + // Decode compact instructions from SignV2 data + const rawData = signV2Ix.data ? new Uint8Array(signV2Ix.data) : new Uint8Array(0); + const compactInstructions = decodeSwigCompactInstructions(rawData); + + // Resolve compact instruction indices through signV2's account list + const signV2Accounts = signV2Ix.accounts ?? []; + for (const ci of compactInstructions) { + if (ci.programIdIndex >= signV2Accounts.length) { + throw new Error( + `compact instruction programIdIndex ${ci.programIdIndex} out of range (signV2 has ${signV2Accounts.length} accounts)`, + ); + } + result.push({ + programAddress: signV2Accounts[ci.programIdIndex].address as Address, + accounts: ci.accounts.map(idx => { + if (idx >= signV2Accounts.length) { + throw new Error( + `compact instruction account index ${idx} out of range (signV2 has ${signV2Accounts.length} accounts)`, + ); + } + return { address: signV2Accounts[idx].address as Address, role: 1 }; + }), + data: ci.data, + }); + } + } + + return { instructions: result, swigPda }; +} + +/** + * Decode the compact instructions embedded inside a Swig SignV2 instruction. + * + * Layout of the outer instruction data: + * [0..1] discriminator U16 LE + * [2..3] instructionPayloadLen U16 LE (byte count of compact instructions) + * [4..7] roleId U32 LE + * [8..] compact instructions (instructionPayloadLen bytes) + * + * Compact instructions payload: + * [0] numInstructions U8 + * [1..] compact instruction entries... + * + * Each CompactInstruction: + * [0] programIdIndex U8 + * [1] numAccounts U8 + * [2..N+1] accounts U8[numAccounts] + * [N+2..N+3] dataLen U16 LE + * [N+4..] data raw bytes + * + * @param data - The full instruction data bytes of the outer Swig instruction + * @returns Array of decoded compact instructions (may be empty if data is malformed) + */ +export function decodeSwigCompactInstructions(data: Uint8Array): SwigCompactInstruction[] { + if (data.length < 4) { + throw new Error(`swig instruction data too short: need ≥4 bytes, got ${data.length}`); + } + + // instructionPayloadLen at bytes 2-3 (U16 LE) + const instructionPayloadLen = data[2] | (data[3] << 8); + + // Compact instructions start at byte 8 (after discriminator + payloadLen + roleId) + const startOffset = 8; + if (data.length < startOffset + instructionPayloadLen) { + throw new Error( + `swig instruction data truncated: payload needs ${instructionPayloadLen} bytes but only ${data.length - startOffset} available after offset ${startOffset}`, + ); + } + + const results: SwigCompactInstruction[] = []; + let offset = startOffset + 1; // skip numInstructions count byte + const endOffset = startOffset + instructionPayloadLen; + + while (offset < endOffset) { + if (offset >= data.length) break; + + // programIdIndex: U8 + const programIdIndex = data[offset]; + offset += 1; + + // numAccounts: U8 + if (offset >= endOffset) break; + const numAccounts = data[offset]; + offset += 1; + + // accounts: U8[numAccounts] + if (offset + numAccounts > endOffset) break; + const accounts = Array.from(data.slice(offset, offset + numAccounts)); + offset += numAccounts; + + // dataLen: U16 LE + if (offset + 2 > endOffset) break; + const dataLen = data[offset] | (data[offset + 1] << 8); + offset += 2; + + // instruction data + if (offset + dataLen > endOffset) break; + const instrData = new Uint8Array(data.slice(offset, offset + dataLen)); + offset += dataLen; + + results.push({ programIdIndex, accounts, data: instrData }); + } + + return results; +} + diff --git a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts index 5d27bb523f..fef7d835f1 100644 --- a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts @@ -1,8 +1,38 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; import type { FacilitatorSvmSigner } from "../../src/signer"; import type { PaymentRequirements, PaymentPayload } from "@x402/core/types"; -import { USDC_DEVNET_ADDRESS, SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { + USDC_DEVNET_ADDRESS, + SOLANA_DEVNET_CAIP2, + SWIG_PROGRAM_ADDRESS, + SWIG_SIGN_V2_DISCRIMINATOR, + COMPUTE_BUDGET_PROGRAM_ADDRESS, + SECP256R1_PRECOMPILE_ADDRESS, +} from "../../src/constants"; +import { + decodeSwigCompactInstructions, + isSwigTransaction, + parseSwigTransaction, +} from "../../src/utils"; +import { normalizeTransaction } from "../../src/normalizer"; +import { type Address, type Transaction, getProgramDerivedAddress, getAddressEncoder } from "@solana/kit"; + +// Derive the SwigWalletAddress PDA from the test SWIG_PDA at module level +const SWIG_PDA = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; +let SWIG_WALLET_ADDRESS: string; + +beforeAll(async () => { + const addressEncoder = getAddressEncoder(); + const [walletAddress] = await getProgramDerivedAddress({ + programAddress: SWIG_PROGRAM_ADDRESS as Address, + seeds: [ + new TextEncoder().encode("swig-wallet-address"), + addressEncoder.encode(SWIG_PDA as Address), + ], + }); + SWIG_WALLET_ADDRESS = walletAddress.toString(); +}); describe("ExactSvmScheme", () => { let mockSigner: FacilitatorSvmSigner; @@ -213,6 +243,632 @@ describe("ExactSvmScheme", () => { }); }); + // ─── Swig wallet utility tests ─────────────────────────────────────────── + + describe("isSwigTransaction", () => { + it("should return true for a valid Swig transaction (2 compute budgets + SignV2)", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]) }, + ]; + expect(isSwigTransaction(instructions)).toBe(true); + }); + + it("should return true when secp256r1 precompile instructions are present", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: SECP256R1_PRECOMPILE_ADDRESS, data: new Uint8Array([]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]) }, + ]; + expect(isSwigTransaction(instructions)).toBe(true); + }); + + it("should return false when last instruction is not Swig program", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", data: new Uint8Array([12, 0, 0, 0]) }, + ]; + expect(isSwigTransaction(instructions)).toBe(false); + }); + + it("should return false when a non-allowed instruction precedes the last", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", data: new Uint8Array([12, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]) }, + ]; + expect(isSwigTransaction(instructions)).toBe(false); + }); + + it("should return false for unknown discriminator (only V2 supported)", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([4, 0, 0, 0]) }, // V1 discriminator + ]; + expect(isSwigTransaction(instructions)).toBe(false); + }); + + it("should return false for empty instructions", () => { + expect(isSwigTransaction([])).toBe(false); + }); + + it("should return false when Swig instruction data is too short", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([11]) }, // only 1 byte + ]; + expect(isSwigTransaction(instructions)).toBe(false); + }); + + it("should return true for a transaction with 2 SignV2 instructions", () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]) }, + { programAddress: SWIG_PROGRAM_ADDRESS, data: new Uint8Array([SWIG_SIGN_V2_DISCRIMINATOR, 0, 0, 0]) }, + ]; + expect(isSwigTransaction(instructions)).toBe(true); + }); + }); + + describe("decodeSwigCompactInstructions", () => { + // Helper: build a Swig signV2 instruction data buffer with the given compact instruction payload + function buildSwigData(payload: Uint8Array, numInstructions = 1): Uint8Array { + // Prepend numInstructions count byte + const withCount = new Uint8Array(1 + payload.length); + withCount[0] = numInstructions; + withCount.set(payload, 1); + const buf = new Uint8Array(8 + withCount.length); + buf[0] = SWIG_SIGN_V2_DISCRIMINATOR; + buf[1] = 0; + buf[2] = withCount.length & 0xff; + buf[3] = (withCount.length >> 8) & 0xff; + // bytes 4-7: roleId = 0 + buf.set(withCount, 8); + return buf; + } + + // Helper: build a TransferChecked compact instruction entry + function buildTransferCheckedCompact( + programIdIndex: number, + accountIndices: number[], + amount: bigint, + decimals = 6, + ): Uint8Array { + const instrData = new Uint8Array(10); + instrData[0] = 12; // transferChecked discriminator + new DataView(instrData.buffer).setBigUint64(1, amount, true); + instrData[9] = decimals; + + const accounts = new Uint8Array(accountIndices); + const entry = new Uint8Array( + 1 + 1 + accounts.length + 2 + instrData.length, // progId + numAccounts + accounts + dataLen + data + ); + let off = 0; + entry[off++] = programIdIndex; + entry[off++] = accounts.length; + entry.set(accounts, off); + off += accounts.length; + entry[off++] = instrData.length & 0xff; + entry[off++] = (instrData.length >> 8) & 0xff; + entry.set(instrData, off); + return entry; + } + + it("should throw when data is shorter than 4 bytes", () => { + expect(() => decodeSwigCompactInstructions(new Uint8Array([1, 2, 3]))).toThrow( + "swig instruction data too short", + ); + }); + + it("should throw when instructionPayloadLen exceeds available data", () => { + // instructionPayloadLen = 100 but only 8 bytes total + const data = new Uint8Array([4, 0, 100, 0, 0, 0, 0, 0]); + expect(() => decodeSwigCompactInstructions(data)).toThrow( + "swig instruction data truncated", + ); + }); + + it("should correctly decode a single TransferChecked compact instruction", () => { + // Build payload: programIdIndex=5, accounts=[1,2,3,0], amount=100000, decimals=6 + const compact = buildTransferCheckedCompact(5, [1, 2, 3, 0], 100000n); + const data = buildSwigData(compact); + + const result = decodeSwigCompactInstructions(data); + expect(result).toHaveLength(1); + expect(result[0].programIdIndex).toBe(5); + expect(result[0].accounts).toEqual([1, 2, 3, 0]); + expect(result[0].data[0]).toBe(12); // transferChecked discriminator + // Check amount (U64 LE at bytes 1-8) + const amountBuf = new Uint8Array(8); + amountBuf.set(result[0].data.slice(1, 9)); + const amount = new DataView(amountBuf.buffer).getBigUint64(0, true); + expect(amount).toBe(100000n); + }); + + it("should throw when compact instruction data is truncated", () => { + // payload length = 5 in header but actual payload is empty → truncated + const data = new Uint8Array([4, 0, 5, 0, 0, 0, 0, 0]); // payloadLen=5 but no payload bytes + expect(() => decodeSwigCompactInstructions(data)).toThrow( + "swig instruction data truncated", + ); + }); + }); + + describe("parseSwigTransaction", () => { + const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + // staticAccounts: [swigPDA, TOKEN_PROGRAM, source, mint, destATA, SWIG_PROGRAM, COMPUTE_BUDGET] + const staticAccounts = [ + SWIG_PDA as Address, + TOKEN_PROGRAM as Address, + "sourceAccount111111111111111111111111111" as Address, + USDC_DEVNET_ADDRESS as Address, + "destinationATA11111111111111111111111111" as Address, + SWIG_PROGRAM_ADDRESS as Address, + COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, + ]; + + // SignV2 account list with SwigWalletAddress at index 1: + // pos 0 → swigPDA, pos 1 → swigWalletAddress, pos 2 → dest, pos 3 → TOKEN_PROGRAM, pos 4 → mint, pos 5 → source + function getSignV2Accounts() { + return [ + { address: SWIG_PDA as Address }, + { address: SWIG_WALLET_ADDRESS as Address }, + { address: "destinationATA11111111111111111111111111" as Address }, + { address: TOKEN_PROGRAM as Address }, + { address: USDC_DEVNET_ADDRESS as Address }, + { address: "sourceAccount111111111111111111111111111" as Address }, + ]; + } + + function buildSwigV2Data(payload: Uint8Array, numInstructions = 1): Uint8Array { + // Prepend numInstructions count byte + const withCount = new Uint8Array(1 + payload.length); + withCount[0] = numInstructions; + withCount.set(payload, 1); + const buf = new Uint8Array(8 + withCount.length); + buf[0] = SWIG_SIGN_V2_DISCRIMINATOR; + buf[1] = 0; + buf[2] = withCount.length & 0xff; + buf[3] = (withCount.length >> 8) & 0xff; + buf.set(withCount, 8); + return buf; + } + + function buildTransferCheckedCompact( + programIdIndex: number, + accountIndices: number[], + amount: bigint, + decimals = 6, + ): Uint8Array { + const instrData = new Uint8Array(10); + instrData[0] = 12; + new DataView(instrData.buffer).setBigUint64(1, amount, true); + instrData[9] = decimals; + const accounts = new Uint8Array(accountIndices); + const entry = new Uint8Array(1 + 1 + accounts.length + 2 + instrData.length); + let off = 0; + entry[off++] = programIdIndex; + entry[off++] = accounts.length; + entry.set(accounts, off); off += accounts.length; + entry[off++] = instrData.length & 0xff; + entry[off++] = (instrData.length >> 8) & 0xff; + entry.set(instrData, off); + return entry; + } + + it("should flatten a Swig transaction with embedded TransferChecked", async () => { + const signV2Accounts = getSignV2Accounts(); + // compact indices reference signV2's account list: + // programIdIndex=3 → signV2Accounts[3]=TOKEN_PROGRAM + // accounts=[5,4,2,0] → signV2Accounts[5,4,2,0] = [source, mint, dest, swigPDA] + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + const result = await parseSwigTransaction(instructions, staticAccounts); + + // Should have 3 instructions: 2 compute budgets + 1 TransferChecked + expect(result.instructions).toHaveLength(3); + expect(result.swigPda).toBe(SWIG_PDA); + + // First two are compute budget (unchanged) + expect(result.instructions[0].programAddress.toString()).toBe(COMPUTE_BUDGET_PROGRAM_ADDRESS); + expect(result.instructions[1].programAddress.toString()).toBe(COMPUTE_BUDGET_PROGRAM_ADDRESS); + + // Third is the resolved TransferChecked + expect(result.instructions[2].programAddress.toString()).toBe(TOKEN_PROGRAM); + expect(result.instructions[2].data[0]).toBe(12); // transferChecked discriminator + }); + + it("should filter out secp256r1 precompile instructions", async () => { + const signV2Accounts = getSignV2Accounts(); + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: SECP256R1_PRECOMPILE_ADDRESS as Address, data: new Uint8Array([]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + const result = await parseSwigTransaction(instructions, staticAccounts); + + // Should have 3 instructions: 2 compute budgets + 1 TransferChecked (precompile filtered) + expect(result.instructions).toHaveLength(3); + expect(result.swigPda).toBe(SWIG_PDA); + }); + + it("should resolve compact instruction account indices to addresses", async () => { + const signV2Accounts = getSignV2Accounts(); + // compact accounts=[5,4,2,0] → signV2Accounts[5,4,2,0] = [source, mint, dest, swigPDA] + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + const result = await parseSwigTransaction(instructions, staticAccounts); + const transferIx = result.instructions[2]; + + expect(transferIx.accounts).toHaveLength(4); + expect(transferIx.accounts[0].address.toString()).toBe(staticAccounts[2].toString()); // source + expect(transferIx.accounts[1].address.toString()).toBe(staticAccounts[3].toString()); // mint + expect(transferIx.accounts[2].address.toString()).toBe(staticAccounts[4].toString()); // destATA + expect(transferIx.accounts[3].address.toString()).toBe(staticAccounts[0].toString()); // swigPDA (authority) + }); + + it("should extract swigPda from first account of SignV2 instruction", async () => { + const signV2Accounts = getSignV2Accounts(); + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + const result = await parseSwigTransaction(instructions, staticAccounts); + expect(result.swigPda).toBe(SWIG_PDA); + }); + + it("should throw when compact instruction index exceeds signV2 accounts", async () => { + const signV2Accounts = getSignV2Accounts(); + // programIdIndex=6 is out of range for signV2Accounts (len 6, valid 0-5) + const compact = buildTransferCheckedCompact(6, [0, 1, 2, 3], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + await expect(parseSwigTransaction(instructions, staticAccounts)).rejects.toThrow(/out of range/); + }); + + it("should flatten a Swig transaction with 2 SignV2 instructions", async () => { + const signV2Accounts = getSignV2Accounts(); + const compact1 = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const compact2 = buildTransferCheckedCompact(3, [5, 4, 2, 0], 200000n); + const signV2Data1 = buildSwigV2Data(compact1); + const signV2Data2 = buildSwigV2Data(compact2); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data1, + }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data2, + }, + ]; + + const result = await parseSwigTransaction(instructions, staticAccounts); + + // Should have 3 instructions: 1 compute budget + 2 TransferChecked (one from each SignV2) + expect(result.instructions).toHaveLength(3); + expect(result.swigPda).toBe(SWIG_PDA); + + // First is compute budget (unchanged) + expect(result.instructions[0].programAddress.toString()).toBe(COMPUTE_BUDGET_PROGRAM_ADDRESS); + + // Second and third are the resolved TransferChecked from each SignV2 + expect(result.instructions[1].programAddress.toString()).toBe(TOKEN_PROGRAM); + expect(result.instructions[1].data[0]).toBe(12); + expect(result.instructions[2].programAddress.toString()).toBe(TOKEN_PROGRAM); + expect(result.instructions[2].data[0]).toBe(12); + + // Verify different amounts + const amount1 = new DataView(result.instructions[1].data.buffer, result.instructions[1].data.byteOffset).getBigUint64(1, true); + const amount2 = new DataView(result.instructions[2].data.buffer, result.instructions[2].data.byteOffset).getBigUint64(1, true); + expect(amount1).toBe(100000n); + expect(amount2).toBe(200000n); + }); + + it("should throw when 2 SignV2 instructions have different swigPdas", async () => { + const signV2Accounts1 = getSignV2Accounts(); + // Use a different PDA for the second SignV2 + const DIFFERENT_PDA = "11111111111111111111111111111111"; + const signV2Accounts2 = [ + { address: DIFFERENT_PDA as Address }, + { address: SWIG_WALLET_ADDRESS as Address }, // wallet address won't match but PDA check comes first + { address: "destinationATA11111111111111111111111111" as Address }, + { address: TOKEN_PROGRAM as Address }, + { address: USDC_DEVNET_ADDRESS as Address }, + { address: "sourceAccount111111111111111111111111111" as Address }, + ]; + + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data1 = buildSwigV2Data(compact); + const signV2Data2 = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts1, + data: signV2Data1, + }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts2, + data: signV2Data2, + }, + ]; + + await expect(parseSwigTransaction(instructions, staticAccounts)).rejects.toThrow( + "swig_pda_mismatch", + ); + }); + + it("should throw when SwigWalletAddress does not match expected derivation", async () => { + // Use a wrong address at signV2Accounts[1] instead of the real derived address + const badSignV2Accounts = [ + { address: SWIG_PDA as Address }, + { address: "WrongWalletAddr1111111111111111111111111111" as Address }, + { address: "destinationATA11111111111111111111111111" as Address }, + { address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" as Address }, + { address: USDC_DEVNET_ADDRESS as Address }, + { address: "sourceAccount111111111111111111111111111" as Address }, + ]; + + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: badSignV2Accounts, + data: signV2Data, + }, + ]; + + await expect(parseSwigTransaction(instructions, staticAccounts)).rejects.toThrow( + "invalid_swig_wallet_address_derivation", + ); + }); + }); + + describe("normalizeTransaction", () => { + const TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + + const staticAccounts = [ + SWIG_PDA as Address, + TOKEN_PROGRAM as Address, + "sourceAccount111111111111111111111111111" as Address, + USDC_DEVNET_ADDRESS as Address, + "destinationATA11111111111111111111111111" as Address, + SWIG_PROGRAM_ADDRESS as Address, + COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, + ]; + + function getSignV2Accounts() { + return [ + { address: SWIG_PDA as Address }, + { address: SWIG_WALLET_ADDRESS as Address }, + { address: "destinationATA11111111111111111111111111" as Address }, + { address: TOKEN_PROGRAM as Address }, + { address: USDC_DEVNET_ADDRESS as Address }, + { address: "sourceAccount111111111111111111111111111" as Address }, + ]; + } + + function buildSwigV2Data(payload: Uint8Array, numInstructions = 1): Uint8Array { + const withCount = new Uint8Array(1 + payload.length); + withCount[0] = numInstructions; + withCount.set(payload, 1); + const buf = new Uint8Array(8 + withCount.length); + buf[0] = SWIG_SIGN_V2_DISCRIMINATOR; + buf[1] = 0; + buf[2] = withCount.length & 0xff; + buf[3] = (withCount.length >> 8) & 0xff; + buf.set(withCount, 8); + return buf; + } + + function buildTransferCheckedCompact( + programIdIndex: number, + accountIndices: number[], + amount: bigint, + decimals = 6, + ): Uint8Array { + const instrData = new Uint8Array(10); + instrData[0] = 12; + new DataView(instrData.buffer).setBigUint64(1, amount, true); + instrData[9] = decimals; + const accounts = new Uint8Array(accountIndices); + const entry = new Uint8Array(1 + 1 + accounts.length + 2 + instrData.length); + let off = 0; + entry[off++] = programIdIndex; + entry[off++] = accounts.length; + entry.set(accounts, off); off += accounts.length; + entry[off++] = instrData.length & 0xff; + entry[off++] = (instrData.length >> 8) & 0xff; + entry.set(instrData, off); + return entry; + } + + it("should dispatch to SwigNormalizer for Swig transactions", async () => { + const signV2Accounts = getSignV2Accounts(); + const compact = buildTransferCheckedCompact(3, [5, 4, 2, 0], 100000n); + const signV2Data = buildSwigV2Data(compact); + + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { + programAddress: SWIG_PROGRAM_ADDRESS as Address, + accounts: signV2Accounts, + data: signV2Data, + }, + ]; + + const result = await normalizeTransaction(instructions, staticAccounts, {} as Transaction); + expect(result.payer).toBe(SWIG_PDA); + expect(result.instructions).toHaveLength(3); + }); + + it("should dispatch to RegularNormalizer for non-Swig transactions", async () => { + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 0]) }, + { programAddress: TOKEN_PROGRAM as Address, data: new Uint8Array([12, 0, 0, 0]) }, + ]; + + // Build a minimal mock transaction whose messageBytes contain a token + // TransferChecked so getTokenPayerFromTransaction can extract the payer. + // We use the @solana/kit encoders to build valid bytes. + const { + getBase64Encoder, + getTransactionDecoder, + getTransactionEncoder, + getCompiledTransactionMessageEncoder, + } = require("@solana/kit"); + + const compiledMessage = { + version: 0, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 2, + }, + staticAccounts: [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" as Address, // fee payer / authority + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" as Address, // source + TOKEN_PROGRAM as Address, // token program + USDC_DEVNET_ADDRESS as Address, // mint + "11111111111111111111111111111111" as Address, // dest + ], + lifetimeToken: "11111111111111111111111111111111", + instructions: [ + { + programAddressIndex: 2, + accountIndices: [1, 3, 4, 0], // source, mint, dest, authority + data: new Uint8Array([12, 160, 134, 1, 0, 0, 0, 0, 0, 6]), // transferChecked 100000, 6 decimals + }, + ], + addressTableLookups: [], + }; + + const messageEncoder = getCompiledTransactionMessageEncoder(); + const messageBytes = messageEncoder.encode(compiledMessage); + + const mockTransaction: Transaction = { + signatures: [new Uint8Array(64)], + messageBytes, + } as Transaction; + + const result = await normalizeTransaction(instructions, [], mockTransaction); + expect(result.payer).toBe("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"); + // Instructions are passed through from the input + expect(result.instructions).toHaveLength(3); + }); + + it("should throw when no normalizer can handle the transaction", async () => { + // RegularNormalizer always canHandle, so this test verifies it throws + // when the regular path can't find a payer (empty transaction) + const instructions = [ + { programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, data: new Uint8Array([2, 0, 0, 0, 0]) }, + ]; + + // Mock transaction with no token instructions → getTokenPayerFromTransaction returns "" + const compiledMessage = { + version: 0, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 0, + }, + staticAccounts: [ + COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, + ], + lifetimeToken: "11111111111111111111111111111111", + instructions: [ + { + programAddressIndex: 0, + accountIndices: [], + data: new Uint8Array([2, 0, 0, 0, 0]), + }, + ], + addressTableLookups: [], + }; + + const { getCompiledTransactionMessageEncoder } = require("@solana/kit"); + const messageEncoder = getCompiledTransactionMessageEncoder(); + const messageBytes = messageEncoder.encode(compiledMessage); + + const mockTransaction: Transaction = { + signatures: [new Uint8Array(64)], + messageBytes, + } as Transaction; + + await expect(normalizeTransaction(instructions, [], mockTransaction)).rejects.toThrow( + "invalid_exact_svm_payload_no_transfer_instruction", + ); + }); + }); + describe("settle", () => { it("should fail settlement if verification fails", async () => { const facilitator = new ExactSvmScheme(mockSigner); diff --git a/typescript/packages/mechanisms/svm/test/unit/swig-real-tx.test.ts b/typescript/packages/mechanisms/svm/test/unit/swig-real-tx.test.ts new file mode 100644 index 0000000000..9eb4c165f4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/swig-real-tx.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from "vitest"; +import { + getBase64Encoder, + getTransactionDecoder, + getCompiledTransactionMessageDecoder, + decompileTransactionMessage, + type Address, +} from "@solana/kit"; +import { isSwigTransaction, parseSwigTransaction } from "../../src/utils"; +import { normalizeTransaction } from "../../src/normalizer"; +import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; +import type { FacilitatorSvmSigner } from "../../src/signer"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { SOLANA_DEVNET_CAIP2, USDC_DEVNET_ADDRESS } from "../../src/constants"; + +// Real confirmed Swig smart wallet USDC transfer on devnet +// tx: 2TAkeCETVcsbtmK1UMdgk2BZVdWQjnv7s2s7QUYv3Ynaqh36iXVdwM1ong8hmRw4Za3Yw8CkjgVwiyUpGR6SQP1g +const REAL_SWIG_TX_BASE64 = + "AkiVWpmnwCMi7VKkTgzdR2vqY1fOSr14KPzUnzCNQpeOMif5NskDc4uS+gOp8RgsErjrnGLEYL1N" + + "268w+qF+dge3oCdndWRM1K0yufH+fFvkZZ4Bs3zo54vRPaX9frRvVfnjAvIaF+LrUcesSgDzelLub" + + "NZgz/xTZpMF+M73W2QBgAIBBAqZaoBA6PatAWpRvzksIlZIPBdwhETOtNqkgD0atmy0InVOnwjWNA" + + "xK9dVi7s3ExZUKIESvFVgLxy2EuifanfHXNuKlxHOPekji0xlP2QWZWAXWe2Waz6nHvKl8rEzDOBW" + + "YZE9jRaDJ3Di+pFN1xwc5xnR4DB9Ie84lQHbJaXPMB+psglirF8mTyZ49SOemjo+02LMohN2jyoK" + + "VBiPYUEFBZOwM3pq0f7lZsDDur9i+ue/ujyUjQwUnXvJRe7/+3hMDBkZv5SEXMv/srbpyw5vnvIz" + + "lu8X3EmssQ5s6QAAAAA0M6ULh58UG4hjfDX3xxS+v3DUp5I1nTR2yTHW1TMy+Bt324ddloZPZy+F" + + "Gzut5rBy0he1fWzeROoz1hX7/AKk7RCyzkSFX8TqTPQE0KC0DK1/+zQGi2/G3eQYI3wAup78zWM" + + "i4yXOAY9JrFqsFFkNRq8dONAlh9AOdsB99f/m6AwYACQNkAAAAAAAAAAYABQKAGgYABwcCAwEIBA" + + "kFHAsAEwAAAAAAAQMEBAUGAQoADAEAAAAAAAAABgIA"; + +const FEE_PAYER = "BKsZvzPUY6VT2GpLMxx6fA6fuC8MK3hVxwdjK8yqmqSR"; +const SWIG_PDA = "4hFTuZxrMbZciAxA9DcLYYC9vupNuw89v527ys6PvRo2"; +const PAY_TO = "EkkpfzUdwwgeqWb25hWcSi2c5gquELLUB3Z2asr1Xroo"; + +function decodeRealTx() { + const base64Encoder = getBase64Encoder(); + const transactionBytes = base64Encoder.encode(REAL_SWIG_TX_BASE64); + const transactionDecoder = getTransactionDecoder(); + return transactionDecoder.decode(transactionBytes); +} + +function decompileRealTx() { + const transaction = decodeRealTx(); + const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); + const decompiled = decompileTransactionMessage(compiled); + return { transaction, compiled, decompiled, instructions: decompiled.instructions ?? [], staticAccounts: compiled.staticAccounts ?? [] }; +} + +describe("Real Swig devnet transaction", () => { + describe("isSwigTransaction", () => { + it("should detect real devnet tx as Swig", () => { + const { instructions } = decompileRealTx(); + expect(isSwigTransaction(instructions)).toBe(true); + }); + }); + + describe("parseSwigTransaction", () => { + it("should flatten to 3 instructions", async () => { + const { instructions, staticAccounts } = decompileRealTx(); + const result = await parseSwigTransaction(instructions, staticAccounts); + expect(result.instructions).toHaveLength(3); + }); + + it("should extract correct swig PDA", async () => { + const { instructions, staticAccounts } = decompileRealTx(); + const result = await parseSwigTransaction(instructions, staticAccounts); + expect(result.swigPda).toBe(SWIG_PDA); + }); + + it("should have TransferChecked discriminator (12) on third instruction", async () => { + const { instructions, staticAccounts } = decompileRealTx(); + const result = await parseSwigTransaction(instructions, staticAccounts); + expect(result.instructions[2].data[0]).toBe(12); + }); + + it("should have amount=1 and decimals=6", async () => { + const { instructions, staticAccounts } = decompileRealTx(); + const result = await parseSwigTransaction(instructions, staticAccounts); + const transferData = result.instructions[2].data; + const amount = new DataView( + transferData.buffer, + transferData.byteOffset, + ).getBigUint64(1, true); + const decimals = transferData[9]; + expect(amount).toBe(1n); + expect(decimals).toBe(6); + }); + + it("should sort compute budget instructions correctly", async () => { + const { instructions, staticAccounts } = decompileRealTx(); + const result = await parseSwigTransaction(instructions, staticAccounts); + // First instruction should be SetComputeUnitLimit (disc=2) + expect(result.instructions[0].data[0]).toBe(2); + // Second instruction should be SetComputeUnitPrice (disc=3) + expect(result.instructions[1].data[0]).toBe(3); + }); + }); + + describe("normalizeTransaction", () => { + it("should return swig PDA as payer", async () => { + const { transaction, instructions, staticAccounts } = decompileRealTx(); + const normalized = await normalizeTransaction(instructions, staticAccounts, transaction); + expect(normalized.payer).toBe(SWIG_PDA); + }); + + it("should return 3 instructions", async () => { + const { transaction, instructions, staticAccounts } = decompileRealTx(); + const normalized = await normalizeTransaction(instructions, staticAccounts, transaction); + expect(normalized.instructions).toHaveLength(3); + }); + }); + + describe("verify pipeline", () => { + it("should verify as valid with mock signer", async () => { + const mockSigner: FacilitatorSvmSigner = { + getAddresses: vi.fn().mockReturnValue([FEE_PAYER]) as never, + signTransaction: vi.fn().mockResolvedValue(REAL_SWIG_TX_BASE64) as never, + simulateTransaction: vi.fn().mockResolvedValue(undefined) as never, + sendTransaction: vi.fn().mockResolvedValue("mockSignature123") as never, + confirmTransaction: vi.fn().mockResolvedValue(undefined) as never, + }; + + const scheme = new ExactSvmScheme(mockSigner); + + const requirements: PaymentRequirements = { + scheme: "exact", + network: SOLANA_DEVNET_CAIP2, + asset: USDC_DEVNET_ADDRESS, + amount: "1", + payTo: PAY_TO, + maxTimeoutSeconds: 3600, + extra: { feePayer: FEE_PAYER }, + }; + + const payload: PaymentPayload = { + x402Version: 2, + resource: { + url: "http://example.com/protected", + description: "Test resource", + mimeType: "application/json", + }, + accepted: requirements, + payload: { transaction: REAL_SWIG_TX_BASE64 }, + }; + + const result = await scheme.verify(payload, requirements); + expect(result.isValid).toBe(true); + expect(result.payer).toBe(SWIG_PDA); + }); + }); +});