Skip to content

feat: optimize Transaction Pay amount quote latency#9543

Open
pedronfigueiredo wants to merge 3 commits into
mainfrom
pnf/money-account-quote-latency-poc
Open

feat: optimize Transaction Pay amount quote latency#9543
pedronfigueiredo wants to merge 3 commits into
mainfrom
pnf/money-account-quote-latency-poc

Conversation

@pedronfigueiredo

@pedronfigueiredo pedronfigueiredo commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Explanation

Context

Updating the amount of a Money Account deposit currently fans one logical user action out through multiple nested transaction updates, parent calldata rebuilds, gas/simulation preparation, controller publications, and quote generations. In the reference iOS simulator trace, Update/Done to an executable quote took 8.503 seconds:

Segment Baseline
Update/Done → Relay request 3,393.546 ms
Relay request 4,107.940 ms
Relay response → executable quote 1,001.045 ms
Total 8,502.531 ms

Relay is a third-party request and its latency varies independently. The client-controlled portions are therefore the primary optimization target.

What this PR changes

This proof of concept adds generic controller foundations for an explicit, coherent amount-update-and-quote pipeline.

TransactionController

  • Adds beginAtomicBatchUpdate to apply required assets and all nested calldata updates in one canonical state publication.
  • Regenerates parent atomic-batch calldata once and starts one gas preparation generation.
  • Assigns a monotonic transaction revision and binds preparation results to that revision.
  • Prevents stale gas and simulation results from overwriting a newer revision.
  • Keeps the existing single-update API intact for existing consumers.

TransactionPayController

  • Adds updateAmount, backed by a consumer-provided prepareTransactionAmount callback, so transaction-specific amount conversion and calldata construction remain outside the generic controller.
  • Deduplicates identical in-flight intents and aborts/suppresses superseded generations.
  • Avoids the existing TransactionController listener starting a duplicate quote for the explicit update.
  • Carries revision-bound local preparation through quote generation and publishes only a quote matching the current transaction revision.
  • Separates raw Relay fetching from the preparation-aware normalization path so vendor waiting can overlap local work without publishing stale executable data.

The result is one coherent revision, one local preparation generation, and one quote generation for the standard path rather than intermediate revisions repeatedly triggering work.

Flow and parallelization

Simplified standard, non-max, non-post-quote path:

Before
======
Update/Done
    |
    +--> update approval call --> rebuild parent + gas preparation + publish
    |                                  `--> quote generation A (superseded)
    |
    `--> update deposit call  --> rebuild parent + gas preparation + publish
                                       `--> quote generation B
                                                |
                                           Relay fetch
                                                |
                                       normalize + publish

After
=====
Update/Done
    |
    `--> prepare complete amount patch (required assets + approval + deposit)
             |
             `--> atomic commit: one parent rebuild, revision N
                      |
                      +--> local gas/simulation preparation for N --------+
                      |                                                   |
                      `--> balance/capability/delegation --> raw Relay ---+
                                                                          |
                                                       verify revision N and
                                                       await matching preparation
                                                                          |
                                                             normalize + publish
                                                             executable quote

A newer amount aborts/supersedes the previous generation; late gas, simulation,
or quote results are discarded unless they still match the current revision.

Why start with Money Account deposits?

Money Account deposits are a useful first adopter because they expose the amplification clearly: an amount change updates both approval and deposit calldata, requires previewDeposit, and then feeds Transaction Pay and EIP-7702/delegation preparation. The flow is narrow enough to validate behind a Mobile feature flag while retaining the legacy path as a kill switch.

The APIs in this PR are intentionally not Money Account-specific. TransactionController accepts generic required assets and nested transaction updates, while TransactionPayController delegates transaction-specific preparation to an injected callback. Other Transaction Pay flows can adopt the same orchestration later once their patch completeness, strategy semantics, and freshness requirements are validated.

Benchmark result

A follow-up React Native DevTools trace used the same inferred Update/Done and executable-commit anchors:

