Skip to content

feat(transaction-pay-controller): atomic quoting for subsidized max Money Account deposits - #10224

Open
OGPoyraz wants to merge 5 commits into
mainfrom
ogp/CONF-1949
Open

OGPoyraz wants to merge 5 commits into
mainfrom
ogp/CONF-1949

Conversation

@OGPoyraz

@OGPoyraz OGPoyraz commented Sep 14, 2026

Copy link
Copy Markdown
Member

Explanation

Max deposits to the Money Account currently use EXACT_INPUT quoting plus a separate MetaMask-sponsored vault transaction (approve + teller.deposit) submitted as a second leg.

This PR promotes subsidized (fixed-spread) max direct Money Account deposits to an atomic EXACT_OUTPUT Relay re-quote with the vault calls embedded, reusing the existing getAmountData / getDelegationTransaction machinery and the standard atomic submit path (no second vault transaction). Non-subsidized max behavior is unchanged.

Fail-closed by design: any promotion failure (amount rewrite, re-quote error, subsidy loss, spend-cap exceed including same-token gas, final validation failure) blocks terminally with the new atomic-promotion-failed quote error reason instead of silently falling back to a non-atomic quote. quote.request.atomic on the selected quote already drives submission, so no double-vault is possible.

No public API signatures changed; no client code changes required — consumers pick this up via package bump.

Screenshot & recordings

Exact Input for Max ARB token:
Exact Input for Max ARB token

Exact output for Max USDC token:
Exact Output for Max USDC token

Recording:

Simulator.Screen.Recording.-.iPhone.17.Pro.Max.-.2026-09-14.at.14.57.04.mov

References

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

High Risk
Changes max-deposit quoting, spend-cap math, and terminal error handling on a subsidized Money Account path; a promotion bug could block deposits or mis-size amounts instead of falling back.

Overview
Subsidized max direct Money Account Relay deposits now run a two-step quote path: after the usual max “discovery” quote, eligible subsidized flows are promoted to an atomic EXACT_OUTPUT re-quote with vault/deposit calldata embedded in the Relay request (via getAmountData + delegation), and the returned quote keeps atomic: true and isMaxAmount: true. Non-subsidized max deposits are unchanged.

Promotion is fail-closed: subsidy loss, invalid amount rewrites, spend-cap violations (including same-token gas), or post-promotion validation failures surface a new terminal atomic-promotion-failed QuoteErrorReason instead of falling back to a non-atomic quote. Strategy selection in quotes.ts stops trying alternate strategies on that reason and clears quotes/batch state; validation errors during promotion are wrapped as atomic-promotion-failed when a promoted quote was in play.

isSubsidizedRelayQuote is exported for reuse in promotion gating. Submission tests assert promoted atomic max quotes go through Relay execute with embedded steps and skip the separate vault helper; non-atomic max behavior is preserved.

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

@OGPoyraz

Copy link
Copy Markdown
Member Author

@metamaskbot publish-preview

@github-actions

Copy link
Copy Markdown
Contributor

Preview builds have been published. Learn how to use preview builds in other projects.

