Skip to content

Commit 9bfa824

Browse files
feat(svm): add simulation-based smart wallet verification
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
1 parent 8a19b2c commit 9bfa824

5 files changed

Lines changed: 1160 additions & 94 deletions

File tree

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

Lines changed: 188 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,53 @@ import { SettlementCache } from "../../settlement-cache";
3333
import type { FacilitatorSvmSigner } from "../../signer";
3434
import type { ExactSvmPayloadV2 } from "../../types";
3535
import { decodeTransactionFromPayload, getTokenPayerFromTransaction } from "../../utils";
36+
import { verifySmartWalletTransaction } from "./smartWalletVerification";
37+
38+
/**
39+
* Configuration options for ExactSvmScheme.
40+
*/
41+
export type ExactSvmSchemeOptions = {
42+
/**
43+
* Enable simulation-based smart wallet verification.
44+
* When enabled, transactions rejected by the static validation path
45+
* (unknown programs, wrong instruction count) are re-verified using
46+
* simulation inner instruction analysis. Works for any smart wallet
47+
* program (Squads, Swig, SPL Governance, etc.) without per-wallet parsers.
48+
*
49+
* Default: false (only standard wallet transactions are accepted)
50+
*/
51+
enableSmartWalletVerification?: boolean;
52+
53+
/**
54+
* Maximum compute units allowed for smart wallet transactions.
55+
* Smart wallet programs need more CU for CPI overhead.
56+
* Only applies when enableSmartWalletVerification is true.
57+
*
58+
* Default: 400,000
59+
*/
60+
smartWalletMaxComputeUnits?: number;
61+
62+
/**
63+
* Maximum priority fee in microlamports for smart wallet transactions.
64+
* Only applies when enableSmartWalletVerification is true.
65+
*
66+
* Default: 50,000
67+
*/
68+
smartWalletMaxPriorityFeeMicroLamports?: number;
69+
};
3670

3771
/**
3872
* SVM facilitator implementation for the Exact payment scheme.
73+
*
74+
* Dual-path verification:
75+
*
76+
* Path 1 (Static): Strict positional instruction validation for standard wallets.
77+
* Fast, preserves existing behavior.
78+
*
79+
* Path 2 (Simulation): Outcome-based verification for smart wallets.
80+
* When Path 1 rejects a transaction and smart wallet verification is enabled,
81+
* falls back to simulation-based validation that inspects CPI inner instructions.
82+
* Works for any wallet program that executes TransferChecked via CPI.
3983
*/
4084
export class ExactSvmScheme implements SchemeNetworkFacilitator {
4185
readonly scheme = "exact";
@@ -44,15 +88,16 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
4488
private readonly settlementCache: SettlementCache;
4589

4690
/**
47-
* Creates a new ExactSvmFacilitator instance.
91+
* Creates a new ExactSvmScheme instance.
4892
*
49-
* @param signer - The SVM RPC client for facilitator operations
93+
* @param signer - The SVM signer for facilitator operations
5094
* @param settlementCache - Optional shared settlement cache (one is created if omitted)
51-
* @returns ExactSvmFacilitator instance
95+
* @param options - Optional configuration for smart wallet verification
5296
*/
5397
constructor(
5498
private readonly signer: FacilitatorSvmSigner,
5599
settlementCache?: SettlementCache,
100+
private readonly options?: ExactSvmSchemeOptions,
56101
) {
57102
this.settlementCache = settlementCache ?? new SettlementCache();
58103
}
@@ -70,9 +115,11 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
70115
const addresses = this.signer.getAddresses();
71116
const randomIndex = Math.floor(Math.random() * addresses.length);
72117

73-
return {
74-
feePayer: addresses[randomIndex],
75-
};
118+
const extra: Record<string, unknown> = { feePayer: addresses[randomIndex] };
119+
if (this.options?.enableSmartWalletVerification) {
120+
extra.features = { smartWalletSupported: true };
121+
}
122+
return extra;
76123
}
77124

