Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions go/mechanisms/svm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go/mechanisms/svm/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 20 additions & 34 deletions go/mechanisms/svm/exact/facilitator/scheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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())
}

Expand Down Expand Up @@ -372,50 +375,34 @@ 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)
}
}

// Verify mint address
mintAddr := accounts[1].PublicKey.String()
mintAddr := transfer.Mint.String()
if mintAddr != requirements.Asset {
return errors.New(ErrMintMismatch)
}
Expand All @@ -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)
}

Expand All @@ -447,7 +433,7 @@ func (f *ExactSvmScheme) verifyTransferInstruction(
return errors.New(ErrAmountInsufficient)
}

if *transferChecked.Amount < requiredAmount {
if transfer.Amount < requiredAmount {
return errors.New(ErrAmountInsufficient)
}

Expand Down
99 changes: 99 additions & 0 deletions go/mechanisms/svm/exact/facilitator/wrapped_transfer_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
54 changes: 20 additions & 34 deletions go/mechanisms/svm/exact/v1/facilitator/scheme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
}

Expand Down Expand Up @@ -377,50 +380,34 @@ 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)
}
}

// Verify mint address
mintAddr := accounts[1].PublicKey.String()
mintAddr := transfer.Mint.String()
if mintAddr != requirements.Asset {
return errors.New(ErrMintMismatch)
}
Expand All @@ -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)
}

Expand All @@ -454,7 +440,7 @@ func (f *ExactSvmSchemeV1) verifyTransferInstruction(
return errors.New(ErrAmountInsufficient)
}

if *transferChecked.Amount < requiredAmount {
if transfer.Amount < requiredAmount {
return errors.New(ErrAmountInsufficient)
}

Expand Down
Loading
Loading