Skip to content

feat(svm): add simulation-based smart wallet verification#1527

Open
BranchManager69 wants to merge 9 commits intox402-foundation:mainfrom
BranchManager69:feat/svm-smart-wallet-simulation
Open

feat(svm): add simulation-based smart wallet verification#1527
BranchManager69 wants to merge 9 commits intox402-foundation:mainfrom
BranchManager69:feat/svm-smart-wallet-simulation

Conversation

@BranchManager69
Copy link
Copy Markdown
Contributor

@BranchManager69 BranchManager69 commented Mar 9, 2026

Description

Adds wallet-agnostic smart wallet verification to the SVM facilitator (TypeScript). Verifies payment outcomes by inspecting top-level instructions and the CPI trace from transaction simulation, rather than parsing each wallet type's proprietary instruction format.

Works for any smart wallet program (Squads, Swig, SPL Governance, Crossmint, future wallets) that executes TransferChecked via CPI. No per-wallet code required. This approach has been running in production at Dexter and is the first implementation to verify and settle smart wallet transactions on-chain.

Motivation

The existing SVM facilitator rejects any transaction that doesn't match the exact 3-6 instruction layout (ComputeLimit + ComputePrice + TransferChecked + optional Lighthouse/Memo). Smart wallet programs wrap transfers inside their own instructions (e.g., Squads vaultTransactionExecute, Swig SignV2), so the facilitator sees an unknown program at position [2] and rejects with no_transfer_instruction.

This is a fundamental limitation of positional instruction validation. Every smart wallet has a different instruction format, but they all do the same thing: execute a TransferChecked via CPI.

Approach

ExactSvmScheme.verify() now has two paths:

Path 1 (Static): Existing positional instruction validation for standard wallets. Unchanged. Standard wallets keep this fast path; simulation is only a fallback for transactions the current verifier cannot structurally understand.

Path 2 (Simulation): When Path 1 rejects and enableSmartWalletVerification is set:

  1. Fee payer isolation: the facilitator's fee payer must not appear in any instruction's accounts. If the fee payer is never referenced, the Solana runtime cannot authorize it for SOL transfers, token approvals, ATA creation, account closes, or any other drain vector.
  2. Compute budget caps: operator-configurable CU limit and priority fee caps on ComputeBudget instructions. Bounds the facilitator's fee exposure.
  3. Simulate with innerInstructions: true: the facilitator already calls simulateTransaction as its final verification step. Path 2 uses the same RPC method with innerInstructions: true to get the full CPI trace. No additional round-trip.
  4. Match exactly one transfer: require exactly one TransferChecked across top-level instructions and the CPI trace, matching payment requirements (correct mint, destination ATA derived from payTo, amount exactly equals required).
flowchart TD
    TX["Transaction arrives"] --> DECODE["Decode"]
    DECODE --> P1{"Path 1: Static layout check"}
    P1 -->|"Layout matches"| SIM1["Sign + Simulate"]
    SIM1 --> PASS1["VERIFIED"]
    P1 -->|"Unknown program / wrong layout"| SW{"Smart wallet enabled?"}
    SW -->|"No"| REJECT["REJECTED"]
    SW -->|"Yes"| FPI{"Fee payer isolated?"}
    FPI -->|"No"| REJECT
    FPI -->|"Yes"| CB{"Compute budget OK?"}
    CB -->|"No"| REJECT
    CB -->|"Yes"| SIM2["Simulate + innerInstructions"]
    SIM2 -->|"Fails"| REJECT
    SIM2 -->|"Succeeds"| EXTRACT["Extract TransferChecked"]
    EXTRACT --> MATCH{"Exactly 1 match?"}
    MATCH -->|"No"| REJECT
    MATCH -->|"Yes"| PASS2["VERIFIED"]
Loading

Security model

Property How it's enforced
Facilitator funds protected Fee payer isolation: fee payer never appears in any instruction's accounts list
Correct payment recipient Destination ATA derived from payTo + mint, matched against observed transfers
Correct payment amount Amount from TransferChecked checked against requirements
Correct token Mint from TransferChecked checked against requirements
Fee exposure bounded Operator-configurable compute unit and priority fee caps
No double-payment Exactly one matching transfer required; multiple matches rejected
Transaction viability Simulation proves the transaction would succeed on-chain

Production data

Tested against ecosystem facilitators using a real Squads multisig vault transaction, both verify and settle. No other facilitator currently passes.

Changes

