Skip to content

Commit b9678c8

Browse files
feat(svm): add program allowlist for smart wallet Path 2
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
1 parent 4c92e95 commit b9678c8

5 files changed

Lines changed: 125 additions & 7 deletions

File tree

typescript/packages/http/paywall/src/evm/gen/template.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

typescript/packages/http/paywall/src/svm/gen/template.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

typescript/packages/mechanisms/svm/src/exact/facilitator/scheme.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ import type { ExactSvmPayloadV2 } from "../../types";
3535
import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "../../utils";
3636
import { verifySmartWalletTransaction, verifyPostSettlement } from "./smartWalletVerification";
3737

38+
/**
39+
* Default allowed smart wallet program addresses.
40+
* Only these programs can reach Path 2 (simulation-based verification).
41+
* Operators can override via smartWalletAllowedPrograms in options.
42+
*/
43+
const DEFAULT_SMART_WALLET_ALLOWED_PROGRAMS = [
44+
"SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf", // Squads Multisig v4
45+
"SMRTzfY6DfH5ik3TKiyLFfXexV8uSG3d2UksSCYdunG", // Squads Smart Account
46+
"SWiGmQedKzMz1tiTqoJCWeGDnGXfNBp2PkXLkpCAtQo", // Swig
47+
"GovER5Lthms3bLBqWub97yVrMmEogzX7xNjdXpPPCVZw", // SPL Governance
48+
];
49+
3850
/**
3951
* Configuration options for ExactSvmScheme.
4052
*/
@@ -66,6 +78,16 @@ export type ExactSvmSchemeOptions = {
6678
* Default: 50,000
6779
*/
6880
smartWalletMaxPriorityFeeMicroLamports?: number;
81+
82+
/**
83+
* Allowed smart wallet program addresses for Path 2 verification.
84+
* Only transactions whose top-level non-ComputeBudget instruction invokes
85+
* a program in this list will be accepted through the simulation path.
86+
* Prevents unknown/malicious programs from reaching CPI verification.
87+
*
88+
* Default: Squads Multisig v4, Squads Smart Account, Swig, SPL Governance
89+
*/
90+
smartWalletAllowedPrograms?: string[];
6991
};
7092

