Skip to content

Add Swig fee-transfer filtering across TypeScript, Go, and Python SDKs#1

Open
maxsch-xmint wants to merge 3 commits intoadd-swig-instructions-validationfrom
add-swig-fee-transfer-instruction-validation
Open

Add Swig fee-transfer filtering across TypeScript, Go, and Python SDKs#1
maxsch-xmint wants to merge 3 commits intoadd-swig-instructions-validationfrom
add-swig-fee-transfer-instruction-validation

Conversation

@maxsch-xmint
Copy link
Owner

Description

Add filterFeeTransfers to the Swig normalizer in all three SDKs (TypeScript, Go, Python) so x402 can skip internal fee/routing transfers that Swig injects and only validate the actual merchant payment.

How it works

When a Swig transaction contains multiple SPL TransferChecked instructions, the filter:

  1. Decodes each inner instruction as a TransferChecked (discriminator byte 12).
  2. Keeps transfers where destination matches the merchant ATA and mint matches the expected asset.
  3. Rejects as suspicious_fee_transfer any transfer whose source or destination is a signer address or the merchant owner.
  4. Discards remaining transfers (protocol fees, routing hops).

The normalizer now receives a NormalizationContext (asset mint, payTo address, signer addresses) from the facilitator and passes it through to the filter.

Files changed

Core filter logic

  • typescript/packages/mechanisms/svm/src/utils.tsfilterFeeTransfers, tryDecodeTransferChecked
  • go/pkg/mechanisms/svm/swig.goFilterFeeTransfers, tryDecodeTransferChecked
  • python/python_x402/swig.pyfilter_fee_transfers, try_decode_transfer_checked

Normalizer — accept context & call filter

  • typescript/packages/mechanisms/svm/src/normalizer.ts
  • go/pkg/mechanisms/svm/normalizer.go
  • python/python_x402/normalizer.py

Facilitator — supply NormalizationContext

  • typescript/packages/mechanisms/svm/src/schemes/exact/facilitator/scheme.ts
  • go/pkg/mechanisms/svm/schemes/exact/facilitator/scheme.go
  • python/python_x402/exact/facilitator.py

Test plan

  • TypeScript: vitest unit tests with real Swig transaction fixtures
  • Go: go test with equivalent fixtures
  • Python: pytest with equivalent fixtures
  • Edge cases: suspicious source/destination, no merchant transfer, multiple fee transfers
  • Manual: verify a live Swig payment still settles correctly through the facilitator

Tests

Tests

  • typescript/packages/mechanisms/svm/test/unit/swig-fee-transfer.test.ts
  • go/pkg/mechanisms/svm/swig_fee_transfer_test.go
  • python/tests/test_swig_fee_transfer.py

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits
  • I added a changelog fragment for user-facing changes (docs-only changes can skip)

@notorious-d-e-v
Copy link

notorious-d-e-v commented Mar 9, 2026

Hey @maxsch-xmint thanks for putting this together.

Here are some issues that stand out.


Issues

  1. Suspicious transfer check compares ATAs against ATA owner addresses (all SDKs)

The safety check compares sourceAddr/destAddr (which are token account addresses) against signerAddresses and payTo (which are owner/wallet addresses).

These will never match because an ATA is a PDA derived from the owner — it's a different address entirely.

Go (swig.go:157-164):

for _, signerAddr := range signerAddresses {
    if sourceAddr == signerAddr || destAddr == signerAddr {
        // sourceAddr is an ATA, signerAddr is an owner — never equal

TypeScript (utils.ts:1302-1306):

  signerAddresses.includes(sourceAddress) ||  // sourceAddress is ATA, signerAddresses are owners
  signerAddresses.includes(destATA) ||

Python (swig.py:776-780): Same issue.

To actually catch malicious fee transfers draining signer/merchant funds, you need to either:

  • Derive ATAs for each signer/payTo and compare against those, or
  • Resolve the token account owner on-chain and compare owners

As-is, the safety check is effectively a no-op — it will never trigger, making the ErrSuspiciousFeeTransfer error dead code.

  1. Assumes exactly 2 compute budget instructions at indices 0-1 (all SDKs)

The filter loop starts at i = 2 and the result always places instructions[0] and instructions[1] as compute budget prefix:

result := []solana.CompiledInstruction{instructions[0], instructions[1], *merchantTransfer}

If a Swig transaction has 0 or 1 compute budget instructions, or has them in a different order, this will silently include non-compute-budget
instructions in the wrong position, or misidentify the merchant transfer. The parent ParseSwigTransaction does produce compute budget instructions at
0-1, but this assumption isn't validated here and would break if the parser output format ever changes.

  1. TypeScript: ALT resolution makes an RPC call during verify() (scheme.ts)

The new code in scheme.ts:1057-1070 fetches ALT data from an RPC node during verification:

  const rpc = createRpcClient(requirements.network);
  decompiled = await decompileTransactionMessageFetchingLookupTables(compiled, rpc);

Why are the ALTs needed? Are we being too permissive here?

This introduces a network dependency into what was previously a pure/offline verification path. If the RPC is slow or unavailable, verification fails.

This is a behavioral change worth flagging — consider whether this should be documented or whether the ALT data could be passed through the payload instead.

  1. TypeScript: deriveExpectedATA fallback logic is wrong (utils.ts:1247-1263)
  async function deriveExpectedATA(asset: string, owner: string): Promise<string> {
    try {
      const [ata] = await findAssociatedTokenPda({ tokenProgram: TOKEN_PROGRAM_ADDRESS ... });
      return ata.toString();
    } catch {
      const [ata] = await findAssociatedTokenPda({ tokenProgram: TOKEN_2022_PROGRAM_ADDRESS ... });
      return ata.toString();
    }
  }

findAssociatedTokenPda is a deterministic PDA derivation — it doesn't do RPC calls and shouldn't throw for valid inputs. The try/catch fallback to Token-2022 won't trigger in practice.

More importantly, you need to check both ATAs (SPL Token and Token-2022) since the merchant transfer could use either program.

The Python implementation correctly does this (swig.py:758-759), but TypeScript only returns one.

  1. Go: Inconsistent error string prefix (swig.go:159 vs errors.go)

Go uses "invalid_exact_solana_payload_suspicious_fee_transfer" but Python and TypeScript use "invalid_exact_svm_payload_suspicious_fee_transfer". The Go
error constant in errors.go uses solana while the other SDKs use svm. Cross-SDK consumers expecting consistent error codes will see different strings.

  1. Error propagation change loses structured error info (all SDKs)

The change from:
return nil, x402.NewVerifyError(ErrNoTransferInstruction, "", err.Error())
to:
return nil, x402.NewVerifyError(err.Error(), "", err.Error())

...means the errorCode field now contains raw error messages instead of well-known constants. Any downstream consumers keying on specific error codes
(like ErrNoTransferInstruction) will break. The error message and error code serve different purposes — the code should remain a known constant even if the message changes.

  1. TypeScript: Indentation is broken (scheme.ts:1103)
  -    // Instruction count check AFTER flattening (3-6)
  +        // Instruction count check AFTER flattening (3-6)

Extra indentation was introduced on this comment line.

  1. TypeScript: as never casts (utils.ts:1234-1236, 1289)

return parseTransferCheckedInstructionToken(instruction as never) as never;

And:
const parsed = tryParseTransferChecked(instructions[i] as never);

These bypass type safety entirely.

The functions should accept the actual types or use proper type narrowing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants