Skip to content

Commit 6948c57

Browse files
feat(svm): add post-settlement transfer verification (TOCTOU defense)
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
1 parent 27e3e6d commit 6948c57

7 files changed

Lines changed: 419 additions & 3 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: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ 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";
36+
import { verifySmartWalletTransaction, verifyPostSettlement } from "./smartWalletVerification";
3737

3838
/**
3939
* Configuration options for ExactSvmScheme.
@@ -267,6 +267,40 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
267267
};
268268
}
269269

270+
// Determine if this settlement went through the smart wallet (Path 2) verify path.
271+
// If so, we need post-settlement verification to defend against TOCTOU.
272+
const isSmartWalletSettlement = this.options?.enableSmartWalletVerification && (() => {
273+
try {
274+
const tx = decodeTransactionFromPayload(exactSvmPayload);
275+
const payer = getTokenPayerFromTransaction(tx);
276+
// If getTokenPayerFromTransaction returns null, the static path would have
277+
// failed, meaning this went through the smart wallet path.
278+
return !payer;
279+
} catch {
280+
return false;
281+
}
282+
})();
283+
284+
// For smart wallet settlements: record destination ATA balance before sending.
285+
// Used as fallback verification if getTransaction has indexing lag.
286+
let balanceBefore: bigint | null = null;
287+
if (isSmartWalletSettlement && typeof this.signer.getTokenAccountBalance === "function") {
288+
try {
289+
const [destinationAta] = await findAssociatedTokenPda({
290+
mint: requirements.asset as Address,
291+
owner: requirements.payTo as Address,
292+
tokenProgram: TOKEN_PROGRAM_ADDRESS as unknown as Address,
293+
});
294+
balanceBefore = await this.signer.getTokenAccountBalance(
295+
destinationAta.toString(),
296+
requirements.network,
297+
);
298+
} catch {
299+
// If balance fetch fails, we proceed without fallback capability.
300+
// The primary getTransaction path still works.
301+
}
302+
}
303+
270304
try {
271305
// Extract feePayer from requirements (already validated in verify)
272306
const feePayer = requirements.extra.feePayer as Address;
@@ -287,6 +321,30 @@ export class ExactSvmScheme implements SchemeNetworkFacilitator {
287321
// Wait for confirmation
288322
await this.signer.confirmTransaction(signature, requirements.network);
289323

324+
// Post-settlement verification for smart wallet transactions.
325+
// Confirms the TransferChecked actually executed on-chain (TOCTOU defense).
326+
if (isSmartWalletSettlement) {
327+
const signerAddresses = this.signer.getAddresses().map(a => a.toString());
328+
const postVerify = await verifyPostSettlement(
329+
this.signer,
330+
signature,
331+
requirements.network,
332+
requirements,
333+
signerAddresses,
334+
balanceBefore,
335+
);
336+
337+
if (!postVerify.verified && postVerify.method !== "unverified") {
338+
return {
339+
success: false,
340+
errorReason: "post_settlement_transfer_not_confirmed",
341+
transaction: signature,
342+
network: payload.accepted.network,
343+
payer: valid.payer || "",
344+
};
345+
}
346+
}
347+
290348
return {
291349
success: true,
292350
transaction: signature,

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,3 +396,108 @@ export async function verifySmartWalletTransaction(
396396

397397
return { isValid: true, payer: matchingTransfers[0].authority };
398398
}
399+
400+
/**
401+
* Post-settlement verification for smart wallet transactions.
402+
*
403+
* After a transaction confirms on-chain, verifies the TransferChecked actually
404+
* executed by inspecting the confirmed transaction's inner instructions.
405+
* Falls back to balance-delta checking if the RPC's transaction index has lag.
406+
*
407+
* This closes the TOCTOU gap where a malicious program could pass simulation
408+
* but skip the transfer during on-chain execution.
409+
*
410+
* @param signer - Facilitator signer with optional getConfirmedTransactionInnerInstructions
411+
* @param signature - Confirmed transaction signature
412+
* @param network - CAIP-2 network identifier
413+
* @param requirements - Payment requirements (asset, payTo, amount)
414+
* @param signerAddresses - Facilitator signer addresses
415+
* @param balanceBefore - Destination ATA balance before settlement (for fallback)
416+
* @returns Whether the transfer was verified on-chain
417+
*/
418+
export async function verifyPostSettlement(
419+
signer: FacilitatorSvmSigner,
420+
signature: string,
421+
network: string,
422+
requirements: PaymentRequirements,
423+
signerAddresses: string[],
424+
balanceBefore: bigint | null,
425+
): Promise<{ verified: boolean; method: "innerInstructions" | "balanceDelta" | "unverified" }> {
426+
const requiredAmount = BigInt(requirements.amount);
427+
428+
// Primary path: fetch confirmed transaction and inspect inner instructions.
429+
if (typeof signer.getConfirmedTransactionInnerInstructions === "function") {
430+
try {
431+
const confirmed = await signer.getConfirmedTransactionInnerInstructions(signature, network);
432+
433+
if (confirmed?.innerInstructions) {
434+
// Reuse the same extraction logic used for simulation results.
435+
// We pass an empty accountKeys array because the confirmed transaction's
436+
// inner instructions from jsonParsed encoding are already in parsed format
437+
// (programId as string, not index), so index-based resolution isn't needed.
438+
const transfers = extractTransfersFromInnerInstructions(confirmed.innerInstructions, []);
439+
440+
// Derive expected destination ATAs (same logic as in verifySmartWalletTransaction).
441+
const expectedATAs = new Set<string>();
442+
for (const tokenProgram of [TOKEN_PROGRAM_ADDRESS, TOKEN_2022_PROGRAM_ADDRESS]) {
443+
try {
444+
const [ata] = await findAssociatedTokenPda({
445+
mint: requirements.asset as Address,
446+
owner: requirements.payTo as Address,
447+
tokenProgram: tokenProgram as unknown as Address,
448+
});
449+
expectedATAs.add(ata.toString());
450+
} catch {
451+
// Skip invalid address combinations
452+
}
453+
}
454+
455+
const matching = transfers.filter(
456+
t =>
457+
t.mint === requirements.asset &&
458+
expectedATAs.has(t.destination) &&
459+
t.amount === requiredAmount &&
460+
!signerAddresses.includes(t.authority),
461+
);
462+
463+
if (matching.length >= 1) {
464+
return { verified: true, method: "innerInstructions" };
465+
}
466+
467+
// Inner instructions fetched but no matching transfer found.
468+
// The malicious program skipped the CPI. TOCTOU caught.
469+
return { verified: false, method: "innerInstructions" };
470+
}
471+
} catch {
472+
// getTransaction failed or returned null (indexing lag). Fall through to balance delta.
473+
}
474+
}
475+
476+
// Fallback: balance-delta check.
477+
// If we recorded balanceBefore and the signer supports getTokenAccountBalance,
478+
// check whether the destination ATA balance increased by at least the required amount.
479+
if (balanceBefore !== null && typeof signer.getTokenAccountBalance === "function") {
480+
try {
481+
const [destinationAta] = await findAssociatedTokenPda({
482+
mint: requirements.asset as Address,
483+
owner: requirements.payTo as Address,
484+
tokenProgram: TOKEN_PROGRAM_ADDRESS as unknown as Address,
485+
});
486+
487+
const balanceAfter = await signer.getTokenAccountBalance(destinationAta.toString(), network);
488+
489+
if (balanceAfter !== null && balanceAfter - balanceBefore >= requiredAmount) {
490+
return { verified: true, method: "balanceDelta" };
491+
}
492+
493+
// Balance didn't increase enough. Transfer likely didn't execute.
494+
return { verified: false, method: "balanceDelta" };
495+
} catch {
496+
// Balance check failed. Cannot verify.
497+
}
498+
}
499+
500+
// Neither verification method available or both failed.
501+
// Return unverified — caller decides policy.
502+
return { verified: false, method: "unverified" };
503+
}