File Change
svm/src/signer.ts Add optional simulateTransactionWithInnerInstructions to FacilitatorSvmSigner; implement in default factory
svm/src/exact/facilitator/scheme.ts Add ExactSvmSchemeOptions with smart wallet config; refactor verify() into dual-path (static + simulation)
svm/src/exact/facilitator/smartWalletVerification.ts New: assertFeePayerIsolated, validateComputeBudgetLimits, extractTransfersFromInnerInstructions, verifySmartWalletTransaction
svm/src/index.ts Re-export smart wallet helpers and types
svm/test/unit/smartWalletVerification.test.ts New: 19 unit tests
svm/test/unit/smartWalletFallback.test.ts New: 3 integration tests (fallback path verify)

Go and Python implementations are left for follow-up. This PR is intentionally TypeScript-only to validate the upstream architecture first. If maintainers agree with outcome-based verification as the direction, Go/Python parity can follow without replicating wallet-specific parsers across all SDKs. This PR targets non-ALT smart-wallet transaction shapes first; ALT-aware inner instruction resolution can follow with concrete test cases.

Usage

import { ExactSvmScheme } from "@x402/svm";

const scheme = new ExactSvmScheme(signer, undefined, {
  enableSmartWalletVerification: true,
  smartWalletMaxComputeUnits: 400_000,            // optional, default 400k
  smartWalletMaxPriorityFeeMicroLamports: 50_000,  // optional, default 50k
});

Tests

  • All existing tests pass
  • 19 new smart wallet verification tests pass (security helpers, adversarial cases, extraction, fallback path integration)
  • ESM and CJS builds succeed
  • No new TypeScript errors introduced

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed

@cb-heimdall
Copy link
Copy Markdown

cb-heimdall commented Mar 9, 2026

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 9, 2026

@BranchManager69 is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added typescript sdk Changes to core v2 packages labels Mar 9, 2026
@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch 6 times, most recently from 9bfa824 to 870b3c6 Compare March 10, 2026 00:54
@BranchManager69
Copy link
Copy Markdown
Contributor Author

This approach is intentionally outcome-based rather than wallet-parser-based. The verifier does not trust arbitrary wallet programs blindly; it only accepts transactions that (1) never reference the facilitator fee payer in instruction accounts, (2) stay within bounded compute/priority-fee limits, (3) simulate successfully, and (4) produce exactly one TransferChecked matching the required mint, destination ATA, and exact amount. Standard wallets remain on the existing static verification path unchanged; this is only a fallback for smart-wallet transaction shapes the current verifier cannot parse structurally.

@tenequm
Copy link
Copy Markdown
Contributor

tenequm commented Mar 11, 2026

Nice to see this direction getting an upstream implementation. This aligns closely with the outcome-based verification model I proposed in #646 back in November - same two-path architecture, fee payer isolation, simulation with innerInstructions: true, exactly-one-TransferChecked.

We've been running this approach in production (facilitator.cascade.fyi) and wanted to flag two bugs you'll likely hit:

1. Solana RPC returns innerInstructions in jsonParsed format for known programs

When you call simulateTransaction with innerInstructions: true, the RPC returns "jsonParsed where possible, otherwise json." For SPL Token, that means TransferChecked comes back as:

{
  "parsed": {
    "type": "transferChecked",
    "info": { "mint": "...", "destination": "...", "authority": "...", "tokenAmount": { "amount": "10000" } }
  },
  "program": "spl-token",
  "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
}

There's no data field. If your extraction code assumes raw format and tries to decode ix.data, you get Buffer.from(undefined). This is what breaks Squads Multisig v4 vaultTransactionExecute transactions - the CPI'd TransferChecked is a known program, so the RPC parses it.

You need to handle both formats: parsed (read from parsed.info) and raw (decode from data). Same applies to detecting unauthorized transfer types - check parsed.type for "transfer", "transferCheckedWithFee", etc.

2. Raw inner instructions use base58, not base64

The raw format data field is base58-encoded, not base64. The top-level simulateTransaction uses base64 for the transaction wire format, but inner instruction data comes back as base58. Using the wrong decoder silently produces garbage bytes and fails to match discriminators.


Both confirmed against real Squads Multisig v4 vault transactions on mainnet. We have regression tests covering parsed inner instruction handling if useful as reference.

One design note: your PR requires exact amount match, while #646 specifies amount >= maxAmountRequired (allowing overpayment). Overpayment tolerance matters for smart wallets where the CPI amount might include rounding from internal fee calculations.

@github-actions github-actions Bot added examples Changes to examples legacy Changes to legacy sdk or examples labels Mar 11, 2026
@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch from 63e0f0d to 6948c57 Compare March 11, 2026 03:43
@github-actions github-actions Bot removed examples Changes to examples legacy Changes to legacy sdk or examples labels Mar 11, 2026
@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch 3 times, most recently from a9ee98e to 10601a6 Compare March 11, 2026 03:56
@notorious-d-e-v
Copy link
Copy Markdown
Contributor