Segment Baseline PoC Change
Update/Done → Relay request 3,393.546 ms 1,512.891 ms -55.4%
Relay request 4,107.940 ms 5,118.617 ms +24.6%
Relay response → executable quote 1,001.045 ms 805.860 ms -19.5%
Observed total 8,502.531 ms 7,437.368 ms -12.5%

The Relay request regressed by 1.011 seconds in the second sample even though this PR does not optimize the Relay backend. Treating Relay as the same 4.108-second contribution in both traces gives a normalized PoC total of 6,426.691 ms, a 2,075.840 ms / 24.4% end-to-end improvement. Client-controlled time fell from 4,394.591 ms to 2,318.751 ms, a 47.2% reduction.

Structural trace evidence supports the client improvement:

  • 29 → 19 requests in the measured window
  • 18 → 11 requests before Relay
  • two → one eth_estimateGas calls
  • 1,640 ms → 483 ms of pre-request React scheduler time
  • 1,091 ms → 313 ms of pre-request render time

These are two individual simulator traces, not production p50/p95 measurements. Relay latency should continue to be reported separately and old/new runs should be alternated over multiple samples.

Correctness and rollout constraints

  • The adopting callback must return the complete nested update set before a canonical revision is committed.
  • Required assets and nested calldata are committed together or not at all.
  • Late local preparation and quote results are rejected by intent/revision checks.
  • Existing listener-driven behavior remains available to non-adopting flows.
  • The Mobile integration is gated and retains the legacy path.
  • Security/privacy review is still required before rollout to confirm whether Sentinel gates execution only or must also gate vendor disclosure. A Sentinel-blocked revision must never become executable.

Future work

  • Start live balance and safe capability work earlier and concurrently with transaction-specific preparation.
  • Deduplicate or advance remaining capability/metadata reads.
  • Add explicit spans around delegation, request construction, normalization, and state publication.
  • Reduce the remaining response-to-executable publication/render cycles.
  • Collect alternating old/new samples and release profiles on physical iOS and Android devices.
  • Validate and incrementally adopt the generic pipeline in other Transaction Pay flows.

Validation

  • yarn workspace @metamask/transaction-controller run jest --no-coverage src/TransactionController.test.ts --runInBand — 249 passed
  • yarn workspace @metamask/transaction-pay-controller run jest --no-coverage src/TransactionPayController.test.ts src/strategy/relay/relay-quotes.test.ts src/utils/quotes.test.ts --runInBand — 260 passed
  • Both changed workspaces build successfully.
  • Messenger action type checks pass for both controllers.
  • yarn changelog:validate passes.
  • ESLint passes for all changed TypeScript files.

References

  • Mobile first-adopter PR: MetaMask/metamask-mobile#33433
  • Baseline trace SHA-256: 28e517a73d33db3aa63470f9bc55bdbb47fc61060b09227dce3c5df2d4ae13a9
  • PoC trace SHA-256: e80c86da8913924a228627627d851ab75965174a30c7607cee1416917aa2c34f

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Touches transaction calldata, gas estimation, and quote publication with revision races; behavior is gated behind new APIs and extensive tests, but incorrect adoption could publish stale executable quotes.

Overview
Adds a coherent amount-update pipeline for Transaction Pay so one user amount change does not fan out into multiple intermediate transaction revisions and quote runs.

TransactionController introduces beginAtomicBatchUpdate, which commits required assets and all nested calldata updates in one publication, bumps a monotonic transactionRevision, clears stale gas/simulation metadata, and runs revision-bound gas/simulation preparation. Stale preparation and simulation results are ignored when a newer revision exists. updateAtomicBatchData is refactored to delegate to this path.

TransactionPayController adds updateAmount with an injected prepareTransactionAmount callback, complete-patch validation, in-flight intent deduplication, and supersession via abort signals. Quotes are tied to the atomic revision through transactionPreparation, with listener-driven duplicate quote launches suppressed during the explicit update. Relay quoting can fetch raw quotes in parallel with local preparation but normalizes only after preparation succeeds for the matching revision.

