diff --git a/go/mechanisms/svm/README.md b/go/mechanisms/svm/README.md index 93266ad572..5e1c093a32 100644 --- a/go/mechanisms/svm/README.md +++ b/go/mechanisms/svm/README.md @@ -48,6 +48,7 @@ github.com/x402-foundation/x402/go/mechanisms/svm/exact/facilitator - `NewExactSvmScheme(signer)` - Creates facilitator-side SVM exact payment mechanism - Used for verifying transaction signatures and settling payments on-chain - Requires facilitator signer with Solana RPC integration +- Accepts direct SPL `TransferChecked` payments and vetted wrapped payment programs such as SWIG ## Supported Networks @@ -85,6 +86,20 @@ v2Scheme := facilitator.NewExactSvmScheme(signer, cache) v1Scheme := v1facilitator.NewExactSvmSchemeV1(signer, cache) ``` +## Wrapped Transfer Support + +Some Solana payment stacks wrap the token transfer inside a higher-level program +instruction instead of placing a direct top-level SPL `TransferChecked` in the +transaction message. The exact SVM facilitators now support: + +- direct top-level SPL `TransferChecked` +- SWIG-wrapped SPL `TransferChecked` + +The verifier still checks the same canonical transfer details (`source`, `mint`, +`destination`, `authority`, `amount`) and keeps the same anti-self-pay and +recipient validation rules. Additional known wrapped payment programs can be +added over time without changing the external facilitator API. + For full details on the race condition and mitigation strategy, see the [Exact SVM Scheme Specification](../../specs/schemes/exact/scheme_exact_svm.md#duplicate-settlement-mitigation-recommended). ## Future Schemes diff --git a/go/mechanisms/svm/constants.go b/go/mechanisms/svm/constants.go index 61dca8641c..bf2b49f29f 100644 --- a/go/mechanisms/svm/constants.go +++ b/go/mechanisms/svm/constants.go @@ -35,6 +35,9 @@ const ( // MemoProgramAddress is the SPL Memo program address MemoProgramAddress = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" + // SwigProgramAddress is the SWIG programmable wallet program. + SwigProgramAddress = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB" + // 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 72c29fc6de..e30053328e 100644 --- a/go/mechanisms/svm/exact/facilitator/scheme.go +++ b/go/mechanisms/svm/exact/facilitator/scheme.go @@ -9,7 +9,6 @@ import ( solana "github.com/gagliardetto/solana-go" computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" - "github.com/gagliardetto/solana-go/programs/token" x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/svm" @@ -151,11 +150,15 @@ func (f *ExactSvmScheme) Verify( return nil, x402.NewVerifyError(err.Error(), "", err.Error()) } - // Extract payer from transaction - payer, err := svm.GetTokenPayerFromTransaction(tx) + // Extract payer from the payment instruction using the built-in exact SVM + // payment decoders. This keeps exact verification compatible with the stock + // top-level TransferChecked flow while allowing known wrapped payment + // programs such as SWIG to expose the same canonical transfer details. + transferDetails, err := svm.ExtractTransferDetails(tx, tx.Message.Instructions[2]) if err != nil { - return nil, x402.NewVerifyError(ErrNoTransferInstruction, payer, err.Error()) + return nil, x402.NewVerifyError(ErrNoTransferInstruction, "", err.Error()) } + payer := transferDetails.Authority.String() // V2: payload.Accepted.Network is already validated by scheme lookup // Network matching is implicit - facilitator was selected based on requirements.Network @@ -171,7 +174,7 @@ func (f *ExactSvmScheme) Verify( } // Step 4: Verify Transfer Instruction - if err := f.verifyTransferInstruction(tx, tx.Message.Instructions[2], reqStruct, signerAddressStrs); err != nil { + if err := f.verifyTransferDetails(transferDetails, reqStruct, signerAddressStrs); err != nil { return nil, x402.NewVerifyError(err.Error(), payer, err.Error()) } @@ -372,42 +375,26 @@ func (f *ExactSvmScheme) verifyComputePriceInstruction(tx *solana.Transaction, i return nil } -// verifyTransferInstruction verifies the transfer instruction -func (f *ExactSvmScheme) verifyTransferInstruction( - tx *solana.Transaction, - inst solana.CompiledInstruction, +// verifyTransferDetails verifies the canonical transfer details extracted from +// the payment instruction. +func (f *ExactSvmScheme) verifyTransferDetails( + transfer *svm.TransferDetails, requirements x402.PaymentRequirements, signerAddresses []string, ) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - // Must be Token Program or Token-2022 Program - if progID != solana.TokenProgramID && progID != solana.Token2022ProgramID { - return errors.New(ErrNoTransferInstruction) - } - - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { + if transfer == nil { return errors.New(ErrNoTransferInstruction) } - if len(accounts) < 4 { - return errors.New(ErrNoTransferInstruction) - } - - decoded, err := token.DecodeInstruction(accounts, inst.Data) - if err != nil { - return errors.New(ErrNoTransferInstruction) - } - - transferChecked, ok := decoded.Impl.(*token.TransferChecked) - if !ok { + // Must still be a token transfer, even if it arrived through a wrapped + // instruction parser. + if transfer.ProgramID != solana.TokenProgramID && transfer.ProgramID != solana.Token2022ProgramID { return errors.New(ErrNoTransferInstruction) } // SECURITY: Verify that the facilitator's signers are not transferring their own funds // Prevent facilitator from signing away their own tokens - authorityAddr := accounts[3].PublicKey.String() // TransferChecked: [source, mint, destination, authority, ...] + authorityAddr := transfer.Authority.String() for _, signerAddr := range signerAddresses { if authorityAddr == signerAddr { return errors.New(ErrFeePayerTransferringFunds) @@ -415,7 +402,7 @@ func (f *ExactSvmScheme) verifyTransferInstruction( } // Verify mint address - mintAddr := accounts[1].PublicKey.String() + mintAddr := transfer.Mint.String() if mintAddr != requirements.Asset { return errors.New(ErrMintMismatch) } @@ -436,8 +423,7 @@ func (f *ExactSvmScheme) verifyTransferInstruction( return errors.New(ErrRecipientMismatch) } - destATA := transferChecked.GetDestinationAccount().PublicKey - if destATA.String() != expectedDestATA.String() { + if transfer.Destination.String() != expectedDestATA.String() { return errors.New(ErrRecipientMismatch) } @@ -447,7 +433,7 @@ func (f *ExactSvmScheme) verifyTransferInstruction( return errors.New(ErrAmountInsufficient) } - if *transferChecked.Amount < requiredAmount { + if transfer.Amount < requiredAmount { return errors.New(ErrAmountInsufficient) } diff --git a/go/mechanisms/svm/exact/facilitator/wrapped_transfer_test.go b/go/mechanisms/svm/exact/facilitator/wrapped_transfer_test.go new file mode 100644 index 0000000000..c24bb4e930 --- /dev/null +++ b/go/mechanisms/svm/exact/facilitator/wrapped_transfer_test.go @@ -0,0 +1,99 @@ +package facilitator + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/x402-foundation/x402/go/mechanisms/svm" + "github.com/x402-foundation/x402/go/types" +) + +type wrappedPaymentFixture struct { + Name string `json:"name"` + Transaction string `json:"transaction"` + Network string `json:"network"` + Asset string `json:"asset"` + PayTo string `json:"payTo"` + Amount string `json:"amount"` + FeePayer string `json:"feePayer"` + Payer string `json:"payer"` +} + +type mockFacilitatorSigner struct { + addresses []solana.PublicKey +} + +func (m *mockFacilitatorSigner) GetAddresses(context.Context, string) []solana.PublicKey { + return m.addresses +} + +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 loadWrappedPaymentFixtures(t *testing.T) []wrappedPaymentFixture { + t.Helper() + + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + fixturePath := filepath.Join(filepath.Dir(currentFile), "..", "..", "testdata", "swig_wrapped_payments.json") + fixtureBytes, err := os.ReadFile(fixturePath) + require.NoError(t, err) + + var fixtures []wrappedPaymentFixture + require.NoError(t, json.Unmarshal(fixtureBytes, &fixtures)) + return fixtures +} + +func TestVerifySupportsBuiltInSwigTransfers(t *testing.T) { + for _, fixture := range loadWrappedPaymentFixtures(t) { + t.Run(fixture.Name, func(t *testing.T) { + feePayer := solana.MustPublicKeyFromBase58(fixture.FeePayer) + scheme := NewExactSvmScheme(&mockFacilitatorSigner{ + addresses: []solana.PublicKey{feePayer}, + }) + + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: types.PaymentRequirements{ + Scheme: svm.SchemeExact, + Network: fixture.Network, + Asset: fixture.Asset, + Amount: fixture.Amount, + PayTo: fixture.PayTo, + MaxTimeoutSeconds: 300, + Extra: map[string]interface{}{ + "feePayer": fixture.FeePayer, + }, + }, + Payload: map[string]interface{}{ + "transaction": fixture.Transaction, + }, + } + + response, err := scheme.Verify(context.Background(), payload, payload.Accepted, nil) + require.NoError(t, err) + assert.True(t, response.IsValid) + assert.Equal(t, fixture.Payer, response.Payer) + }) + } +} diff --git a/go/mechanisms/svm/exact/v1/facilitator/scheme.go b/go/mechanisms/svm/exact/v1/facilitator/scheme.go index 24bcb15051..d83a623262 100644 --- a/go/mechanisms/svm/exact/v1/facilitator/scheme.go +++ b/go/mechanisms/svm/exact/v1/facilitator/scheme.go @@ -10,7 +10,6 @@ import ( solana "github.com/gagliardetto/solana-go" computebudget "github.com/gagliardetto/solana-go/programs/compute-budget" - "github.com/gagliardetto/solana-go/programs/token" x402 "github.com/x402-foundation/x402/go" svm "github.com/x402-foundation/x402/go/mechanisms/svm" @@ -161,14 +160,18 @@ func (f *ExactSvmSchemeV1) Verify( return nil, x402.NewVerifyError(err.Error(), "", err.Error()) } - // Extract payer from transaction - payer, err := svm.GetTokenPayerFromTransaction(tx) + // Extract payer from the payment instruction using the built-in exact SVM + // payment decoders. This keeps exact verification compatible with the stock + // top-level TransferChecked flow while allowing known wrapped payment + // programs such as SWIG to expose the same canonical transfer details. + transferDetails, err := svm.ExtractTransferDetails(tx, tx.Message.Instructions[2]) if err != nil { - return nil, x402.NewVerifyError(ErrNoTransferInstruction, payer, err.Error()) + return nil, x402.NewVerifyError(ErrNoTransferInstruction, "", err.Error()) } + payer := transferDetails.Authority.String() // Step 4: Verify Transfer Instruction - if err := f.verifyTransferInstruction(tx, tx.Message.Instructions[2], requirements, signerAddressStrs); err != nil { + if err := f.verifyTransferDetails(transferDetails, requirements, signerAddressStrs); err != nil { return nil, x402.NewVerifyError(err.Error(), payer, err.Error()) } @@ -377,42 +380,26 @@ func (f *ExactSvmSchemeV1) verifyComputePriceInstruction(tx *solana.Transaction, return nil } -// verifyTransferInstruction verifies the transfer instruction -func (f *ExactSvmSchemeV1) verifyTransferInstruction( - tx *solana.Transaction, - inst solana.CompiledInstruction, +// verifyTransferDetails verifies the canonical transfer details extracted from +// the payment instruction. +func (f *ExactSvmSchemeV1) verifyTransferDetails( + transfer *svm.TransferDetails, requirements types.PaymentRequirementsV1, signerAddresses []string, ) error { - progID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - // Must be Token Program or Token-2022 Program - if progID != solana.TokenProgramID && progID != solana.Token2022ProgramID { - return errors.New(ErrNoTransferInstruction) - } - - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { + if transfer == nil { return errors.New(ErrNoTransferInstruction) } - if len(accounts) < 4 { - return errors.New(ErrNoTransferInstruction) - } - - decoded, err := token.DecodeInstruction(accounts, inst.Data) - if err != nil { - return errors.New(ErrNoTransferInstruction) - } - - transferChecked, ok := decoded.Impl.(*token.TransferChecked) - if !ok { + // Must still be a token transfer, even if it arrived through a wrapped + // instruction parser. + if transfer.ProgramID != solana.TokenProgramID && transfer.ProgramID != solana.Token2022ProgramID { return errors.New(ErrNoTransferInstruction) } // SECURITY: Verify that the facilitator's signers are not transferring their own funds // Prevent facilitator from signing away their own tokens - authorityAddr := accounts[3].PublicKey.String() // TransferChecked: [source, mint, destination, authority, ...] + authorityAddr := transfer.Authority.String() for _, signerAddr := range signerAddresses { if authorityAddr == signerAddr { return errors.New(ErrFeePayerTransferringFunds) @@ -420,7 +407,7 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction( } // Verify mint address - mintAddr := accounts[1].PublicKey.String() + mintAddr := transfer.Mint.String() if mintAddr != requirements.Asset { return errors.New(ErrMintMismatch) } @@ -441,8 +428,7 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction( return errors.New(ErrRecipientMismatch) } - destATA := transferChecked.GetDestinationAccount().PublicKey - if destATA.String() != expectedDestATA.String() { + if transfer.Destination.String() != expectedDestATA.String() { return errors.New(ErrRecipientMismatch) } @@ -454,7 +440,7 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction( return errors.New(ErrAmountInsufficient) } - if *transferChecked.Amount < requiredAmount { + if transfer.Amount < requiredAmount { return errors.New(ErrAmountInsufficient) } diff --git a/go/mechanisms/svm/exact/v1/facilitator/wrapped_transfer_test.go b/go/mechanisms/svm/exact/v1/facilitator/wrapped_transfer_test.go new file mode 100644 index 0000000000..dbbf91d847 --- /dev/null +++ b/go/mechanisms/svm/exact/v1/facilitator/wrapped_transfer_test.go @@ -0,0 +1,106 @@ +package facilitator + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + solana "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + svm "github.com/x402-foundation/x402/go/mechanisms/svm" + "github.com/x402-foundation/x402/go/types" +) + +type wrappedPaymentFixture struct { + Name string `json:"name"` + Transaction string `json:"transaction"` + Network string `json:"network"` + Asset string `json:"asset"` + PayTo string `json:"payTo"` + Amount string `json:"amount"` + FeePayer string `json:"feePayer"` + Payer string `json:"payer"` +} + +type mockFacilitatorSigner struct { + addresses []solana.PublicKey +} + +func (m *mockFacilitatorSigner) GetAddresses(context.Context, string) []solana.PublicKey { + return m.addresses +} + +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 loadWrappedPaymentFixtures(t *testing.T) []wrappedPaymentFixture { + t.Helper() + + _, currentFile, _, ok := runtime.Caller(0) + require.True(t, ok) + fixturePath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "testdata", "swig_wrapped_payments.json") + fixtureBytes, err := os.ReadFile(fixturePath) + require.NoError(t, err) + + var fixtures []wrappedPaymentFixture + require.NoError(t, json.Unmarshal(fixtureBytes, &fixtures)) + return fixtures +} + +func TestVerifySupportsBuiltInSwigTransfersV1(t *testing.T) { + for _, fixture := range loadWrappedPaymentFixtures(t) { + t.Run(fixture.Name, func(t *testing.T) { + feePayer := solana.MustPublicKeyFromBase58(fixture.FeePayer) + scheme := NewExactSvmSchemeV1(&mockFacilitatorSigner{ + addresses: []solana.PublicKey{feePayer}, + }) + + payload := types.PaymentPayloadV1{ + X402Version: 1, + Scheme: svm.SchemeExact, + Network: fixture.Network, + Payload: map[string]interface{}{ + "transaction": fixture.Transaction, + }, + } + + extraJSON, err := json.Marshal(map[string]interface{}{ + "feePayer": fixture.FeePayer, + }) + require.NoError(t, err) + + requirements := types.PaymentRequirementsV1{ + Scheme: svm.SchemeExact, + Network: fixture.Network, + MaxAmountRequired: fixture.Amount, + Resource: "https://example.com/paid", + PayTo: fixture.PayTo, + MaxTimeoutSeconds: 300, + Asset: fixture.Asset, + Extra: (*json.RawMessage)(&extraJSON), + } + + response, err := scheme.Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, response.IsValid) + assert.Equal(t, fixture.Payer, response.Payer) + }) + } +} diff --git a/go/mechanisms/svm/testdata/swig_wrapped_payments.json b/go/mechanisms/svm/testdata/swig_wrapped_payments.json new file mode 100644 index 0000000000..bf27088b70 --- /dev/null +++ b/go/mechanisms/svm/testdata/swig_wrapped_payments.json @@ -0,0 +1,22 @@ +[ + { + "name": "swig_sign_v2", + "transaction": "AnnpfCkeDPONF3bOqtL0csHpyttIqfFmNw6MpHM1PHt3VQXARtyUUcHyK8cdPODRBp5RSs2XK53VS48AitGOygV/IDoG07NhH30Pd1oEDCV7doIKD0x3YNK/gaUag851NEPGxI+M6r36oKvIM+MxvHa4X5u1c6rLiG9JpEsGDMcFAgEECmg2fXZKbRL2mFs8pt7im7JM8oq1ZlXAABsmWOhVL0GgQfDvsyv0vowoWwEi/TVV7LQuFkUIEZ6sY+hLw6ygpjVMPJLobmSnfEfmTVfiyKyF9bYbM8BE4QAohY40oy5RM8vzSSvCvyS9ZsWCS2xHtFFf7T0F3umQjDCLs6FCWt8W5BF6RwpodT3QoFmqt1mdmZ2HBcU3VnfqJFbVV0yszKR9Tf6uTLPE3GQvCPcdF/iRs7BLCtoOl5O53Wpi4tiItAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpMffL/pYF/bGOeRl0yNsXY+TPhSoFNLcgAbrsrbKtxbUDBkZv5SEXMv/srbpyw5vnvIzlu8X3EmssQ5s6QAAAAA0M6ULh58UG4hjfDX3xxS+v3DUp5I1nTR2yTHW1TMy+QlYtumRe1aNaTdbsJ+vCxMalKtwnzBjJJMMvR0AYSCwDCAAFAiBOAAAIAAkDAQAAAAAAAAAJBwIDAQYEBwUcCwATAAEAAAABAwQEBQYBCgAMQEIPAAAAAAAGAg==", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "4N48YGNQuNfjsjnKMwumFWFUo6VSARtZPY6mn5T4eHWk", + "payTo": "6DYRodat6pPWUnVeLQC5QGH44LbZoQSTKkutyw3hokqu", + "amount": "1000000", + "feePayer": "81obL53W2YNhCpcdx4q2weCdhNb1YaFRoTadNsNerSEK", + "payer": "Ej8vhbhok6zRgUYjWN9tQxqrP4eu3My7nofmtULqryZo" + }, + { + "name": "swig_subaccount_sign_v1", + "transaction": "AojyWUyisJbVay3VA15zznQ8o/3gkHrqftD8C6Bfp5cScFCyR//YSRiBU18Yeu+vbFnrNYBcavV/JGsJV2bu6giR5hjpCXdwn5b7LnaMHrD3rfwlgbWIz8SsIxq0BEfysGcZojPmheZyYfN16dZo9fUHC04gvDSqAjZGNThH1YEKAgEFCxV4IqA6U8qxSKYm+Z8VNSyaosvhEMF5SiqCG2zXSpBrp/WlpmS8YqOUUlotj+MUu+o/Pgf25hnAlCABZmTcICk6mBM4IXRppoDyh+0JNlHpsSAlCfypYCpqSCNItapFAAn6b8NhRK7Eas/vU/ackxfMBvDHY8jGzGmGwPv9Zucg63TjraEDSSU8ja3u7kL9uYpJEKKNJE8fSlbUhCvYEMqQpZi0ktfcJaD/4+NxLx2ZOfpc1qORxonKmewTVPuVHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKlV1fWUBVGkOXL1aC4yDfcOqClueAiwn+kbh4keCXQh9gMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAADQzpQuHnxQbiGN8NffHFL6/cNSnkjWdNHbJMdbVMzL5CVi26ZF7Vo1pN1uwn68LExqUq3CfMGMkkwy9HQBhILAMJAAUCIE4AAAkACQMBAAAAAAAAAAoIAgMGAQcECAUkCQATAAEAAAAAAAAAAAAAAAEEBAUGBwEKAAxAQg8AAAAAAAYD", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "asset": "6n4t6enGxdJExPxRQq5dL6n3txpix8jRVYy7EhGeDyv5", + "payTo": "Dgx4okUUN58Y1nDQzhzHS49zvWtCjyGvPuN6vZ7kznnD", + "amount": "1000000", + "feePayer": "2SooyT1pmhQgzMkpB4nxoF1Sje9aCovdDVb2EMJ25Ff8", + "payer": "fxA5zXxGwrxjV8JJjtu8qwu1HAh386BXtxvE8gxxa95" + } +] diff --git a/go/mechanisms/svm/transfer_extractors.go b/go/mechanisms/svm/transfer_extractors.go new file mode 100644 index 0000000000..f35ce23ee7 --- /dev/null +++ b/go/mechanisms/svm/transfer_extractors.go @@ -0,0 +1,272 @@ +package svm + +import ( + "encoding/binary" + "errors" + "fmt" + + solana "github.com/gagliardetto/solana-go" +) + +// ErrTransferNotRecognized indicates that an extractor does not understand the +// instruction it was asked to inspect. +var ErrTransferNotRecognized = errors.New("svm transfer not recognized") + +// TransferDetails is the canonical token transfer shape expected by the exact +// SVM facilitator, regardless of how a transaction encodes that transfer. +type TransferDetails struct { + ProgramID solana.PublicKey + Source solana.PublicKey + Mint solana.PublicKey + Destination solana.PublicKey + Authority solana.PublicKey + Amount uint64 +} + +const ( + swigInstructionSignV2 = 11 + swigInstructionSubAccountSignV1 = 9 + tokenTransferCheckedDiscriminator = 12 +) + +var ( + swigProgramID = solana.MustPublicKeyFromBase58(SwigProgramAddress) + memoProgramID = solana.MustPublicKeyFromBase58(MemoProgramAddress) +) + +type swigCompactInstruction struct { + ProgramIDIndex uint8 + AccountIndexes []uint8 + Data []byte +} + +// ExtractTransferDetails returns the canonical transfer description for the +// payment instruction accepted by the exact SVM facilitator. The facilitator +// supports the stock top-level SPL TransferChecked instruction plus vetted +// wrapped transfer programs such as SWIG. +func ExtractTransferDetails( + tx *solana.Transaction, + inst solana.CompiledInstruction, +) (*TransferDetails, error) { + if tx == nil || tx.Message.Instructions == nil { + return nil, fmt.Errorf("invalid transaction: nil transaction or instructions") + } + + details, err := extractDirectTransferDetails(tx, inst) + if err == nil { + return details, nil + } + if !errors.Is(err, ErrTransferNotRecognized) { + return nil, err + } + + details, err = extractSwigTransferDetails(tx, inst) + if err == nil { + return details, nil + } + + return nil, err +} + +// FindTransferDetails scans every instruction in the transaction until it finds +// a canonical token transfer supported by the exact SVM facilitator. +func FindTransferDetails(tx *solana.Transaction) (*TransferDetails, error) { + if tx == nil || tx.Message.Instructions == nil { + return nil, fmt.Errorf("invalid transaction: nil transaction or instructions") + } + + var lastErr error + for _, inst := range tx.Message.Instructions { + details, err := ExtractTransferDetails(tx, inst) + if err == nil { + return details, nil + } + + lastErr = err + } + + if lastErr == nil { + lastErr = ErrTransferNotRecognized + } + + return nil, lastErr +} + +func extractDirectTransferDetails( + tx *solana.Transaction, + inst solana.CompiledInstruction, +) (*TransferDetails, error) { + if tx == nil || tx.Message.Instructions == nil { + return nil, fmt.Errorf("invalid transaction: nil transaction or instructions") + } + + programID := tx.Message.AccountKeys[inst.ProgramIDIndex] + if programID != solana.TokenProgramID && programID != solana.Token2022ProgramID { + return nil, ErrTransferNotRecognized + } + + if len(inst.Accounts) < 4 || len(inst.Data) < 10 || inst.Data[0] != tokenTransferCheckedDiscriminator { + return nil, ErrTransferNotRecognized + } + + if int(inst.Accounts[3]) >= len(tx.Message.AccountKeys) { + return nil, ErrTransferNotRecognized + } + + return &TransferDetails{ + ProgramID: programID, + Source: tx.Message.AccountKeys[inst.Accounts[0]], + Mint: tx.Message.AccountKeys[inst.Accounts[1]], + Destination: tx.Message.AccountKeys[inst.Accounts[2]], + Authority: tx.Message.AccountKeys[inst.Accounts[3]], + Amount: binary.LittleEndian.Uint64(inst.Data[1:9]), + }, nil +} + +func extractSwigTransferDetails( + tx *solana.Transaction, + inst solana.CompiledInstruction, +) (*TransferDetails, error) { + programID := tx.Message.AccountKeys[inst.ProgramIDIndex] + if !programID.Equals(swigProgramID) { + return nil, ErrTransferNotRecognized + } + + outerAccounts, err := inst.ResolveInstructionAccounts(&tx.Message) + if err != nil { + return nil, ErrTransferNotRecognized + } + + compactInstructions, err := decodeSwigCompactInstructions(inst.Data) + if err != nil { + return nil, ErrTransferNotRecognized + } + + var transfer *TransferDetails + for _, compactInstruction := range compactInstructions { + if int(compactInstruction.ProgramIDIndex) >= len(outerAccounts) { + return nil, ErrTransferNotRecognized + } + + innerProgramID := outerAccounts[compactInstruction.ProgramIDIndex].PublicKey + switch { + case innerProgramID.Equals(solana.TokenProgramID) || innerProgramID.Equals(solana.Token2022ProgramID): + if transfer != nil { + return nil, ErrTransferNotRecognized + } + details, err := decodeTransferDetailsFromIndexes( + outerAccounts, + innerProgramID, + compactInstruction.AccountIndexes, + compactInstruction.Data, + ) + if err != nil { + return nil, err + } + transfer = details + case innerProgramID.Equals(memoProgramID): + continue + default: + return nil, ErrTransferNotRecognized + } + } + + if transfer == nil { + return nil, ErrTransferNotRecognized + } + + return transfer, nil +} + +func decodeSwigCompactInstructions(data []byte) ([]swigCompactInstruction, error) { + if len(data) < 4 { + return nil, ErrTransferNotRecognized + } + + discriminator := binary.LittleEndian.Uint16(data[:2]) + var compactOffset int + switch discriminator { + case swigInstructionSignV2: + compactOffset = 8 + case swigInstructionSubAccountSignV1: + compactOffset = 16 + default: + return nil, ErrTransferNotRecognized + } + + compactLen := int(binary.LittleEndian.Uint16(data[2:4])) + if compactLen <= 0 || len(data) < compactOffset+compactLen { + return nil, ErrTransferNotRecognized + } + + compactData := data[compactOffset : compactOffset+compactLen] + if len(compactData) == 0 { + return nil, ErrTransferNotRecognized + } + + instructionCount := int(compactData[0]) + offset := 1 + instructions := make([]swigCompactInstruction, 0, instructionCount) + for i := 0; i < instructionCount; i++ { + if len(compactData[offset:]) < 4 { + return nil, ErrTransferNotRecognized + } + + programIDIndex := compactData[offset] + offset++ + accountCount := int(compactData[offset]) + offset++ + if len(compactData[offset:]) < accountCount+2 { + return nil, ErrTransferNotRecognized + } + + accountIndexes := append([]uint8(nil), compactData[offset:offset+accountCount]...) + offset += accountCount + dataLen := int(binary.LittleEndian.Uint16(compactData[offset : offset+2])) + offset += 2 + if len(compactData[offset:]) < dataLen { + return nil, ErrTransferNotRecognized + } + + instructionData := append([]byte(nil), compactData[offset:offset+dataLen]...) + offset += dataLen + + instructions = append(instructions, swigCompactInstruction{ + ProgramIDIndex: programIDIndex, + AccountIndexes: accountIndexes, + Data: instructionData, + }) + } + + if offset != len(compactData) { + return nil, ErrTransferNotRecognized + } + + return instructions, nil +} + +func decodeTransferDetailsFromIndexes( + accounts solana.AccountMetaSlice, + programID solana.PublicKey, + accountIndexes []uint8, + data []byte, +) (*TransferDetails, error) { + if len(accountIndexes) < 4 || len(data) < 10 || data[0] != tokenTransferCheckedDiscriminator { + return nil, ErrTransferNotRecognized + } + + for _, index := range accountIndexes[:4] { + if int(index) >= len(accounts) { + return nil, ErrTransferNotRecognized + } + } + + return &TransferDetails{ + ProgramID: programID, + Source: accounts[accountIndexes[0]].PublicKey, + Mint: accounts[accountIndexes[1]].PublicKey, + Destination: accounts[accountIndexes[2]].PublicKey, + Authority: accounts[accountIndexes[3]].PublicKey, + Amount: binary.LittleEndian.Uint64(data[1:9]), + }, nil +} diff --git a/go/mechanisms/svm/utils.go b/go/mechanisms/svm/utils.go index 749faf6f51..a4a150c4d9 100644 --- a/go/mechanisms/svm/utils.go +++ b/go/mechanisms/svm/utils.go @@ -10,7 +10,6 @@ import ( bin "github.com/gagliardetto/binary" solana "github.com/gagliardetto/solana-go" - "github.com/gagliardetto/solana-go/programs/token" ) var ( @@ -171,41 +170,15 @@ func DecodeTransaction(base64Tx string) (*solana.Transaction, error) { return tx, nil } -// GetTokenPayerFromTransaction extracts the token payer (owner) address from a transaction -// This looks for the TransferChecked instruction and returns the owner/authority address +// GetTokenPayerFromTransaction extracts the token payer (owner) address from a +// transaction using the built-in exact SVM payment decoders supported by x402. func GetTokenPayerFromTransaction(tx *solana.Transaction) (string, error) { - if tx == nil || tx.Message.Instructions == nil { - return "", fmt.Errorf("invalid transaction: nil transaction or instructions") - } - - // Iterate through instructions to find TransferChecked - for _, inst := range tx.Message.Instructions { - programID := tx.Message.AccountKeys[inst.ProgramIDIndex] - - // Check if this is a token program instruction - if programID == solana.TokenProgramID || programID == solana.Token2022ProgramID { - // Decode the instruction - accounts, err := inst.ResolveInstructionAccounts(&tx.Message) - if err != nil { - continue - } - - decoded, err := token.DecodeInstruction(accounts, inst.Data) - if err != nil { - continue - } - - // Check if it's a TransferChecked instruction - if _, ok := decoded.Impl.(*token.TransferChecked); ok { - // The owner/authority is the 4th account (index 3) - if len(accounts) >= 4 { - return accounts[3].PublicKey.String(), nil - } - } - } + details, err := FindTransferDetails(tx) + if err != nil { + return "", fmt.Errorf("no supported payment instruction found in transaction: %w", err) } - return "", fmt.Errorf("no TransferChecked instruction found in transaction") + return details.Authority.String(), nil } // EncodeTransaction encodes a Solana transaction to base64 diff --git a/python/x402/changelog.d/swig-wrapped-exact-svm-payments.feature.md b/python/x402/changelog.d/swig-wrapped-exact-svm-payments.feature.md new file mode 100644 index 0000000000..52c5387aec --- /dev/null +++ b/python/x402/changelog.d/swig-wrapped-exact-svm-payments.feature.md @@ -0,0 +1 @@ +Add built-in facilitator support for SWIG-wrapped exact SVM payments alongside direct `TransferChecked` transfers. diff --git a/python/x402/mechanisms/svm/constants.py b/python/x402/mechanisms/svm/constants.py index bbe8748428..c4d7750610 100644 --- a/python/x402/mechanisms/svm/constants.py +++ b/python/x402/mechanisms/svm/constants.py @@ -14,6 +14,7 @@ COMPUTE_BUDGET_PROGRAM_ADDRESS = "ComputeBudget111111111111111111111111111111" MEMO_PROGRAM_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" LIGHTHOUSE_PROGRAM_ADDRESS = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" +SWIG_PROGRAM_ADDRESS = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB" # Default RPC URLs for Solana networks DEVNET_RPC_URL = "https://api.devnet.solana.com" @@ -62,8 +63,12 @@ ERR_UNSUPPORTED_SCHEME = "unsupported_scheme" ERR_NETWORK_MISMATCH = "network_mismatch" ERR_INVALID_PAYLOAD = "invalid_exact_svm_payload" -ERR_TRANSACTION_DECODE_FAILED = "invalid_exact_svm_payload_transaction_could_not_be_decoded" -ERR_INVALID_INSTRUCTION_COUNT = "invalid_exact_svm_payload_transaction_instructions_length" +ERR_TRANSACTION_DECODE_FAILED = ( + "invalid_exact_svm_payload_transaction_could_not_be_decoded" +) +ERR_INVALID_INSTRUCTION_COUNT = ( + "invalid_exact_svm_payload_transaction_instructions_length" +) ERR_UNKNOWN_FOURTH_INSTRUCTION = "invalid_exact_svm_payload_unknown_fourth_instruction" ERR_UNKNOWN_FIFTH_INSTRUCTION = "invalid_exact_svm_payload_unknown_fifth_instruction" ERR_UNKNOWN_SIXTH_INSTRUCTION = "invalid_exact_svm_payload_unknown_sixth_instruction" @@ -73,16 +78,16 @@ ERR_INVALID_COMPUTE_PRICE = ( "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction" ) -ERR_COMPUTE_PRICE_TOO_HIGH = ( - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" -) +ERR_COMPUTE_PRICE_TOO_HIGH = "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" ERR_NO_TRANSFER_INSTRUCTION = "invalid_exact_svm_payload_no_transfer_instruction" ERR_MINT_MISMATCH = "invalid_exact_svm_payload_mint_mismatch" ERR_RECIPIENT_MISMATCH = "invalid_exact_svm_payload_recipient_mismatch" ERR_AMOUNT_INSUFFICIENT = "invalid_exact_svm_payload_amount_insufficient" ERR_FEE_PAYER_MISSING = "invalid_exact_svm_payload_missing_fee_payer" ERR_FEE_PAYER_NOT_MANAGED = "fee_payer_not_managed_by_facilitator" -ERR_FEE_PAYER_TRANSFERRING = "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds" +ERR_FEE_PAYER_TRANSFERRING = ( + "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds" +) ERR_SIMULATION_FAILED = "transaction_simulation_failed" ERR_TRANSACTION_FAILED = "transaction_failed" ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement" diff --git a/python/x402/mechanisms/svm/exact/facilitator.py b/python/x402/mechanisms/svm/exact/facilitator.py index 42b90ce1e8..00bcd60e4e 100644 --- a/python/x402/mechanisms/svm/exact/facilitator.py +++ b/python/x402/mechanisms/svm/exact/facilitator.py @@ -53,7 +53,7 @@ from ..utils import ( decode_transaction_from_payload, derive_ata, - get_token_payer_from_transaction, + get_transfer_details_from_instruction, ) @@ -146,16 +146,25 @@ def verify( network = str(requirements.network) # Step 1: Validate Payment Requirements - if payload.accepted.scheme != SCHEME_EXACT or requirements.scheme != SCHEME_EXACT: - return VerifyResponse(is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer="") + if ( + payload.accepted.scheme != SCHEME_EXACT + or requirements.scheme != SCHEME_EXACT + ): + return VerifyResponse( + is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer="" + ) if str(payload.accepted.network) != str(requirements.network): - return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="") + return VerifyResponse( + is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="" + ) extra = requirements.extra or {} fee_payer_str = extra.get("feePayer") if not fee_payer_str or not isinstance(fee_payer_str, str): - return VerifyResponse(is_valid=False, invalid_reason=ERR_FEE_PAYER_MISSING, payer="") + return VerifyResponse( + is_valid=False, invalid_reason=ERR_FEE_PAYER_MISSING, payer="" + ) # Verify that the requested feePayer is managed by this facilitator signer_addresses = self._signer.get_addresses() @@ -222,20 +231,21 @@ def verify( payer="", ) - # Get token payer - payer = get_token_payer_from_transaction(tx) - if not payer: + transfer_details = get_transfer_details_from_instruction( + static_accounts, instructions[2] + ) + if transfer_details is None: return VerifyResponse( is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer="" ) + payer = transfer_details.authority # Step 4: Verify Transfer Instruction - transfer_ix = instructions[2] - transfer_program = static_accounts[transfer_ix.program_id_index] - transfer_program_str = str(transfer_program) + transfer_program_str = transfer_details.token_program token_program = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) token_2022_program = Pubkey.from_string(TOKEN_2022_PROGRAM_ADDRESS) + transfer_program = Pubkey.from_string(transfer_program_str) if transfer_program != token_program and transfer_program != token_2022_program: return VerifyResponse( @@ -263,56 +273,35 @@ def verify( if idx < len(invalid_reasons) else ERR_UNKNOWN_SIXTH_INSTRUCTION ) - return VerifyResponse(is_valid=False, invalid_reason=reason, payer=payer) - - # Parse transfer instruction - transfer_accounts = list(transfer_ix.accounts) - transfer_data = bytes(transfer_ix.data) - - # TransferChecked data: [12 (discriminator), u64 amount, u8 decimals] - if len(transfer_data) < 10 or transfer_data[0] != 12: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer=payer - ) - - # TransferChecked accounts: [source, mint, destination, owner] - if len(transfer_accounts) < 4: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer=payer - ) - - _source_ata = static_accounts[transfer_accounts[0]] # noqa: F841 - mint = static_accounts[transfer_accounts[1]] - dest_ata = static_accounts[transfer_accounts[2]] - authority = static_accounts[transfer_accounts[3]] - - amount = int.from_bytes(transfer_data[1:9], "little") + return VerifyResponse( + is_valid=False, invalid_reason=reason, payer=payer + ) # Verify facilitator's signers are not transferring their own funds # SECURITY: Prevent facilitator from signing away their own tokens - authority_str = str(authority) - if authority_str in signer_addresses: + if transfer_details.authority in signer_addresses: return VerifyResponse( is_valid=False, invalid_reason=ERR_FEE_PAYER_TRANSFERRING, payer=payer ) # Verify mint address matches requirements - mint_str = str(mint) - if mint_str != requirements.asset: - return VerifyResponse(is_valid=False, invalid_reason=ERR_MINT_MISMATCH, payer=payer) + if transfer_details.mint != requirements.asset: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_MINT_MISMATCH, payer=payer + ) # Verify destination ATA matches expected ATA for payTo address expected_dest_ata = derive_ata( requirements.pay_to, requirements.asset, transfer_program_str ) - if str(dest_ata) != expected_dest_ata: + if transfer_details.destination != expected_dest_ata: return VerifyResponse( is_valid=False, invalid_reason=ERR_RECIPIENT_MISMATCH, payer=payer ) # Verify transfer amount meets requirements required_amount = int(requirements.amount) - if amount < required_amount: + if transfer_details.amount < required_amount: return VerifyResponse( is_valid=False, invalid_reason=ERR_AMOUNT_INSUFFICIENT, payer=payer ) diff --git a/python/x402/mechanisms/svm/exact/v1/facilitator.py b/python/x402/mechanisms/svm/exact/v1/facilitator.py index 389ac2f9a9..d6d35cce6c 100644 --- a/python/x402/mechanisms/svm/exact/v1/facilitator.py +++ b/python/x402/mechanisms/svm/exact/v1/facilitator.py @@ -48,7 +48,7 @@ from ...utils import ( decode_transaction_from_payload, derive_ata, - get_token_payer_from_transaction, + get_transfer_details_from_instruction, ) @@ -132,16 +132,22 @@ def verify( # V1: Validate scheme at top level if payload.scheme != SCHEME_EXACT or requirements.scheme != SCHEME_EXACT: - return VerifyResponse(is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer="") + return VerifyResponse( + is_valid=False, invalid_reason=ERR_UNSUPPORTED_SCHEME, payer="" + ) # V1: Validate network at top level if payload.network != requirements.network: - return VerifyResponse(is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="") + return VerifyResponse( + is_valid=False, invalid_reason=ERR_NETWORK_MISMATCH, payer="" + ) extra = requirements.extra or {} fee_payer_str = extra.get("feePayer") if not fee_payer_str or not isinstance(fee_payer_str, str): - return VerifyResponse(is_valid=False, invalid_reason=ERR_FEE_PAYER_MISSING, payer="") + return VerifyResponse( + is_valid=False, invalid_reason=ERR_FEE_PAYER_MISSING, payer="" + ) # Verify that the requested feePayer is managed by this facilitator signer_addresses = self._signer.get_addresses() @@ -208,20 +214,21 @@ def verify( payer="", ) - # Get token payer - payer = get_token_payer_from_transaction(tx) - if not payer: + transfer_details = get_transfer_details_from_instruction( + static_accounts, instructions[2] + ) + if transfer_details is None: return VerifyResponse( is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer="" ) + payer = transfer_details.authority # Verify Transfer Instruction - transfer_ix = instructions[2] - transfer_program = static_accounts[transfer_ix.program_id_index] - transfer_program_str = str(transfer_program) + transfer_program_str = transfer_details.token_program token_program = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) token_2022_program = Pubkey.from_string(TOKEN_2022_PROGRAM_ADDRESS) + transfer_program = Pubkey.from_string(transfer_program_str) if transfer_program != token_program and transfer_program != token_2022_program: return VerifyResponse( @@ -249,55 +256,34 @@ def verify( if idx < len(invalid_reasons) else ERR_UNKNOWN_SIXTH_INSTRUCTION ) - return VerifyResponse(is_valid=False, invalid_reason=reason, payer=payer) - - # Parse transfer instruction - transfer_accounts = list(transfer_ix.accounts) - transfer_data = bytes(transfer_ix.data) - - # TransferChecked data: [12 (discriminator), u64 amount, u8 decimals] - if len(transfer_data) < 10 or transfer_data[0] != 12: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer=payer - ) - - # TransferChecked accounts: [source, mint, destination, owner] - if len(transfer_accounts) < 4: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_NO_TRANSFER_INSTRUCTION, payer=payer - ) - - _source_ata = static_accounts[transfer_accounts[0]] # noqa: F841 - mint = static_accounts[transfer_accounts[1]] - dest_ata = static_accounts[transfer_accounts[2]] - authority = static_accounts[transfer_accounts[3]] - - amount = int.from_bytes(transfer_data[1:9], "little") + return VerifyResponse( + is_valid=False, invalid_reason=reason, payer=payer + ) # Verify facilitator's signers are not transferring their own funds - authority_str = str(authority) - if authority_str in signer_addresses: + if transfer_details.authority in signer_addresses: return VerifyResponse( is_valid=False, invalid_reason=ERR_FEE_PAYER_TRANSFERRING, payer=payer ) # Verify mint address matches requirements - mint_str = str(mint) - if mint_str != requirements.asset: - return VerifyResponse(is_valid=False, invalid_reason=ERR_MINT_MISMATCH, payer=payer) + if transfer_details.mint != requirements.asset: + return VerifyResponse( + is_valid=False, invalid_reason=ERR_MINT_MISMATCH, payer=payer + ) # Verify destination ATA expected_dest_ata = derive_ata( requirements.pay_to, requirements.asset, transfer_program_str ) - if str(dest_ata) != expected_dest_ata: + if transfer_details.destination != expected_dest_ata: return VerifyResponse( is_valid=False, invalid_reason=ERR_RECIPIENT_MISMATCH, payer=payer ) # V1: Verify transfer amount meets maxAmountRequired required_amount = int(requirements.max_amount_required) - if amount < required_amount: + if transfer_details.amount < required_amount: return VerifyResponse( is_valid=False, invalid_reason=ERR_AMOUNT_INSUFFICIENT, payer=payer ) diff --git a/python/x402/mechanisms/svm/types.py b/python/x402/mechanisms/svm/types.py index 1f2d07466f..6b0c41e324 100644 --- a/python/x402/mechanisms/svm/types.py +++ b/python/x402/mechanisms/svm/types.py @@ -41,6 +41,18 @@ def from_dict(cls, data: dict[str, Any]) -> "ExactSvmPayload": ExactSvmPayloadV2 = ExactSvmPayload +@dataclass +class TransferDetails: + """Canonical SPL token transfer details extracted from a transaction.""" + + token_program: str + source: str + mint: str + destination: str + authority: str + amount: int + + @dataclass class TransactionInfo: """Information extracted from a parsed Solana transaction.""" diff --git a/python/x402/mechanisms/svm/utils.py b/python/x402/mechanisms/svm/utils.py index 37170f3bae..e3c30aeb9d 100644 --- a/python/x402/mechanisms/svm/utils.py +++ b/python/x402/mechanisms/svm/utils.py @@ -3,6 +3,7 @@ import base64 import re from decimal import Decimal +from typing import Any try: from solders.pubkey import Pubkey @@ -13,11 +14,14 @@ ) from e from .constants import ( + DEFAULT_DECIMALS, + MEMO_PROGRAM_ADDRESS, NETWORK_CONFIGS, SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2, SOLANA_TESTNET_CAIP2, SVM_ADDRESS_REGEX, + SWIG_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS, USDC_DEVNET_ADDRESS, @@ -27,7 +31,11 @@ AssetInfo, NetworkConfig, ) -from .types import ExactSvmPayload, TransactionInfo +from .types import ExactSvmPayload, TransactionInfo, TransferDetails + +TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12 +SWIG_SIGN_V2_DISCRIMINATOR = 11 +SWIG_SUBACCOUNT_SIGN_V1_DISCRIMINATOR = 9 def normalize_network(network: str) -> str: @@ -132,7 +140,9 @@ def get_asset_info(network: str, asset_address: str | None = None) -> AssetInfo: if not asset_address or asset_address == default_asset["address"]: return default_asset - raise ValueError(f"Token {asset_address} is not a registered asset for network {network}.") + raise ValueError( + f"Token {asset_address} is not a registered asset for network {network}." + ) def convert_to_token_amount(decimal_amount: str, decimals: int) -> str: @@ -239,27 +249,189 @@ def get_token_payer_from_transaction(tx: VersionedTransaction) -> str: Returns: The token payer address as a base58 string, or empty string if not found. """ + transfer_details = get_transfer_details_from_transaction(tx) + return transfer_details.authority if transfer_details else "" + + +def get_transfer_details_from_transaction( + tx: VersionedTransaction, +) -> TransferDetails | None: + """Extract canonical transfer details from a transaction.""" message = tx.message static_accounts = list(message.account_keys) instructions = message.instructions + for ix in instructions: + transfer_details = get_transfer_details_from_instruction(static_accounts, ix) + if transfer_details is not None: + return transfer_details + + return None + + +def get_transfer_details_from_instruction( + static_accounts: list[Pubkey], ix: Any +) -> TransferDetails | None: + """Extract canonical transfer details from one compiled instruction.""" + transfer_details = _extract_direct_transfer_details(static_accounts, ix) + if transfer_details is not None: + return transfer_details + + return _extract_swig_transfer_details(static_accounts, ix) + + +def _extract_direct_transfer_details( + static_accounts: list[Pubkey], ix: Any +) -> TransferDetails | None: + program_address = static_accounts[ix.program_id_index] token_program = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) token_2022_program = Pubkey.from_string(TOKEN_2022_PROGRAM_ADDRESS) - for ix in instructions: - program_index = ix.program_id_index - program_address = static_accounts[program_index] + if program_address != token_program and program_address != token_2022_program: + return None + + account_indices = list(ix.accounts) + ix_data = bytes(ix.data) + if ( + len(account_indices) < 4 + or len(ix_data) < 10 + or ix_data[0] != TOKEN_TRANSFER_CHECKED_DISCRIMINATOR + ): + return None + + return TransferDetails( + token_program=str(program_address), + source=str(static_accounts[account_indices[0]]), + mint=str(static_accounts[account_indices[1]]), + destination=str(static_accounts[account_indices[2]]), + authority=str(static_accounts[account_indices[3]]), + amount=int.from_bytes(ix_data[1:9], "little"), + ) + + +def _extract_swig_transfer_details( + static_accounts: list[Pubkey], ix: Any +) -> TransferDetails | None: + swig_program = Pubkey.from_string(SWIG_PROGRAM_ADDRESS) + if static_accounts[ix.program_id_index] != swig_program: + return None + + outer_accounts = [static_accounts[index] for index in ix.accounts] + compact_instructions = _decode_swig_compact_instructions(bytes(ix.data)) + if compact_instructions is None: + return None + + transfer_details: TransferDetails | None = None + token_program = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) + token_2022_program = Pubkey.from_string(TOKEN_2022_PROGRAM_ADDRESS) + memo_program = Pubkey.from_string(MEMO_PROGRAM_ADDRESS) - # Check if this is a token program instruction - if program_address == token_program or program_address == token_2022_program: - account_indices = list(ix.accounts) - # TransferChecked account order: [source, mint, destination, owner, ...] - if len(account_indices) >= 4: - owner_index = account_indices[3] - owner_address = static_accounts[owner_index] - return str(owner_address) + for compact_instruction in compact_instructions: + program_index = compact_instruction["program_id_index"] + if program_index >= len(outer_accounts): + return None - return "" + program_address = outer_accounts[program_index] + if program_address == token_program or program_address == token_2022_program: + if transfer_details is not None: + return None + transfer_details = _decode_transfer_details_from_indexes( + outer_accounts, + str(program_address), + compact_instruction["account_indexes"], + compact_instruction["data"], + ) + if transfer_details is None: + return None + continue + + if program_address == memo_program: + continue + + return None + + return transfer_details + + +def _decode_swig_compact_instructions(data: bytes) -> list[dict[str, Any]] | None: + if len(data) < 4: + return None + + discriminator = int.from_bytes(data[0:2], "little") + if discriminator == SWIG_SIGN_V2_DISCRIMINATOR: + compact_offset = 8 + elif discriminator == SWIG_SUBACCOUNT_SIGN_V1_DISCRIMINATOR: + compact_offset = 16 + else: + return None + + compact_length = int.from_bytes(data[2:4], "little") + if compact_length <= 0 or compact_offset + compact_length > len(data): + return None + + compact_data = data[compact_offset : compact_offset + compact_length] + if not compact_data: + return None + + instruction_count = compact_data[0] + offset = 1 + instructions: list[dict[str, object]] = [] + + for _ in range(instruction_count): + if offset + 4 > len(compact_data): + return None + + program_id_index = compact_data[offset] + offset += 1 + account_count = compact_data[offset] + offset += 1 + if offset + account_count + 2 > len(compact_data): + return None + + account_indexes = list(compact_data[offset : offset + account_count]) + offset += account_count + inner_data_length = int.from_bytes(compact_data[offset : offset + 2], "little") + offset += 2 + if offset + inner_data_length > len(compact_data): + return None + + instruction_data = compact_data[offset : offset + inner_data_length] + offset += inner_data_length + instructions.append( + { + "program_id_index": program_id_index, + "account_indexes": account_indexes, + "data": instruction_data, + } + ) + + if offset != len(compact_data): + return None + + return instructions + + +def _decode_transfer_details_from_indexes( + accounts: list[Pubkey], token_program: str, account_indexes: list[int], data: bytes +) -> TransferDetails | None: + if ( + len(account_indexes) < 4 + or len(data) < 10 + or data[0] != TOKEN_TRANSFER_CHECKED_DISCRIMINATOR + ): + return None + + if any(index >= len(accounts) for index in account_indexes[:4]): + return None + + return TransferDetails( + token_program=token_program, + source=str(accounts[account_indexes[0]]), + mint=str(accounts[account_indexes[1]]), + destination=str(accounts[account_indexes[2]]), + authority=str(accounts[account_indexes[3]]), + amount=int.from_bytes(data[1:9], "little"), + ) def extract_transaction_info(tx: VersionedTransaction) -> TransactionInfo | None: @@ -275,49 +447,20 @@ def extract_transaction_info(tx: VersionedTransaction) -> TransactionInfo | None """ message = tx.message static_accounts = list(message.account_keys) - instructions = message.instructions - - token_program = Pubkey.from_string(TOKEN_PROGRAM_ADDRESS) - token_2022_program = Pubkey.from_string(TOKEN_2022_PROGRAM_ADDRESS) - - # Fee payer is always the first account - fee_payer = str(static_accounts[0]) - - for ix in instructions: - program_index = ix.program_id_index - program_address = static_accounts[program_index] - - # Check if this is a token program instruction - if program_address == token_program or program_address == token_2022_program: - account_indices = list(ix.accounts) - # TransferChecked account order: [source, mint, destination, owner, ...] - if len(account_indices) >= 4: - source_index = account_indices[0] - mint_index = account_indices[1] - dest_index = account_indices[2] - owner_index = account_indices[3] - - # TransferChecked data layout: - # byte 0: instruction type (12 for TransferChecked) - # bytes 1-8: amount (u64, little-endian) - # byte 9: decimals (u8) - ix_data = bytes(ix.data) - if len(ix_data) >= 10 and ix_data[0] == 12: # TransferChecked = 12 - amount = int.from_bytes(ix_data[1:9], "little") - decimals = ix_data[9] - - return TransactionInfo( - fee_payer=fee_payer, - payer=str(static_accounts[owner_index]), - source_ata=str(static_accounts[source_index]), - destination_ata=str(static_accounts[dest_index]), - mint=str(static_accounts[mint_index]), - amount=amount, - decimals=decimals, - token_program=str(program_address), - ) - - return None + transfer_details = get_transfer_details_from_transaction(tx) + if transfer_details is None: + return None + + return TransactionInfo( + fee_payer=str(static_accounts[0]), + payer=transfer_details.authority, + source_ata=transfer_details.source, + destination_ata=transfer_details.destination, + mint=transfer_details.mint, + amount=transfer_details.amount, + decimals=DEFAULT_DECIMALS, + token_program=transfer_details.token_program, + ) def derive_ata(owner: str, mint: str, token_program: str | None = None) -> str: @@ -333,7 +476,9 @@ def derive_ata(owner: str, mint: str, token_program: str | None = None) -> str: """ from solders.pubkey import Pubkey - ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + ASSOCIATED_TOKEN_PROGRAM_ID = Pubkey.from_string( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + ) if token_program is None: token_program = TOKEN_PROGRAM_ADDRESS diff --git a/python/x402/tests/unit/mechanisms/svm/test_facilitator.py b/python/x402/tests/unit/mechanisms/svm/test_facilitator.py index d5eadf5eb1..3bb4b13312 100644 --- a/python/x402/tests/unit/mechanisms/svm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/svm/test_facilitator.py @@ -1,5 +1,7 @@ """Tests for ExactSvmScheme facilitator.""" +import json +from pathlib import Path from unittest.mock import patch from x402.mechanisms.svm import ( @@ -8,7 +10,24 @@ USDC_DEVNET_ADDRESS, ) from x402.mechanisms.svm.exact import ExactSvmFacilitatorScheme -from x402.schemas import PaymentPayload, PaymentRequirements, ResourceInfo, VerifyResponse +from x402.schemas import ( + PaymentPayload, + PaymentRequirements, + ResourceInfo, + VerifyResponse, +) + + +def load_wrapped_payment_fixtures() -> list[dict[str, str]]: + fixture_path = ( + Path(__file__).resolve().parents[6] + / "go" + / "mechanisms" + / "svm" + / "testdata" + / "swig_wrapped_payments.json" + ) + return json.loads(fixture_path.read_text()) class MockFacilitatorSigner: @@ -358,10 +377,14 @@ def test_should_allow_distinct_transactions(self): "verify", return_value=VerifyResponse(is_valid=True, payer="PayerAddress"), ): - result1 = facilitator.settle(self._make_payload("transactionA=="), requirements) + result1 = facilitator.settle( + self._make_payload("transactionA=="), requirements + ) assert result1.success is True - result2 = facilitator.settle(self._make_payload("transactionB=="), requirements) + result2 = facilitator.settle( + self._make_payload("transactionB=="), requirements + ) assert result2.success is True def test_should_evict_cache_entries_after_ttl(self): @@ -485,6 +508,66 @@ def test_should_reject_if_fee_payer_not_managed(self): assert result.invalid_reason == "fee_payer_not_managed_by_facilitator" +class TestBuiltInWrappedTransfers: + def test_v2_verify_accepts_swig_wrapped_transfers(self): + for fixture in load_wrapped_payment_fixtures(): + signer = MockFacilitatorSigner([fixture["feePayer"]]) + facilitator = ExactSvmFacilitatorScheme(signer) + + payload = PaymentPayload( + x402_version=2, + resource=ResourceInfo( + url="http://example.com/protected", + description="Test resource", + mime_type="application/json", + ), + accepted=PaymentRequirements( + scheme="exact", + network=fixture["network"], + asset=fixture["asset"], + amount=fixture["amount"], + pay_to=fixture["payTo"], + max_timeout_seconds=300, + extra={"feePayer": fixture["feePayer"]}, + ), + payload={"transaction": fixture["transaction"]}, + ) + + result = facilitator.verify(payload, payload.accepted) + assert result.is_valid is True + assert result.payer == fixture["payer"] + + def test_v1_verify_accepts_swig_wrapped_transfers(self): + from x402.mechanisms.svm.exact.v1.facilitator import ( + ExactSvmSchemeV1 as ExactSvmFacilitatorSchemeV1, + ) + from x402.schemas.v1 import PaymentPayloadV1, PaymentRequirementsV1 + + for fixture in load_wrapped_payment_fixtures(): + signer = MockFacilitatorSigner([fixture["feePayer"]]) + facilitator = ExactSvmFacilitatorSchemeV1(signer) + + payload = PaymentPayloadV1( + scheme="exact", + network=fixture["network"], + payload={"transaction": fixture["transaction"]}, + ) + requirements = PaymentRequirementsV1( + scheme="exact", + network=fixture["network"], + asset=fixture["asset"], + max_amount_required=fixture["amount"], + pay_to=fixture["payTo"], + max_timeout_seconds=300, + resource="https://example.com", + extra={"feePayer": fixture["feePayer"]}, + ) + + result = facilitator.verify(payload, requirements) + assert result.is_valid is True + assert result.payer == fixture["payer"] + + class TestSettlementCachePruneOptimization: """Verify the early-break prune optimization preserves insertion-order semantics.""" @@ -502,7 +585,9 @@ def test_prunes_only_expired_entries_preserves_fresh_ones(self): base = cache.entries["tx-a"] cache.entries["tx-a"] = base - 121.0 - assert cache.is_duplicate("tx-a") is False, "expired entry should have been pruned" + assert ( + cache.is_duplicate("tx-a") is False + ), "expired entry should have been pruned" assert cache.is_duplicate("tx-b") is True, "fresh entry should still be cached" assert cache.is_duplicate("tx-c") is True, "fresh entry should still be cached" @@ -557,5 +642,7 @@ def test_early_break_preserves_ordered_entries(self): assert "tx-old-1" not in cache.entries, "first expired entry should be pruned" assert "tx-old-2" not in cache.entries, "second expired entry should be pruned" - assert "tx-fresh" in cache.entries, "fresh entry after expired ones should survive" + assert ( + "tx-fresh" in cache.entries + ), "fresh entry after expired ones should survive" assert "tx-new" in cache.entries, "newly inserted entry should be present" diff --git a/specs/schemes/exact/scheme_exact_svm.md b/specs/schemes/exact/scheme_exact_svm.md index d22402b859..8976d667b5 100644 --- a/specs/schemes/exact/scheme_exact_svm.md +++ b/specs/schemes/exact/scheme_exact_svm.md @@ -112,7 +112,11 @@ A facilitator verifying an `exact`-scheme SVM payment MUST enforce all of the fo - The decompiled transaction MUST contain 3 to 6 instructions in this order: 1. Compute Budget: Set Compute Unit Limit 2. Compute Budget: Set Compute Unit Price - 3. SPL Token or Token-2022 TransferChecked + 3. A payment instruction that either: + - is a direct SPL Token / Token-2022 `TransferChecked`, or + - is a known wrapped payment program that can be deterministically decoded by the facilitator into the same + canonical token transfer details (`source`, `mint`, `destination`, + `authority`, `amount`) 4. (Optional) Lighthouse or Memo program instruction 5. (Optional) Lighthouse or Memo program instruction 6. (Optional) Memo program instruction @@ -125,7 +129,7 @@ A facilitator verifying an `exact`-scheme SVM payment MUST enforce all of the fo 2. Fee payer (facilitator) safety - The configured fee payer address MUST NOT appear in the `accounts` of any instruction in the transaction. -- The fee payer MUST NOT be the `authority` for the TransferChecked instruction. +- The fee payer MUST NOT be the `authority` for the canonical token transfer. - The fee payer MUST NOT be the `source` of the transferred funds. 3. Compute budget validity @@ -135,7 +139,7 @@ A facilitator verifying an `exact`-scheme SVM payment MUST enforce all of the fo 4. Transfer intent and destination -- The TransferChecked program MUST be either `spl-token` or `token-2022`. +- The canonical token transfer MUST target either `spl-token` or `token-2022`. - Destination MUST equal the Associated Token Account PDA for `(owner = payTo, mint = asset)` under the selected token program. 5. Account existence @@ -145,10 +149,34 @@ A facilitator verifying an `exact`-scheme SVM payment MUST enforce all of the fo 6. Amount -- The `amount` in TransferChecked MUST equal `PaymentRequirements.amount` exactly. +- The canonical token transfer `amount` MUST equal `PaymentRequirements.amount` exactly. These checks are security-critical to ensure the fee payer cannot be tricked into transferring their own funds or sponsoring unintended actions. Implementations MAY introduce stricter limits (e.g., lower compute price caps) but MUST NOT relax the above constraints. +### Known Wrapped Payment Programs + +The reference implementation currently treats the following wrapped payment +programs as valid exact SVM payment instructions in addition to direct +`TransferChecked`: + +- **SWIG** + - `SignV2` + - `SubAccountSignV1` + +When a wrapped payment program is accepted, the facilitator MUST decode the +wrapped instruction into the same canonical SPL transfer effect it would verify +for a direct payment: + +- token program (`spl-token` or `token-2022`) +- source token account +- mint +- destination associated token account +- transfer authority +- exact amount + +This preserves compatibility with programmable wallets while keeping the same +exact-scheme safety guarantees. + ## Duplicate Settlement Mitigation (RECOMMENDED) ### Vulnerability @@ -166,4 +194,4 @@ Merchants and/or Facilitators SHOULD maintain a short-term, in-memory cache of t 3. If the key is not present, insert it into the cache and proceed with signing and submission. 4. Evict entries older than 120 seconds (approximately twice the Solana blockhash lifetime of ~60–90 seconds). After this window, the transaction's blockhash will have expired and it cannot land on-chain regardless. -This approach requires no external storage or long-lived state — only an in-process map with time-based eviction. It preserves the facilitator's otherwise stateless design while closing the duplicate settlement attack vector. \ No newline at end of file +This approach requires no external storage or long-lived state — only an in-process map with time-based eviction. It preserves the facilitator's otherwise stateless design while closing the duplicate settlement attack vector. diff --git a/typescript/.changeset/swig-built-in-svm.md b/typescript/.changeset/swig-built-in-svm.md new file mode 100644 index 0000000000..4596a9cd9e --- /dev/null +++ b/typescript/.changeset/swig-built-in-svm.md @@ -0,0 +1,5 @@ +--- +"@x402/svm": patch +--- + +Add built-in facilitator support for SWIG-wrapped exact SVM payments in addition to direct `TransferChecked` transfers. diff --git a/typescript/packages/mechanisms/svm/src/constants.ts b/typescript/packages/mechanisms/svm/src/constants.ts index 508f4306af..05abd9f935 100644 --- a/typescript/packages/mechanisms/svm/src/constants.ts +++ b/typescript/packages/mechanisms/svm/src/constants.ts @@ -6,6 +6,7 @@ export const TOKEN_PROGRAM_ADDRESS = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5D export const TOKEN_2022_PROGRAM_ADDRESS = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; export const COMPUTE_BUDGET_PROGRAM_ADDRESS = "ComputeBudget111111111111111111111111111111"; export const MEMO_PROGRAM_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"; +export const SWIG_PROGRAM_ADDRESS = "swigypWHEksbC64pWKwah1WTeh9JXwx8H1rJHLdbQMB"; /** * Phantom/Solflare Lighthouse program address diff --git a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts index 1e81bb89dd..319563313c 100644 --- a/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts @@ -3,15 +3,8 @@ import { parseSetComputeUnitLimitInstruction, parseSetComputeUnitPriceInstruction, } from "@solana-program/compute-budget"; -import { - parseTransferCheckedInstruction as parseTransferCheckedInstructionToken, - TOKEN_PROGRAM_ADDRESS, -} from "@solana-program/token"; -import { - findAssociatedTokenPda, - parseTransferCheckedInstruction as parseTransferCheckedInstruction2022, - TOKEN_2022_PROGRAM_ADDRESS, -} from "@solana-program/token-2022"; +import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; +import { findAssociatedTokenPda, TOKEN_2022_PROGRAM_ADDRESS } from "@solana-program/token-2022"; import { decompileTransactionMessage, getCompiledTransactionMessageDecoder, @@ -32,7 +25,10 @@ import { import { SettlementCache } from "../../settlement-cache"; import type { FacilitatorSvmSigner } from "../../signer"; import type { ExactSvmPayloadV2 } from "../../types"; -import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "../../utils"; +import { + decodeTransactionFromPayload, + getTransferDetailsFromCompiledInstruction, +} from "../../utils"; /** * SVM facilitator implementation for the Exact payment scheme. @@ -177,18 +173,21 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { }; } - const payer = getTokenPayerFromTransaction(transaction); - if (!payer) { + const transferDetails = getTransferDetailsFromCompiledInstruction( + compiled, + compiled.instructions[2], + ); + if (!transferDetails) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", payer: "", }; } + const payer = transferDetails.authority; // Step 4: Verify Transfer Instruction - const transferIx = instructions[2]; - const programAddress = transferIx.programAddress.toString(); + const programAddress = transferDetails.programAddress; if ( programAddress !== TOKEN_PROGRAM_ADDRESS.toString() && @@ -201,26 +200,9 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { }; } - // Parse the transfer instruction using the appropriate library helper - let parsedTransfer; - try { - if (programAddress === TOKEN_PROGRAM_ADDRESS.toString()) { - parsedTransfer = parseTransferCheckedInstructionToken(transferIx as never); - } else { - parsedTransfer = parseTransferCheckedInstruction2022(transferIx as never); - } - } catch { - return { - isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", - payer, - }; - } - // Verify that the facilitator's signers are not transferring their own funds // SECURITY: Prevent facilitator from signing away their own tokens - const authorityAddress = parsedTransfer.accounts.authority.address.toString(); - if (signerAddresses.includes(authorityAddress)) { + if (signerAddresses.includes(transferDetails.authority)) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", @@ -229,8 +211,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { } // Verify mint address matches requirements - const mintAddress = parsedTransfer.accounts.mint.address.toString(); - if (mintAddress !== requirements.asset) { + if (transferDetails.mint !== requirements.asset) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_mint_mismatch", @@ -239,7 +220,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { } // Verify destination ATA matches expected ATA for payTo address - const destATA = parsedTransfer.accounts.destination.address.toString(); try { const [expectedDestATA] = await findAssociatedTokenPda({ mint: requirements.asset as Address, @@ -250,7 +230,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { : (TOKEN_2022_PROGRAM_ADDRESS as Address), }); - if (destATA !== expectedDestATA.toString()) { + if (transferDetails.destination !== expectedDestATA.toString()) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_recipient_mismatch", @@ -266,8 +246,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator { } // Verify transfer amount meets requirements - const amount = parsedTransfer.data.amount; - if (amount !== BigInt(requirements.amount)) { + if (transferDetails.amount !== BigInt(requirements.amount)) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_amount_mismatch", diff --git a/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts index 7d0dee6cf6..6535cdfc5a 100644 --- a/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/exact/v1/facilitator/scheme.ts @@ -3,15 +3,8 @@ import { parseSetComputeUnitLimitInstruction, parseSetComputeUnitPriceInstruction, } from "@solana-program/compute-budget"; -import { - parseTransferCheckedInstruction as parseTransferCheckedInstructionToken, - TOKEN_PROGRAM_ADDRESS, -} from "@solana-program/token"; -import { - findAssociatedTokenPda, - parseTransferCheckedInstruction as parseTransferCheckedInstruction2022, - TOKEN_2022_PROGRAM_ADDRESS, -} from "@solana-program/token-2022"; +import { TOKEN_PROGRAM_ADDRESS } from "@solana-program/token"; +import { findAssociatedTokenPda, TOKEN_2022_PROGRAM_ADDRESS } from "@solana-program/token-2022"; import { decompileTransactionMessage, getCompiledTransactionMessageDecoder, @@ -33,7 +26,10 @@ import { import { SettlementCache } from "../../../settlement-cache"; import type { FacilitatorSvmSigner } from "../../../signer"; import type { ExactSvmPayloadV1 } from "../../../types"; -import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "../../../utils"; +import { + decodeTransactionFromPayload, + getTransferDetailsFromCompiledInstruction, +} from "../../../utils"; /** * SVM facilitator implementation for the Exact payment scheme (V1). @@ -180,18 +176,21 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { }; } - const payer = getTokenPayerFromTransaction(transaction); - if (!payer) { + const transferDetails = getTransferDetailsFromCompiledInstruction( + compiled, + compiled.instructions[2], + ); + if (!transferDetails) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", payer: "", }; } + const payer = transferDetails.authority; // Step 4: Verify Transfer Instruction - const transferIx = instructions[2]; - const programAddress = transferIx.programAddress.toString(); + const programAddress = transferDetails.programAddress; if ( programAddress !== TOKEN_PROGRAM_ADDRESS.toString() && @@ -204,26 +203,9 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { }; } - // Parse the transfer instruction using the appropriate library helper - let parsedTransfer; - try { - if (programAddress === TOKEN_PROGRAM_ADDRESS.toString()) { - parsedTransfer = parseTransferCheckedInstructionToken(transferIx as never); - } else { - parsedTransfer = parseTransferCheckedInstruction2022(transferIx as never); - } - } catch { - return { - isValid: false, - invalidReason: "invalid_exact_svm_payload_no_transfer_instruction", - payer, - }; - } - // Verify that the facilitator's signers are not transferring their own funds // SECURITY: Prevent facilitator from signing away their own tokens - const authorityAddress = parsedTransfer.accounts.authority.address.toString(); - if (signerAddresses.includes(authorityAddress)) { + if (signerAddresses.includes(transferDetails.authority)) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", @@ -232,8 +214,7 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { } // Verify mint address matches requirements - const mintAddress = parsedTransfer.accounts.mint.address.toString(); - if (mintAddress !== requirements.asset) { + if (transferDetails.mint !== requirements.asset) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_mint_mismatch", @@ -242,7 +223,6 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { } // Verify destination ATA matches expected ATA for payTo address - const destATA = parsedTransfer.accounts.destination.address.toString(); try { const [expectedDestATA] = await findAssociatedTokenPda({ mint: requirements.asset as Address, @@ -253,7 +233,7 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { : (TOKEN_2022_PROGRAM_ADDRESS as Address), }); - if (destATA !== expectedDestATA.toString()) { + if (transferDetails.destination !== expectedDestATA.toString()) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_recipient_mismatch", @@ -269,8 +249,7 @@ export class ExactSvmSchemeV1 implements SchemeNetworkFacilitator { } // Verify transfer amount exactly matches requirements - const amount = parsedTransfer.data.amount; - if (amount !== BigInt(requirementsV1.maxAmountRequired)) { + if (transferDetails.amount !== BigInt(requirementsV1.maxAmountRequired)) { return { isValid: false, invalidReason: "invalid_exact_svm_payload_amount_mismatch", diff --git a/typescript/packages/mechanisms/svm/src/utils.ts b/typescript/packages/mechanisms/svm/src/utils.ts index c613a3034f..20567b85eb 100644 --- a/typescript/packages/mechanisms/svm/src/utils.ts +++ b/typescript/packages/mechanisms/svm/src/utils.ts @@ -22,6 +22,8 @@ import { DEVNET_RPC_URL, TESTNET_RPC_URL, MAINNET_RPC_URL, + MEMO_PROGRAM_ADDRESS, + SWIG_PROGRAM_ADDRESS, USDC_MAINNET_ADDRESS, USDC_DEVNET_ADDRESS, USDC_TESTNET_ADDRESS, @@ -32,6 +34,30 @@ import { } from "./constants"; import type { ExactSvmPayloadV1 } from "./types"; +const TOKEN_TRANSFER_CHECKED_DISCRIMINATOR = 12; +const SWIG_SIGN_V2_DISCRIMINATOR = 11; +const SWIG_SUBACCOUNT_SIGN_V1_DISCRIMINATOR = 9; + +type CompiledInstructionLike = { + programAddressIndex: number; + accountIndices?: readonly number[]; + data: Uint8Array; +}; + +type CompiledMessageLike = { + staticAccounts?: readonly { toString(): string }[]; + instructions?: readonly CompiledInstructionLike[]; +}; + +export type TransferDetails = { + programAddress: string; + source: string; + mint: string; + destination: string; + authority: string; + amount: bigint; +}; + /** * Normalize network identifier to CAIP-2 format * Handles both V1 names (solana, solana-devnet) and V2 CAIP-2 format @@ -92,30 +118,305 @@ export function decodeTransactionFromPayload(svmPayload: ExactSvmPayloadV1): Tra * @returns The token payer address as a base58 string */ export function getTokenPayerFromTransaction(transaction: Transaction): string { - const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); + const transferDetails = getTransferDetailsFromTransaction(transaction); + return transferDetails?.authority ?? ""; +} + +/** + * Extract canonical transfer details from the first supported payment + * instruction in a transaction. + * + * @param transaction - Decoded Solana transaction to inspect. + * @returns Canonical transfer details when a supported payment instruction is found. + */ +export function getTransferDetailsFromTransaction( + transaction: Transaction, +): TransferDetails | null { + const compiled = getCompiledTransactionMessageDecoder().decode( + transaction.messageBytes, + ) as CompiledMessageLike; + + for (const ix of compiled.instructions ?? []) { + const transferDetails = getTransferDetailsFromCompiledInstruction(compiled, ix); + if (transferDetails) { + return transferDetails; + } + } + + return null; +} + +/** + * Extract canonical transfer details from a compiled instruction using the + * built-in exact SVM payment parsers. + * + * @param compiled - Compiled transaction message containing the instruction. + * @param instruction - Compiled instruction to inspect. + * @returns Canonical transfer details when the instruction is recognized. + */ +export function getTransferDetailsFromCompiledInstruction( + compiled: CompiledMessageLike, + instruction: CompiledInstructionLike, +): TransferDetails | null { const staticAccounts = compiled.staticAccounts ?? []; - const instructions = compiled.instructions ?? []; + const direct = extractDirectTransferDetails(staticAccounts, instruction); + if (direct) { + return direct; + } + + return extractSwigTransferDetails(staticAccounts, instruction); +} + +/** + * Decode a direct top-level SPL `TransferChecked` instruction into canonical + * transfer details. + * + * @param staticAccounts - Static account list from the compiled message. + * @param instruction - Compiled instruction to inspect. + * @returns Canonical transfer details for a direct SPL transfer. + */ +function extractDirectTransferDetails( + staticAccounts: readonly { toString(): string }[], + instruction: CompiledInstructionLike, +): TransferDetails | null { + const programAddress = staticAccounts[instruction.programAddressIndex]?.toString(); + if ( + programAddress !== TOKEN_PROGRAM_ADDRESS.toString() && + programAddress !== TOKEN_2022_PROGRAM_ADDRESS.toString() + ) { + return null; + } + + const accountIndices = instruction.accountIndices ?? []; + if ( + accountIndices.length < 4 || + instruction.data.length < 10 || + instruction.data[0] !== TOKEN_TRANSFER_CHECKED_DISCRIMINATOR + ) { + return null; + } + + const source = staticAccounts[accountIndices[0]]?.toString(); + const mint = staticAccounts[accountIndices[1]]?.toString(); + const destination = staticAccounts[accountIndices[2]]?.toString(); + const authority = staticAccounts[accountIndices[3]]?.toString(); + if (!source || !mint || !destination || !authority) { + return null; + } - for (const ix of instructions) { - const programIndex = ix.programAddressIndex; - const programAddress = staticAccounts[programIndex].toString(); + return { + programAddress, + source, + mint, + destination, + authority, + amount: readU64LE(instruction.data, 1), + }; +} + +/** + * Decode a SWIG-wrapped SPL transfer into canonical transfer details. + * + * @param staticAccounts - Static account list from the compiled message. + * @param instruction - Compiled instruction to inspect. + * @returns Canonical transfer details for a supported SWIG wrapper. + */ +function extractSwigTransferDetails( + staticAccounts: readonly { toString(): string }[], + instruction: CompiledInstructionLike, +): TransferDetails | null { + if (staticAccounts[instruction.programAddressIndex]?.toString() !== SWIG_PROGRAM_ADDRESS) { + return null; + } + + const outerAccounts = Array.from( + instruction.accountIndices ?? [], + index => staticAccounts[index], + ); + if (outerAccounts.length === 0) { + return null; + } + + const compactInstructions = decodeSwigCompactInstructions(instruction.data); + if (!compactInstructions) { + return null; + } + + let transferDetails: TransferDetails | null = null; + for (const compactInstruction of compactInstructions) { + const innerProgramAddress = outerAccounts[compactInstruction.programAddressIndex]?.toString(); + if (!innerProgramAddress) { + return null; + } - // Check if this is a token program instruction if ( - programAddress === TOKEN_PROGRAM_ADDRESS.toString() || - programAddress === TOKEN_2022_PROGRAM_ADDRESS.toString() + innerProgramAddress === TOKEN_PROGRAM_ADDRESS.toString() || + innerProgramAddress === TOKEN_2022_PROGRAM_ADDRESS.toString() ) { - const accountIndices: number[] = ix.accountIndices ?? []; - // TransferChecked account order: [source, mint, destination, owner, ...] - if (accountIndices.length >= 4) { - const ownerIndex = accountIndices[3]; - const ownerAddress = staticAccounts[ownerIndex].toString(); - if (ownerAddress) return ownerAddress; + if (transferDetails) { + return null; + } + transferDetails = decodeTransferDetailsFromIndexes( + outerAccounts, + innerProgramAddress, + compactInstruction.accountIndices, + compactInstruction.data, + ); + if (!transferDetails) { + return null; } + continue; } + + if (innerProgramAddress === MEMO_PROGRAM_ADDRESS) { + continue; + } + + return null; } - return ""; + return transferDetails; +} + +/** + * Reconstruct canonical transfer details from indexed account references and + * `TransferChecked` instruction data. + * + * @param accounts - Resolved account list for the instruction scope. + * @param programAddress - Token program address for the transfer. + * @param accountIndices - Indexed transfer account order. + * @param data - Raw instruction data bytes. + * @returns Canonical transfer details when the instruction data matches `TransferChecked`. + */ +function decodeTransferDetailsFromIndexes( + accounts: readonly { toString(): string }[], + programAddress: string, + accountIndices: readonly number[], + data: Uint8Array, +): TransferDetails | null { + if ( + accountIndices.length < 4 || + data.length < 10 || + data[0] !== TOKEN_TRANSFER_CHECKED_DISCRIMINATOR + ) { + return null; + } + + const source = accounts[accountIndices[0]]?.toString(); + const mint = accounts[accountIndices[1]]?.toString(); + const destination = accounts[accountIndices[2]]?.toString(); + const authority = accounts[accountIndices[3]]?.toString(); + if (!source || !mint || !destination || !authority) { + return null; + } + + return { + programAddress, + source, + mint, + destination, + authority, + amount: readU64LE(data, 1), + }; +} + +/** + * Decode SWIG compact instruction bytes from SignV2 or SubAccountSignV1. + * + * @param data - Raw SWIG instruction data. + * @returns Decoded compact instructions when the payload matches a supported SWIG wrapper. + */ +function decodeSwigCompactInstructions(data: Uint8Array): CompiledInstructionLike[] | null { + if (data.length < 4) { + return null; + } + + const discriminator = readU16LE(data, 0); + let compactOffset = 0; + if (discriminator === SWIG_SIGN_V2_DISCRIMINATOR) { + compactOffset = 8; + } else if (discriminator === SWIG_SUBACCOUNT_SIGN_V1_DISCRIMINATOR) { + compactOffset = 16; + } else { + return null; + } + + const compactLength = readU16LE(data, 2); + if (compactLength <= 0 || compactOffset + compactLength > data.length) { + return null; + } + + const compactData = data.slice(compactOffset, compactOffset + compactLength); + if (compactData.length === 0) { + return null; + } + + const instructionCount = compactData[0]; + let offset = 1; + const instructions = []; + + for (let i = 0; i < instructionCount; i += 1) { + if (offset + 4 > compactData.length) { + return null; + } + + const programAddressIndex = compactData[offset]; + offset += 1; + const accountCount = compactData[offset]; + offset += 1; + if (offset + accountCount + 2 > compactData.length) { + return null; + } + + const accountIndices = Array.from(compactData.slice(offset, offset + accountCount)); + offset += accountCount; + const innerDataLength = readU16LE(compactData, offset); + offset += 2; + if (offset + innerDataLength > compactData.length) { + return null; + } + + const instructionData = compactData.slice(offset, offset + innerDataLength); + offset += innerDataLength; + + instructions.push({ + programAddressIndex, + accountIndices, + data: instructionData, + }); + } + + if (offset !== compactData.length) { + return null; + } + + return instructions; +} + +/** + * Read a little-endian `u16` from a byte buffer. + * + * @param data - Byte buffer to read from. + * @param offset - Start offset. + * @returns Parsed unsigned 16-bit integer. + */ +function readU16LE(data: Uint8Array, offset: number): number { + return data[offset] | (data[offset + 1] << 8); +} + +/** + * Read a little-endian `u64` from a byte buffer. + * + * @param data - Byte buffer to read from. + * @param offset - Start offset. + * @returns Parsed unsigned 64-bit integer as bigint. + */ +function readU64LE(data: Uint8Array, offset: number): bigint { + let result = 0n; + for (let i = 0; i < 8; i += 1) { + result |= BigInt(data[offset + i]) << BigInt(i * 8); + } + return result; } /** diff --git a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts index cb7fe1f535..12055edc36 100644 --- a/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/facilitator.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; import { ExactSvmScheme } from "../../src/exact/facilitator/scheme"; import { ExactSvmSchemeV1 } from "../../src/exact/v1/facilitator/scheme"; import { SettlementCache } from "../../src/settlement-cache"; @@ -7,6 +8,27 @@ import type { PaymentRequirements, PaymentPayload } from "@x402/core/types"; import type { PaymentPayloadV1, PaymentRequirementsV1 } from "@x402/core/types/v1"; import { USDC_DEVNET_ADDRESS, SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +type WrappedPaymentFixture = { + name: string; + transaction: string; + network: string; + asset: string; + payTo: string; + amount: string; + feePayer: string; + payer: string; +}; + +const wrappedPaymentFixtures = JSON.parse( + readFileSync( + new URL( + "../../../../../../go/mechanisms/svm/testdata/swig_wrapped_payments.json", + import.meta.url, + ), + "utf8", + ), +) as WrappedPaymentFixture[]; + describe("ExactSvmScheme", () => { let mockSigner: FacilitatorSvmSigner; @@ -405,6 +427,44 @@ describe("ExactSvmScheme", () => { expect(v1Result.errorReason).toBe("duplicate_settlement"); }); }); + + describe("built-in SWIG support", () => { + it.each(wrappedPaymentFixtures)("should verify $name wrapped transfers", async fixture => { + const facilitator = new ExactSvmScheme({ + ...mockSigner, + getAddresses: vi.fn().mockReturnValue([fixture.feePayer]) as never, + signTransaction: vi + .fn() + .mockImplementation(async (transaction: string) => transaction) as never, + simulateTransaction: vi.fn().mockResolvedValue(undefined) as never, + }); + + const payload: PaymentPayload = { + x402Version: 2, + resource: { + url: "http://example.com/protected", + description: "Test resource", + mimeType: "application/json", + }, + accepted: { + scheme: "exact", + network: fixture.network, + asset: fixture.asset, + amount: fixture.amount, + payTo: fixture.payTo, + maxTimeoutSeconds: 300, + extra: { feePayer: fixture.feePayer }, + }, + payload: { + transaction: fixture.transaction, + }, + }; + + const result = await facilitator.verify(payload, payload.accepted); + expect(result.isValid).toBe(true); + expect(result.payer).toBe(fixture.payer); + }); + }); }); describe("SettlementCache prune optimization", () => { diff --git a/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts index 0502996217..ec53c61041 100644 --- a/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/v1/facilitator.test.ts @@ -1,10 +1,32 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; import { ExactSvmSchemeV1 } from "../../../src/exact/v1/facilitator/scheme"; import type { FacilitatorSvmSigner } from "../../../src/signer"; import type { PaymentRequirementsV1 } from "@x402/core/types/v1"; import type { PaymentPayloadV1 } from "@x402/core/types/v1"; import { USDC_DEVNET_ADDRESS } from "../../../src/constants"; +type WrappedPaymentFixture = { + name: string; + transaction: string; + network: string; + asset: string; + payTo: string; + amount: string; + feePayer: string; + payer: string; +}; + +const wrappedPaymentFixtures = JSON.parse( + readFileSync( + new URL( + "../../../../../../../go/mechanisms/svm/testdata/swig_wrapped_payments.json", + import.meta.url, + ), + "utf8", + ), +) as WrappedPaymentFixture[]; + describe("ExactSvmSchemeV1", () => { let mockSigner: FacilitatorSvmSigner; @@ -293,4 +315,42 @@ describe("ExactSvmSchemeV1", () => { } }); }); + + describe("built-in SWIG support", () => { + it.each(wrappedPaymentFixtures)("should verify $name wrapped transfers", async fixture => { + const facilitator = new ExactSvmSchemeV1({ + ...mockSigner, + getAddresses: vi.fn().mockReturnValue([fixture.feePayer]) as never, + signTransaction: vi + .fn() + .mockImplementation(async (transaction: string) => transaction) as never, + simulateTransaction: vi.fn().mockResolvedValue(undefined) as never, + }); + + const payload: PaymentPayloadV1 = { + x402Version: 1, + scheme: "exact", + network: fixture.network, + payload: { + transaction: fixture.transaction, + }, + }; + + const requirements: PaymentRequirementsV1 = { + scheme: "exact", + network: fixture.network, + asset: fixture.asset, + maxAmountRequired: fixture.amount, + payTo: fixture.payTo, + maxTimeoutSeconds: 300, + extra: { + feePayer: fixture.feePayer, + }, + }; + + const result = await facilitator.verify(payload as never, requirements as never); + expect(result.isValid).toBe(true); + expect(result.payer).toBe(fixture.payer); + }); + }); });