7193
/**
@@ -224,10 +246,29 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
224246

225247
// ─── Path 2: Simulation-based verification (smart wallets) ──────────
226248
// If static validation failed and smart wallet verification is enabled,
227-
// try simulation-based outcome verification. This handles any wallet
228-
// program (Squads, Swig, SPL Governance, etc.) that executes
229-
// TransferChecked via CPI.
249+
// try simulation-based outcome verification for allowed wallet programs.
230250
if (this.options?.enableSmartWalletVerification) {
251+
// Program allowlist: only known, audited smart wallet programs can reach Path 2.
252+
// This prevents custom malicious programs from exploiting the simulation path.
253+
const allowedPrograms = new Set(
254+
this.options.smartWalletAllowedPrograms ?? DEFAULT_SMART_WALLET_ALLOWED_PROGRAMS,
255+
);
256+
257+
const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes);
258+
const decompiledForCheck = decompileTransactionMessage(compiled);
259+
const topLevelPrograms = (decompiledForCheck.instructions ?? [])
260+
.map(ix => ix.programAddress.toString())
261+
.filter(addr => addr !== COMPUTE_BUDGET_PROGRAM_ADDRESS.toString());
262+
263+
const disallowedProgram = topLevelPrograms.find(addr => !allowedPrograms.has(addr));
264+
if (disallowedProgram) {
265+
return {
266+
isValid: false,
267+
invalidReason: `smart_wallet_program_not_allowed: ${disallowedProgram}`,
268+
payer: "",
269+
};
270+
}
271+
231272
const feePayer = requirements.extra.feePayer;
232273
return verifySmartWalletTransaction(
233274
exactSvmPayload.transaction,

typescript/packages/mechanisms/svm/src/exact/facilitator/smartWalletVerification.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,10 +465,23 @@ export async function verifyPostSettlement(
465465
const requiredAmount = BigInt(requirements.amount);
466466

467467
// Primary path: fetch confirmed transaction and inspect inner instructions.
468+
// Retry with backoff to handle RPC indexing lag (transaction confirmed but
469+
// not yet indexed). Same polling pattern as confirmTransaction in the SVM signer.
468470
if (typeof signer.getConfirmedTransactionInnerInstructions === "function") {
469-
try {
470-
const confirmed = await signer.getConfirmedTransactionInnerInstructions(signature, network);
471+
let confirmed: SmartWalletSimulationResult | null = null;
472+
for (let attempt = 0; attempt < 3; attempt++) {
473+
try {
474+
confirmed = await signer.getConfirmedTransactionInnerInstructions(signature, network);
475+
if (confirmed?.innerInstructions) break;
476+
} catch {
477+
// RPC error — retry
478+
}
479+
if (attempt < 2) {
480+
await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
481+
}
482+
}
471483

484+
try {
472485
if (confirmed?.innerInstructions) {
473486
// Reuse the same extraction logic used for simulation results.
474487
// We pass an empty accountKeys array because the confirmed transaction's

typescript/packages/mechanisms/svm/test/unit/smartWalletFallback.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => {
143143

144144
const scheme = new ExactSvmScheme(mockSigner as never, undefined, {
145145
enableSmartWalletVerification: true,
146+
smartWalletAllowedPrograms: [unknownProgram.address],
146147
});
147148

148149
const result = await scheme.verify(
@@ -228,6 +229,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => {
228229

229230
const scheme = new ExactSvmScheme(mockSigner as never, undefined, {
230231
enableSmartWalletVerification: true,
232+
smartWalletAllowedPrograms: [unknownProgram.address],
231233
});
232234

233235
const result = await scheme.verify(
@@ -307,6 +309,7 @@ describe("ExactSvmScheme smart wallet fallback path", () => {
307309

308310
const scheme = new ExactSvmScheme(mockSigner as never, undefined, {
309311
enableSmartWalletVerification: true,
312+
smartWalletAllowedPrograms: [unknownProgram.address],
310313
});
311314

312315
const result = await scheme.verify(
@@ -340,4 +343,65 @@ describe("ExactSvmScheme smart wallet fallback path", () => {
340343
"invalid_exact_svm_payload_transaction_fee_payer_transferring_funds",
341344
);
342345
});
346+
347+
it("verify rejects smart wallet transaction when program is not in allowlist", async () => {
348+
const { ExactSvmScheme } = await import("../../src/exact/facilitator/scheme");
349+
350+
const feePayer = await generateKeyPairSigner();
351+
const unknownProgram = await generateKeyPairSigner();
352+
const payTo = await generateKeyPairSigner();
353+
const payer = await generateKeyPairSigner();
354+
355+
const txBase64 = await buildSmartWalletPayload(
356+
feePayer.address,
357+
unknownProgram.address,
358+
payer.address,
359+
);
360+
361+
const mockSigner = {
362+
getAddresses: vi.fn().mockReturnValue([feePayer.address]),
363+
signTransaction: vi.fn().mockResolvedValue(txBase64),
364+
simulateTransaction: vi.fn().mockResolvedValue(undefined),
365+
sendTransaction: vi.fn(),
366+
confirmTransaction: vi.fn(),
367+
simulateTransactionWithInnerInstructions: vi.fn().mockResolvedValue({
368+
innerInstructions: [],
369+
}),
370+
getConfirmedTransactionInnerInstructions: vi.fn().mockResolvedValue(null),
371+
getTokenAccountBalance: vi.fn().mockResolvedValue(null),
372+
};
373+
374+
// Allowlist does NOT include unknownProgram
375+
const scheme = new ExactSvmScheme(mockSigner as never, undefined, {
376+
enableSmartWalletVerification: true,
377+
smartWalletAllowedPrograms: ["SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf"],
378+
});
379+
380+
const result = await scheme.verify(
381+
{
382+
x402Version: 2,
383+
accepted: {
384+
scheme: "exact",
385+
network: SOLANA_DEVNET_CAIP2,
386+
asset: USDC_DEVNET_ADDRESS,
387+
amount: "100000",
388+
payTo: payTo.address,
389+
extra: { feePayer: feePayer.address },
390+
},
391+
payload: { transaction: txBase64 },
392+
} as never,
393+
{
394+
scheme: "exact",
395+
network: SOLANA_DEVNET_CAIP2,
396+
asset: USDC_DEVNET_ADDRESS,
397+
amount: "100000",
398+
payTo: payTo.address,
399+
maxTimeoutSeconds: 3600,
400+
extra: { feePayer: feePayer.address },
401+
} as never,
402+
);
403+
404+
expect(result.isValid).toBe(false);
405+
expect(result.invalidReason).toContain("smart_wallet_program_not_allowed");
406+
});
343407
});

0 commit comments

Comments
 (0)