feat: optimize Transaction Pay amount quote latency#9543
feat: optimize Transaction Pay amount quote latency#9543pedronfigueiredo wants to merge 3 commits into
Conversation
d284b3c to
732f0f7
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ 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(), | ||
| ]); |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 54520d2. Configure here.
|
|
||
| log('Beginning atomic batch update', request); | ||
|
|
||
| const updatedTransactionMeta = this.#updateTransactionInternal( |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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([ |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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?


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:
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
beginAtomicBatchUpdateto apply required assets and all nested calldata updates in one canonical state publication.TransactionPayController
updateAmount, backed by a consumer-providedprepareTransactionAmountcallback, so transaction-specific amount conversion and calldata construction remain outside the generic controller.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:
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.
TransactionControlleraccepts generic required assets and nested transaction updates, whileTransactionPayControllerdelegates 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:
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:
eth_estimateGascallsThese 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
Future work
Validation
yarn workspace @metamask/transaction-controller run jest --no-coverage src/TransactionController.test.ts --runInBand— 249 passedyarn 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 passedyarn changelog:validatepasses.References
28e517a73d33db3aa63470f9bc55bdbb47fc61060b09227dce3c5df2d4ae13a9e80c86da8913924a228627627d851ab75965174a30c7607cee1416917aa2c34fChecklist
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 monotonictransactionRevision, clears stale gas/simulation metadata, and runs revision-bound gas/simulation preparation. Stale preparation and simulation results are ignored when a newer revision exists.updateAtomicBatchDatais refactored to delegate to this path.TransactionPayController adds
updateAmountwith an injectedprepareTransactionAmountcallback, complete-patch validation, in-flight intent deduplication, and supersession via abort signals. Quotes are tied to the atomic revision throughtransactionPreparation, 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.