78125
/**
@@ -134,7 +181,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
134181
};
135182
}
136183

137-
// Step 2: Parse and Validate Transaction Structure
184+
// Step 2: Parse Transaction
138185
let transaction;
139186
try {
140187
transaction = decodeTransactionFromPayload(exactSvmPayload);
@@ -146,6 +193,135 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
146193
};
147194
}
148195

196+
// ─── Path 1: Static validation (standard wallets) ───────────────────
197+
const staticResult = await this.verifyStaticPath(
198+
transaction,
199+
exactSvmPayload,
200+
requirements,
201+
signerAddresses,
202+
);
203+
204+
if (staticResult.isValid) {
205+
return staticResult;
206+
}
207+
208+
// ─── Path 2: Simulation-based verification (smart wallets) ──────────
209+
// If static validation failed and smart wallet verification is enabled,
210+
// try simulation-based outcome verification. This handles any wallet
211+
// program (Squads, Swig, SPL Governance, etc.) that executes
212+
// TransferChecked via CPI.
213+
if (this.options?.enableSmartWalletVerification) {
214+
const feePayer = requirements.extra.feePayer;
215+
return verifySmartWalletTransaction(
216+
exactSvmPayload.transaction,
217+
requirements,
218+
this.signer,
219+
feePayer,
220+
signerAddresses,
221+
{
222+
enabled: true,
223+
maxComputeUnits: this.options.smartWalletMaxComputeUnits,
224+
maxPriorityFeeMicroLamports: this.options.smartWalletMaxPriorityFeeMicroLamports,
225+
},
226+
);
227+
}
228+
229+
return staticResult;
230+
}
231+
232+
/**
233+
* Settles a payment by submitting the transaction.
234+
* Ensures the correct signer is used based on the feePayer specified in requirements.
235+
*
236+
* @param payload - The payment payload to settle
237+
* @param requirements - The payment requirements
238+
* @returns Promise resolving to settlement response
239+
*/
240+
async settle(
241+
payload: PaymentPayload,
242+
requirements: PaymentRequirements,
243+
): Promise<SettleResponse> {
244+
const exactSvmPayload = payload.payload as ExactSvmPayloadV2;
245+
246+
const valid = await this.verify(payload, requirements);
247+
if (!valid.isValid) {
248+
return {
249+
success: false,
250+
network: payload.accepted.network,
251+
transaction: "",
252+
errorReason: valid.invalidReason ?? "verification_failed",
253+
payer: valid.payer || "",
254+
};
255+
}
256+
257+
// Duplicate settlement check: reject if this transaction is already being settled.
258+
// Must occur before any async work so concurrent calls for the same tx are caught.
259+
const txKey = exactSvmPayload.transaction;
260+
if (this.settlementCache.isDuplicate(txKey)) {
261+
return {
262+
success: false,
263+
network: payload.accepted.network,
264+
transaction: "",
265+
errorReason: "duplicate_settlement",
266+
payer: valid.payer || "",
267+
};
268+
}
269+
270+
try {
271+
// Extract feePayer from requirements (already validated in verify)
272+
const feePayer = requirements.extra.feePayer as Address;
273+
274+
// Sign transaction with the feePayer's signer
275+
const fullySignedTransaction = await this.signer.signTransaction(
276+
exactSvmPayload.transaction,
277+
feePayer,
278+
requirements.network,
279+
);
280+
281+
// Send transaction to network
282+
const signature = await this.signer.sendTransaction(
283+
fullySignedTransaction,
284+
requirements.network,
285+
);
286+
287+
// Wait for confirmation
288+
await this.signer.confirmTransaction(signature, requirements.network);
289+
290+
return {
291+
success: true,
292+
transaction: signature,
293+
network: payload.accepted.network,
294+
payer: valid.payer,
295+
};
296+
} catch (error) {
297+
console.error("Failed to settle transaction:", error);
298+
return {
299+
success: false,
300+
errorReason: "transaction_failed",
301+
transaction: "",
302+
network: payload.accepted.network,
303+
payer: valid.payer || "",
304+
};
305+
}
306+
}
307+
308+
/**
309+
* Path 1: Static instruction-layout verification for standard wallets.
310+
* Validates positional instruction structure, program allowlist, and
311+
* transfer details. Unchanged from the original implementation.
312+
*
313+
* @param transaction - Decoded transaction to verify
314+
* @param exactSvmPayload - The raw SVM payload containing the base64 transaction
315+
* @param requirements - Payment requirements to verify against
316+
* @param signerAddresses - Facilitator signer addresses (for self-spend protection)
317+
* @returns Verification result
318+
*/
319+
private async verifyStaticPath(
320+
transaction: ReturnType<typeof decodeTransactionFromPayload>,
321+
exactSvmPayload: ExactSvmPayloadV2,
322+
requirements: PaymentRequirements,
323+
signerAddresses: string[],
324+
): Promise<VerifyResponse> {
149325
const compiled = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes);
150326
const decompiled = decompileTransactionMessage(compiled);
151327
const instructions = decompiled.instructions ?? [];
@@ -164,7 +340,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
164340
};
165341
}
166342

167-
// Step 3: Verify Compute Budget Instructions
343+
// Verify Compute Budget Instructions
168344
try {
169345
this.verifyComputeLimitInstruction(instructions[0] as never);
170346
this.verifyComputePriceInstruction(instructions[1] as never);
@@ -186,7 +362,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
186362
};
187363
}
188364

189-
// Step 4: Verify Transfer Instruction
365+
// Verify Transfer Instruction
190366
const transferIx = instructions[2];
191367
const programAddress = transferIx.programAddress.toString();
192368

@@ -201,7 +377,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
201377
};
202378
}
203379