Great work on this PR — we like the outcome-based verification direction and think simulation + CPI trace inspection is the right architecture for supporting smart wallets without per-wallet parsers. We've done a security review and have some feedback.

+1 to @tenequm's comment on jsonParsed vs raw format handling — worth verifying against real Squads v4 vault transactions.

Security Invariants

# Invariant Description
I1 No token loss Fee payer must never be used as authority/source/delegate on any token operation. Enforced before signing.
I2 Bounded SOL exposure Per-transaction SOL cost capped via compute budget limits.
I3 Merchant truth Success returned only after confirming actual on-chain effects — not simulation.
I4 Payment landed At least one confirmed TransferChecked matches the full required amount, correct mint, and correct destination ATA.
I5 Simulation is non-critical Simulation saves fees. Security comes from I1 (pre-sign) and I3 (post-settle).
I6 No hidden accounts Every account the transaction can access must be visible to verification.
I7 Known programs only Top-level smart wallet program must be from an audited allowlist.

Recommendations

1. Post-Settlement Verification ✅

Commit 6948c574 addresses the TOCTOU risk. Simulation becomes a pre-check; the security guarantee comes from inspecting confirmed on-chain effects.

2. Address Lookup Table Blindness (must fix)

assertFeePayerIsolated calls decompileTransactionMessage(compiled) without resolving ALTs. For v0 transactions, ALT-resolved accounts are invisible to the isolation check. Since the fee payer has signed the transaction, an instruction referencing the fee payer via an ALT-resolved account could drain the facilitator's tokens. This violates I1.

Wallet providers like Crossmint use ALTs when constructing transactions, so rejecting ALTs outright would limit smart wallet coverage. We recommend resolving them — decompileTransactionMessageFetchingLookupTables(compiled, rpc) is already used in the legacy facilitator code. One additional batched RPC call per ALT transaction.

3. Program Allowlist (should have)

Path 2 currently allows any program to execute. An allowlist for the top-level non-ComputeBudget instructions (e.g., Squads, Swig) constrains the surface to audited programs.

4. Relax "Exactly One" Transfer (should have)

Some smart wallets bundle ATA creation with the payment, producing multiple transfers. Recommend relaxing to "at least one transfer matching the full required amount."

Summary

Priority Change
Must have Resolve ALTs before fee payer isolation check (I1 / I6)
Already in progress Post-settlement verification ✅
Should have Program allowlist (I7)
Should have Relax to "at least one matching full amount" (I4)

@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch 2 times, most recently from f25b533 to b0af04c Compare March 12, 2026 23:57
@BranchManager69
Copy link
Copy Markdown
Contributor Author

BranchManager69 commented Mar 12, 2026

Thanks for the detailed feedback and the production validation from Cascade. This aligns well with what we found during development. A few responses:

1 & 2: jsonParsed format + base58 encoding — Good catches — these are exactly the traps we ran into during development. Both are handled in the current code:

  • jsonParsed format (extractTransfersFromInnerInstructions, lines 199-215): checks parsed.type === "transferChecked" and reads from parsed.info.mint, parsed.info.destination, parsed.info.authority, parsed.info.tokenAmount.amount. Never touches data in this path.
  • Raw compiled format (lines 218-243): decodes data from base58 using getBase58Encoder().encode(dataStr), checks discriminator byte 12, parses amount/accounts from byte layout.

Both paths were confirmed against real Squads Multisig v4 vaultTransactionExecute transactions on mainnet.

Overpayment tolerance: Changed === to >= in both the simulation matching and post-settlement verification paths. Smart wallets with internal fee rounding should now be accepted.

Would welcome any reference to your regression tests for parsed inner instruction handling if you're open to sharing.

@BranchManager69
Copy link
Copy Markdown
Contributor Author

BranchManager69 commented Mar 12, 2026

Thanks for the thorough security review and the invariant framework — it's exactly the right way to think about this.

Responses to each recommendation:

1. Post-Settlement Verification ✅ — Implemented. Primary path fetches confirmed transaction inner instructions via getConfirmedTransactionInnerInstructions. Fallback checks destination ATA balance delta for RPC indexing lag resilience. Both signer methods are now required when enableSmartWalletVerification is enabled — the constructor throws if they're missing. No silent degradation. This satisfies I3 and I5.