Expand for full list of packages and versions.
@metamask-previews/account-tree-controller@10.0.1-preview-e81860e6b
@metamask-previews/accounts-controller@40.0.0-preview-e81860e6b
@metamask-previews/address-book-controller@8.0.0-preview-e81860e6b
@metamask-previews/ai-controllers@2.0.0-preview-e81860e6b
@metamask-previews/analytics-controller@3.0.0-preview-e81860e6b
@metamask-previews/analytics-data-regulation-controller@0.0.0-preview-e81860e6b
@metamask-previews/announcement-controller@9.0.0-preview-e81860e6b
@metamask-previews/app-metadata-controller@3.0.0-preview-e81860e6b
@metamask-previews/approval-controller@10.0.0-preview-e81860e6b
@metamask-previews/assets-controller@16.0.0-preview-e81860e6b
@metamask-previews/assets-controllers@112.0.1-preview-e81860e6b
@metamask-previews/authenticated-user-storage@4.0.0-preview-e81860e6b
@metamask-previews/base-controller@10.0.0-preview-e81860e6b
@metamask-previews/base-data-service@2.0.0-preview-e81860e6b
@metamask-previews/bitcoin-regtest-up@2.0.0-preview-e81860e6b
@metamask-previews/bridge-controller@81.1.0-preview-e81860e6b
@metamask-previews/bridge-status-controller@76.2.0-preview-e81860e6b
@metamask-previews/build-utils@4.0.0-preview-e81860e6b
@metamask-previews/chain-agnostic-permission@2.0.0-preview-e81860e6b
@metamask-previews/chomp-api-service@5.0.0-preview-e81860e6b
@metamask-previews/claims-controller@1.0.1-preview-e81860e6b
@metamask-previews/client-controller@2.0.0-preview-e81860e6b
@metamask-previews/client-utils@3.0.0-preview-e81860e6b
@metamask-previews/compliance-controller@3.0.0-preview-e81860e6b
@metamask-previews/composable-controller@13.0.0-preview-e81860e6b
@metamask-previews/config-registry-controller@4.0.0-preview-e81860e6b
@metamask-previews/connectivity-controller@1.0.0-preview-e81860e6b
@metamask-previews/controller-utils@13.0.0-preview-e81860e6b
@metamask-previews/core-backend@10.0.1-preview-e81860e6b
@metamask-previews/cryptography@0.0.0-preview-e81860e6b
@metamask-previews/delegation-controller@4.0.0-preview-e81860e6b
@metamask-previews/earn-controller@13.0.0-preview-e81860e6b
@metamask-previews/eip-5792-middleware@4.0.0-preview-e81860e6b
@metamask-previews/eip-7702-internal-rpc-middleware@1.0.0-preview-e81860e6b
@metamask-previews/eip1193-permission-middleware@3.0.0-preview-e81860e6b
@metamask-previews/eth-block-tracker@16.0.0-preview-e81860e6b
@metamask-previews/eth-json-rpc-middleware@25.0.0-preview-e81860e6b
@metamask-previews/eth-json-rpc-provider@7.0.0-preview-e81860e6b
@metamask-previews/foundryup@2.0.0-preview-e81860e6b
@metamask-previews/gas-fee-controller@27.0.0-preview-e81860e6b
@metamask-previews/gator-permissions-controller@6.0.0-preview-e81860e6b
@metamask-previews/geolocation-controller@2.0.0-preview-e81860e6b
@metamask-previews/java-tron-up@2.0.0-preview-e81860e6b
@metamask-previews/json-rpc-engine@11.0.0-preview-e81860e6b
@metamask-previews/json-rpc-middleware-stream@9.0.0-preview-e81860e6b
@metamask-previews/keyring-controller@28.0.0-preview-e81860e6b
@metamask-previews/kyc-controller@0.3.0-preview-e81860e6b
@metamask-previews/local-node-utils@2.0.0-preview-e81860e6b
@metamask-previews/logging-controller@10.0.0-preview-e81860e6b
@metamask-previews/message-manager@15.0.0-preview-e81860e6b
@metamask-previews/messenger@3.0.0-preview-e81860e6b
@metamask-previews/messenger-cli@1.0.0-preview-e81860e6b
@metamask-previews/money-account-api-data-service@1.0.0-preview-e81860e6b
@metamask-previews/money-account-balance-service@3.0.0-preview-e81860e6b
@metamask-previews/money-account-controller@2.0.0-preview-e81860e6b
@metamask-previews/money-account-upgrade-controller@5.0.0-preview-e81860e6b
@metamask-previews/money-account-utils@2.0.0-preview-e81860e6b
@metamask-previews/multichain-account-service@14.0.0-preview-e81860e6b
@metamask-previews/multichain-api-middleware@5.0.0-preview-e81860e6b
@metamask-previews/multichain-network-controller@4.0.0-preview-e81860e6b
@metamask-previews/multichain-transactions-controller@8.0.0-preview-e81860e6b
@metamask-previews/name-controller@10.0.0-preview-e81860e6b
@metamask-previews/network-connection-banner-controller@1.0.0-preview-e81860e6b
@metamask-previews/network-controller@37.0.0-preview-e81860e6b
@metamask-previews/network-enablement-controller@7.0.0-preview-e81860e6b
@metamask-previews/notification-services-controller@28.0.1-preview-e81860e6b
@metamask-previews/passkey-controller@4.0.0-preview-e81860e6b
@metamask-previews/permission-controller@14.0.0-preview-e81860e6b
@metamask-previews/permission-log-controller@6.0.0-preview-e81860e6b
@metamask-previews/perps-controller@17.1.0-preview-e81860e6b
@metamask-previews/phishing-controller@18.0.0-preview-e81860e6b
@metamask-previews/platform-api-docs@0.2.0-preview-e81860e6b
@metamask-previews/polling-controller@17.0.0-preview-e81860e6b
@metamask-previews/preferences-controller@24.0.0-preview-e81860e6b
@metamask-previews/profile-metrics-controller@5.1.0-preview-e81860e6b
@metamask-previews/profile-sync-controller@32.1.1-preview-e81860e6b
@metamask-previews/ramps-controller@22.0.0-preview-e81860e6b
@metamask-previews/rate-limit-controller@8.0.0-preview-e81860e6b
@metamask-previews/react-data-query@2.0.0-preview-e81860e6b
@metamask-previews/remote-feature-flag-controller@7.0.0-preview-e81860e6b
@metamask-previews/sample-controllers@6.0.0-preview-e81860e6b
@metamask-previews/seedless-onboarding-controller@11.0.0-preview-e81860e6b
@metamask-previews/selected-network-controller@27.0.0-preview-e81860e6b
@metamask-previews/sentinel-api-service@2.0.0-preview-e81860e6b
@metamask-previews/shield-controller@7.0.1-preview-e81860e6b
@metamask-previews/signature-controller@40.0.0-preview-e81860e6b
@metamask-previews/smart-transactions-controller@27.0.1-preview-e81860e6b
@metamask-previews/snap-account-service@3.0.0-preview-e81860e6b
@metamask-previews/social-controllers@3.0.1-preview-e81860e6b
@metamask-previews/solana-test-validator-up@2.0.0-preview-e81860e6b
@metamask-previews/stellar-quickstart-up@0.0.0-preview-e81860e6b
@metamask-previews/storage-service@2.0.0-preview-e81860e6b
@metamask-previews/subscription-controller@9.0.1-preview-e81860e6b
@metamask-previews/transaction-controller@70.0.0-preview-e81860e6b
@metamask-previews/transaction-pay-controller@28.0.2-preview-e81860e6b
@metamask-previews/user-operation-controller@42.0.0-preview-e81860e6b
@metamask-previews/utils@12.0.0-preview-e81860e6b
@metamask-previews/wallet@13.0.0-preview-e81860e6b
@metamask-previews/wallet-cli@0.0.0-preview-e81860e6b

