feat(svm): add Swig smart wallet support via transaction flattening#1380
feat(svm): add Swig smart wallet support via transaction flattening#1380maxsch-xmint wants to merge 13 commits intox402-foundation:mainfrom
Conversation
🟡 Heimdall Review Status
|
|
@maxsch-xmint is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Nice work @maxsch-xmint!
Overall I agree with this approach and would rather allowlist known Solana programs rather than keeping it open to any arbitrary program.
Sorry if the review sounds wordy or written by AI -- I promise that I made multiple passes with multiple AI models :)
The flattening approach is a clean architectural choice — normalizing Swig transactions before verification avoids branching in every validation step. That said, there are:
- two parsing bugs that could cause this to produce incorrect results on real Swig transactions
- a scalability concern with how this sets precedent for future wallet types
- and some smaller code quality issues.
🔴 Critical: Compact instruction parsing doesn't match the on-chain format
As far as I can telll, there are two bugs in DecodeSwigCompactInstructions / decodeSwigCompactInstructions that cause the parser to produce incorrect results for real Swig transactions. The unit tests pass because the test helpers construct synthetic data with the same structural errors.
Bug 1: Missing numInstructions count byte
The Swig contract serializes compact instructions with a leading u8 count byte (compact_instructions.rs:164-175):
pub fn into_bytes(&self) -> Vec<u8> {
let mut bytes = vec![self.inner_instructions.len() as u8]; // ← count byte
for ix in self.inner_instructions.iter() {
bytes.push(ix.program_id_index);
bytes.push(ix.accounts.len() as u8);
bytes.extend(ix.accounts.iter());
bytes.extend((ix.data.len() as u16).to_le_bytes());
bytes.extend(ix.data.iter());
}
bytes
}And the on-chain parser confirms this (lib.rs — InstructionIterator::new):
cursor: 1, // Start AFTER the number of instructions byte
remaining: unsafe { *data.get_unchecked(0) } as usize, // byte 0 is the countThe actual payload format starting at offset 8 is:
[0] numInstructions U8 ← PR skips this
[1] programIdIndex U8
[2] numAccounts U8
...
But the PR starts reading directly at offset 8, treating the count byte as the first instruction's programIdIndex:
// Go — swig.go
offset := startOffset // = 8
for offset < endOffset {
programIDIndex := data[offset] // ← this is actually the count byte// TypeScript — utils.ts
let offset = startOffset; // = 8
while (offset < endOffset) {
const programIdIndex = data[offset]; // ← this is actually the count byteWith a real Swig transaction containing 1 embedded transferChecked, the count byte 0x01 gets read as programIdIndex = 1, and every subsequent field is shifted by one byte — producing wrong instruction data.
Bug 2: Compact indices need remapping through SignV2's account list
In the Swig SDK, compact instruction indices reference positions in the SignV2 instruction's account list, not the transaction's global static accounts. This is visible in how the SDK builds them (compact_instructions.rs:35-72) — indices are assigned into a local accounts vector that becomes SignV2's account list. On-chain, the iterator resolves them against all_accounts passed to the SignV2 instruction (lib.rs — parse_next_instruction):
let program_id = self.accounts.get_account(program_id_index as usize)?.pubkey();
// ^^^^^^^^^ SignV2's accounts, NOT tx static accountsThe correct resolution is: compact_index → signV2.accounts[compact_index] → tx.staticAccounts[that_value]
But the PR resolves directly: compact_index → tx.staticAccounts[compact_index]
// Go — swig.go:305-311
result = append(result, solana.CompiledInstruction{
ProgramIDIndex: uint16(ci.ProgramIDIndex), // ← treated as global index
Accounts: accounts, // ← treated as global indices// TypeScript — utils.ts:941-949
programAddress: staticAccounts[ci.programIdIndex], // should be staticAccounts[signV2.accounts[ci.programIdIndex]]
accounts: ci.accounts.map(idx => ({
address: staticAccounts[idx], // should be staticAccounts[signV2.accounts[idx]]These only align when the SignV2 instruction's account ordering happens to match the transaction's static account ordering, which is not guaranteed and not how the Swig SDK constructs transactions.
Security implications of both bugs combined
The facilitator's static checks (mint, destination ATA, amount, authority) operate on the flattened instructions, while simulation runs on the real transaction.
Simulation only proves execution success — not that the mint, destination, and amount are what the facilitator expects.
If broken index resolution produces addresses that happen to satisfy the static checks, the facilitator would signal to a merchant that the payment was made to the merchant whereas the actual on-chain execution pays a different recipient or amount than what verification believed.
Suggested fix:
- Consume and validate the
numInstructionscount byte at the start of the compact payload - Remap compact indices through
signV2.accounts[]before resolving against global static accounts
🟡 Unit tests need to match the real transaction layout
The test helpers (buildSwigInstructionData in Go, buildSwigData/buildTransferCheckedCompact in TS) construct synthetic Swig data that omits the count byte and uses direct indices — matching the buggy parser rather than the actual on-chain format. Once the parsing bugs above are fixed, the test data must be updated to:
- Prepend the
numInstructionscount byte to the compact instruction payload - Use indices relative to a synthetic SignV2 account list, then include that account list in the test transaction so the remapping can be verified end-to-end
🟡 Missing Python implementation
Recent PRs to the coinbase/x402 repo have included implementations across TypeScript, Go, and Python. The Python SDK has a full SVM facilitator (python/x402/mechanisms/svm/exact/facilitator.py) with the same instruction layout validation (3-6 instructions, compute budget checks, transfer verification, simulation). It would reject Swig transactions for the same structural mismatch reasons. Can we include a Python implementation here for completeness and cross-SDK parity?
🟡 Does this need x402 v1 support?
The v1 SVM facilitator exists in both TypeScript (svm/src/exact/v1/facilitator/scheme.ts) Go (svm/exact/v1/facilitator/scheme.go), and Python with the same rigid 3-6 instruction layout validation.
Currently only x402 v2 gets Swig support. Should v1 be included as well? @CarsonRoscoe @phdargen — would appreciate your input on the v1 question.
🟡 Design: Scalability for future smart wallet types
This PR establishes the pattern for how x402 handles non-standard transaction layouts. If more smart wallets are added in the future (Squads, etc.), the current approach produces a growing if/else chain in Verify():
if (isSwigTransaction(instructions)) {
// ...
} else if (isSquadsTransaction(instructions)) {
// ...
} else if (isFutureWalletTransaction(instructions)) {
// ...
} else {
// regular path
}Each wallet type adds detection, parsing, constants, and compact instruction decoding — all duplicated across Go, TypeScript, and Python, all inlined in the facilitator.
Consider formalizing the flattening concept behind a normalizer interface:
interface TransactionNormalizer {
canHandle(instructions: CompiledInstruction[]): boolean;
normalize(instructions: CompiledInstruction[], accounts: Address[]): {
instructions: CompiledInstruction[];
payer: string;
};
}The facilitator becomes wallet-agnostic (normalizers.find(n => n.canHandle(...))), and each wallet type is a self-contained module. Since this PR is setting the precedent, it's worth getting the abstraction right from the start.
🟡 Other issues
-
Bare string throw (TS
utils.ts:273):throw "invalid_exact_svm_payload_no_transfer_instruction"— every other error in the package usesthrow new Error(...). A bare string loses the stack trace and won't matcherror instanceof Errorchecks (e.g.,scheme.ts:473-474). -
as nevertype casts (TSscheme.ts:154-155):isSwigTransaction(instructions as never)andparseSwigTransaction(instructions as never, ...)bypass type safety. The function signatures should accept the actual decompiled instruction type if possible, rather than requiring the caller to escape the type system. -
Inconsistent error handling between SDKs: Go's
DecodeSwigCompactInstructionsreturns an error when data is <4 bytes. TypeScript's version silently returns an empty array. A malformed Swig transaction would fail differently in each SDK. -
No bounds checking on
staticAccounts[ci.programIdIndex](TS): If a compact instruction references an index beyondstaticAccounts.length, this silently returnsundefinedand produces confusing downstream errors. -
signV1mentioned in comments but unsupported: Multiple docstrings reference "signV1/signV2" but only V2 (discriminator 11) is supported. The comments should clarify thatsignV1is intentionally excluded. -
No PDA validation: The Swig PDA is extracted from
signV2.accounts[0]and trusted as-is. An explicitfindProgramAddressderivation check would add defense-in-depth. -
Missing changeset/changelog fragments: The PR checklist requires changeset/changelog entries.
|
@phdargen @CarsonRoscoe can you please take a look when you get a chance? |
|
Good work on the Swig support @maxsch-xmint. We've been running a different approach to smart wallet verification in production at Dexter that I think is worth considering for the upstream facilitator. It handles all smart wallet types generically: no per-wallet parsers or binary format decoders. The problem with per-wallet normalizationEach new wallet type needs its own Simulation-based outcome verificationWe verify payment outcomes by examining the simulation output rather than parsing each wallet type's proprietary instruction format. The facilitator already calls The pipeline:
This works for any wallet program that executes Ecosystem resultsWe tested this against the ecosystem Solana v2 facilitators using a real Squads multisig vault transaction (verify and settle):
Next stepThe simulation approach handles all smart wallet types, including Swig, with a single code path and no per-wallet parsing. Since the facilitator already pays the cost of PR with this approach: #1527 |
|
PR with the simulation-based approach: #1527 |
|
@maxsch-xmint your issue #1458 (allowing wallet provider fee instructions) is another case where the positional allowlist would need expanding. The simulation-based approach in #1527 handles this without allowlist changes — it verifies exactly one TransferChecked matching payment requirements and is agnostic to any other instructions in the transaction. |
Description
feat(svm): add Swig smart wallet support via transaction flattening
Summary
SwigNormalizer/RegularNormalizer) to transparently handle both Swig and regular transactionsMotivation
Swig smart wallets wrap user operations inside a
SignV2instruction that bundles compact sub-instructions, account lists, and a secp256r1 signature verification. A raw Swig transaction looks nothing like a regular SPL token transfer, so the existing facilitator verification logic (which expects a standardTransferCheckedat a known position) rejects them outright.Rather than special-casing the verification logic, we flatten Swig transactions into the same instruction layout the facilitator already understands, then let the existing checks run unchanged.
Approach
Swig transaction layout
A Swig transaction arriving at the facilitator has this shape:
Each SignV2 instruction embeds compact instructions — a packed binary format where each sub-instruction references accounts by index into the SignV2's own account list.
Flattening strategy
parseSwigTransactiontransforms this into:Secp256r1 precompile instructions are filtered out (they are Swig internals). Each compact instruction's local account indices are resolved to actual addresses through the SignV2's account list.
Normalizer pattern
The facilitator's
verify()pipeline callsnormalizeTransaction(), which tries each normalizer in order:isSwigTransaction()returns true, flatten viaparseSwigTransaction()and set payer to the Swig PDAThis keeps the verification logic clean — it always receives a flat instruction list regardless of whether the original transaction was Swig or regular.
New functions
isSwigTransaction(instructions)→booleanDetermines whether a transaction has the Swig layout. Iterates all instructions and returns
trueonly when:parseSwigTransaction(instructions, staticAccounts)→{ instructions, swigPda }Flattens a Swig transaction into regular instructions:
accounts[0], validate wallet address derivation viagetProgramDerivedAddress, decode compact instructions, resolve account indicesswigPdadecodeSwigCompactInstructions(data)→SwigCompactInstruction[]Decodes the binary compact instruction format embedded in SignV2 data:
programIdIndex(u8), accounts (u8[]), data length (u16 LE), raw dataChanges
typescript/packages/mechanisms/svm/src/utils.tsisSwigTransaction,parseSwigTransaction,decodeSwigCompactInstructionstypescript/packages/mechanisms/svm/src/normalizer.tsSwigNormalizer,RegularNormalizer,normalizeTransactiontypescript/packages/mechanisms/svm/src/constants.tsSWIG_PROGRAM_ADDRESS,SWIG_SIGN_V2_DISCRIMINATOR,SECP256R1_PRECOMPILE_ADDRESStypescript/packages/mechanisms/svm/src/index.tstypescript/packages/mechanisms/svm/src/exact/facilitator/scheme.tsnormalizeTransaction()in verify pipelinego/mechanisms/svm/swig.goIsSwigTransaction,ParseSwigTransaction,DecodeSwigCompactInstructionsgo/mechanisms/svm/constants.gogo/mechanisms/svm/normalizer.goSwigNormalizer,RegularNormalizer,NormalizeTransactiongo/mechanisms/svm/exact/facilitator/scheme.goNormalizeTransaction()in verify pipelinetypescript/packages/mechanisms/svm/test/unit/facilitator.test.tsgo/test/unit/svm_test.gopython/x402/mechanisms/svm/constants.pySWIG_PROGRAM_ADDRESS,SWIG_SIGN_V2_DISCRIMINATOR,SECP256R1_PRECOMPILE_ADDRESSpython/x402/mechanisms/svm/swig.pyis_swig_transaction,parse_swig_transaction,decode_swig_compact_instructionspython/x402/mechanisms/svm/normalizer.pySwigNormalizer,RegularNormalizer,normalize_transactionpython/x402/mechanisms/svm/__init__.pypython/x402/mechanisms/svm/exact/facilitator.pynormalize_transaction()in verify pipelinepython/x402/tests/unit/mechanisms/svm/test_swig.pySecurity invariants
isSwigTransactiononly allows ComputeBudget, Secp256r1Precompile, Swig SignV2 — rejects anything elseaccounts[1]is cross-validated againstgetProgramDerivedAddress(SWIG_PROGRAM, ["swig-wallet-address", pda])swig_pda_mismatchotherwiseTests
isSwigTransactiondecodeSwigCompactInstructionsparseSwigTransactionnormalizeTransactionAdded tests for a real Swig transaction parsing. Explorer link
CI
tsc --noEmit)go build ./...)Checklist