2. Address Lookup Table Blindness ✅ — Fixed. assertFeePayerIsolated now checks for addressTableLookups in the compiled message. If ALTs are present and the signer implements fetchAddressLookupTables, they're resolved via fetchAddressesForLookupTables from @solana/kit before the isolation check runs against the full account list. If ALTs are present but no resolution method exists, the transaction is rejected with smart_wallet_alt_resolution_not_available. Non-ALT transactions are unchanged. This satisfies I1 and I6.

3. Program Allowlist ✅ — Agreed, this is a good additional layer. Added an operator-configurable allowlist with sensible defaults (Squads Multisig v4, Squads Smart Account, Swig, SPL Governance). Transactions invoking programs not in the list are rejected with smart_wallet_program_not_allowed before reaching simulation.

4. Relax "Exactly One" Transfer — We checked the CPI patterns for Squads Multisig v4, Squads Smart Account (useSpendingLimit), Swig (SignV2), and the Crossmint fee model from #1458. In every case, extra transfers (like Crossmint's treasury fee) go to a different destination and don't match the filter. With the allowlist constraining which programs can reach Path 2, this is consistent — unknown wallets are blocked before the transfer count check.

All changes are in the latest commits. Happy to discuss any of these further.

@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch from b0af04c to b9678c8 Compare March 13, 2026 02:03
@github-actions github-actions Bot added examples Changes to examples legacy Changes to legacy sdk or examples labels Apr 12, 2026
@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch from 4a3ac7b to 40b993f Compare April 12, 2026 02:48
@github-actions github-actions Bot removed examples Changes to examples legacy Changes to legacy sdk or examples labels Apr 12, 2026
@romeo4934
Copy link
Copy Markdown

@BranchManager69 any update guys ? :)

@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch 2 times, most recently from ab5a89c to 0ee2470 Compare April 15, 2026 00:18
@BranchManager69
Copy link
Copy Markdown
Contributor Author

@tenequm rebased past #1688 and mirrored your memo enforcement onto Path 2, so smart wallet payments can't bypass a seller-required memo. Same error codes as your Step 5b, single top-level instruction shape, covered end-to-end in smartWalletFallback.test.ts. Exempted the Memo program from Path 2's wallet allowlist so it can actually reach the check, same treatment as ComputeBudget since it's not a wallet program. CI is green on the rebased branch. Would appreciate another look when you have a window.

@notorious-d-e-v
Copy link
Copy Markdown
Contributor

Thanks @BranchManager69 nice work! Looks good to me and excited to have this supported!

@phdargen @CarsonRoscoe could you please take a look at this when you get a chance?

@romeo4934
Copy link
Copy Markdown

This PR supports only TS, is it normal ? It should support all language right Go and Python ?

@notorious-d-e-v
Copy link
Copy Markdown
Contributor

This PR supports only TS, is it normal ? It should support all language right Go and Python ?

Yes it should support all.

Would like to get some input from the maintainers first before more work is put into it

@romeo4934
Copy link
Copy Markdown

@phdargen @CarsonRoscoe can you check this PR ? this is essential for Solana.

@blockiosaurus
Copy link
Copy Markdown

Could you please also add CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d to the list of allowed smart wallets? Through our agent identity platform the agentic execution on the SVM happens through the Metaplex Core program PDA.

@romeo4934
Copy link
Copy Markdown

Swig as well!

Adds wallet-agnostic smart wallet verification to the SVM facilitator.
Verifies payment outcomes by inspecting CPI inner instructions from
transaction simulation, rather than parsing each wallet type's
proprietary instruction format.

Works for any smart wallet program (Squads, Swig, SPL Governance, etc.)
that executes TransferChecked via CPI. No per-wallet code required.

Security model:
- Fee payer isolation (fee payer never in instruction accounts)
- Operator-configurable compute budget caps
- Exactly one matching TransferChecked in CPI trace
- Simulation proves transaction viability

Made-with: Cursor
After confirmTransaction on Path 2 (smart wallet) settlements, verify
the TransferChecked actually executed on-chain before returning success.

Primary: fetch confirmed transaction inner instructions via getTransaction.
Fallback: balance-delta check on destination ATA (no indexing lag).

Closes the TOCTOU gap where a malicious program could pass simulation
but skip the transfer during on-chain execution.

New optional signer methods:
- getConfirmedTransactionInnerInstructions
- getTokenAccountBalance

5 new test cases covering both verification paths and failure modes.

Made-with: Cursor
- assertFeePayerIsolated now resolves Address Lookup Tables before
  checking, catching fee payer hidden in ALT-resolved accounts
- Constructor requires all 3 signer methods when smart wallets enabled
  (simulateWithInnerInstructions, getConfirmedTransaction, getTokenBalance)