Reviewed by Cursor Bugbot for commit 54520d2. Bugbot is set up for automated code reviews on this repo. Configure here.

@pedronfigueiredo
pedronfigueiredo force-pushed the pnf/money-account-quote-latency-poc branch from d284b3c to 732f0f7 Compare July 16, 2026 16:26
@pedronfigueiredo
pedronfigueiredo marked this pull request as ready for review July 20, 2026 10:02
@pedronfigueiredo
pedronfigueiredo requested review from a team as code owners July 20, 2026 10:02

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 54520d2. Configure here.

this.#isSimulationEnabled()
? this.#updateSimulationData(draftTransaction)
: Promise.resolve(),
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Simulation failure blocks gas commit

Medium Severity

#prepareAtomicBatchUpdate awaits gas estimation and #updateSimulationData together via Promise.all. If the simulation path rejects (for example while fetching gas fee tokens), preparation fails before the revision gas commit runs, even when #updateGasEstimate already succeeded on the draft. Refactored updateAtomicBatchData now awaits that preparation, so callers can reject without persisting gas that the previous flow still committed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 54520d2. Configure here.


log('Beginning atomic batch update', request);

const updatedTransactionMeta = this.#updateTransactionInternal(

@matthewwalsh0 matthewwalsh0 Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicating lots of the updateAtomicBatchData logic?

Should we export a synchronous util from eip7702 such as updateEIP7702BatchData that accepts existing data and multiple new calls with an index to encapsulate?

);

revision = (transactionMeta.transactionRevision ?? 0) + 1;
transactionMeta.requiredAssets = requiredAssets;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm nervous to couple lots of updates here as it's very context specific.

Something i've wanted to do for a long time is expose a new updateTransactionCallback action that essentially exposes updateTransactionInternal so callers can update multiple properties at once without passing the full metadata, so avoids race conditions.

Then maybe some of this could be client logic to update multiple values at once?

draftTransaction: TransactionMeta,
revision: number,
): Promise<AtomicBatchPreparationResult> {
await Promise.all([

@matthewwalsh0 matthewwalsh0 Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we even need either of these, could our update be entirely synchronous?

The target gas shouldn't matter as the quote dictates the source gas and the provider owns the target gas?

Similarly, the target simulation isn't used or rendered in MetaMask Pay?

For any non-MetaMask Pay scenarios, we could let the caller explicitly update gas and pass it to our new updateTransactionCallback? To avoid adding that overhead implicitly?

I've been meaning to go one step further and disable simulation and gas polling for all MetaMask Pay transactions too. That may not help performance, but would minimise unnecessary network overhead.

* @param request - Complete atomic batch update.
* @returns The synchronously published revision and its preparation handle.
*/
beginAtomicBatchUpdate(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Following my other comment about a generic update method that accepts a callback, do we want to encourage single updates, or should we expose the synchronous updateEIP7702BatchData util I mentioned below, so callers could update data themselves, then pass to updateTransactionCallback?

So total flexibility to empower the caller to update efficiently in a single call, but generically so not limited to single flows?

* @param request - Exact amount and transaction ID.
* @returns Whether the matching quote generation was published.
*/
updateAmount(request: UpdateAmountRequest): Promise<boolean> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a cool mechanism to cancel wasted requests, but as per my other comment, do we even care about gas and simulation so is there anything async we need at all?

return {
revision,
transaction,
preparation: this.#prepareAtomicBatchUpdate(draftTransaction, revision),

@matthewwalsh0 matthewwalsh0 Jul 20, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is clever, but we do similar for transaction lifecycle, returning promise properties, and it introduces some complexity around dangling promises, plus I fear it adds confusion to the interface.

So ideally, would it be safer to stick with explicit synchronous actions and full promise returns?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants