Skip to content

Add EVM verify transaction simulation (#1377)#1446

Open
ruhil6789 wants to merge 3 commits intocoinbase:mainfrom
ruhil6789:evm-tx-simulation
Open

Add EVM verify transaction simulation (#1377)#1446
ruhil6789 wants to merge 3 commits intocoinbase:mainfrom
ruhil6789:evm-tx-simulation

Conversation

@ruhil6789
Copy link

@ruhil6789 ruhil6789 commented Mar 4, 2026

Closes #1377.

This PR adds pre-settlement transaction simulation to the EVM facilitator verify path so that payloads which would revert at settlement time are rejected before the facilitator spends gas.

Concretely:

  • EIP-3009: verifyEIP3009 now calls a new simulateEip3009Settlement helper that:
    • Uses the facilitator signer’s simulateContract to run transferWithAuthorization against the target ERC‑20.
    • Parses ERC‑6492-wrapped signatures and handles both ECDSA (v,r,s) and bytes signatures.
    • Normalizes and maps known revert reasons (e.g. AuthorizationAlreadyUsed, InvalidSignature) into clear invalidReason messages.
  • Permit2: verifyPermit2 now simulates the final settlement call:
    • For EIP‑2612 gas sponsoring it simulates settleWithPermit; otherwise it simulates settle.
    • Decodes custom errors and maps known Permit2 revert reasons (e.g. InvalidNonce, InvalidSignature) into invalidReason on verification failure.
  • Signer and extension types:
    • Extends the EVM facilitator signer and ERC‑20 approval gas sponsoring signer types with a simulateContract method, and wires them to viem’s simulateContract.
    • Updates unit and integration tests to use the new signer shape.
  • Facilitator site fixes:
    • Updates site/app/facilitator/index.ts to:
      • Use dynamic imports for Aptos dependencies (@aptos-labs/ts-sdk, @x402/aptos) so Aptos remains fully optional.
      • Import gas-sponsoring extensions via the @x402/extensions namespace and register EIP2612_GAS_SPONSORING / createErc20ApprovalGasSponsoringExtension correctly.

Tests

  • TypeScript / EVM:
    • pnpm --filter @x402/evm test -- test/unit/exact/facilitator.test.ts test/unit/v1/facilitator.test.ts
      • This run exercises the new EIP‑3009 and Permit2 simulation paths (success and failure cases).
  • TypeScript / Extensions:
    • pnpm --filter @x402/extensions run build
  • TypeScript / Aptos:
    • pnpm --filter @x402/aptos run build
  • Site:
    • pnpm --filter site lint:check (passes, including site/app/facilitator/index.ts)

Checklist

  • I have formatted and linted my code
  • All new and existing tests pass
  • My commits are signed (required for merge) -- you may need to rebase if you initially pushed unsigned commits
  • I added a changelog fragment for user-facing changes (docs-only changes can skip)

@cb-heimdall
Copy link

cb-heimdall commented Mar 4, 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

vercel bot commented Mar 4, 2026

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

A member of the Team first needs to authorize it.

Copy link
Contributor

@ryanRfox ryanRfox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Superseded by approval review below — the author addressed all feedback in subsequent commits.)

…nbase#1377)

Wire simulateContract into @x402/evm verify path: dry-run transferWithAuthorization / settle before settle() to reject payloads that would revert (expired auth, invalid signature, etc.). Includes revert reason decoding, extension signer simulateContract type, and tests.
@github-actions github-actions bot added the sdk Changes to core v2 packages label Mar 5, 2026
@ruhil6789
Copy link
Author

@ryanRfox
Wire simulateContract into @x402/evm verify path: dry-run transferWithAuthorization / settle before settle() to reject payloads that would revert (expired auth, invalid signature, etc.). Includes revert reason decoding, extension signer simulateContract type, and tests.

Copy link
Contributor

@ryanRfox ryanRfox left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work responding to my feedback, @ruhil6789! The core simulation logic in verifyEIP3009 and verifyPermit2 is exactly what #1377 needed — pre-settlement dry-runs that catch doomed transactions before gas is spent. The revert reason decoding and test coverage are solid.

A few suggestions inline, none blocking. Approving as-is.

}
}

function decodePermit2RevertReason(error: unknown): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): decodePermit2RevertReason and decodeEip3009RevertReason are structurally identical — same viem error unwrapping logic, same regex patterns. The only difference is which KNOWN_REVERT_REASONS map gets consulted via the normalize function.

Consider extracting a shared helper:

function decodeRevertReason(error: unknown, knownReasons: Record<string, string>): string

This would live in a shared utils file and be called from both paths. Reduces ~60 lines of duplication and ensures future error-parsing improvements apply to both mechanisms.

* Used by facilitators to perform eth_call-based transaction simulation
* during verification to detect potential settlement reverts.
*/
simulateContract(args: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: simulateContract is added as a required field on FacilitatorEvmSigner. Any existing code that constructs a signer without it will now fail to compile.

Was this intentional? If so, the minor changeset bump is appropriate for the new capability, but it's worth calling out in the changeset description that this is a breaking type change for existing facilitator implementations.

Alternatively, making it optional (simulateContract?:) and having verify gracefully skip simulation when absent would preserve backwards compatibility. The verify path would still work for existing integrations — they'd just not get the simulation safety net until they upgrade their signer.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: simulateContract is added as a required field on FacilitatorEvmSigner. Any existing code that constructs a signer without it will now fail to compile.

Was this intentional? If so, the minor changeset bump is appropriate for the new capability, but it's worth calling out in the changeset description that this is a breaking type change for existing facilitator implementations.

Alternatively, making it optional (simulateContract?:) and having verify gracefully skip simulation when absent would preserve backwards compatibility. The verify path would still work for existing integrations — they'd just not get the simulation safety net until they upgrade their signer.

Great point on the type surface area.

Yes, making simulateContract required on FacilitatorEvmSigner was intentional so that every facilitator wired into the @x402/evm verify path actually gets the simulation safety net. Given that verify now relies on pre-settlement dry-runs to protect against doomed settlements, I felt it was better to make this a required capability rather than silently skipping simulation.

I agree this is a meaningful type change and I’ll update the changeset description to explicitly call out that:

  • FacilitatorEvmSigner now requires a simulateContract method, and
  • custom facilitator implementations need to be updated to provide it (typically by wiring to viemClient.simulateContract).

If you’d prefer a softer migration path (optional method + feature-detection with a log/metric when simulation is absent), I’m happy to follow up with a compatibility shim, but for now I’d like to keep the verify path assuming simulation is available.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m happy with it as-is, so I defer to maintainers on these points. Again, nice work.

const parsedSig = parseSignature(signature);
callArgs = [
...baseArgs,
(parsedSig.v as number | undefined) || parsedSig.yParity,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This uses || which would fall through on any falsy value (including v=0). While valid EIP-155 v values are 27/28 and viem's parseSignature guards against malformed input, using nullish coalescing (??) makes the intent explicit and is more defensive:

(parsedSig.v as number | undefined) ?? parsedSig.yParity

Note: the existing settlement code at line ~421 uses the same || pattern, so this isn't a simulation-vs-settlement divergence — but both could benefit from ??.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This uses || which would fall through on any falsy value (including v=0). While valid EIP-155 v values are 27/28 and viem's parseSignature guards against malformed input, using nullish coalescing (??) makes the intent explicit and is more defensive:

(parsedSig.v as number | undefined) ?? parsedSig.yParity

Note: the existing settlement code at line ~421 uses the same || pattern, so this isn't a simulation-vs-settlement divergence — but both could benefit from ??.

Agreed — ?? is the better fit here.

Even though viem’s parseSignature shouldn’t produce v = 0 for valid inputs and the existing settle path used ||, using nullish coalescing makes the intent explicit and removes the “falsy” edge case. I’ll update both the simulation and settlement call sites to:

(parsedSig.v as number | undefined) ?? parsedSig.yParity

so they stay aligned and a bit more defensive.

.registerV1("solana-devnet" as Network, new ExactSvmSchemeV1(svmSigner));

// Optionally register Aptos if configured
// Optionally register Aptos if configured (dynamic import avoids requiring optional deps at load time)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observation (non-blocking): The Aptos dynamic import and Extensions.* namespace change are nice cleanups but unrelated to #1377. Might be worth a one-liner in the PR description noting these as incidental housekeeping so reviewers can focus on the simulation logic. Not worth splitting at this point.

@cb-heimdall
Copy link

Review Error for ryanRfox @ 2026-03-05 10:59:09 UTC
User failed mfa authentication, either user does not exist or public email is not set on your github profile. \ see go/mfa-help

@ruhil6789
Copy link
Author

@ryanRfox
Good call — the viem error unwrapping and regex logic really is identical between the two paths.

For this PR I kept the functions local to keep the diff focused on wiring up simulation, but I agree this should be a shared helper. I’ll follow up with a small refactor that introduces something like:

decodeRevertReason(error: unknown, knownReasons: Record<string, string>)

in a shared utils module and have both decodeEip3009RevertReason and decodePermit2RevertReason delegate to it. That will de-duplicate the ~60 lines and keep any future error-parsing fixes consistent between mechanisms.

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 website

Development

Successfully merging this pull request may close these issues.

[EVM] Implement transaction simulation in verify to prevent facilitator gas loss

3 participants