typescript/packages/mechanisms/svm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
validateComputeBudgetLimits,
1515
extractTransfersFromInnerInstructions,
1616
verifySmartWalletTransaction,
17+
verifyPostSettlement,
1718
} from "./exact/facilitator/smartWalletVerification";
1819
export type {
1920
SmartWalletOptions,

typescript/packages/mechanisms/svm/src/signer.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,38 @@ export type FacilitatorSvmSigner = {
183183
feePayer: Address,
184184
network: string,
185185
): Promise<SmartWalletSimulationResult>;
186+
187+
/**
188+
* Fetch inner instructions from a confirmed transaction.
189+
* Used for post-settlement verification to confirm that the TransferChecked
190+
* actually executed on-chain (defends against TOCTOU in simulation path).
191+
*
192+
* Optional — if not implemented, post-settlement verification falls back
193+
* to balance-delta checking only.
194+
*
195+
* @param signature - Transaction signature to fetch
196+
* @param network - CAIP-2 network identifier
197+
* @returns Inner instructions from the confirmed transaction, or null if not yet indexed
198+
*/
199+
getConfirmedTransactionInnerInstructions?(
200+
signature: string,
201+
network: string,
202+
): Promise<SmartWalletSimulationResult | null>;
203+
204+
/**
205+
* Get the token balance of a specific token account.
206+
* Used for balance-delta fallback in post-settlement verification.
207+
*
208+
* Optional — if not implemented, balance-delta fallback is unavailable.
209+
*
210+
* @param tokenAccountAddress - Base58 encoded token account (ATA) address
211+
* @param network - CAIP-2 network identifier
212+
* @returns Token balance in atomic units, or null if account not found
213+
*/
214+
getTokenAccountBalance?(
215+
tokenAccountAddress: string,
216+
network: string,
217+
): Promise<bigint | null>;
186218
};
187219

188220
/**
@@ -486,5 +518,42 @@ export function toFacilitatorSvmSigner(
486518

487519
return { innerInstructions: value.innerInstructions ?? null };
488520
},
521+
522+
getConfirmedTransactionInnerInstructions: async (
523+
signature: string,
524+
network: string,
525+
): Promise<SmartWalletSimulationResult | null> => {
526+
const rpc = getRpcForNetwork(network);
527+
const result = await rpc
528+
.getTransaction(signature as never, {
529+
commitment: "confirmed",
530+
maxSupportedTransactionVersion: 0,
531+
encoding: "jsonParsed",
532+
} as never)
533+
.send();
534+
535+
if (!result) {
536+
return null;
537+
}
538+
539+
const meta = (result as unknown as { meta?: { innerInstructions?: SmartWalletSimulationResult["innerInstructions"] } }).meta;
540+
return { innerInstructions: meta?.innerInstructions ?? null };
541+
},
542+
543+
getTokenAccountBalance: async (
544+
tokenAccountAddress: string,
545+
network: string,
546+
): Promise<bigint | null> => {
547+
const rpc = getRpcForNetwork(network);
548+
try {
549+
const result = await rpc
550+
.getTokenAccountBalance(tokenAccountAddress as never, { commitment: "confirmed" } as never)
551+
.send();
552+
const amount = (result as unknown as { value?: { amount?: string } }).value?.amount;
553+
return amount ? BigInt(amount) : null;
554+
} catch {
555+
return null;
556+
}
557+
},
489558
};
490559
}

0 commit comments

Comments
 (0)