@OGPoyraz
OGPoyraz marked this pull request as ready for review September 14, 2026 12:00
@OGPoyraz
OGPoyraz requested review from a team as code owners September 14, 2026 12:00
@OGPoyraz
OGPoyraz deployed to default-branch September 14, 2026 12:00 — with GitHub Actions Active

@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 2 potential issues.

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 e81860e. Configure here.

const sourceAmount = new BigNumber(promotedQuote.sourceAmount.raw);
const sourceTokenIsNative =
request.sourceTokenAddress.toLowerCase() ===
getNativeToken(request.sourceChainId).toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Spend cap misses normalized native

Medium Severity

After normalizeRequest, Polygon native is rewritten to NATIVE_TOKEN_ADDRESS, but getPromotedSourceCost and the earlier spend check only treat getNativeToken as native. Same-token gas is then omitted from the cap, so a promoted max quote can exceed the user's spendable native balance.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e81860e. Configure here.

log('Normalized requests', normalizedRequests);

const quotes = await Promise.all(
quotes = await Promise.all(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Validation uses stale parent transaction

Medium Severity

Promotion rewrites nested calldata and requiredAssets only on a local clone, then validateRelayQuotes runs against the original parent. Validation rebuilds the execute delegation from stale txParams.data, so caveats can disagree with the promoted quote and fail closed or simulate the wrong batch.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e81860e. Configure here.

@matthewwalsh0
matthewwalsh0 self-requested a review September 14, 2026 12:30
* and `detail` on {@link QuoteErrorInfo}.
*/
export type QuoteErrorReason =
| 'atomic-promotion-failed'

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 need an explicit reason for this? Or is this a subset of no-quotes and we could use our error prefix pattern to get a good error message?

}

function isSubsidizedRelayQuote(quote: RelayQuote): boolean {
export function isSubsidizedRelayQuote(quote: RelayQuote): 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.

Minor, exported above private?

} catch (error) {
log('Error fetching quotes', { error });

if (quotes.some(isPromotedSubsidizedMaxMoneyAccountQuote)) {

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.

Can we be sure any error under these conditions is because of those conditions?

Should we instead just add an error prefix around the re-quote?

discoveryQuote: TransactionPayQuote<RelayQuote>,
): boolean {
return (
isMoneyAccountDepositTransaction(transaction) &&

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 needed?

Can we avoid the coupling as atomic and subsidised should always be okay?

Could this be a feature flag keyed by transaction type?

});
}

async function maybePromoteSubsidizedMaxMoneyAccountQuote({

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.

Minor, can some of these utils go in relay-max-gas-station.ts?

Maybe we rename to relay-max.ts?

transaction: transactionClone,
});

const promotedQuote = await getSingleQuote(

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.

We discussed which way to default this, as if the subsidized flow is the more common, we could do the initial quote with atomic and then do the second quote only if resulting quote is not subsidized?

And we could "hint" this from the client by setting atomic based on fixed spreads feature flag and checking route, so it's already off if route cannot be subsidized?

}

if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) {
throw new Error('Discovery quote target amount changed before promotion');

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 don't think we're handling enough error states 😄

}

/* istanbul ignore next: re-quote hardcodes atomic true; defensive guard. */
if (promotedQuote.request.atomic !== true) {

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.

Are some of these necessary? Are we guarding against Relay returning a response that simply ignores our request params?


const budget = new BigNumber(request.sourceTokenAmount);

if (sourceCost.isGreaterThan(budget)) {

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 already validated in quote validation phase generically, checking the amount in the quote is less or equal to balance.

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