204-
// Parse the transfer instruction using the appropriate library helper
205380
let parsedTransfer;
206381
try {
207382
if (programAddress === TOKEN_PROGRAM_ADDRESS.toString()) {
@@ -228,7 +403,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
228403
};
229404
}
230405

231-
// Verify mint address matches requirements
232406
const mintAddress = parsedTransfer.accounts.mint.address.toString();
233407
if (mintAddress !== requirements.asset) {
234408
return {
@@ -238,7 +412,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
238412
};
239413
}
240414

241-
// Verify destination ATA matches expected ATA for payTo address
242415
const destATA = parsedTransfer.accounts.destination.address.toString();
243416
try {
244417
const [expectedDestATA] = await findAssociatedTokenPda({
@@ -265,7 +438,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
265438
};
266439
}
267440

268-
// Verify transfer amount meets requirements
269441
const amount = parsedTransfer.data.amount;
270442
if (amount !== BigInt(requirements.amount)) {
271443
return {
@@ -275,7 +447,7 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
275447
};
276448
}
277449

278-
// Step 5: Verify optional instructions (if present)
450+
// Verify optional instructions (if present)
279451
// Allowed optional programs: Lighthouse (wallet protection) and Memo (uniqueness)
280452
const optionalInstructions = instructions.slice(3);
281453
const invalidReasonByIndex = [
@@ -301,19 +473,17 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
301473
};
302474
}
303475

304-
// Step 6: Sign and Simulate Transaction
476+
// Sign and Simulate Transaction
305477
// CRITICAL: Simulation proves transaction will succeed (catches insufficient balance, invalid accounts, etc)
306478
try {
307-
const feePayer = requirements.extra.feePayer as Address;
479+
const feePayer = requirements.extra!.feePayer as Address;
308480

309-
// Sign transaction with the feePayer's signer
310481
const fullySignedTransaction = await this.signer.signTransaction(
311482
exactSvmPayload.transaction,
312483
feePayer,
313484
requirements.network,
314485
);
315486

316-
// Simulate to verify transaction would succeed
317487
await this.signer.simulateTransaction(fullySignedTransaction, requirements.network);
318488
} catch (error) {
319489
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -332,82 +502,6 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
332502
};
333503
}
334504

335-
/**
336-
* Settles a payment by submitting the transaction.
337-
* Ensures the correct signer is used based on the feePayer specified in requirements.
338-
*
339-
* @param payload - The payment payload to settle
340-
* @param requirements - The payment requirements
341-
* @returns Promise resolving to settlement response
342-
*/
343-
async settle(
344-
payload: PaymentPayload,
345-
requirements: PaymentRequirements,
346-
): Promise<SettleResponse> {
347-
const exactSvmPayload = payload.payload as ExactSvmPayloadV2;
348-
349-
const valid = await this.verify(payload, requirements);
350-
if (!valid.isValid) {
351-
return {
352-
success: false,
353-
network: payload.accepted.network,
354-
transaction: "",
355-
errorReason: valid.invalidReason ?? "verification_failed",
356-
payer: valid.payer || "",
357-
};
358-
}
359-
360-
// Duplicate settlement check: reject if this transaction is already being settled.
361-
// Must occur before any async work so concurrent calls for the same tx are caught.
362-
const txKey = exactSvmPayload.transaction;
363-
if (this.settlementCache.isDuplicate(txKey)) {
364-
return {
365-
success: false,
366-
network: payload.accepted.network,
367-
transaction: "",
368-
errorReason: "duplicate_settlement",
369-
payer: valid.payer || "",
370-
};
371-
}
372-
373-
try {
374-
// Extract feePayer from requirements (already validated in verify)
375-
const feePayer = requirements.extra.feePayer as Address;
376-
377-
// Sign transaction with the feePayer's signer
378-
const fullySignedTransaction = await this.signer.signTransaction(
379-
exactSvmPayload.transaction,
380-
feePayer,
381-
requirements.network,
382-
);
383-
384-
// Send transaction to network
385-
const signature = await this.signer.sendTransaction(
386-
fullySignedTransaction,
387-
requirements.network,
388-
);
389-
390-
// Wait for confirmation
391-
await this.signer.confirmTransaction(signature, requirements.network);
392-
393-
return {
394-
success: true,
395-
transaction: signature,
396-
network: payload.accepted.network,
397-
payer: valid.payer,
398-
};
399-
} catch (error) {
400-
console.error("Failed to settle transaction:", error);
401-
return {
402-
success: false,
403-
errorReason: "transaction_failed",
404-
transaction: "",
405-
network: payload.accepted.network,
406-
payer: valid.payer || "",
407-
};
408-
}
409-
}
410-
411505
/**
412506
* Verify that the compute limit instruction is valid.
413507
*

0 commit comments

Comments
 (0)