- Changed === to >= for overpayment tolerance (per RFC x402-foundation#646)
- Removed silent "unverified" fallthrough in post-settlement verification
- New optional fetchAddressLookupTables signer method
- 8 new tests: ALT handling, constructor enforcement, overpayment, double RPC failure

Made-with: Cursor
Operator-configurable allowlist gates which programs can reach
simulation-based verification. Prevents custom malicious programs
from exploiting the simulation path entirely.

Default: Squads Multisig v4, Squads Smart Account, Swig, SPL Governance.
Override via smartWalletAllowedPrograms in ExactSvmSchemeOptions.
Unknown programs rejected with smart_wallet_program_not_allowed.

Made-with: Cursor
…ests

Address review feedback from notorious-d-e-v (second review, Mar 23):

Token-2022 post-settlement fallback:
- Balance-delta fallback now derives ATAs for both SPL Token and Token-2022
- scheme.ts captures balanceBefore from whichever token program has a live ATA
- Passes token program hint to verifyPostSettlement for optimized lookup
- When all balance checks throw, correctly returns "unverified" not "balanceDelta"

sigVerify/replaceRecentBlockhash alignment:
- Changed smart wallet simulation from sigVerify:false/replaceRecentBlockhash:true
  to sigVerify:true/replaceRecentBlockhash:false, matching existing Path 1 behavior
- Closes forged-signature attack vector identified in review
- Correct for future durable nonce support per RFC x402-foundation#646

Tests:
- Token-2022 fallback when SPL Token ATA shows no delta
- Hinted token program optimization in balance fallback
Path 1 (upstream x402-foundation#1688) verifies that a seller-required extra.memo is
present as exactly one top-level Memo instruction with matching content.
Path 2 previously ignored memo instructions entirely, so smart wallet
transactions silently bypassed the seller's memo requirement.

This commit brings Path 2 to feature parity with Path 1:

- scheme.ts: exempt Memo program from the wallet-program allowlist.
  The allowlist exists to restrict which wallet programs can reach
  simulation; memo is a standalone system-category program with no
  security surface, same as ComputeBudget. Leaving it subject to the
  allowlist meant smart wallet users could not include a memo at all,
  even when the seller required one.

- smartWalletVerification.ts: add Step 4a verifying memo count and
  content against requirements.extra.memo, mirroring Path 1's Step 5b
  semantics and returning the same error codes
  (invalid_exact_svm_payload_memo_count,
  invalid_exact_svm_payload_memo_mismatch).

Tests cover the four memo paths end-to-end through ExactSvmScheme.verify:
required memo present-and-matching (accept), missing (reject_count),
mismatched content (reject_mismatch), duplicated (reject_count).
Per request from @blockiosaurus on the PR — Metaplex's agent identity
platform routes agentic execution through the Metaplex Core program PDA
(assetSignerPda). Verified end-to-end against mainnet through a real
$0.02 USDC payment for the live x402 endpoint at mcp.cryptoiz.org/btc/regime.

The on-chain transaction shape is exactly what Path 2 was designed for:
top-level Metaplex Core `execute` (unknown to static parsing), inner
SPL TransferChecked matching the merchant's payment requirements.
extractTransfersFromInnerInstructions matches the inner transfer,
simulation passes, settlement lands.

Settlement tx: 5g5B3ZPHndS19tzUjPANgFC7J3nS8rT6dAFSuZ21bDfRHSSREKTbnN7XrYVp5haBmj4xDq2gD5A375evHq5y3vw6
@BranchManager69 BranchManager69 force-pushed the feat/svm-smart-wallet-simulation branch from 0ee2470 to bc7a9bd Compare April 30, 2026 08:01
@BranchManager69
Copy link
Copy Markdown
Contributor Author

Added Metaplex Core (CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d) to the default allowlist per @blockiosaurus. Verified on mainnet via Path 2: real $0.02 USDC payment, Core execute with inner SPL TransferChecked.
https://solscan.io/tx/5g5B3ZPHndS19tzUjPANgFC7J3nS8rT6dAFSuZ21bDfRHSSREKTbnN7XrYVp5haBmj4xDq2gD5A375evHq5y3vw6

@romeo4934, Swig (SWiGmQedKzMz1tiTqoJCWeGDnGXfNBp2PkXLkpCAtQo) is already in (line 46). Different program?

Templates were reverted to upstream's version during rebase; CI's
check-paywall-template caught the drift. Regenerated locally via
pnpm --filter @x402/paywall run build:paywall. AVM template, Go,
and Python equivalents already match upstream.
@romeo4934
Copy link
Copy Markdown

no all good ;)

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

Labels

sdk Changes to core v2 packages typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants