diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 3c7c67c95ea..8d49c12be2b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -6588,13 +6588,13 @@ "count": 11 }, "typescript/no-unsafe-assignment": { - "count": 9 + "count": 6 }, "typescript/no-unsafe-call": { - "count": 12 + "count": 9 }, "typescript/no-unsafe-member-access": { - "count": 12 + "count": 9 } }, "packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.test.ts": { @@ -6660,13 +6660,13 @@ }, "packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts": { "typescript/no-unsafe-assignment": { - "count": 269 + "count": 268 }, "typescript/no-unsafe-call": { - "count": 83 + "count": 84 }, "typescript/no-unsafe-member-access": { - "count": 217 + "count": 216 }, "typescript/no-unsafe-return": { "count": 2 @@ -6702,13 +6702,13 @@ "count": 1 }, "typescript/no-unsafe-assignment": { - "count": 36 + "count": 34 }, "typescript/no-unsafe-call": { - "count": 19 + "count": 17 }, "typescript/no-unsafe-member-access": { - "count": 28 + "count": 26 } }, "packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts": { @@ -6726,14 +6726,11 @@ } }, "packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts": { - "no-unused-vars": { - "count": 1 - }, "typescript/no-unnecessary-type-assertion": { "count": 1 }, "typescript/no-unsafe-assignment": { - "count": 47 + "count": 66 }, "typescript/no-unsafe-call": { "count": 9 @@ -6758,7 +6755,7 @@ "count": 4 }, "typescript/no-unsafe-assignment": { - "count": 44 + "count": 46 }, "typescript/no-unsafe-call": { "count": 21 @@ -6821,11 +6818,6 @@ "count": 1 } }, - "packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts": { - "no-useless-return": { - "count": 2 - } - }, "packages/transaction-pay-controller/src/utils/provider.test.ts": { "no-unused-vars": { "count": 1 diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d0ef73701a4..4eb38491e34 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,9 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Support submitting target calls as a separate second leg when the quote provider cannot execute them ([#10501](https://github.com/MetaMask/core/pull/10501)) + - Add `requiresSecondLeg` to `TransactionPayQuote`. + - `ServerStrategy` now supports non-atomic requests. + - Non-atomic quotes settle funds on the account that executes the calls. + ### Changed - Bump `@metamask/transaction-controller` from `^72.1.0` to `^72.2.0` ([#10772](https://github.com/MetaMask/core/pull/10772)) +- Fail fiat and second-leg submissions when the settled amount cannot be read from an on-chain transaction, rather than falling back to `order.cryptoAmount` or the quote minimum ([#10501](https://github.com/MetaMask/core/pull/10501)) + +### Fixed + +- Zero source network fees for Relay quotes on Polymarket deposit-wallet Predict withdraws, as the Polymarket relayer pays origin gas ([#10501](https://github.com/MetaMask/core/pull/10501)) +- Set `isIntentComplete` on the parent transaction only after the non-atomic second leg of a Relay quote is submitted, so a failed second leg no longer leaves a failed transaction flagged as complete ([#10501](https://github.com/MetaMask/core/pull/10501)) ## [30.0.3] diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts index 9bf58a51114..fff7ce41344 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts @@ -91,7 +91,9 @@ describe('FiatStrategy', () => { it('preserves nested Post-Ramp and Vault prefixes', async () => { submitFiatQuotesMock.mockRejectedValue( - new Error('Post-Ramp: Direct mUSD: Vault: Missing transaction hash'), + new Error( + 'Post-Ramp: Direct mUSD: Second leg: Vault: Missing transaction hash', + ), ); await expect( @@ -102,7 +104,7 @@ describe('FiatStrategy', () => { transaction: { txParams: { from: '0x1' } } as TransactionMeta, }), ).rejects.toThrow( - 'Fiat: Post-Ramp: Direct mUSD: Vault: Missing transaction hash', + 'Fiat: Post-Ramp: Direct mUSD: Second leg: Vault: Missing transaction hash', ); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index 92a98c27c56..94c58f4b7a1 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -17,14 +17,13 @@ import type { } from '../../types.js'; import { prefixError } from '../../utils/error-prefix.js'; import { getFiatVaultDisabled } from '../../utils/feature-flags.js'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; +import { submitSecondLeg } from '../../utils/second-leg.js'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; import { MUSD_MONAD_FIAT_ASSET } from './constants.js'; import type { FiatQuote } from './types.js'; import { getRampsQuote, getRawSourceAmountFromOrderCryptoAmount, - resolveSourceAmountRaw, validateOrderAsset, } from './utils.js'; @@ -135,6 +134,7 @@ export async function submitDirectMusdAfterFiatCompletion({ request: PayStrategyExecuteRequest; }): Promise<{ transactionHash?: Hex }> { const { messenger, transaction } = request; + const moneyAccountAddress = transaction.txParams.from as Hex; try { validateOrderAsset({ @@ -143,20 +143,24 @@ export async function submitDirectMusdAfterFiatCompletion({ transactionId: transaction.id, }); - const { amountRaw: sourceAmountRaw, fromBlock } = - await resolveSourceAmountRaw({ - messenger, - order, - fiatAsset: MUSD_MONAD_FIAT_ASSET, - walletAddress: transaction.txParams.from as Hex, + // Kill switch: leave the on-ramped mUSD in the Money Account rather than + // depositing it into the vault. + if (getFiatVaultDisabled(messenger)) { + log('Skipping vault deposit because the fiat vault is disabled', { + moneyAccountAddress, + transactionId: transaction.id, }); - return await submitMoneyAccountVaultDeposit({ - fromBlock, + return { transactionHash: '0x' }; + } + + return await submitSecondLeg({ + chainId: MUSD_MONAD_FIAT_ASSET.chainId, + from: moneyAccountAddress, messenger, - sourceAmountRaw, + settlementHash: order.txHash as Hex | undefined, + tokenAddress: MUSD_MONAD_FIAT_ASSET.address, transaction, - vaultDisabled: getFiatVaultDisabled(messenger), }); } catch (error) { throw prefixError(error, DIRECT_MUSD_ERROR_PREFIX); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index d98f5b22d5a..ee7d3f9b122 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -19,6 +19,7 @@ import { buildCaipAssetType } from '../../utils/token.js'; import { collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction.js'; @@ -142,15 +143,18 @@ function getFiatOrderMock({ cryptoAmount = '1', cryptoCurrency, status = RampsOrderStatus.Completed, + txHash, }: { cryptoAmount?: RampsOrder['cryptoAmount']; cryptoCurrency?: RampsOrderCryptoCurrency; status?: RampsOrderStatus; + txHash?: RampsOrder['txHash']; } = {}): RampsOrder { return { cryptoAmount, cryptoCurrency, status, + txHash, } as RampsOrder; } @@ -312,6 +316,9 @@ describe('submitFiatQuotes', () => { const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); const resolveSourceAmountRawMock = jest.mocked(resolveSourceAmountRaw); const updateTransactionMock = jest.mocked(updateTransaction); const collectTransactionIdsMock = jest.mocked(collectTransactionIds); @@ -345,10 +352,7 @@ describe('submitFiatQuotes', () => { ); waitForTransactionConfirmedMock.mockResolvedValue(); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); - resolveSourceAmountRawMock.mockResolvedValue({ - amountRaw: '1000000000000000000', - fromBlock: undefined, - }); + resolveSourceAmountRawMock.mockResolvedValue('1000000000000000000'); fundFiatOrderFromTestSourceMock.mockResolvedValue( getFiatOrderMock({ cryptoAmount: '1', @@ -375,10 +379,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ - amountRaw: '1234500000000000000', - fromBlock: undefined, - }); + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); const { callMock, request } = getRequest({ order }); const result = await submitFiatQuotes(request); @@ -425,10 +426,7 @@ describe('submitFiatQuotes', () => { }, status: RampsOrderStatus.Completed, }); - resolveSourceAmountRawMock.mockResolvedValue({ - amountRaw: '1234500000000000000', - fromBlock: undefined, - }); + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); const callMock = jest.fn((action: string) => { if (action === 'TransactionPayController:getState') { @@ -551,10 +549,7 @@ describe('submitFiatQuotes', () => { ], } as unknown as TransactionMeta; - resolveSourceAmountRawMock.mockResolvedValue({ - amountRaw: '1234500000000000000', - fromBlock: undefined, - }); + resolveSourceAmountRawMock.mockResolvedValue('1234500000000000000'); const { callMock, request } = getRequest({ transaction: nestedTransaction, @@ -1094,6 +1089,16 @@ describe('submitFiatQuotes', () => { describe('direct mUSD to money account flow', () => { const MONEY_ACCOUNT_ADDRESS = '0x3333333333333333333333333333333333333333' as Hex; + const MUSD_SETTLEMENT_HASH_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; + const MUSD_SETTLED_AMOUNT_MOCK = '10000000'; + + beforeEach(() => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: MUSD_SETTLED_AMOUNT_MOCK, + blockNumber: undefined, + }); + }); const MUSD_QUOTE_REQUEST: QuoteRequest = { from: WALLET_ADDRESS_MOCK, @@ -1111,7 +1116,11 @@ describe('submitFiatQuotes', () => { id: TRANSACTION_ID_MOCK, nestedTransactions: [ { data: '0xoldApprove', to: '0xapprove' }, - { data: '0xoldDeposit', to: '0xdeposit' }, + { + data: '0xoldDeposit', + to: '0xdeposit', + type: TransactionType.moneyAccountDeposit, + }, ], txParams: { from: MONEY_ACCOUNT_ADDRESS }, type: 'batch', @@ -1120,6 +1129,7 @@ describe('submitFiatQuotes', () => { it('uses txParams.from as walletAddress when quote is direct mUSD', async () => { const order = getFiatOrderMock({ status: RampsOrderStatus.Completed, + txHash: MUSD_SETTLEMENT_HASH_MOCK, }); const { callMock, request } = getRequest({ order, @@ -1149,6 +1159,7 @@ describe('submitFiatQuotes', () => { symbol: 'MUSD', }, status: RampsOrderStatus.Completed, + txHash: MUSD_SETTLEMENT_HASH_MOCK, }); buildCaipAssetTypeMock.mockReturnValue( 'eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b687a2435da', @@ -1170,6 +1181,7 @@ describe('submitFiatQuotes', () => { it('submits a sponsored Money Account vault batch for direct pure-fiat mUSD', async () => { const { callMock, request } = getRequest({ + order: getFiatOrderMock({ txHash: MUSD_SETTLEMENT_HASH_MOCK }), quotes: [ getFiatQuoteMock({ includeRelayQuote: false, @@ -1192,13 +1204,13 @@ describe('submitFiatQuotes', () => { expect(callMock).toHaveBeenCalledWith( 'TransactionPayController:getAmountData', expect.objectContaining({ - amount: '1000000000000000000', + amount: MUSD_SETTLED_AMOUNT_MOCK, transaction: expect.objectContaining({ id: TRANSACTION_ID_MOCK }), }), ); expect(updateTransactionMock).toHaveBeenCalledWith( expect.objectContaining({ - note: 'Money Account vault deposit: update vault amount', + note: 'Second leg: update amount', transactionId: TRANSACTION_ID_MOCK, }), expect.any(Function), @@ -1226,7 +1238,7 @@ describe('submitFiatQuotes', () => { ); expect(updateTransactionMock).toHaveBeenCalledWith( expect.objectContaining({ - note: 'Add required transaction ID from Money Account vault submission', + note: 'Add required transaction ID from second leg submission', transactionId: TRANSACTION_ID_MOCK, }), expect.any(Function), @@ -1247,6 +1259,7 @@ describe('submitFiatQuotes', () => { : undefined, ); const { request } = getRequest({ + order: getFiatOrderMock({ txHash: MUSD_SETTLEMENT_HASH_MOCK }), quotes: [ getFiatQuoteMock({ includeRelayQuote: false, @@ -1257,7 +1270,7 @@ describe('submitFiatQuotes', () => { }); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Post-Ramp: Direct mUSD: Missing transaction hash', + 'Post-Ramp: Direct mUSD: Second leg: Vault: Missing transaction hash', ); }); @@ -1297,7 +1310,7 @@ describe('submitFiatQuotes', () => { } if (action === 'RampsController:getOrder') { - return getFiatOrderMock(); + return getFiatOrderMock({ txHash: MUSD_SETTLEMENT_HASH_MOCK }); } if (action === 'TransactionPayController:getAmountData') { @@ -1322,7 +1335,7 @@ describe('submitFiatQuotes', () => { }); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Post-Ramp: Direct mUSD: Vault: batch failed', + 'Post-Ramp: Direct mUSD: Second leg: Vault: batch failed', ); }); @@ -1389,7 +1402,7 @@ describe('submitFiatQuotes', () => { ); expect(updateTransactionMock).not.toHaveBeenCalledWith( expect.objectContaining({ - note: 'Add required transaction ID from Money Account vault submission', + note: 'Add required transaction ID from second leg submission', }), expect.any(Function), ); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 19042c2a3ee..26492319c58 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -267,7 +267,7 @@ async function submitRelayAfterFiatCompletion({ const baseRequest = fiatQuote.request; - const { amountRaw: sourceAmountRaw } = await resolveSourceAmountRaw({ + const sourceAmountRaw = await resolveSourceAmountRaw({ messenger, order, fiatAsset, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts index ba94b989f90..05ab347f9f1 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -4,9 +4,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller.js'; -import { NATIVE_TOKEN_ADDRESS } from '../../constants.js'; import { getMessengerMock } from '../../tests/messenger-mock.js'; -import { buildCaipAssetType } from '../../utils/token.js'; import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE, @@ -26,11 +24,6 @@ const CHAIN_ID_MOCK = '0x1' as Hex; const NETWORK_CLIENT_ID_MOCK = 'net-client-1'; const PROVIDER_MOCK = { request: jest.fn() }; -const NATIVE_FIAT_ASSET_MOCK: TransactionPayFiatAsset = { - address: NATIVE_TOKEN_ADDRESS, - chainId: CHAIN_ID_MOCK, -}; - const ERC20_FIAT_ASSET_MOCK: TransactionPayFiatAsset = { address: ERC20_ADDRESS_MOCK, chainId: CHAIN_ID_MOCK, @@ -253,11 +246,8 @@ describe('Fiat Utils', () => { const { messenger: resolveMessenger, findNetworkClientIdByChainIdMock, - getAssetsControllerStateMock, getNetworkClientByIdMock, getNetworkConfigurationByChainIdMock, - getRemoteFeatureFlagControllerStateMock: - resolveRemoteFeatureFlagControllerStateMock, } = getMessengerMock(); beforeEach(() => { @@ -266,27 +256,12 @@ describe('Fiat Utils', () => { findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); getNetworkConfigurationByChainIdMock.mockReturnValue(undefined); getNetworkClientByIdMock.mockReturnValue({ + configuration: { chainId: CHAIN_ID_MOCK }, provider: PROVIDER_MOCK, }); - - resolveRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - }); - - const assetId = buildCaipAssetType(CHAIN_ID_MOCK, ERC20_ADDRESS_MOCK); - getAssetsControllerStateMock.mockReturnValue({ - assetsInfo: { - [assetId]: { - decimals: 6, - name: 'USDC', - symbol: 'USDC', - type: 'erc20', - }, - }, - }); }); - it('returns on-chain ERC-20 amount and block number from receipt', async () => { + it('returns the raw amount read from the on-chain ERC-20 transfer', async () => { PROVIDER_MOCK.request.mockResolvedValue({ blockNumber: '0x1a2b3c', logs: [ @@ -303,110 +278,51 @@ describe('Fiat Utils', () => { }); const result = await resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock(), - fiatAsset: ERC20_FIAT_ASSET_MOCK, - walletAddress: WALLET_ADDRESS_MOCK, - }); - - expect(result.amountRaw).toBe('7000000'); - expect(result.fromBlock).toBe('0x1a2b3c'); - }); - - it('falls back to cryptoAmount when txHash is missing', async () => { - const result = await resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, - walletAddress: WALLET_ADDRESS_MOCK, - }); - - expect(result.amountRaw).toBe('1500000'); - expect(result.fromBlock).toBeUndefined(); - expect(PROVIDER_MOCK.request).not.toHaveBeenCalled(); - }); - - it('falls back to cryptoAmount when receipt is null', async () => { - PROVIDER_MOCK.request.mockResolvedValue(null); - - const result = await resolveSourceAmountRaw({ messenger: resolveMessenger, order: getOrderMock(), - fiatAsset: ERC20_FIAT_ASSET_MOCK, walletAddress: WALLET_ADDRESS_MOCK, }); - expect(result.amountRaw).toBe('1500000'); - expect(result.fromBlock).toBeUndefined(); + expect(result).toBe('7000000'); }); - it('falls back to cryptoAmount when on-chain read throws', async () => { - PROVIDER_MOCK.request.mockRejectedValue(new Error('Network error')); - - const result = await resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock(), - fiatAsset: ERC20_FIAT_ASSET_MOCK, - walletAddress: WALLET_ADDRESS_MOCK, - }); - - expect(result.amountRaw).toBe('1500000'); - expect(result.fromBlock).toBeUndefined(); - }); - - it('returns native amount from debug_traceTransaction', async () => { - PROVIDER_MOCK.request.mockResolvedValue({ - to: WALLET_ADDRESS_MOCK.toLowerCase(), - value: '0x1bc16d674ec80000', - calls: [], - }); - - const result = await resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock(), - fiatAsset: NATIVE_FIAT_ASSET_MOCK, - walletAddress: WALLET_ADDRESS_MOCK, - }); - - expect(result.amountRaw).toBe('2000000000000000000'); - expect(result.fromBlock).toBeUndefined(); + it('throws when the order has no transaction hash', async () => { + await expect( + resolveSourceAmountRaw({ + fiatAsset: ERC20_FIAT_ASSET_MOCK, + messenger: resolveMessenger, + order: getOrderMock({ txHash: '' }), + walletAddress: WALLET_ADDRESS_MOCK, + }), + ).rejects.toThrow('Missing fiat order transaction hash'); }); - it('falls back to tx.value for native when trace is unsupported', async () => { - PROVIDER_MOCK.request.mockImplementation( - ({ method }: { method: string }) => { - if (method === 'debug_traceTransaction') { - return Promise.reject(new Error('Method not found')); - } - return Promise.resolve({ - to: WALLET_ADDRESS_MOCK.toLowerCase(), - value: '0x1bc16d674ec80000', - }); - }, - ); - - const result = await resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock(), - fiatAsset: NATIVE_FIAT_ASSET_MOCK, - walletAddress: WALLET_ADDRESS_MOCK, - }); + it('propagates errors from getTransferredAmountFromTxHash', async () => { + PROVIDER_MOCK.request.mockRejectedValue(new Error('Network error')); - expect(result.amountRaw).toBe('2000000000000000000'); + await expect( + resolveSourceAmountRaw({ + fiatAsset: ERC20_FIAT_ASSET_MOCK, + messenger: resolveMessenger, + order: getOrderMock(), + walletAddress: WALLET_ADDRESS_MOCK, + }), + ).rejects.toThrow('Network error'); }); - it('throws when token info cannot be resolved for fallback', async () => { - getAssetsControllerStateMock.mockReturnValue({ assetsInfo: {} }); + it('throws when no matching transfer is found in the transaction', async () => { + PROVIDER_MOCK.request.mockResolvedValue(null); await expect( resolveSourceAmountRaw({ - messenger: resolveMessenger, - order: getOrderMock({ txHash: '' }), fiatAsset: ERC20_FIAT_ASSET_MOCK, + messenger: resolveMessenger, + order: getOrderMock(), walletAddress: WALLET_ADDRESS_MOCK, }), ).rejects.toThrow( - `Unable to resolve token info for fiat asset ${ERC20_ADDRESS_MOCK} on chain ${CHAIN_ID_MOCK}`, + `Could not determine transferred amount from transaction ${TX_HASH_MOCK}`, ); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 4658bab9fff..63e90e8c8d3 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -15,7 +15,7 @@ import { getFiatAssetPerTransactionType, getFiatEnabledTypes, } from '../../utils/feature-flags.js'; -import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; +import { buildCaipAssetType } from '../../utils/token.js'; import { getTransferredAmountFromTxHash } from '../../utils/transaction.js'; import type { RelayQuote } from '../relay/types.js'; import type { TransactionPayFiatAsset } from './constants.js'; @@ -174,37 +174,18 @@ export function validateOrderAsset({ } /** - * Result from {@link resolveSourceAmountRaw}. - */ -export type ResolvedSourceAmount = { - /** Raw (atomic) source amount as a decimal string. */ - amountRaw: string; - /** - * Block number of the ramps settlement transaction as a 0x-prefixed hex - * string. Populated when `order.txHash` is present and the on-chain receipt - * was successfully fetched (ERC-20 only). Use this as the `fromBlock` for - * CHOMP idempotency log queries — it reuses the receipt already fetched for - * the amount and requires no additional network request. - */ - fromBlock: Hex | undefined; -}; - -/** - * Resolves the raw source amount for a completed fiat order. - * - * Attempts to read the actual transferred amount from the on-chain transaction - * identified by `order.txHash`. If the on-chain read fails or returns - * no amount, falls back to computing the amount from `order.cryptoAmount`. + * Resolves the raw source amount for a completed fiat order, read from the + * on-chain transfer identified by `order.txHash`. * - * Also returns the receipt `blockNumber` from the ramps tx when available, so - * callers can use it as a CHOMP idempotency baseline without any extra request. + * Throws when the order has no transaction hash or no matching transfer, + * rather than trusting the provider-reported `order.cryptoAmount`. * * @param options - The resolution options. * @param options.messenger - Controller messenger for network access. * @param options.order - The completed on-ramp order. * @param options.fiatAsset - The fiat asset describing the expected token. * @param options.walletAddress - Recipient wallet address for on-chain lookup. - * @returns The raw (atomic) source amount and optional receipt block number. + * @returns The raw (atomic) source amount. */ export async function resolveSourceAmountRaw({ messenger, @@ -216,52 +197,31 @@ export async function resolveSourceAmountRaw({ order: RampsOrder; fiatAsset: TransactionPayFiatAsset; walletAddress: Hex; -}): Promise { - if (order.txHash) { - try { - const { amountRaw: onChainAmount, blockNumber } = - await getTransferredAmountFromTxHash({ - messenger, - txHash: order.txHash, - chainId: fiatAsset.chainId, - tokenAddress: fiatAsset.address, - walletAddress, - }); - - if (onChainAmount) { - log('Resolved source amount from on-chain transaction', { - txHash: order.txHash, - onChainAmount, - blockNumber, - }); - return { amountRaw: onChainAmount, fromBlock: blockNumber }; - } - } catch (error) { - log( - 'Failed to read on-chain amount, falling back to order.cryptoAmount', - { txHash: order.txHash, error }, - ); - } +}): Promise { + if (!order.txHash) { + throw new Error('Missing fiat order transaction hash'); } - const tokenInfo = getTokenInfo( + const { amountRaw } = await getTransferredAmountFromTxHash({ messenger, - fiatAsset.address, - fiatAsset.chainId, - ); + txHash: order.txHash, + chainId: fiatAsset.chainId, + tokenAddress: fiatAsset.address, + walletAddress, + }); - if (!tokenInfo) { + if (!amountRaw) { throw new Error( - `Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`, + `Could not determine transferred amount from transaction ${order.txHash}`, ); } - const amountRaw = getRawSourceAmountFromOrderCryptoAmount({ - cryptoAmount: order.cryptoAmount, - decimals: tokenInfo.decimals, + log('Resolved source amount from on-chain transaction', { + amountRaw, + txHash: order.txHash, }); - return { amountRaw, fromBlock: undefined }; + return amountRaw; } /** diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 836f463b1c0..1056326b5b2 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -66,7 +66,10 @@ jest.mock('../../utils/feature-flags', () => ({ })); jest.mock('./relay-validation'); -const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; +const FROM_MOCK = '0x1234567890123456789012345678901234567891' as Hex; +const TRANSACTION_META_MOCK = { + txParams: { from: FROM_MOCK }, +} as TransactionMeta; const PREDICT_WITHDRAW_TRANSACTION_MOCK = { txParams: {}, nestedTransactions: [{ type: TransactionType.predictWithdraw }], @@ -74,7 +77,6 @@ const PREDICT_WITHDRAW_TRANSACTION_MOCK = { const TOKEN_TRANSFER_RECIPIENT_MOCK = '0x5678901234567890123456789012345678901234'; const NESTED_TRANSACTION_DATA_MOCK = '0xdef' as Hex; -const FROM_MOCK = '0x1234567890123456789012345678901234567891' as Hex; const NETWORK_CLIENT_ID_MOCK = 'networkClientIdMock'; const CHAIN_ID_LINEA = '0xe708' as Hex; @@ -517,9 +519,10 @@ describe('Relay Quotes Utils', () => { transaction: { ...TRANSACTION_META_MOCK, txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); expect(successfulFetchMock).toHaveBeenCalledTimes(1); @@ -545,9 +548,10 @@ describe('Relay Quotes Utils', () => { transaction: { ...TRANSACTION_META_MOCK, txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); const body = JSON.parse( @@ -614,35 +618,6 @@ describe('Relay Quotes Utils', () => { }); }); - it('falls back to request.from for the funding recipient when transaction.txParams.from is unset', async () => { - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => QUOTE_MOCK, - }); - - await getRelayQuotes({ - accountSupports7702: true, - messenger, - requests: [QUOTE_REQUEST_MOCK], - transaction: { - ...TRANSACTION_META_MOCK, - txParams: { - data: '0xabc', - }, - } as TransactionMeta, - }); - - const body = JSON.parse( - successfulFetchMock.mock.calls[0][1]?.body as string, - ); - - expect(body.txs[0]).toStrictEqual({ - to: QUOTE_REQUEST_MOCK.targetTokenAddress, - data: '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567891000000000000000000000000000000000000000000000000000000000000007b', - value: '0x0', - }); - }); - it('includes request in quote', async () => { successfulFetchMock.mockResolvedValue({ ok: true, @@ -739,9 +714,10 @@ describe('Relay Quotes Utils', () => { }, ], txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); const body = JSON.parse( @@ -793,9 +769,10 @@ describe('Relay Quotes Utils', () => { }, ], txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); expect(getDelegationTransactionMock).not.toHaveBeenCalled(); @@ -819,9 +796,10 @@ describe('Relay Quotes Utils', () => { }, ], txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); const body = JSON.parse( @@ -852,9 +830,10 @@ describe('Relay Quotes Utils', () => { }, ], txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); const body = JSON.parse( @@ -883,9 +862,10 @@ describe('Relay Quotes Utils', () => { transaction: { ...TRANSACTION_META_MOCK, txParams: { + ...TRANSACTION_META_MOCK.txParams, data: '0xabc', }, - } as TransactionMeta, + }, }); expect(getDelegationTransactionMock).not.toHaveBeenCalled(); @@ -5661,6 +5641,31 @@ describe('Relay Quotes Utils', () => { expect(body.useDepositAddress).toBe(true); expect(body.strict).toBe(true); }); + + it('zeroes source network fees (gasless)', async () => { + polymarketGetDepositWalletAddressMock.mockResolvedValue( + DEPOSIT_WALLET_MOCK, + ); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + }); + + const result = await getRelayQuotes({ + accountSupports7702: true, + from: FROM_MOCK, + messenger, + requests: [POLYMARKET_REQUEST], + transaction: TRANSACTION_META_MOCK, + }); + + const zeroAmount = { fiat: '0', human: '0', raw: '0', usd: '0' }; + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual(zeroAmount); + expect(result[0].fees.sourceNetwork.max).toStrictEqual(zeroAmount); + expect(estimateGasMock).not.toHaveBeenCalled(); + }); }); describe('gas buffer support', () => { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 109a875e516..e5190f70679 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -50,6 +50,7 @@ import { import { calculateGasCost } from '../../utils/gas.js'; import { estimateQuoteGasLimits } from '../../utils/quote-gas.js'; import type { QuoteGasTransaction } from '../../utils/quote-gas.js'; +import { resolveNonAtomicRecipient } from '../../utils/second-leg.js'; import { getNativeToken, getTokenFiatRate, @@ -438,54 +439,6 @@ function normalizeAuthorizationList( })); } -/** - * Derives the Relay quote recipient for non-atomic flows, where the second leg - * runs after settlement so funds must land directly on the account submitting - * that leg. - * - * Post-quote flows (e.g. Perps/Predict withdraw to Money Account) ask the - * client `getPaymentOverrideData` callback, which knows the Money Account - * address that cannot be derived from the request. Non-post-quote flows (e.g. - * max-amount Money Account deposit) use the parent transaction's own `from`, - * which is the Money Account rather than the funding EOA in `request.from`. - * - * @param transaction - Transaction metadata. - * @param request - Quote request. - * @param messenger - Controller messenger. - * @returns The recipient address, or `undefined` for atomic flows. - */ -async function resolveNonAtomicRecipient( - transaction: TransactionMeta, - request: QuoteRequest, - messenger: TransactionPayControllerMessenger, -): Promise { - if (request.atomic !== false) { - return undefined; - } - - if (!request.isPostQuote) { - return (transaction.txParams?.from as Hex | undefined) ?? request.from; - } - - const { transactionData: transactionDataList } = messenger.call( - 'TransactionPayController:getState', - ); - - const transactionData = transactionDataList[transaction.id]; - const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; - - const { recipient } = await messenger.call( - 'TransactionPayController:getPaymentOverrideData', - { - amount: amountHuman, - transaction, - transactionData, - }, - ); - - return recipient; -} - /** * Add tranasction data to request body if needed. * @@ -571,13 +524,11 @@ async function processTransactions( requestBody.refundTo = request.from; } - const fundingRecipient = (transaction.txParams?.from as Hex) ?? request.from; - requestBody.txs = [ { to: request.targetTokenAddress, data: buildTokenTransferData( - fundingRecipient, + transaction.txParams.from as Hex, request.targetAmountMinimum, ), value: '0x0', @@ -917,15 +868,18 @@ async function calculateSourceNetworkCost( > { const { from, sourceChainId, sourceTokenAddress } = request; - // Neither flow bills origin gas to the user: the execute flow has a relayer - // redeem a signed delegation, and a HyperLiquid withdrawal's "deposit" step - // is an off-chain HL sendAsset signature rather than an on-chain - // transaction. + // None of these flows bill origin gas to the user: the execute flow has a + // relayer redeem a signed delegation, a HyperLiquid withdrawal's "deposit" + // step is an off-chain HL sendAsset signature rather than an on-chain + // transaction, and a Polymarket deposit-wallet withdraw is submitted by the + // Polymarket relayer. const isExecuteFlow = Boolean(quote.metamask?.isExecute); const isHyperliquidWithdrawal = Boolean(request.isHyperliquidSource); + const isPolymarketDepositWallet = Boolean(request.isPolymarketDepositWallet); const gasPayment = resolveGasPayment({ - isDelegated: isExecuteFlow || isHyperliquidWithdrawal, + isDelegated: + isExecuteFlow || isHyperliquidWithdrawal || isPolymarketDepositWallet, sourceTokenAddress, sponsorship: { accountSupports7702, @@ -938,6 +892,7 @@ async function calculateSourceNetworkCost( log('Zeroing network fees as the user does not pay origin gas', { isExecuteFlow, isHyperliquidWithdrawal, + isPolymarketDepositWallet, }); return { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 2a56c504398..b3fc6520d2d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -20,7 +20,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags.js'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; +import { submitSecondLeg } from '../../utils/second-leg.js'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -28,7 +28,6 @@ import { import { collectTransactionIds, getTransaction, - getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction.js'; @@ -37,7 +36,12 @@ import { submitViaRelayExecute } from './relay-submit-execute.js'; import { getRelaySubmitCalls, submitRelayQuotes } from './relay-submit.js'; import type { RelayQuote } from './types.js'; -jest.mock('../../utils/ma-vault-deposit'); +jest.mock('../../utils/second-leg', () => ({ + ...jest.requireActual( + '../../utils/second-leg', + ), + submitSecondLeg: jest.fn(), +})); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); @@ -166,12 +170,7 @@ describe('Relay Submit Utils', () => { ); const submitViaRelayExecuteMock = jest.mocked(submitViaRelayExecute); - const submitMoneyAccountVaultDepositMock = jest.mocked( - submitMoneyAccountVaultDeposit, - ); - const getTransferredAmountFromTxHashMock = jest.mocked( - getTransferredAmountFromTxHash, - ); + const submitSecondLegMock = jest.mocked(submitSecondLeg); beforeEach(() => { jest.resetAllMocks(); @@ -1192,7 +1191,7 @@ describe('Relay Submit Utils', () => { PaymentOverride.MoneyAccount; request.quotes[0].request.atomic = false; request.quotes[0].original.details.currencyOut.minimumAmount = '530000'; - submitMoneyAccountVaultDepositMock.mockResolvedValue({ + submitSecondLegMock.mockResolvedValue({ transactionHash: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', }); @@ -1979,7 +1978,6 @@ describe('Relay Submit Utils', () => { const RECIPIENT_MOCK = '0xrecip0000000000000000000000000000000001' as Hex; const TARGET_HASH_MOCK = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; - const ON_CHAIN_AMOUNT_MOCK = '535000'; const MINIMUM_AMOUNT_MOCK = '530000'; const VAULT_HASH_MOCK = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; @@ -2002,7 +2000,7 @@ describe('Relay Submit Utils', () => { minimumAmount: MINIMUM_AMOUNT_MOCK, }; - submitMoneyAccountVaultDepositMock.mockResolvedValue({ + submitSecondLegMock.mockResolvedValue({ transactionHash: VAULT_HASH_MOCK, }); @@ -2016,53 +2014,47 @@ describe('Relay Submit Utils', () => { }); }); - it('resolves settled amount from on-chain Transfer log and submits vault deposit', async () => { - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - + it('submits the second leg with the polled target hash as settlementHash', async () => { const result = await submitRelayQuotes(request); - expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ - messenger: expect.anything(), - txHash: TARGET_HASH_MOCK, - chainId: TARGET_CHAIN_ID_MOCK, - tokenAddress: TARGET_TOKEN_ADDRESS_MOCK, - walletAddress: RECIPIENT_MOCK, - }); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect(submitSecondLegMock).toHaveBeenCalledWith( expect.objectContaining({ - sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, - moneyAccountAddress: RECIPIENT_MOCK, - vaultDisabled: false, + chainId: TARGET_CHAIN_ID_MOCK, + from: RECIPIENT_MOCK, + settlementHash: TARGET_HASH_MOCK, + tokenAddress: TARGET_TOKEN_ADDRESS_MOCK, }), ); expect(result).toStrictEqual({ transactionHash: VAULT_HASH_MOCK }); }); - it('throws when the cross-chain on-chain amount is unavailable', async () => { - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: undefined, - blockNumber: undefined, - }); + it('marks the intent complete only after the second leg is submitted', async () => { + await submitRelayQuotes(request); - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Cannot resolve settled amount from on-chain transaction', + const completionIndex = updateTransactionMock.mock.calls.findIndex( + ([{ note }]) => note === 'Intent complete after Relay completion', ); - expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + + expect(completionIndex).not.toBe(-1); + expect( + updateTransactionMock.mock.invocationCallOrder[completionIndex], + ).toBeGreaterThan(submitSecondLegMock.mock.invocationCallOrder[0]); }); - it('propagates the error when the cross-chain on-chain read throws', async () => { - getTransferredAmountFromTxHashMock.mockRejectedValue( - new Error('rpc error'), + it('does not mark the intent complete when the second leg fails', async () => { + submitSecondLegMock.mockRejectedValue(new Error('Second leg failed')); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Second leg failed', ); - await expect(submitRelayQuotes(request)).rejects.toThrow('rpc error'); - expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + const completionUpdate = updateTransactionMock.mock.calls.find( + ([{ note }]) => note === 'Intent complete after Relay completion', + ); + expect(completionUpdate).toBeUndefined(); }); - it('skips on-chain read and uses quote minimum when targetHash is FALLBACK_HASH', async () => { + it('passes settlementHash as undefined when targetHash is FALLBACK_HASH', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -2074,62 +2066,33 @@ describe('Relay Submit Utils', () => { await submitRelayQuotes(request); - expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ settlementHash: undefined }), ); }); - it('uses quote minimum on same-chain flows when submit returns FALLBACK_HASH', async () => { + it('passes the submitted source hash as settlementHash on same-chain flows', async () => { request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; request.quotes[0].original.details.currencyIn.currency.chainId = 1; request.quotes[0].original.details.currencyOut.currency.chainId = 1; - request.quotes[0].original.metamask.isExecute = true; - - submitViaRelayExecuteMock.mockResolvedValue(FALLBACK_HASH); - - successfulFetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ - status: 'success', - inTxHashes: [SOURCE_HASH_MOCK], - txHashes: [FALLBACK_HASH], - }), - }); await submitRelayQuotes(request); - expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ + settlementHash: TRANSACTION_HASH_MOCK, + }), ); }); - it('reads settled amount from submitted source hash on same-chain flows', async () => { + it('passes settlementHash as undefined on same-chain flows when submit returns FALLBACK_HASH', async () => { request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; request.quotes[0].original.details.currencyIn.currency.chainId = 1; request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.metamask.isExecute = true; - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - - await submitRelayQuotes(request); - - expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith( - expect.objectContaining({ - txHash: TRANSACTION_HASH_MOCK, - chainId: CHAIN_ID_MOCK, - walletAddress: RECIPIENT_MOCK, - }), - ); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK }), - ); - }); + submitViaRelayExecuteMock.mockResolvedValue(FALLBACK_HASH); - it('throws when the same-chain flow has no quote-minimum amount', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -2138,20 +2101,16 @@ describe('Relay Submit Utils', () => { txHashes: [FALLBACK_HASH], }), }); - request.quotes[0].original.details.currencyOut.minimumAmount = ''; - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Cannot resolve post-completion amount', + await submitRelayQuotes(request); + + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ settlementHash: undefined }), ); - expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); }); - it('falls back to completion targetHash when submit returns no hash', async () => { - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - submitMoneyAccountVaultDepositMock.mockResolvedValue({ + it('falls back to completion targetHash when submitSecondLeg returns no hash', async () => { + submitSecondLegMock.mockResolvedValue({ transactionHash: undefined, }); @@ -2160,20 +2119,21 @@ describe('Relay Submit Utils', () => { expect(result).toStrictEqual({ transactionHash: TARGET_HASH_MOCK }); }); + it('uses from as quote.request.recipient when set', async () => { + await submitRelayQuotes(request); + + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ from: RECIPIENT_MOCK }), + ); + }); + it('falls back to quote.request.from when no recipient is set', async () => { request.quotes[0].request.recipient = undefined; - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); await submitRelayQuotes(request); - expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith( - expect.objectContaining({ walletAddress: FROM_MOCK }), - ); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ moneyAccountAddress: FROM_MOCK }), + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ from: FROM_MOCK }), ); }); @@ -2190,6 +2150,7 @@ describe('Relay Submit Utils', () => { value: '0x0', }, ]; + const ON_CHAIN_AMOUNT_MOCK = '535000'; beforeEach(() => { request.quotes[0].request.isPostQuote = true; @@ -2201,81 +2162,37 @@ describe('Relay Submit Utils', () => { getPaymentOverrideDataMock.mockResolvedValue({ calls: DEPOSIT_CALLS_MOCK, }); - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); }); - it('calls getPaymentOverrideData with the settled amount (in human units) and forwards deposit calls', async () => { + it('builds the second leg calls via getPaymentOverrideData with the settled amount', async () => { await submitRelayQuotes(request); + const { getCalls } = submitSecondLegMock.mock.calls[0][0]; + const calls = await getCalls?.(ON_CHAIN_AMOUNT_MOCK); + + // 535000 raw with 6 decimals -> 0.535 human expect(getPaymentOverrideDataMock).toHaveBeenCalledWith({ - // 535000 raw with 6 decimals → 0.535 human amount: '0.535', transaction: expect.objectContaining({ id: ORIGINAL_TRANSACTION_ID_MOCK, }), - transactionData: expect.anything(), + transactionData: {}, }); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ - depositCalls: DEPOSIT_CALLS_MOCK, - sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, - }), - ); - }); - - it('prefers the recipient returned by getPaymentOverrideData', async () => { - const CALLBACK_RECIPIENT_MOCK = - '0xcallback00000000000000000000000000000001' as Hex; - getPaymentOverrideDataMock.mockResolvedValue({ - calls: DEPOSIT_CALLS_MOCK, - recipient: CALLBACK_RECIPIENT_MOCK, - }); - - await submitRelayQuotes(request); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ - moneyAccountAddress: CALLBACK_RECIPIENT_MOCK, - }), - ); - }); - - it('falls back to the quote recipient when getPaymentOverrideData omits it', async () => { - await submitRelayQuotes(request); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ moneyAccountAddress: RECIPIENT_MOCK }), - ); - }); - - it('throws when getPaymentOverrideData returns no calls', async () => { - getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); - - await expect(submitRelayQuotes(request)).rejects.toThrow( - 'Missing post-quote deposit calls', - ); - expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + expect(calls).toStrictEqual(DEPOSIT_CALLS_MOCK); }); }); describe('non-post-quote flow', () => { beforeEach(() => { request.quotes[0].request.isPostQuote = false; - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); }); - it('does not call getPaymentOverrideData and forwards no depositCalls', async () => { + it('does not pass getCalls to submitSecondLeg', async () => { await submitRelayQuotes(request); expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ depositCalls: undefined }), + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ getCalls: undefined }), ); }); }); @@ -2289,7 +2206,6 @@ describe('Relay Submit Utils', () => { const RECIPIENT_MOCK = '0xrecip0000000000000000000000000000000001' as Hex; const TARGET_HASH_MOCK = '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; - const NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK = '535000'; const NON_ATOMIC_MINIMUM_AMOUNT_MOCK = '530000'; const VAULT_HASH_MOCK = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; @@ -2355,7 +2271,7 @@ describe('Relay Submit Utils', () => { // Vault helper is NOT called separately - the vault calls are already // embedded in the promoted Relay quote's steps and submitted atomically. - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(0); + expect(submitSecondLegMock).toHaveBeenCalledTimes(0); // Relay execute is invoked exactly once with the promoted quote's // embedded calldata, not any stale parent calldata. @@ -2373,15 +2289,11 @@ describe('Relay Submit Utils', () => { ]); }); - it('non-atomic success: still invokes the vault helper exactly once with the settled amount', async () => { + it('non-atomic success: still invokes the vault helper exactly once with the settlement hash', async () => { configureNonAtomicMaxMaQuote(); - submitMoneyAccountVaultDepositMock.mockResolvedValue({ + submitSecondLegMock.mockResolvedValue({ transactionHash: VAULT_HASH_MOCK, }); - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); successfulFetchMock.mockResolvedValue({ ok: true, json: async () => ({ @@ -2393,18 +2305,18 @@ describe('Relay Submit Utils', () => { await submitRelayQuotes(request); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(1); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect(submitSecondLegMock).toHaveBeenCalledTimes(1); + expect(submitSecondLegMock).toHaveBeenCalledWith( expect.objectContaining({ - sourceAmountRaw: NON_ATOMIC_ON_CHAIN_AMOUNT_MOCK, - moneyAccountAddress: RECIPIENT_MOCK, + settlementHash: TARGET_HASH_MOCK, + from: RECIPIENT_MOCK, }), ); }); it('non-atomic failure: does not invoke the vault helper when Relay reports a failed status', async () => { configureNonAtomicMaxMaQuote(); - submitMoneyAccountVaultDepositMock.mockResolvedValue({ + submitSecondLegMock.mockResolvedValue({ transactionHash: VAULT_HASH_MOCK, }); successfulFetchMock.mockResolvedValue({ @@ -2419,7 +2331,7 @@ describe('Relay Submit Utils', () => { 'Relay: Request failed with status: failure', ); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledTimes(0); + expect(submitSecondLegMock).toHaveBeenCalledTimes(0); }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index f87d49b50dc..1c500087ce9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -30,8 +30,8 @@ import { logGasPaymentOutcome, resolveGasPayment, } from '../../utils/gas-payment.js'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; import { getNetworkClientId } from '../../utils/provider.js'; +import { submitSecondLeg } from '../../utils/second-leg.js'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -40,7 +40,6 @@ import { import { collectTransactionIds, getTransaction, - getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction.js'; @@ -207,6 +206,23 @@ async function executeSingleQuote( } } + // Non-atomic flow: the quote bridged funds to `recipient` without embedding + // the second leg. Now that Relay has settled, resolve the settled amount from + // the on-chain Transfer log and submit the second-leg batch (approve + vault + // deposit) sponsored from `recipient`. + const { transactionHash: secondLegHash } = + quote.request.atomic === false && completion.status === 'success' + ? await submitPostNonAtomic({ + completion, + messenger, + quote, + submittedSourceHash, + transaction, + }) + : {}; + + // Only complete once every leg has landed, so a failed second leg does not + // leave the parent transaction flagged as achieved. updateTransaction( { transactionId: transaction.id, @@ -218,40 +234,22 @@ async function executeSingleQuote( }, ); - // Non-atomic flow: the quote bridged funds to `recipient` without embedding - // the second leg. Now that Relay has settled, resolve the settled amount from - // the on-chain Transfer log and submit the second-leg batch (approve + vault - // deposit) sponsored from `recipient`. - if (quote.request.atomic === false && completion.status === 'success') { - const { transactionHash } = await submitPostNonAtomic({ - completion, - messenger, - quote, - submittedSourceHash, - transaction, - }); - - return { transactionHash: transactionHash ?? completion.targetHash }; - } - - return { transactionHash: completion.targetHash }; + return { transactionHash: secondLegHash ?? completion.targetHash }; } /** - * Runs the second leg of a non-atomic Relay quote. Resolves the settled amount - * from the on-chain Transfer log, then submits the batch via - * `submitMoneyAccountVaultDeposit`. Post-quote flows fetch pre-built calls via - * the client `getPaymentOverrideData` callback; non-post-quote flows fall - * through to the transaction's own nested calls re-encoded via - * `getAmountData`. Funds settled on `quote.request.recipient`, derived at - * quote time by `resolveNonAtomicRecipient`. + * Runs the second leg of a non-atomic Relay quote on the target chain from + * `quote.request.recipient`, where the funds settled (derived at quote time by + * `resolveNonAtomicRecipient`). Post-quote flows build their calls via the + * client `getPaymentOverrideData` callback; non-post-quote flows fall through + * to the transaction's own nested calls re-encoded via `getAmountData`. * * @param options - Submit options. * @param options.completion - Outcome of `waitForRelayCompletion`. * @param options.messenger - Controller messenger. * @param options.quote - The Relay quote that was submitted. * @param options.submittedSourceHash - Hash of the submitted source - * transaction, used to read the settled amount when Relay skips polling on + * transaction, used as the settlement hash when Relay skips polling on * same-chain flows. * @param options.transaction - Original transaction meta. * @returns Hash of the final submitted child transaction, if available. @@ -269,56 +267,42 @@ async function submitPostNonAtomic({ submittedSourceHash?: Hex; transaction: TransactionMeta; }): Promise<{ transactionHash?: Hex }> { - const sourceAmountRaw = await resolveSettledAmount({ - completion, + const { from, isPostQuote, recipient, targetChainId, targetTokenAddress } = + quote.request; + + return await submitSecondLeg({ + chainId: targetChainId, + from: recipient ?? from, + getCalls: isPostQuote + ? async (sourceAmountRaw): Promise => + await buildPostQuoteDepositCalls({ + messenger, + quote, + sourceAmountRaw, + transaction, + }) + : undefined, messenger, - quote, - submittedSourceHash, - }); - - const override = quote.request.isPostQuote - ? await buildPostQuoteDepositCalls({ - messenger, - sourceAmountRaw, - transaction, - quote, - }) - : undefined; - - const recipient = - override?.recipient ?? quote.request.recipient ?? quote.request.from; - - return submitMoneyAccountVaultDeposit({ - messenger, - moneyAccountAddress: recipient, - depositCalls: override?.calls, - sourceAmountRaw, + settlementHash: getSettlementHash({ + completion, + quote, + submittedSourceHash, + }), + tokenAddress: targetTokenAddress, transaction, - vaultDisabled: false, }); } /** - * Builds the post-completion batch for a post-quote flow whose parent - * transaction carries no vault calls. Delegates to the client - * `getPaymentOverrideData` callback with the settled amount. - * - * The callback MUST return a non-empty batch. Post-quote parent metas (e.g. - * Perps/Predict withdraws) carry no vault-side nested calls, so falling back - * to `getAmountData` in `resolveVaultDepositBatch` cannot recover the second - * leg once Relay has already settled funds to the recipient. Throw eagerly so - * the failure surfaces at the correct call site with an actionable message. - * - * The callback may also return the `recipient` that funds settled on, which the - * caller prefers as the source of truth for the second-leg account. + * Builds the second-leg batch for a post-quote flow whose parent transaction + * carries no vault calls, via the client `getPaymentOverrideData` callback. * * @param options - Build options. * @param options.messenger - Controller messenger. * @param options.quote - The Relay quote that was submitted. * @param options.sourceAmountRaw - Settled amount in raw units. * @param options.transaction - Original transaction meta. - * @returns The batch calls and optional recipient. - * @throws If the callback returns an empty batch. + * @returns The batch calls. */ async function buildPostQuoteDepositCalls({ messenger, @@ -330,7 +314,7 @@ async function buildPostQuoteDepositCalls({ quote: TransactionPayQuote; sourceAmountRaw: string; transaction: TransactionMeta; -}): Promise<{ calls: BatchTransactionParams[]; recipient?: Hex }> { +}): Promise { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); @@ -340,7 +324,7 @@ async function buildPostQuoteDepositCalls({ .shiftedBy(-decimals) .toFixed(); - const { calls, recipient } = await messenger.call( + const { calls } = await messenger.call( 'TransactionPayController:getPaymentOverrideData', { amount: amountHuman, @@ -349,100 +333,46 @@ async function buildPostQuoteDepositCalls({ }, ); - if (!calls.length) { - throw new Error('Missing post-quote deposit calls'); - } - - return { calls, recipient }; + return calls; } /** - * Resolves the actual amount that landed on the recipient after a Relay bridge. - * - * Cross-chain relays surface a real target-chain hash from polling; same-chain - * relays skip polling (the `FALLBACK_HASH` placeholder) but the submitted - * source transaction itself moved the funds, so its hash is read instead. In - * both cases the exact settled amount comes from the on-chain Transfer log; a - * read failure or missing amount throws rather than guessing, since using the - * quote minimum would knowingly strand dust and defeat an EXACT_INPUT quote. + * Resolves the hash of the transaction that delivered the funds to the + * recipient. * - * Relay execute submissions return `FALLBACK_HASH` instead of a real source - * hash, leaving nothing to read; only then is the quote's minimum output used - * as the last available source. + * Cross-chain relays surface the target-chain hash from polling. Same-chain + * relays skip polling, but the submitted source transaction itself moved the + * funds, so its hash is used instead. Relay execute submissions return + * `FALLBACK_HASH` rather than a real hash, leaving nothing to read. * * @param options - Resolution options. * @param options.completion - Outcome of `waitForRelayCompletion`. - * @param options.messenger - Controller messenger. * @param options.quote - The Relay quote that was submitted. * @param options.submittedSourceHash - Hash of the submitted source - * transaction, used when polling was skipped on same-chain flows. - * @returns The raw (atomic) settled amount as a decimal string. + * transaction. + * @returns The settlement hash, or `undefined` when none is available. */ -async function resolveSettledAmount({ +function getSettlementHash({ completion, - messenger, quote, submittedSourceHash, }: { completion: RelayCompletionOutcome; - messenger: TransactionPayControllerMessenger; quote: TransactionPayQuote; submittedSourceHash?: Hex; -}): Promise { - const recipient = (quote.request.recipient ?? quote.request.from) as - | Hex - | undefined; +}): Hex | undefined { + if (completion.targetHash && completion.targetHash !== FALLBACK_HASH) { + return completion.targetHash; + } const isSameChain = quote.request.sourceChainId === quote.request.targetChainId; - const hasPolledTargetHash = Boolean( - completion.targetHash && completion.targetHash !== FALLBACK_HASH, - ); - - let settlementHash: Hex | undefined; - - if (hasPolledTargetHash) { - settlementHash = completion.targetHash; - } else if (isSameChain && submittedSourceHash !== FALLBACK_HASH) { - settlementHash = submittedSourceHash; + if (isSameChain && submittedSourceHash !== FALLBACK_HASH) { + return submittedSourceHash; } - if (recipient && settlementHash) { - const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash({ - messenger, - txHash: settlementHash, - chainId: quote.request.targetChainId, - tokenAddress: quote.request.targetTokenAddress, - walletAddress: recipient, - }); - - if (!onChainAmount) { - throw new Error( - 'Cannot resolve settled amount from on-chain transaction', - ); - } - - log('Resolved settled amount from on-chain transaction', { - settlementHash, - onChainAmount, - }); - - return onChainAmount; - } - - const fallback = quote.original.details.currencyOut.minimumAmount; - - if (!fallback) { - throw new Error('Cannot resolve post-completion amount'); - } - - log('Resolved settled amount from quote minimum output', { - fallback, - targetHash: completion.targetHash, - }); - - return fallback; + return undefined; } function setRelaySourceHash( diff --git a/packages/transaction-pay-controller/src/strategy/server/server-api.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-api.test.ts index 10e11880599..7bd7b0eb4d7 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-api.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-api.test.ts @@ -6,7 +6,7 @@ import { getServerStatus, submitServerIntent, } from './server-api.js'; -import { ServerProviderName, ServerStatus } from './types.js'; +import { ServerStatus } from './types.js'; import type { ServerQuoteRequest, ServerSubmitRequest } from './types.js'; jest.mock('../../utils/feature-flags'); @@ -62,7 +62,7 @@ describe('server-api', () => { const QUOTE_RESPONSE_MOCK = { results: [ { - provider: ServerProviderName.Relay, + provider: 'relay', quote: { id: '0xid', input: { @@ -160,7 +160,7 @@ describe('server-api', () => { chainId: 1, data: '0xbbb', id: '0xid', - provider: ServerProviderName.Relay, + provider: 'relay', to: '0xaaa', value: '0', }; @@ -195,7 +195,7 @@ describe('server-api', () => { describe('getServerStatus', () => { const STATUS_PARAMS_MOCK = { - provider: ServerProviderName.Relay, + provider: 'relay', id: '0xabc', }; diff --git a/packages/transaction-pay-controller/src/strategy/server/server-api.ts b/packages/transaction-pay-controller/src/strategy/server/server-api.ts index 56e45874d5c..c49717372af 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-api.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-api.ts @@ -4,7 +4,6 @@ import { projectLogger } from '../../logger.js'; import type { TransactionPayControllerMessenger } from '../../types.js'; import { getPayStrategiesConfig } from '../../utils/feature-flags.js'; import type { - ServerProviderName, ServerQuoteRequest, ServerQuoteResponse, ServerStatusResponse, @@ -79,7 +78,7 @@ export async function submitServerIntent( */ export async function getServerStatus( messenger: TransactionPayControllerMessenger, - params: { provider: ServerProviderName; id: string; hash?: string }, + params: { provider: string; id: string; hash?: string }, ): Promise { const { server } = getPayStrategiesConfig(messenger); const query = new URLSearchParams({ diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index f467d8555b1..c479053f046 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -2,6 +2,8 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, CHAIN_ID_HYPERCORE, PaymentOverride, TransactionPayStrategy, @@ -27,7 +29,6 @@ import { import { TradeType } from '../../utils/trade-type.js'; import { fetchServerQuote } from './server-api.js'; import { getServerQuotes } from './server-quotes.js'; -import { ServerProviderName } from './types.js'; jest.mock('../../utils/feature-flags', () => ({ ...jest.requireActual('../../utils/feature-flags'), @@ -56,12 +57,16 @@ const TOKEN_TRANSFER_RECIPIENT_MOCK = '0x5678901234567890123456789012345678901234' as Hex; const TOKEN_TRANSFER_DATA_MOCK = '0xa9059cbb0000000000000000000000005678901234567890123456789012345678901234000000000000000000000000000000000000000000000000000000000000007b' as Hex; +const HYPERLIQUID_BRIDGE_ADDRESS_MOCK = + '0x2df1c51e09aecf9cacb7bc98cb1742757f163df7' as Hex; const SOURCE_ACCOUNT_TRANSFER_DATA_MOCK = '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567891000000000000000000000000000000000000000000000000000000000000007b' as Hex; -const TRANSACTION_META_MOCK = { txParams: {} } as TransactionMeta; +const TRANSACTION_META_MOCK = { + txParams: { from: FROM_MOCK }, +} as TransactionMeta; const COMPLEX_TRANSACTION_META_MOCK = { - txParams: { data: '0x1234' }, + txParams: { data: '0x1234', from: FROM_MOCK }, } as TransactionMeta; const QUOTE_REQUEST_MOCK: QuoteRequest = { @@ -76,7 +81,7 @@ const QUOTE_REQUEST_MOCK: QuoteRequest = { }; const FULFILLED_RESULT_MOCK = { - provider: ServerProviderName.Relay, + provider: 'relay', quote: { duration: 42, fees: { metamask: '0', provider: '0.25', subsidized: false }, @@ -110,7 +115,7 @@ const FULFILLED_RESULT_MOCK = { const REJECTED_RESULT_MOCK = { error: { message: 'no route' }, - provider: ServerProviderName.Relay, + provider: 'relay', }; const CONTRACT_CALL_TRANSACTION_MOCK = { @@ -118,7 +123,7 @@ const CONTRACT_CALL_TRANSACTION_MOCK = { { data: '0x1234', to: '0xcontract' }, { data: '0x5678', to: '0xcontract' }, ], - txParams: {}, + txParams: { from: FROM_MOCK }, } as TransactionMeta; const DELEGATION_RESULT_MOCK = { @@ -246,6 +251,62 @@ describe('server-quotes', () => { expect(getDelegationTransactionMock).not.toHaveBeenCalled(); }); + it('settles on the executing account when an account override pays for the quote', async () => { + const MONEY_ACCOUNT_MOCK = + '0xaaaa000000000000000000000000000000000001' as Hex; + + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + nestedTransactions: [{ data: '0x1234' }], + txParams: { from: MONEY_ACCOUNT_MOCK }, + } as TransactionMeta, + }); + + // `from` is the account override paying for the quote, but the nested calls + // execute from the money account, so the funds have to settle there. + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + recipient: MONEY_ACCOUNT_MOCK, + sender: FROM_MOCK, + }), + undefined, + ); + + // The embedded transfer has to fund the account the delegated calls run + // from, not the paying account. + expect(fetchServerQuoteMock.mock.calls[0][1].calls?.[0]).toStrictEqual({ + data: '0xa9059cbb000000000000000000000000aaaa000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000007b', + to: TARGET_TOKEN_ADDRESS_MOCK, + value: '0x0', + }); + + // The second leg resolves the settled amount from `request.recipient`, so + // it has to match where the quote actually delivered the funds. + expect(result[0].request.recipient).toBe(MONEY_ACCOUNT_MOCK); + }); + + it('settles on the sender when no account override is active', async () => { + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + nestedTransactions: [{ data: '0x1234' }], + txParams: { from: FROM_MOCK }, + } as TransactionMeta, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ recipient: FROM_MOCK }), + undefined, + ); + }); + it('decodes ERC-20 transfer calldata from a single nested transaction', async () => { await getServerQuotes({ accountSupports7702: true, @@ -458,7 +519,8 @@ describe('server-quotes', () => { provider: FULFILLED_RESULT_MOCK.provider, steps: quote.steps, }, - request: QUOTE_REQUEST_MOCK, + request: { ...QUOTE_REQUEST_MOCK, recipient: FROM_MOCK }, + requiresSecondLeg: false, sourceAmount: { fiat: '0', human: quote.input.formatted, @@ -902,9 +964,6 @@ describe('server-quotes', () => { describe('processMoneyAccountPostQuote', () => { const DEPOSIT_AMOUNT_RAW_MOCK = '1500000'; - // transfer(TOKEN_TRANSFER_RECIPIENT_MOCK, DEPOSIT_AMOUNT_RAW_MOCK) - const MONEY_ACCOUNT_TRANSFER_DATA_MOCK = - '0xa9059cbb0000000000000000000000005678901234567890123456789012345678901234000000000000000000000000000000000000000000000000000000000016e360' as Hex; const OVERRIDE_CALL_MOCK = { data: '0xoverride' as Hex, to: '0xcccc000000000000000000000000000000000000' as Hex, @@ -978,7 +1037,7 @@ describe('server-quotes', () => { ); }); - it('transfers the deposit amount rather than the source token amount', async () => { + it('settles on the money account and carries only the deposit calls', async () => { await getServerQuotes({ accountSupports7702: true, messenger, @@ -992,17 +1051,14 @@ describe('server-quotes', () => { transaction: TRANSACTION_META_MOCK, }); + // The deposit calls are delegated from the money account, so the funds + // must settle there directly rather than on the EOA behind a forwarding + // transfer that only runs if the provider executes the calls. expect(fetchServerQuoteMock).toHaveBeenCalledWith( messenger, expect.objectContaining({ - calls: [ - { - data: MONEY_ACCOUNT_TRANSFER_DATA_MOCK, - to: QUOTE_REQUEST_MOCK.targetTokenAddress, - value: '0x0', - }, - OVERRIDE_CALL_MOCK, - ], + calls: [OVERRIDE_CALL_MOCK], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, }), undefined, ); @@ -1028,20 +1084,9 @@ describe('server-quotes', () => { transaction: TRANSACTION_META_MOCK, }); - // The transfer call targets the source token address with FROM_MOCK as recipient. expect(fetchServerQuoteMock).toHaveBeenCalledWith( messenger, - expect.objectContaining({ - calls: expect.arrayContaining([ - expect.objectContaining({ - to: TARGET_TOKEN_ADDRESS_MOCK, - // data encodes FROM_MOCK (lower-cased, no 0x prefix) as the recipient - data: expect.stringContaining( - FROM_MOCK.slice(2).toLowerCase(), - ) as string, - }), - ]), - }), + expect.objectContaining({ recipient: FROM_MOCK }), undefined, ); }); @@ -1177,4 +1222,301 @@ describe('server-quotes', () => { ); }); }); + + describe('requiresSecondLeg', () => { + it('is true when the quote could not embed the calls', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(true); + }); + + it('is true when atomic is false, and the calls are left out of the quote', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, atomic: false }], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(true); + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.not.objectContaining({ calls: expect.anything() }), + undefined, + ); + }); + + it('is false when the calls are embedded in the quote', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false when there are no calls to run', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false for plain token transfers', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: { + nestedTransactions: [{ data: TOKEN_TRANSFER_DATA_MOCK }], + txParams: {}, + } as TransactionMeta, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false when the nested calls carry no calldata', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: { + nestedTransactions: [{ data: '0x' }], + txParams: {}, + } as TransactionMeta, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false when the resolved calldata is empty', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], + transaction: { + nestedTransactions: [{ data: '0x' }], + txParams: { data: '0x' }, + } as TransactionMeta, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false when the target chain is HyperCore', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isMaxAmount: true, + targetChainId: CHAIN_ID_HYPERCORE, + }, + ], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false when the source chain is HyperCore', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isMaxAmount: true, + sourceChainId: CHAIN_ID_HYPERCORE, + }, + ], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false for perps deposits rewritten to HyperCore', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }, + ], + transaction: { + nestedTransactions: [ + { data: '0x1234', to: HYPERLIQUID_BRIDGE_ADDRESS_MOCK }, + ], + txParams: {}, + } as TransactionMeta, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + target: expect.objectContaining({ + chainId: Number(CHAIN_ID_HYPERCORE), + }), + }), + undefined, + ); + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is false for post-quote flows, which submit the original transaction separately', async () => { + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, isPostQuote: true }], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + + it('is true when the provider will not execute the embedded calls', async () => { + fetchServerQuoteMock.mockResolvedValue({ + results: [ + { + ...FULFILLED_RESULT_MOCK, + quote: { ...FULFILLED_RESULT_MOCK.quote, callsDeferred: true }, + }, + ], + }); + + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(true); + }); + + it('is false when the provider confirms it will not defer the embedded calls', async () => { + fetchServerQuoteMock.mockResolvedValue({ + results: [ + { + ...FULFILLED_RESULT_MOCK, + quote: { ...FULFILLED_RESULT_MOCK.quote, callsDeferred: false }, + }, + ], + }); + + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: CONTRACT_CALL_TRANSACTION_MOCK, + }); + + expect(result[0].requiresSecondLeg).toBe(false); + }); + }); + + describe('supportsDeferredCalls', () => { + const SPONSORED_TRANSACTION_MOCK = { + ...CONTRACT_CALL_TRANSACTION_MOCK, + chainId: QUOTE_REQUEST_MOCK.targetChainId, + isGasFeeSponsored: true, + } as TransactionMeta; + + beforeEach(() => { + isEIP7702ChainMock.mockReturnValue(true); + }); + + it('opts in when we can run the embedded calls ourselves', async () => { + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: SPONSORED_TRANSACTION_MOCK, + }); + + expect(isEIP7702ChainMock).toHaveBeenCalledWith( + messenger, + QUOTE_REQUEST_MOCK.targetChainId, + ); + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ supportsDeferredCalls: true }), + undefined, + ); + }); + + it.each([ + ['the calls are left out of the quote', { request: { atomic: false } }], + ['the flow submits its own calls', { request: { isPostQuote: true } }], + [ + 'the account cannot sign EIP-7702 authorizations', + { accountSupports7702: false }, + ], + ['gas is not sponsored', { transaction: { isGasFeeSponsored: false } }], + [ + 'the transaction is not on the target chain', + { transaction: { chainId: '0x3' } }, + ], + ['the target chain does not support EIP-7702', { eip7702Chain: false }], + [ + 'the calls are only on the top-level transaction', + { + transaction: { + nestedTransactions: undefined, + txParams: { data: '0x1234', from: FROM_MOCK }, + }, + }, + ], + ])( + 'is omitted when %s', + async ( + _title, + { + accountSupports7702 = true, + eip7702Chain = true, + request = {}, + transaction = {}, + }: { + accountSupports7702?: boolean; + eip7702Chain?: boolean; + request?: Partial; + transaction?: Partial; + }, + ) => { + isEIP7702ChainMock.mockReturnValue(eip7702Chain); + + await getServerQuotes({ + accountSupports7702, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, ...request }], + transaction: { ...SPONSORED_TRANSACTION_MOCK, ...transaction }, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.not.objectContaining({ + supportsDeferredCalls: expect.anything(), + }), + undefined, + ); + }, + ); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 84d27e67ae4..b70254a3106 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -41,12 +41,14 @@ import { normalizeServerPerpsRequest } from './perps.js'; import { fetchServerQuote } from './server-api.js'; import type { ServerQuote, + ServerQuotePayload, ServerQuoteRequest, ServerQuoteResult, ServerTransactionStep, } from './types.js'; const log = createModuleLogger(projectLogger, 'server-quotes'); +const HYPERCORE_CHAIN_ID_NUMBER = Number(CHAIN_ID_HYPERCORE); const TOKEN_TRANSFER_FOUR_BYTE = '0xa9059cbb'; const TRANSFER_INTERFACE = new Interface([ 'function transfer(address to, uint256 amount)', @@ -116,14 +118,14 @@ async function getQuotesForRequest( ): Promise[]> { const { accountSupports7702, messenger, signal, transaction } = fullRequest; - const body = await buildServerQuoteRequest( - quoteRequest, - transaction, - messenger, - accountSupports7702, - ); - try { + const body = await buildServerQuoteRequest( + quoteRequest, + transaction, + messenger, + accountSupports7702, + ); + log('Request body', body); const response = await fetchServerQuote(messenger, body, signal); @@ -132,15 +134,23 @@ async function getQuotesForRequest( const fulfilledResults = response.results.filter(isFulfilledResult); + // The second leg runs from `request.recipient`, so it has to match the + // executing account the quote settles on. + const settledRequest = { + ...quoteRequest, + recipient: transaction.txParams.from as Hex, + }; + const normalized = await Promise.all( fulfilledResults.map((result) => normalizeQuote( result, - quoteRequest, + settledRequest, messenger, body.tradeType === TradeType.ExactInput, transaction, accountSupports7702, + isSecondLegRequired(body, quoteRequest, transaction, result.quote), ), ), ); @@ -165,6 +175,7 @@ async function buildServerQuoteRequest( transaction, ); const { + atomic, from, isMaxAmount, isPostQuote, @@ -182,11 +193,14 @@ async function buildServerQuoteRequest( const isTokenTransfer = !isHypercore && Boolean(singleData?.startsWith(TOKEN_TRANSFER_FOUR_BYTE)); - let recipient = from; + // `from` is the payer, which differs from the account the transaction's calls + // run from when an account override (e.g. Money Account) is active. + const executionAccount = transaction.txParams.from as Hex; - if (isTokenTransfer && singleData) { - recipient = decodeTransferRecipient(singleData); - } + const recipient = + isTokenTransfer && singleData + ? decodeTransferRecipient(singleData) + : executionAccount; const isHypercoreSource = sourceChainId === CHAIN_ID_HYPERCORE; const supportsGasless = @@ -211,6 +225,9 @@ async function buildServerQuoteRequest( isTokenTransfer || isHypercore || isHypercoreSource || + // Explicitly non-atomic requests want the calls run after settlement, so + // they must not be embedded in the quote. + atomic === false || (isPostQuote ?? false) || (isMaxAmount ?? false); @@ -229,7 +246,7 @@ async function buildServerQuoteRequest( body.calls = [ { - data: buildTransferData(from, targetAmountMinimum), + data: buildTransferData(executionAccount, targetAmountMinimum), to: targetTokenAddress, value: '0x0', }, @@ -240,6 +257,22 @@ async function buildServerQuoteRequest( }, ]; + // Prefer atomic execution, but let providers that cannot run the calls + // still quote for the funds. Only this path can fall back to a second leg: + // the calls run as the execution account, which is also the recipient, so + // we can submit them ourselves once the funds land. Post-quote flows submit + // their own calls and must not opt in. + if ( + canDeferCalls({ + accountSupports7702, + messenger, + targetChainId, + transaction, + }) + ) { + body.supportsDeferredCalls = true; + } + if (delegation.authorizationList?.length) { body.authorizationList = normalizeAuthorizationList( delegation.authorizationList, @@ -263,6 +296,48 @@ async function buildServerQuoteRequest( }; } +/** + * Whether we can run the calls ourselves after the quote settles without + * involving the user, so a funds-only quote is acceptable. + * + * The second leg is an internal, sponsored EIP-7702 batch on the target chain, + * so the account must be able to sign EIP-7702 authorizations, the target chain + * must support EIP-7702, and MetaMask must sponsor gas for the transaction on + * that chain. Otherwise the user would need native gas on the target chain. + * + * Temporarily, only nested transactions are supported as second-leg calls, as + * they are what the second leg re-encodes for the settled amount. Calls that + * exist only on the top-level transaction are not deferred, so a provider that + * cannot execute them declines rather than returning a funds-only quote. + * + * @param options - Check options. + * @param options.accountSupports7702 - Whether the account can sign EIP-7702 + * authorizations. + * @param options.messenger - Controller messenger. + * @param options.targetChainId - Chain the second leg would run on. + * @param options.transaction - Transaction being paid for. + * @returns Whether the calls can be deferred to a second leg. + */ +function canDeferCalls({ + accountSupports7702, + messenger, + targetChainId, + transaction, +}: { + accountSupports7702: boolean; + messenger: TransactionPayControllerMessenger; + targetChainId: Hex; + transaction: TransactionMeta; +}): boolean { + return ( + Boolean(transaction.nestedTransactions?.length) && + accountSupports7702 && + transaction.isGasFeeSponsored === true && + transaction.chainId === targetChainId && + isEIP7702Chain(messenger, targetChainId) + ); +} + function normalizeAuthorizationList( authorizationList: AuthorizationList, ): NonNullable { @@ -311,18 +386,15 @@ async function processMoneyAccountPostQuote( // letting the amount be derived from the request. body.amount = rawAmount; - body.calls = [ - { - data: buildTransferData(fundingRecipient, rawAmount), - to: request.targetTokenAddress, - value: '0x0', - }, - ...overrideCalls.map((call) => ({ - data: call.data as Hex, - to: call.to as Hex, - value: call.value ?? '0x0', - })), - ]; + // Settle directly on the Money Account. The deposit calls are delegated from + // the Money Account, so the funds have to be there before they run. + body.recipient = fundingRecipient; + + body.calls = overrideCalls.map((call) => ({ + data: call.data as Hex, + to: call.to as Hex, + value: call.value ?? '0x0', + })); if (authorizationList?.length) { body.authorizationList = normalizeAuthorizationList(authorizationList); @@ -342,6 +414,71 @@ function shouldRequestQuote(quoteRequest: QuoteRequest): boolean { ); } +/** + * Determines whether the transaction's calls must be submitted as a separate + * second leg on the target chain after the quote settles. + * + * `atomic` is only a hint: whether the calls can be executed by the provider + * depends on the flow, so this checks what the built request actually carries + * rather than what the caller asked for. Flows with nothing to run, or that + * submit their own calls, are excluded. + * + * A second leg is needed either because we declined to embed the calls, or + * because the provider that won the quote told us it will not execute them. + * + * @param body - The built server quote request. + * @param quoteRequest - The originating quote request. + * @param transaction - Original transaction meta. + * @param quote - The provider's quote payload. + * @returns `true` when a second leg is required. + */ +function isSecondLegRequired( + body: ServerQuoteRequest, + quoteRequest: QuoteRequest, + transaction: TransactionMeta, + quote: ServerQuotePayload, +): boolean { + // Embedded calls are executed by the provider as part of the quote, unless + // it quoted for the funds alone and told us to run the calls ourselves. + if (body.calls?.length && !quote.callsDeferred) { + return false; + } + + // No calls to run. Plain funding transfers and empty calldata are already + // satisfied by the quote delivering the target token to the recipient. + if (!transaction.nestedTransactions?.length) { + return false; + } + + const singleData = getSingleTransactionData(transaction); + + if (singleData === undefined || singleData === '0x') { + return false; + } + + if (singleData.startsWith(TOKEN_TRANSFER_FOUR_BYTE)) { + return false; + } + + // HyperCore settles off-chain, so there is no target-chain transfer to read + // a settled amount from. Checks the built body rather than the original + // request, since perps deposits are only rewritten to HyperCore there. + if ( + body.target.chainId === HYPERCORE_CHAIN_ID_NUMBER || + body.source.chainId === HYPERCORE_CHAIN_ID_NUMBER + ) { + return false; + } + + // Post-quote flows submit the original transaction separately, so running it + // again as a second leg would double-execute it. + if (quoteRequest.isPostQuote) { + return false; + } + + return true; +} + async function normalizeQuote( result: FulfilledServerQuoteResult, quoteRequest: QuoteRequest, @@ -349,6 +486,7 @@ async function normalizeQuote( isInputBased: boolean, transaction: TransactionMeta, accountSupports7702: boolean, + requiresSecondLeg: boolean, ): Promise> { const { quote } = result; const { gasless } = quote; @@ -422,6 +560,7 @@ async function normalizeQuote( steps: quote.steps, }, request: quoteRequest, + requiresSecondLeg, sourceAmount: { fiat: sourceFiatRate ? new BigNumber(quote.input.formatted) diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts index 7c3a4bbfdf7..f1ca0cd761a 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts @@ -17,6 +17,7 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags.js'; +import { submitSecondLeg } from '../../utils/second-leg.js'; import { getLiveTokenBalance } from '../../utils/token.js'; import { collectTransactionIds, @@ -25,7 +26,7 @@ import { } from '../../utils/transaction.js'; import { getServerStatus, submitServerIntent } from './server-api.js'; import { submitServerQuotes } from './server-submit.js'; -import { ServerProviderName, ServerStatus } from './types.js'; +import { ServerStatus } from './types.js'; import type { ServerQuote, ServerSignatureStep } from './types.js'; jest.mock('@metamask/controller-utils', () => ({ @@ -33,6 +34,7 @@ jest.mock('@metamask/controller-utils', () => ({ successfulFetch: jest.fn(), })); jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/second-leg'); jest.mock('../../utils/token', () => ({ ...jest.requireActual('../../utils/token'), getLiveTokenBalance: jest.fn(), @@ -92,7 +94,7 @@ const ORIGINAL_QUOTE_MOCK: ServerQuote = { raw: '1000000', token: '0x6666666666666666666666666666666666666666', }, - provider: ServerProviderName.Relay, + provider: 'relay', steps: [ { type: 'transaction' as const, @@ -229,7 +231,7 @@ describe('submitServerQuotes', () => { chainId: 137, data: DELEGATION_MOCK.data, id: ORIGINAL_QUOTE_MOCK.id, - provider: ServerProviderName.Relay, + provider: 'relay', to: DELEGATION_MOCK.to, value: '16', }); @@ -301,6 +303,112 @@ describe('submitServerQuotes', () => { expect(getServerStatusMock).toHaveBeenCalledTimes(2); }); + describe('second leg', () => { + const SECOND_LEG_HASH_MOCK = '0xsecondleg' as Hex; + const RECIPIENT_MOCK = '0x9999999999999999999999999999999999999999' as Hex; + + const submitSecondLegMock = jest.mocked(submitSecondLeg); + + beforeEach(() => { + submitSecondLegMock.mockResolvedValue({ + transactionHash: SECOND_LEG_HASH_MOCK, + }); + + getServerStatusMock.mockResolvedValue({ + status: ServerStatus.Confirmed, + targetHash: TARGET_HASH_MOCK, + }); + + request.quotes = [{ ...cloneDeep(QUOTE_MOCK), requiresSecondLeg: true }]; + }); + + it('submits the second leg with the settlement hash and target token address', async () => { + const result = await submitServerQuotes(request); + + expect(submitSecondLegMock).toHaveBeenCalledWith({ + chainId: '0x1', + from: QUOTE_FROM_MOCK, + messenger, + settlementHash: TARGET_HASH_MOCK, + tokenAddress: '0x6666666666666666666666666666666666666666', + transaction: expect.objectContaining({ + id: ORIGINAL_TRANSACTION_ID_MOCK, + }), + }); + expect(result).toStrictEqual({ transactionHash: SECOND_LEG_HASH_MOCK }); + }); + + it('submits from the quote recipient when set', async () => { + request.quotes = [ + { + ...cloneDeep(QUOTE_MOCK), + request: { ...QUOTE_MOCK.request, recipient: RECIPIENT_MOCK }, + requiresSecondLeg: true, + }, + ]; + + await submitServerQuotes(request); + + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ from: RECIPIENT_MOCK }), + ); + }); + + it('falls back to the settlement hash when the second leg returns none', async () => { + submitSecondLegMock.mockResolvedValue({ transactionHash: undefined }); + + const result = await submitServerQuotes(request); + + expect(result).toStrictEqual({ transactionHash: TARGET_HASH_MOCK }); + }); + + it('passes no settlement hash when the intent confirmed without one', async () => { + getServerStatusMock.mockResolvedValue({ + status: ServerStatus.Confirmed, + targetHash: undefined, + }); + + await submitServerQuotes(request); + + expect(submitSecondLegMock).toHaveBeenCalledWith( + expect.objectContaining({ settlementHash: undefined }), + ); + }); + + it('is skipped when the quote embedded the calls', async () => { + request.quotes = [cloneDeep(QUOTE_MOCK)]; + + const result = await submitServerQuotes(request); + + expect(submitSecondLegMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: TARGET_HASH_MOCK }); + }); + + it('marks the intent complete only after the second leg is submitted', async () => { + let isIntentCompleteDuringSecondLeg: boolean | undefined; + + submitSecondLegMock.mockImplementation(async () => { + isIntentCompleteDuringSecondLeg = currentTransaction.isIntentComplete; + return { transactionHash: SECOND_LEG_HASH_MOCK }; + }); + + await submitServerQuotes(request); + + expect(isIntentCompleteDuringSecondLeg).toBeUndefined(); + expect(currentTransaction.isIntentComplete).toBe(true); + }); + + it('does not mark the intent complete when the second leg fails', async () => { + submitSecondLegMock.mockRejectedValue(new Error('Second leg failed')); + + await expect(submitServerQuotes(request)).rejects.toThrow( + 'Second leg failed', + ); + + expect(currentTransaction.isIntentComplete).toBeUndefined(); + }); + }); + it('marks the parent transaction isIntentComplete after confirmation', async () => { getServerStatusMock.mockResolvedValue({ status: ServerStatus.Confirmed, diff --git a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts index 3a59685dc9a..b7efe3994ed 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -31,6 +31,7 @@ import { resolveGasPayment, } from '../../utils/gas-payment.js'; import { getNetworkClientId } from '../../utils/provider.js'; +import { submitSecondLeg } from '../../utils/second-leg.js'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -55,6 +56,13 @@ import { ServerStatus } from './types.js'; const log = createModuleLogger(projectLogger, 'server-strategy'); +/** + * Placeholder returned when an intent confirms without reporting a + * target-chain transaction hash, so there is no settlement to read amounts + * from. + */ +const MISSING_TARGET_HASH = '0x' as Hex; + const DOMAIN_FIELD_MAP: Record = { name: { name: 'name', type: 'string' }, version: { name: 'version', type: 'string' }, @@ -151,6 +159,14 @@ async function executeSingleServerQuote( log('Server request completed', targetHash); + // Phase 4: submit the calls the quote could not execute itself, now that the + // funds have settled on the target chain. + const secondLegHash = quote.requiresSecondLeg + ? await submitServerSecondLeg(quote, messenger, transaction, targetHash) + : undefined; + + // Only complete once every leg has landed, so a failed second leg does not + // leave the parent transaction flagged as achieved. updateTransaction( { transactionId: transaction.id, @@ -162,7 +178,37 @@ async function executeSingleServerQuote( }, ); - return { transactionHash: targetHash }; + return { transactionHash: secondLegHash ?? targetHash }; +} + +/** + * Submit the calls a server quote could not execute itself, from the account + * the funds settled on. + * + * @param quote - Server quote. + * @param messenger - Controller messenger. + * @param transaction - Original transaction meta. + * @param targetHash - Hash of the settlement on the target chain. + * @returns Hash of the second-leg transaction, if available. + */ +async function submitServerSecondLeg( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, + targetHash: Hex | undefined, +): Promise { + const { from, recipient, targetChainId, targetTokenAddress } = quote.request; + + const { transactionHash } = await submitSecondLeg({ + chainId: targetChainId, + from: recipient ?? from, + messenger, + settlementHash: targetHash === MISSING_TARGET_HASH ? undefined : targetHash, + tokenAddress: targetTokenAddress, + transaction, + }); + + return transactionHash; } /** @@ -992,7 +1038,7 @@ async function waitForServerCompletion( } if (statusResponse.status === ServerStatus.Confirmed) { - return statusResponse.targetHash ?? '0x'; + return statusResponse.targetHash ?? MISSING_TARGET_HASH; } if ( diff --git a/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts index 7b1634c9c1c..81cfe8de582 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-support.test.ts @@ -115,7 +115,6 @@ describe('server-support', () => { ); it.each([ - ['atomic', { atomic: false }, ServerUnsupportedReason.NonAtomic], [ 'hyperliquidActivationFeeUsd', { hyperliquidActivationFeeUsd: '1' }, @@ -149,6 +148,12 @@ describe('server-support', () => { ).toBe(ServerUnsupportedReason.MaxAmount); }); + it('supports non-atomic requests', () => { + expect( + getReason({ requests: [{ ...REQUEST_MOCK, atomic: false }] }), + ).toBeUndefined(); + }); + it('ignores capability flags that are explicitly false or undefined', () => { expect( getReason({ diff --git a/packages/transaction-pay-controller/src/strategy/server/server-support.ts b/packages/transaction-pay-controller/src/strategy/server/server-support.ts index beb5d6f1703..80335c34f51 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-support.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-support.ts @@ -31,9 +31,6 @@ export enum ServerUnsupportedReason { /** Two-phase max-amount gas station probing is not implemented. */ MaxAmount = 'maxAmount', - /** Non-atomic multi-leg submission is not implemented. */ - NonAtomic = 'nonAtomic', - /** Polymarket deposit-wallet routing is not implemented. */ PolymarketDepositWallet = 'polymarketDepositWallet', } @@ -94,10 +91,6 @@ export function getServerUnsupportedReason({ function getRequestUnsupportedReason( request: QuoteRequest, ): ServerUnsupportedReason | undefined { - if (request.atomic === false) { - return ServerUnsupportedReason.NonAtomic; - } - if (request.hyperliquidActivationFeeUsd !== undefined) { return ServerUnsupportedReason.HyperliquidActivationFee; } diff --git a/packages/transaction-pay-controller/src/strategy/server/types.ts b/packages/transaction-pay-controller/src/strategy/server/types.ts index 781ce760aa6..72018de831b 100644 --- a/packages/transaction-pay-controller/src/strategy/server/types.ts +++ b/packages/transaction-pay-controller/src/strategy/server/types.ts @@ -2,12 +2,6 @@ import type { Hex } from '@metamask/utils'; import type { TradeType } from '../../utils/trade-type.js'; -/** Provider names supported by the server intents API. */ -export enum ServerProviderName { - Relay = 'relay', - Across = 'across', -} - /** Token amount with chain and token context. */ export type ServerQuoteAmount = { chainId: number; @@ -73,9 +67,10 @@ export type ServerQuoteRequest = { recipient: Hex; refundTo?: Hex; slippage?: number; - providers?: ServerProviderName[]; + providers?: string[]; calls?: ServerCall[]; authorizationList?: ServerAuthorization[]; + supportsDeferredCalls?: boolean; supportsGasless?: boolean; }; @@ -94,6 +89,7 @@ export type ServerQuotePayload = { duration: number; steps: ServerStep[]; gasless: boolean; + callsDeferred?: boolean; }; /** Fee breakdown from a quote. */ @@ -105,7 +101,7 @@ export type ServerQuoteFees = { /** A single provider result within the quote response. */ export type ServerQuoteResult = { - provider: ServerProviderName; + provider: string; quote?: ServerQuotePayload; error?: ServerQuoteError; }; @@ -125,7 +121,7 @@ export type ServerQuoteClient = { /** Normalized server quote stored in TransactionPayQuote.original. */ export type ServerQuote = { id: string; - provider: ServerProviderName; + provider: string; input: ServerQuoteAmount; output: ServerQuoteAmount; fees: ServerQuoteFees; @@ -155,7 +151,7 @@ export type ServerStatusResponse = { /** Request body for POST /submit. */ export type ServerSubmitRequest = { - provider: ServerProviderName; + provider: string; id: string; chainId: number; to: Hex; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 486c56d64de..32ea4d7b7ec 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -670,6 +670,17 @@ export type TransactionPayQuote = { /** Associated quote request. */ request: QuoteRequest; + /** + * Whether the target calls could not be embedded in the quote and must be + * submitted separately, on the target chain, once the quote settles. + * + * Set by the strategy at quote time, since only it knows whether the provider + * accepted the calls. {@link TransactionConfig.atomic} is a hint rather than + * a guarantee: a quote requested atomically still falls back to a second leg + * when the provider cannot execute the calls itself. + */ + requiresSecondLeg?: boolean; + /** Amount of source token required. */ sourceAmount: Amount; diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts index acbffb5e2f8..9e51c197c13 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.test.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts @@ -1,8 +1,10 @@ +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; -import { findRecentChompVaultDeposit } from './chomp.js'; +import { findRecentChompVaultDeposit, withChompRecovery } from './chomp.js'; import { rpcRequest } from './provider.js'; jest.mock('./provider'); @@ -20,6 +22,28 @@ const TRANSFER_DATA_SUFFICIENT = const TRANSFER_DATA_INSUFFICIENT = '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; +const SUBMITTED_HASH = '0x5eb' as Hex; + +const MONEY_ACCOUNT_DEPOSIT = { + id: 'tx-id', + txParams: { from: MONEY_ACCOUNT_ADDRESS }, + type: TransactionType.moneyAccountDeposit, +} as unknown as TransactionMeta; + +const NESTED_MONEY_ACCOUNT_DEPOSIT = { + ...MONEY_ACCOUNT_DEPOSIT, + nestedTransactions: [ + { type: TransactionType.tokenMethodApprove }, + { type: TransactionType.moneyAccountDeposit }, + ], + type: TransactionType.batch, +} as unknown as TransactionMeta; + +const OTHER_TRANSACTION = { + ...MONEY_ACCOUNT_DEPOSIT, + type: TransactionType.contractInteraction, +} as unknown as TransactionMeta; + const ERC20_TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; @@ -191,4 +215,123 @@ describe('chomp', () => { expect(result).toBeUndefined(); }); }); + + describe('withChompRecovery', () => { + let submitMock: jest.Mock>; + + function run( + options: { fromBlock?: Hex; transaction?: TransactionMeta } = {}, + ): Promise<{ transactionHash?: Hex }> { + const { + fromBlock = 'fromBlock' in options ? undefined : FROM_BLOCK, + transaction = MONEY_ACCOUNT_DEPOSIT, + } = options; + + return withChompRecovery( + { + from: MONEY_ACCOUNT_ADDRESS, + fromBlock, + messenger: buildMessenger(), + sourceAmountRaw: SOURCE_AMOUNT_RAW, + transaction, + }, + submitMock, + ); + } + + beforeEach(() => { + submitMock = jest + .fn, []>() + .mockResolvedValue({ transactionHash: SUBMITTED_HASH }); + + rpcRequestMock.mockResolvedValue([]); + }); + + describe('when the transaction is not a Money Account deposit', () => { + it('submits without checking for CHOMP', async () => { + const result = await run({ transaction: OTHER_TRANSACTION }); + + expect(result).toStrictEqual({ transactionHash: SUBMITTED_HASH }); + expect(rpcRequestMock).not.toHaveBeenCalled(); + }); + + it('surfaces submission errors unprefixed', async () => { + submitMock.mockRejectedValue(new Error('batch failed')); + + await expect(run({ transaction: OTHER_TRANSACTION })).rejects.toThrow( + /^batch failed$/u, + ); + }); + }); + + describe('when the transaction is a Money Account deposit', () => { + it('skips submission when CHOMP has already vaulted the funds', async () => { + rpcRequestMock.mockResolvedValue([buildMusdTransferLog()]); + + const result = await run(); + + expect(result).toStrictEqual({ transactionHash: CHOMP_TX_HASH }); + expect(submitMock).not.toHaveBeenCalled(); + }); + + it('detects a Money Account deposit nested in a batch', async () => { + rpcRequestMock.mockResolvedValue([buildMusdTransferLog()]); + + const result = await run({ transaction: NESTED_MONEY_ACCOUNT_DEPOSIT }); + + expect(result).toStrictEqual({ transactionHash: CHOMP_TX_HASH }); + }); + + it('submits when CHOMP has not vaulted the funds', async () => { + const result = await run(); + + expect(result).toStrictEqual({ transactionHash: SUBMITTED_HASH }); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('submits when the CHOMP pre-check fails', async () => { + rpcRequestMock.mockRejectedValue(new Error('network error')); + + const result = await run(); + + expect(result).toStrictEqual({ transactionHash: SUBMITTED_HASH }); + }); + + it('returns the CHOMP hash when CHOMP wins the race and submission fails', async () => { + rpcRequestMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([buildMusdTransferLog()]); + submitMock.mockRejectedValue(new Error('batch failed')); + + const result = await run(); + + expect(result).toStrictEqual({ transactionHash: CHOMP_TX_HASH }); + expect(rpcRequestMock).toHaveBeenCalledTimes(2); + }); + + it('prefixes the submission error when CHOMP has not vaulted the funds', async () => { + submitMock.mockRejectedValue(new Error('batch failed')); + + await expect(run()).rejects.toThrow(/^Vault: batch failed$/u); + }); + + it('prefixes the submission error when the CHOMP post-check fails', async () => { + rpcRequestMock + .mockResolvedValueOnce([]) + .mockRejectedValueOnce(new Error('rpc error')); + submitMock.mockRejectedValue(new Error('batch failed')); + + await expect(run()).rejects.toThrow(/^Vault: batch failed$/u); + }); + + it('skips CHOMP checks when the settlement block is unknown', async () => { + submitMock.mockRejectedValue(new Error('batch failed')); + + await expect(run({ fromBlock: undefined })).rejects.toThrow( + /^Vault: batch failed$/u, + ); + expect(rpcRequestMock).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/chomp.ts b/packages/transaction-pay-controller/src/utils/chomp.ts index 2c2cc466276..520a5eccf1c 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.ts @@ -1,11 +1,16 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; import { projectLogger } from '../logger.js'; +import { isMoneyAccountDepositTransaction } from '../strategy/fiat/utils.js'; import type { TransactionPayControllerMessenger } from '../types.js'; +import { prefixError } from './error-prefix.js'; import { rpcRequest } from './provider.js'; +export const VAULT_ERROR_PREFIX = 'Vault: '; + const log = createModuleLogger(projectLogger, 'chomp'); /** keccak256('Transfer(address,address,uint256)') */ @@ -81,6 +86,113 @@ export async function findRecentChompVaultDeposit({ return undefined; } +/** + * Runs a second-leg submission, recovering when CHOMP (the automated vaulting + * service) wins the race to vault a Money Account deposit. + * + * Runs `submit` directly when the transaction is not a Money Account deposit. + * Otherwise a pre-check skips submission if CHOMP already vaulted the funds, + * and on any submission failure a post-check returns the CHOMP hash instead of + * surfacing the error. Unrecovered errors get the `Vault: ` prefix. CHOMP + * checks only run when `fromBlock` is known. + * + * @param options - Recovery options. + * @param options.from - Money Account submitting the second leg. + * @param options.fromBlock - Block at or after which the funds settled. + * @param options.messenger - Controller messenger. + * @param options.sourceAmountRaw - Settled amount in raw units. + * @param options.transaction - Parent transaction meta. + * @param submit - Performs the second-leg submission. + * @returns Hash of the submitted or CHOMP transaction, if available. + */ +export async function withChompRecovery( + { + from, + fromBlock, + messenger, + sourceAmountRaw, + transaction, + }: { + from: Hex; + fromBlock?: Hex; + messenger: TransactionPayControllerMessenger; + sourceAmountRaw: string; + transaction: TransactionMeta; + }, + submit: () => Promise<{ transactionHash?: Hex }>, +): Promise<{ transactionHash?: Hex }> { + if (!isMoneyAccountDepositTransaction(transaction)) { + return await submit(); + } + + const findChompDeposit = async (): Promise => + await tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress: from, + sourceAmountRaw, + transactionId: transaction.id, + }); + + const preChompHash = await findChompDeposit(); + + if (preChompHash) { + log('CHOMP already vaulted the funds, skipping submission', { + preChompHash, + transactionId: transaction.id, + }); + + return { transactionHash: preChompHash }; + } + + try { + return await submit(); + } catch (error) { + const postChompHash = await findChompDeposit(); + + if (postChompHash) { + log('CHOMP vaulted the funds during submission', { + postChompHash, + transactionId: transaction.id, + }); + + return { transactionHash: postChompHash }; + } + + throw prefixError(error, VAULT_ERROR_PREFIX); + } +} + +async function tryFindChompDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, +}: { + fromBlock: Hex | undefined; + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId: string; +}): Promise { + if (!fromBlock) { + return undefined; + } + + try { + return await findRecentChompVaultDeposit({ + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + }); + } catch (chompError) { + log('CHOMP check failed', { chompError, transactionId }); + return undefined; + } +} + function padAddress(address: Hex): string { return `0x${address.replace(/^0x/u, '').toLowerCase().padStart(64, '0')}`; } diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts deleted file mode 100644 index 1865af55e1b..00000000000 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { TransactionType } from '@metamask/transaction-controller'; -import type { - BatchTransactionParams, - TransactionMeta, -} from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; - -import type { TransactionPayControllerMessenger } from '../types.js'; -import { findRecentChompVaultDeposit } from './chomp.js'; -import { submitMoneyAccountVaultDeposit } from './ma-vault-deposit.js'; -import { getNetworkClientId } from './provider.js'; -import { - collectTransactionIds, - getTransaction, - updateTransaction, - waitForTransactionConfirmed, -} from './transaction.js'; - -jest.mock('./chomp'); -jest.mock('./provider'); -jest.mock('./transaction'); - -const TRANSACTION_ID_MOCK = 'tx-id'; -const MONEY_ACCOUNT_ADDRESS_MOCK = - '0x1111111111111111111111111111111111111111' as Hex; -const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; - -const TRANSACTION_MOCK = { - id: TRANSACTION_ID_MOCK, - nestedTransactions: [ - { data: '0xoldApprove' as Hex, to: '0xapprove' as Hex }, - { data: '0xoldDeposit' as Hex, to: '0xdeposit' as Hex }, - ], - requiredAssets: [{ amount: '0x0' }], - txParams: { from: MONEY_ACCOUNT_ADDRESS_MOCK }, - type: TransactionType.batch, -} as unknown as TransactionMeta; - -function buildMessenger( - callMock: jest.Mock = jest.fn(), -): TransactionPayControllerMessenger { - return { call: callMock } as unknown as TransactionPayControllerMessenger; -} - -function callSubmit({ - callMock = jest.fn(), - depositCalls, - moneyAccountAddress, - sourceAmountRaw = '5000000', - transaction = TRANSACTION_MOCK, - vaultDisabled = false, - fromBlock, -}: { - callMock?: jest.Mock; - depositCalls?: BatchTransactionParams[]; - moneyAccountAddress?: Hex; - sourceAmountRaw?: string; - transaction?: TransactionMeta; - vaultDisabled?: boolean; - fromBlock?: Hex; -} = {}): Promise<{ transactionHash?: Hex }> { - return submitMoneyAccountVaultDeposit({ - depositCalls, - fromBlock, - messenger: buildMessenger(callMock), - moneyAccountAddress, - sourceAmountRaw, - transaction, - vaultDisabled, - }); -} - -describe('submitMoneyAccountVaultDeposit', () => { - const collectTransactionIdsMock = jest.mocked(collectTransactionIds); - const getNetworkClientIdMock = jest.mocked(getNetworkClientId); - const getTransactionMock = jest.mocked(getTransaction); - const updateTransactionMock = jest.mocked(updateTransaction); - const waitForTransactionConfirmedMock = jest.mocked( - waitForTransactionConfirmed, - ); - - beforeEach(() => { - jest.resetAllMocks(); - - getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); - collectTransactionIdsMock.mockImplementation( - (_chainId, _from, _messenger, onTransaction) => { - onTransaction('child-1'); - onTransaction('child-2'); - return { end: jest.fn() }; - }, - ); - getTransactionMock.mockImplementation((transactionId) => { - if (transactionId === TRANSACTION_ID_MOCK) { - return TRANSACTION_MOCK; - } - if (transactionId === 'child-2') { - return { hash: '0xvault' } as TransactionMeta; - } - return; - }); - waitForTransactionConfirmedMock.mockResolvedValue(); - }); - - it('submits a sponsored vault batch with refreshed calldata', async () => { - updateTransactionMock.mockImplementation((_request, callback) => { - callback({ - ...TRANSACTION_MOCK, - nestedTransactions: TRANSACTION_MOCK.nestedTransactions?.map((nt) => ({ - ...nt, - })), - requiredAssets: [{ amount: '0x0' }], - } as TransactionMeta); - }); - - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [ - { data: '0xnewApprove', nestedTransactionIndex: 0 }, - { data: '0xnewDeposit', nestedTransactionIndex: 1 }, - ], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - const result = await callSubmit({ callMock }); - - expect(callMock).toHaveBeenCalledWith( - 'TransactionPayController:getAmountData', - { amount: '5000000', transaction: TRANSACTION_MOCK }, - ); - expect(updateTransactionMock).toHaveBeenCalledWith( - expect.objectContaining({ - note: 'Money Account vault deposit: update vault amount', - transactionId: TRANSACTION_ID_MOCK, - }), - expect.any(Function), - ); - expect(callMock).toHaveBeenCalledWith( - 'TransactionController:addTransactionBatch', - expect.objectContaining({ - disableHook: true, - disableSequential: true, - disableUpgrade: true, - from: MONEY_ACCOUNT_ADDRESS_MOCK, - isGasFeeSponsored: true, - isInternal: true, - networkClientId: NETWORK_CLIENT_ID_MOCK, - origin: 'metamask', - requireApproval: false, - skipInitialGasEstimate: true, - transactions: [ - { - params: { data: '0xnewApprove', to: '0xapprove', value: '0x0' }, - type: TransactionType.tokenMethodApprove, - }, - { - params: { data: '0xnewDeposit', to: '0xdeposit', value: '0x0' }, - type: TransactionType.contractInteraction, - }, - ], - }), - ); - expect(updateTransactionMock).toHaveBeenCalledWith( - expect.objectContaining({ - note: 'Add required transaction ID from Money Account vault submission', - transactionId: TRANSACTION_ID_MOCK, - }), - expect.any(Function), - ); - expect(waitForTransactionConfirmedMock).toHaveBeenCalledWith( - 'child-1', - expect.anything(), - ); - expect(waitForTransactionConfirmedMock).toHaveBeenCalledWith( - 'child-2', - expect.anything(), - ); - expect(result).toStrictEqual({ transactionHash: '0xvault' }); - }); - - it('submits pre-built depositCalls without calling getAmountData', async () => { - const depositCalls: BatchTransactionParams[] = [ - { data: '0xwithdrawApprove', to: '0xw-approve' }, - { data: '0xwithdrawDeposit', to: '0xw-deposit' }, - ]; - const callMock = jest.fn((action: string) => { - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - const result = await callSubmit({ callMock, depositCalls }); - - expect(callMock).not.toHaveBeenCalledWith( - 'TransactionPayController:getAmountData', - expect.anything(), - ); - expect(updateTransactionMock).not.toHaveBeenCalledWith( - expect.objectContaining({ - note: 'Money Account vault deposit: update vault amount', - }), - expect.any(Function), - ); - expect(callMock).toHaveBeenCalledWith( - 'TransactionController:addTransactionBatch', - expect.objectContaining({ - from: MONEY_ACCOUNT_ADDRESS_MOCK, - transactions: [ - { - params: { - data: '0xwithdrawApprove', - to: '0xw-approve', - value: '0x0', - }, - type: TransactionType.tokenMethodApprove, - }, - { - params: { - data: '0xwithdrawDeposit', - to: '0xw-deposit', - value: '0x0', - }, - type: TransactionType.contractInteraction, - }, - ], - }), - ); - expect(result).toStrictEqual({ transactionHash: '0xvault' }); - }); - - it('uses moneyAccountAddress override instead of transaction.txParams.from', async () => { - const overrideAddress = '0x2222222222222222222222222222222222222222' as Hex; - const depositCalls: BatchTransactionParams[] = [{ data: '0xd', to: '0xt' }]; - const callMock = jest.fn((action: string) => { - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await callSubmit({ - callMock, - depositCalls, - moneyAccountAddress: overrideAddress, - }); - - expect(callMock).toHaveBeenCalledWith( - 'TransactionController:addTransactionBatch', - expect.objectContaining({ from: overrideAddress }), - ); - expect(collectTransactionIdsMock).toHaveBeenCalledWith( - expect.anything(), - overrideAddress, - expect.anything(), - expect.any(Function), - ); - }); - - it('skips the vault batch when vaultDisabled is true', async () => { - const callMock = jest.fn(); - - const result = await callSubmit({ callMock, vaultDisabled: true }); - - expect(result).toStrictEqual({ transactionHash: '0x' }); - expect(callMock).not.toHaveBeenCalled(); - expect(updateTransactionMock).not.toHaveBeenCalled(); - expect(collectTransactionIdsMock).not.toHaveBeenCalled(); - }); - - it('throws when the Money Account address is missing', async () => { - const transaction = { - ...TRANSACTION_MOCK, - txParams: {}, - } as TransactionMeta; - - await expect(callSubmit({ transaction })).rejects.toThrow( - 'Missing Money Account address', - ); - }); - - it('throws when getAmountData returns no updates', async () => { - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ updates: [] }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await expect(callSubmit({ callMock })).rejects.toThrow('No amount updates'); - }); - - it('throws when nested transactions are missing', async () => { - const transaction = { - ...TRANSACTION_MOCK, - nestedTransactions: undefined, - } as TransactionMeta; - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - getTransactionMock.mockReturnValue(transaction); - - await expect(callSubmit({ callMock, transaction })).rejects.toThrow( - 'Missing nested transactions', - ); - }); - - it('prefixes addTransactionBatch errors with Vault and stops collecting IDs', async () => { - const endMock = jest.fn(); - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - throw new Error('batch failed'); - } - throw new Error(`Unexpected action: ${action}`); - }); - collectTransactionIdsMock.mockReturnValue({ end: endMock }); - - await expect(callSubmit({ callMock })).rejects.toThrow( - 'Vault: batch failed', - ); - expect(endMock).toHaveBeenCalledTimes(1); - }); - - it('throws when no vault transactions are collected', async () => { - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - collectTransactionIdsMock.mockReturnValue({ end: jest.fn() }); - - await expect(callSubmit({ callMock })).rejects.toThrow( - 'No transactions submitted', - ); - }); - - it('throws when the confirmed vault transaction has no hash', async () => { - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - - getTransactionMock.mockImplementation((transactionId) => { - if (transactionId === TRANSACTION_ID_MOCK) { - return TRANSACTION_MOCK; - } - return; - }); - - await expect(callSubmit({ callMock })).rejects.toThrow( - 'Missing transaction hash', - ); - }); - - describe('CHOMP idempotency', () => { - const CHOMP_FROM_BLOCK = '0x100' as Hex; - const CHOMP_HASH = - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; - const findRecentChompVaultDepositMock = jest.mocked( - findRecentChompVaultDeposit, - ); - - function makeCallMock(): jest.Mock { - return jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [ - { data: '0xnewApprove', nestedTransactionIndex: 0 }, - { data: '0xnewDeposit', nestedTransactionIndex: 1 }, - ], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - return Promise.resolve({ batchId: 'batch-id' }); - } - throw new Error(`Unexpected action: ${action}`); - }); - } - - it('skips addTransactionBatch and returns CHOMP hash when pre-check matches', async () => { - findRecentChompVaultDepositMock.mockResolvedValue(CHOMP_HASH); - - const result = await callSubmit({ - callMock: makeCallMock(), - fromBlock: CHOMP_FROM_BLOCK, - }); - - expect(result).toStrictEqual({ transactionHash: CHOMP_HASH }); - expect(collectTransactionIdsMock).not.toHaveBeenCalled(); - }); - - it('detects CHOMP in the catch path and returns the CHOMP hash', async () => { - findRecentChompVaultDepositMock - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(CHOMP_HASH); - - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - throw new Error('Account does not support EIP-7702'); - } - throw new Error(`Unexpected action: ${action}`); - }); - - const result = await callSubmit({ - callMock, - fromBlock: CHOMP_FROM_BLOCK, - }); - - expect(result).toStrictEqual({ transactionHash: CHOMP_HASH }); - expect(findRecentChompVaultDepositMock).toHaveBeenCalledTimes(2); - }); - - it('preserves the Vault-prefixed error when no CHOMP match in catch path', async () => { - findRecentChompVaultDepositMock.mockResolvedValue(undefined); - - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - throw new Error('batch failed'); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await expect( - callSubmit({ callMock, fromBlock: CHOMP_FROM_BLOCK }), - ).rejects.toThrow('Vault: batch failed'); - }); - - it('proceeds with vault submit when pre-check throws', async () => { - findRecentChompVaultDepositMock - .mockRejectedValueOnce(new Error('network error')) - .mockResolvedValueOnce(undefined); - - const result = await callSubmit({ - callMock: makeCallMock(), - fromBlock: CHOMP_FROM_BLOCK, - }); - - expect(result).toStrictEqual({ transactionHash: '0xvault' }); - }); - - it('re-throws Vault-prefixed error when both addTransactionBatch and CHOMP post-check fail', async () => { - findRecentChompVaultDepositMock - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('rpc error')); - - const callMock = jest.fn((action: string) => { - if (action === 'TransactionPayController:getAmountData') { - return Promise.resolve({ - updates: [{ data: '0xnewApprove', nestedTransactionIndex: 0 }], - }); - } - if (action === 'TransactionController:addTransactionBatch') { - throw new Error('Account does not support EIP-7702'); - } - throw new Error(`Unexpected action: ${action}`); - }); - - await expect( - callSubmit({ callMock, fromBlock: CHOMP_FROM_BLOCK }), - ).rejects.toThrow('Vault: Account does not support EIP-7702'); - - expect(findRecentChompVaultDepositMock).toHaveBeenCalledTimes(2); - }); - - it('skips CHOMP checks when fromBlock is not provided', async () => { - const result = await callSubmit({ callMock: makeCallMock() }); - - expect(result).toStrictEqual({ transactionHash: '0xvault' }); - expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts deleted file mode 100644 index 8f3facf82f5..00000000000 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { ORIGIN_METAMASK } from '@metamask/controller-utils'; -import type { - BatchTransactionParams, - NestedTransactionMetadata, - TransactionMeta, -} from '@metamask/transaction-controller'; -import { TransactionType } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../logger.js'; -import { MUSD_MONAD_FIAT_ASSET } from '../strategy/fiat/constants.js'; -import type { TransactionPayControllerMessenger } from '../types.js'; -import { findRecentChompVaultDeposit } from './chomp.js'; -import { prefixError } from './error-prefix.js'; -import { getNetworkClientId } from './provider.js'; -import { - collectTransactionIds, - getTransaction, - updateTransaction, - waitForTransactionConfirmed, -} from './transaction.js'; - -const log = createModuleLogger(projectLogger, 'ma-vault-deposit'); - -export const VAULT_ERROR_PREFIX = 'Vault: '; - -/** - * Submits a Money Account mUSD vault deposit batch on Monad once the source - * mUSD has settled in the Money Account (fiat on-ramp, Relay bridge, or any - * other path). Re-encodes the original nested vault calldata with the - * settled `sourceAmountRaw` via `getAmountData` and submits as a sponsored, - * internal EIP-7702 batch. Callers decide whether to honour any vault - * kill-switch by passing `vaultDisabled`. - * - * @param options - Submit options. - * @param options.fromBlock - Block number to start searching for CHOMP deposits. - * @param options.messenger - Controller messenger. - * @param options.moneyAccountAddress - Money Account address sending the vault - * deposit. Defaults to `transaction.txParams.from` (the deposit-flow sender); - * withdraw flows pass it explicitly since their `from` is the funding EOA. - * @param options.depositCalls - Pre-built vault-deposit batch for withdraw - * flows, whose parent transaction carries no vault calls. When omitted the - * batch is derived from the transaction's own nested calls re-encoded via - * `getAmountData`. - * @param options.sourceAmountRaw - Settled mUSD amount in raw units. - * @param options.transaction - Original Money Account transaction meta. - * @param options.vaultDisabled - When `true`, skip the vault batch and leave - * the settled mUSD in the Money Account. Caller-evaluated kill-switch. - * @returns Hash of the final submitted child transaction, if available. - */ -export async function submitMoneyAccountVaultDeposit({ - fromBlock, - messenger, - moneyAccountAddress: moneyAccountAddressOverride, - depositCalls, - sourceAmountRaw, - transaction, - vaultDisabled, -}: { - fromBlock?: Hex; - messenger: TransactionPayControllerMessenger; - moneyAccountAddress?: Hex; - depositCalls?: BatchTransactionParams[]; - sourceAmountRaw: string; - transaction: TransactionMeta; - vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { - const transactionId = transaction.id; - const moneyAccountAddress = (moneyAccountAddressOverride ?? - transaction.txParams.from) as Hex | undefined; - - if (!moneyAccountAddress) { - throw new Error('Missing Money Account address'); - } - - if (vaultDisabled) { - log('Skipping vault deposit because vaultDisabled is true', { - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - - return { transactionHash: '0x' }; - } - - const nestedTransactions = await resolveVaultDepositBatch({ - depositCalls, - messenger, - sourceAmountRaw, - transaction, - transactionId, - }); - - // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already - // auto-vaulted the funds during or before the checkout window. - const preChompHash = await tryFindChompDeposit({ - fromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - - if (preChompHash) { - return { transactionHash: preChompHash }; - } - - const networkClientId = getNetworkClientId( - messenger, - MUSD_MONAD_FIAT_ASSET.chainId, - ); - const transactionIds: string[] = []; - const { end } = collectTransactionIds( - MUSD_MONAD_FIAT_ASSET.chainId, - moneyAccountAddress, - messenger, - (id) => { - transactionIds.push(id); - updateTransaction( - { - transactionId, - messenger, - note: 'Add required transaction ID from Money Account vault submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(id); - }, - ); - }, - ); - - log('Submitting Money Account vault deposit', { - moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, - networkClientId, - sourceAmountRaw, - transactionId, - }); - - try { - await messenger.call('TransactionController:addTransactionBatch', { - disableHook: true, - disableSequential: true, - disableUpgrade: true, - from: moneyAccountAddress, - isGasFeeSponsored: true, - isInternal: true, - networkClientId, - origin: ORIGIN_METAMASK, - requireApproval: false, - skipInitialGasEstimate: true, - transactions: nestedTransactions.map((nestedTransaction, index) => ({ - params: { - data: nestedTransaction.data, - to: nestedTransaction.to, - value: nestedTransaction.value ?? '0x0', - }, - type: - index === 0 - ? (nestedTransaction.type ?? TransactionType.tokenMethodApprove) - : TransactionType.contractInteraction, - })), - }); - } catch (error) { - // CHOMP post-check: CHOMP may have won the race between pre-check and - // submit. Return the CHOMP hash instead of surfacing a Vault-prefixed error. - const postChompHash = await tryFindChompDeposit({ - fromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - transactionId, - }); - - if (postChompHash) { - return { transactionHash: postChompHash }; - } - - throw prefixError(error, VAULT_ERROR_PREFIX); - } finally { - end(); - } - - log('Submitted Money Account vault deposit', { - moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, - networkClientId, - sourceAmountRaw, - transactionId, - transactionIds, - }); - - if (!transactionIds.length) { - throw new Error('No transactions submitted'); - } - - await Promise.all( - transactionIds.map((id) => waitForTransactionConfirmed(id, messenger)), - ); - - const hash = getTransaction(transactionIds.slice(-1)[0], messenger)?.hash; - - if (!hash) { - throw new Error('Missing transaction hash'); - } - - log('Confirmed Money Account vault deposit', { - hash, - moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, - networkClientId, - sourceAmountRaw, - transactionId, - transactionIds, - }); - - return { transactionHash: hash as Hex }; -} - -/** - * Resolves the vault-deposit batch (approve + teller deposit) to submit. - * - * Withdraw flows have no vault calls on their own nested transactions, so the - * caller supplies a freshly built `depositCalls` batch. Deposit flows re-encode - * their existing nested vault calldata with the settled amount via - * `getAmountData` and also mutate the parent transaction so its stored calls - * and `requiredAssets` reflect the settled amount. - * - * @param options - Resolution options. - * @param options.depositCalls - Pre-built deposit batch for withdraw flows. - * @param options.messenger - Controller messenger. - * @param options.sourceAmountRaw - Settled mUSD amount in raw units. - * @param options.transaction - Original Money Account transaction meta. - * @param options.transactionId - ID of the original transaction. - * @returns Nested transactions to submit as the vault deposit batch. - */ -async function resolveVaultDepositBatch({ - depositCalls, - messenger, - sourceAmountRaw, - transaction, - transactionId, -}: { - depositCalls?: BatchTransactionParams[]; - messenger: TransactionPayControllerMessenger; - sourceAmountRaw: string; - transaction: TransactionMeta; - transactionId: string; -}): Promise { - if (depositCalls?.length) { - return depositCalls; - } - - const updatedTransaction = - getTransaction(transactionId, messenger) ?? transaction; - const { updates } = await messenger.call( - 'TransactionPayController:getAmountData', - { - amount: sourceAmountRaw, - transaction: updatedTransaction, - }, - ); - - if (!updates.length) { - throw new Error('No amount updates'); - } - - const nestedTransactions = updatedTransaction.nestedTransactions?.map( - (nestedTransaction) => ({ ...nestedTransaction }), - ); - - if (!nestedTransactions?.length) { - throw new Error('Missing nested transactions'); - } - - for (const { nestedTransactionIndex, data } of updates) { - if (nestedTransactions[nestedTransactionIndex]) { - nestedTransactions[nestedTransactionIndex].data = data; - } - } - - updateTransaction( - { - transactionId, - messenger, - note: 'Money Account vault deposit: update vault amount', - }, - (tx) => { - for (const { nestedTransactionIndex, data } of updates) { - if (tx.nestedTransactions?.[nestedTransactionIndex]) { - tx.nestedTransactions[nestedTransactionIndex].data = data; - } - } - - if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString( - 16, - )}`; - } - }, - ); - - return nestedTransactions; -} - -async function tryFindChompDeposit({ - fromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - transactionId, -}: { - fromBlock: Hex | undefined; - messenger: TransactionPayControllerMessenger; - moneyAccountAddress: Hex; - sourceAmountRaw: string; - transactionId: string; -}): Promise { - if (!fromBlock) { - return undefined; - } - - try { - return await findRecentChompVaultDeposit({ - fromBlock, - messenger, - moneyAccountAddress, - sourceAmountRaw, - }); - } catch (chompError) { - log('CHOMP check failed', { chompError, transactionId }); - return undefined; - } -} diff --git a/packages/transaction-pay-controller/src/utils/second-leg.test.ts b/packages/transaction-pay-controller/src/utils/second-leg.test.ts new file mode 100644 index 00000000000..063c2c054a5 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/second-leg.test.ts @@ -0,0 +1,699 @@ +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '../types.js'; +import { withChompRecovery } from './chomp.js'; +import { getNetworkClientId } from './provider.js'; +import { resolveNonAtomicRecipient, submitSecondLeg } from './second-leg.js'; +import { + collectTransactionIds, + getTransaction, + getTransferredAmountFromTxHash, + updateTransaction, + waitForTransactionConfirmed, +} from './transaction.js'; + +jest.mock('./chomp'); +jest.mock('./provider'); +jest.mock('./transaction'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const FROM_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const CHAIN_ID_MOCK = '0x279f' as Hex; +const FROM_BLOCK_MOCK = '0x100' as Hex; +const TOKEN_MOCK = '0x2222222222222222222222222222222222222222' as Hex; +const SETTLEMENT_HASH_MOCK = '0xsettlement' as Hex; +const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; +const AMOUNT_MOCK = '5000000'; +const PAYER_MOCK = '0x3333333333333333333333333333333333333333' as Hex; +const OVERRIDE_RECIPIENT_MOCK = + '0x4444444444444444444444444444444444444444' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + nestedTransactions: [ + { data: '0xoldApprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xoldDeposit' as Hex, to: '0xdeposit' as Hex }, + ], + requiredAssets: [{ amount: '0x0' }], + txParams: { from: FROM_MOCK }, + type: TransactionType.batch, +} as unknown as TransactionMeta; + +function buildMessenger( + callMock: jest.Mock = jest.fn(), +): TransactionPayControllerMessenger { + return { call: callMock } as unknown as TransactionPayControllerMessenger; +} + +function buildAmountDataCallMock( + overrides: { + addTransactionBatch?: () => Promise; + updates?: { data: string; nestedTransactionIndex: number }[]; + } = {}, +): jest.Mock { + return jest.fn((action: string) => { + if (action === 'TransactionPayController:getAmountData') { + return Promise.resolve({ + updates: overrides.updates ?? [ + { data: '0xnewApprove', nestedTransactionIndex: 0 }, + { data: '0xnewDeposit', nestedTransactionIndex: 1 }, + ], + }); + } + + if (action === 'TransactionController:addTransactionBatch') { + return ( + overrides.addTransactionBatch?.() ?? + Promise.resolve({ batchId: 'batch-id' }) + ); + } + + throw new Error(`Unexpected action: ${action}`); + }); +} + +describe('second-leg', () => { + const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const getTransactionMock = jest.mocked(getTransaction); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); + const updateTransactionMock = jest.mocked(updateTransaction); + const waitForTransactionConfirmedMock = jest.mocked( + waitForTransactionConfirmed, + ); + const withChompRecoveryMock = jest.mocked(withChompRecovery); + + beforeEach(() => { + jest.resetAllMocks(); + + withChompRecoveryMock.mockImplementation( + async (_options, submit) => await submit(), + ); + + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + collectTransactionIdsMock.mockImplementation( + (_chainId, _from, _messenger, onTransaction) => { + onTransaction('child-1'); + onTransaction('child-2'); + return { end: jest.fn() }; + }, + ); + + getTransactionMock.mockImplementation((transactionId) => { + if (transactionId === TRANSACTION_ID_MOCK) { + return TRANSACTION_MOCK; + } + + if (transactionId === 'child-2') { + return { hash: '0xsecondleg' } as TransactionMeta; + } + }); + + waitForTransactionConfirmedMock.mockResolvedValue(); + }); + + describe('resolveNonAtomicRecipient', () => { + it('returns undefined for atomic flows', async () => { + expect( + await resolveNonAtomicRecipient( + TRANSACTION_MOCK, + { atomic: true, from: PAYER_MOCK } as never, + buildMessenger(), + ), + ).toBeUndefined(); + }); + + it('returns undefined when the request does not specify atomicity', async () => { + expect( + await resolveNonAtomicRecipient( + TRANSACTION_MOCK, + { from: PAYER_MOCK } as never, + buildMessenger(), + ), + ).toBeUndefined(); + }); + + it('settles on the transaction account rather than the payer for non-post-quote flows', async () => { + expect( + await resolveNonAtomicRecipient( + TRANSACTION_MOCK, + { atomic: false, from: PAYER_MOCK } as never, + buildMessenger(), + ), + ).toBe(FROM_MOCK); + }); + + it('asks the client for the recipient on post-quote flows', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { tokens: [{ amountHuman: '1.5' }] }, + }, + }; + } + + if (action === 'TransactionPayController:getPaymentOverrideData') { + return Promise.resolve({ recipient: OVERRIDE_RECIPIENT_MOCK }); + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await resolveNonAtomicRecipient( + TRANSACTION_MOCK, + { atomic: false, from: PAYER_MOCK, isPostQuote: true } as never, + buildMessenger(callMock), + ); + + expect(result).toBe(OVERRIDE_RECIPIENT_MOCK); + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getPaymentOverrideData', + expect.objectContaining({ amount: '1.5' }), + ); + }); + + it('defaults the post-quote amount to zero when no token amount is known', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { transactionData: {} }; + } + + if (action === 'TransactionPayController:getPaymentOverrideData') { + return Promise.resolve({ recipient: OVERRIDE_RECIPIENT_MOCK }); + } + + throw new Error(`Unexpected action: ${action}`); + }); + + await resolveNonAtomicRecipient( + TRANSACTION_MOCK, + { atomic: false, from: PAYER_MOCK, isPostQuote: true } as never, + buildMessenger(callMock), + ); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:getPaymentOverrideData', + expect.objectContaining({ amount: '0' }), + ); + }); + }); + + describe('submitSecondLeg', () => { + beforeEach(() => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: AMOUNT_MOCK, + blockNumber: FROM_BLOCK_MOCK, + }); + }); + + it('throws when settlement hash is missing', async () => { + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(), + settlementHash: undefined, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Missing settlement hash'); + }); + + it('throws when no amount can be read from the settlement transaction', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: undefined, + }); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow( + `Could not determine settled amount from transaction ${SETTLEMENT_HASH_MOCK}`, + ); + }); + + it('reads the settled amount from the settlement transaction', async () => { + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith( + expect.objectContaining({ + chainId: CHAIN_ID_MOCK, + tokenAddress: TOKEN_MOCK, + txHash: SETTLEMENT_HASH_MOCK, + walletAddress: FROM_MOCK, + }), + ); + }); + + it('passes fromBlock (receipt blockNumber) to type-specific handling', async () => { + const messenger = buildMessenger(buildAmountDataCallMock()); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger, + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(withChompRecoveryMock).toHaveBeenCalledWith( + { + from: FROM_MOCK, + fromBlock: FROM_BLOCK_MOCK, + messenger, + sourceAmountRaw: AMOUNT_MOCK, + transaction: TRANSACTION_MOCK, + }, + expect.any(Function), + ); + }); + + it('calls getCalls with the settled amount', async () => { + const getCallsMock = jest + .fn() + .mockResolvedValue([{ data: '0xcall' as Hex, to: '0xtarget' as Hex }]); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + getCalls: getCallsMock, + messenger: buildMessenger(), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(getCallsMock).toHaveBeenCalledWith(AMOUNT_MOCK); + }); + + it('throws when getCalls returns empty array', async () => { + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + getCalls: async () => [], + messenger: buildMessenger(), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Missing second leg calls'); + }); + + it('submits the calls from getCalls without re-encoding the parent calls', async () => { + const callMock = buildAmountDataCallMock(); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + getCalls: async () => [{ data: '0xprebuilt', to: '0xtarget' }], + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.anything(), + ); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + transactions: [ + { + params: { data: '0xprebuilt', to: '0xtarget', value: '0x0' }, + type: TransactionType.tokenMethodApprove, + }, + ], + }), + ); + }); + + it('updates the parent transaction calls and required asset amount', async () => { + const parentTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: TRANSACTION_MOCK.nestedTransactions?.map((nt) => ({ + ...nt, + })), + requiredAssets: [{ amount: '0x0' }], + } as TransactionMeta; + + updateTransactionMock.mockImplementation((_request, callback) => { + callback(parentTransaction); + }); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(parentTransaction.nestedTransactions).toStrictEqual([ + { data: '0xnewApprove', to: '0xapprove' }, + { data: '0xnewDeposit', to: '0xdeposit' }, + ]); + expect(parentTransaction.requiredAssets?.[0].amount).toBe('0x4c4b40'); + }); + + it('ignores amount updates that target a call index that does not exist', async () => { + const parentTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: [{ data: '0xoldApprove', to: '0xapprove' }], + } as TransactionMeta; + + getTransactionMock.mockImplementation((transactionId) => + transactionId === TRANSACTION_ID_MOCK + ? parentTransaction + : ({ hash: '0xsecondleg' } as TransactionMeta), + ); + updateTransactionMock.mockImplementation((_request, callback) => { + callback(parentTransaction); + }); + + const callMock = buildAmountDataCallMock({ + updates: [{ data: '0xnew', nestedTransactionIndex: 5 }], + }); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(parentTransaction.nestedTransactions).toStrictEqual([ + { data: '0xoldApprove', to: '0xapprove' }, + ]); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + transactions: [ + { + params: { data: '0xoldApprove', to: '0xapprove', value: '0x0' }, + type: TransactionType.tokenMethodApprove, + }, + ], + }), + ); + }); + + it('leaves the required asset amount alone when the transaction has none', async () => { + const parentTransaction = { + ...TRANSACTION_MOCK, + nestedTransactions: TRANSACTION_MOCK.nestedTransactions?.map((nt) => ({ + ...nt, + })), + requiredAssets: undefined, + } as TransactionMeta; + + updateTransactionMock.mockImplementation((_request, callback) => { + callback(parentTransaction); + }); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(parentTransaction.requiredAssets).toBeUndefined(); + }); + + it('throws when there are no amount updates', async () => { + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock({ updates: [] })), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('No amount updates'); + }); + + it('throws when the transaction has no nested calls', async () => { + getTransactionMock.mockReturnValue({ + ...TRANSACTION_MOCK, + nestedTransactions: undefined, + }); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Missing nested transactions'); + }); + + it('submits a sponsored batch on the requested chain from the requested account', async () => { + const callMock = buildAmountDataCallMock(); + + const result = await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(getNetworkClientIdMock).toHaveBeenCalledWith( + expect.anything(), + CHAIN_ID_MOCK, + ); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: FROM_MOCK, + isGasFeeSponsored: true, + isInternal: true, + networkClientId: NETWORK_CLIENT_ID_MOCK, + origin: ORIGIN_METAMASK, + requireApproval: false, + skipInitialGasEstimate: true, + transactions: [ + { + params: { data: '0xnewApprove', to: '0xapprove', value: '0x0' }, + type: TransactionType.tokenMethodApprove, + }, + { + params: { data: '0xnewDeposit', to: '0xdeposit', value: '0x0' }, + type: TransactionType.contractInteraction, + }, + ], + }), + ); + expect(result).toStrictEqual({ transactionHash: '0xsecondleg' }); + }); + + it('submits without sponsorship when sponsored is false', async () => { + const callMock = buildAmountDataCallMock(); + + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + sponsored: false, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ isGasFeeSponsored: false }), + ); + }); + + it('waits for every submitted child transaction to confirm', async () => { + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(waitForTransactionConfirmedMock).toHaveBeenCalledWith( + 'child-1', + expect.anything(), + ); + expect(waitForTransactionConfirmedMock).toHaveBeenCalledWith( + 'child-2', + expect.anything(), + ); + }); + + it('prefixes submission errors', async () => { + const callMock = buildAmountDataCallMock({ + addTransactionBatch: () => Promise.reject(new Error('submit failed')), + }); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Second leg: submit failed'); + }); + + it('records each submitted child transaction as required by the parent', async () => { + await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + const parent = {} as TransactionMeta; + + for (const [options, updater] of updateTransactionMock.mock.calls) { + if ( + options.note === + 'Add required transaction ID from second leg submission' + ) { + updater(parent); + } + } + + expect(parent.requiredTransactionIds).toStrictEqual([ + 'child-1', + 'child-2', + ]); + }); + + it('updates the parent transaction even when type-specific handling skips submission', async () => { + withChompRecoveryMock.mockResolvedValue({ + transactionHash: '0xexternal', + }); + + const callMock = buildAmountDataCallMock(); + + const result = await submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }); + + expect(result).toStrictEqual({ transactionHash: '0xexternal' }); + expect(updateTransactionMock).toHaveBeenCalledTimes(1); + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.anything(), + ); + }); + + it('prefixes errors raised by type-specific handling', async () => { + withChompRecoveryMock.mockRejectedValue( + new Error('Vault: submit failed'), + ); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow(/^Second leg: Vault: submit failed$/u); + }); + + it('does not prefix errors resolving the calls', async () => { + const callMock = jest.fn().mockResolvedValue({ updates: [] }); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(callMock), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow(/^No amount updates$/u); + }); + + it('throws when no child transactions were submitted', async () => { + collectTransactionIdsMock.mockReturnValue({ end: jest.fn() }); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Second leg: No transactions submitted'); + }); + + it('throws when the submitted transaction has no hash', async () => { + getTransactionMock.mockImplementation((transactionId) => + transactionId === TRANSACTION_ID_MOCK ? TRANSACTION_MOCK : undefined, + ); + + await expect( + submitSecondLeg({ + chainId: CHAIN_ID_MOCK, + from: FROM_MOCK, + messenger: buildMessenger(buildAmountDataCallMock()), + settlementHash: SETTLEMENT_HASH_MOCK, + tokenAddress: TOKEN_MOCK, + transaction: TRANSACTION_MOCK, + }), + ).rejects.toThrow('Second leg: Missing transaction hash'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/second-leg.ts b/packages/transaction-pay-controller/src/utils/second-leg.ts new file mode 100644 index 00000000000..1236d0692ab --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/second-leg.ts @@ -0,0 +1,428 @@ +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import type { + BatchTransactionParams, + NestedTransactionMetadata, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../logger.js'; +import type { + QuoteRequest, + TransactionPayControllerMessenger, +} from '../types.js'; +import { withChompRecovery } from './chomp.js'; +import { prefixError } from './error-prefix.js'; +import { getNetworkClientId } from './provider.js'; +import { + collectTransactionIds, + getTransaction, + getTransferredAmountFromTxHash, + updateTransaction, + waitForTransactionConfirmed, +} from './transaction.js'; + +export const SECOND_LEG_ERROR_PREFIX = 'Second leg: '; + +const log = createModuleLogger(projectLogger, 'second-leg'); + +export type SecondLegCallsBuilder = ( + sourceAmountRaw: string, +) => Promise; + +/** + * Derives the quote recipient for non-atomic flows, where the second leg runs + * after settlement so funds must land directly on the account submitting that + * leg. + * + * Post-quote flows (e.g. Perps/Predict withdraw to Money Account) ask the + * client `getPaymentOverrideData` callback, which knows the Money Account + * address that cannot be derived from the request. Non-post-quote flows (e.g. + * max-amount Money Account deposit) use the parent transaction's own `from`, + * which is the Money Account rather than the funding EOA in `request.from`. + * + * Atomic flows return `undefined`: the provider executes the calls as part of + * the quote, so the funds must land on the account running them. + * + * @param transaction - Transaction metadata. + * @param request - Quote request. + * @param messenger - Controller messenger. + * @returns The recipient address, or `undefined` for atomic flows. + */ +export async function resolveNonAtomicRecipient( + transaction: TransactionMeta, + request: QuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + if (request.atomic !== false) { + return undefined; + } + + if (!request.isPostQuote) { + return transaction.txParams.from as Hex; + } + + const { transactionData: transactionDataList } = messenger.call( + 'TransactionPayController:getState', + ); + + const transactionData = transactionDataList[transaction.id]; + const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; + + const { recipient } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: amountHuman, + transaction, + transactionData, + }, + ); + + return recipient; +} + +/** + * Submits the second leg of a non-atomic flow: the calls that the quote could + * not execute itself, run on the target chain once the first leg has settled. + * + * The single entrypoint for every strategy. The caller decides the chain, the + * submitting account, the calls, and whether gas is sponsored, while + * transaction-type-specific handling (e.g. {@link withChompRecovery}) is + * applied here so it holds whichever strategy settled the funds. + * + * The amount spent is read from the settlement transaction's transfer logs + * rather than trusted from a quote or provider, since slippage and fees mean + * the landed amount is only known after settlement. Throws when there is no + * settlement hash or no matching transfer, rather than guessing. + * + * @param options - Submit options. + * @param options.chainId - Chain the funds settled on and the batch is + * submitted on. + * @param options.from - Account that received the settled funds and submits + * the batch. + * @param options.getCalls - Builds the batch for the settled amount. Derived + * from the parent transaction's nested calls when omitted. + * @param options.messenger - Controller messenger. + * @param options.note - Note recorded against the parent transaction update. + * @param options.settlementHash - Hash of the transaction that delivered the + * funds to `from`. + * @param options.sponsored - Whether gas is sponsored. Defaults to `true`, + * since second legs run on chains where MetaMask sponsors gas; submission + * fails rather than silently charging the user when sponsorship is refused. + * @param options.tokenAddress - Token that settled. + * @param options.transaction - Parent transaction meta. + * @returns Hash of the final submitted child transaction, if available. + */ +export async function submitSecondLeg({ + chainId, + from, + getCalls, + messenger, + note, + settlementHash, + sponsored = true, + tokenAddress, + transaction, +}: { + chainId: Hex; + from: Hex; + getCalls?: SecondLegCallsBuilder; + messenger: TransactionPayControllerMessenger; + note?: string; + settlementHash: Hex | undefined; + sponsored?: boolean; + tokenAddress: Hex; + transaction: TransactionMeta; +}): Promise<{ transactionHash?: Hex }> { + const { amountRaw: sourceAmountRaw, fromBlock } = await resolveSettledAmount({ + chainId, + messenger, + recipient: from, + settlementHash, + tokenAddress, + }); + + // Resolved up front so the parent transaction reflects the settled amount + // even when type-specific handling short-circuits the submission. + const nestedTransactions = await resolveSecondLegCalls({ + getCalls, + messenger, + note, + sourceAmountRaw, + transaction, + }); + + try { + return await withChompRecovery( + { from, fromBlock, messenger, sourceAmountRaw, transaction }, + async () => + await submitBatch({ + chainId, + from, + messenger, + nestedTransactions, + sourceAmountRaw, + sponsored, + transaction, + }), + ); + } catch (error) { + throw prefixError(error, SECOND_LEG_ERROR_PREFIX); + } +} + +/** + * Resolves the calls to submit as the second leg. + * + * Callers with no calls on the parent transaction (e.g. withdraw flows) supply + * `getCalls` to build the batch for the settled amount. Otherwise the parent + * transaction's own nested calls are re-encoded for the settled amount via + * `getAmountData`, and the parent transaction is mutated so its stored calls + * and `requiredAssets` reflect that amount. + * + * @param options - Resolution options. + * @param options.getCalls - Builds the batch for the settled amount, used + * as-is when provided. + * @param options.messenger - Controller messenger. + * @param options.note - Note recorded against the parent transaction update. + * @param options.sourceAmountRaw - Settled amount in raw units. + * @param options.transaction - Parent transaction meta. + * @returns Nested transactions to submit as the second leg. + */ +async function resolveSecondLegCalls({ + getCalls, + messenger, + note = 'Second leg: update amount', + sourceAmountRaw, + transaction, +}: { + getCalls?: SecondLegCallsBuilder; + messenger: TransactionPayControllerMessenger; + note?: string; + sourceAmountRaw: string; + transaction: TransactionMeta; +}): Promise { + if (getCalls) { + const calls = await getCalls(sourceAmountRaw); + + if (!calls.length) { + throw new Error('Missing second leg calls'); + } + + return calls; + } + + const transactionId = transaction.id; + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount: sourceAmountRaw, + transaction: updatedTransaction, + }, + ); + + if (!updates.length) { + throw new Error('No amount updates'); + } + + const nestedTransactions = updatedTransaction.nestedTransactions?.map( + (nestedTransaction) => ({ ...nestedTransaction }), + ); + + if (!nestedTransactions?.length) { + throw new Error('Missing nested transactions'); + } + + for (const { nestedTransactionIndex, data } of updates) { + if (nestedTransactions[nestedTransactionIndex]) { + nestedTransactions[nestedTransactionIndex].data = data; + } + } + + updateTransaction({ transactionId, messenger, note }, (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString(16)}`; + } + }); + + return nestedTransactions; +} + +/** + * Resolves the amount that settled on `recipient`, read from the settlement + * transaction's transfer logs, along with the block it settled in. + * + * @param options - Resolution options. + * @param options.chainId - Chain the funds settled on. + * @param options.messenger - Controller messenger. + * @param options.recipient - Account that received the settled funds. + * @param options.settlementHash - Hash of the transaction that delivered the + * funds. + * @param options.tokenAddress - Token that settled. + * @returns The settled amount in raw units, and the settlement block when + * available. + */ +async function resolveSettledAmount({ + chainId, + messenger, + recipient, + settlementHash, + tokenAddress, +}: { + chainId: Hex; + messenger: TransactionPayControllerMessenger; + recipient: Hex; + settlementHash: Hex | undefined; + tokenAddress: Hex; +}): Promise<{ amountRaw: string; fromBlock: Hex | undefined }> { + if (!settlementHash) { + throw new Error('Missing settlement hash'); + } + + const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ + chainId, + messenger, + tokenAddress, + txHash: settlementHash, + walletAddress: recipient, + }); + + if (!amountRaw) { + throw new Error( + `Could not determine settled amount from transaction ${settlementHash}`, + ); + } + + log('Resolved settled amount', { + amountRaw, + blockNumber, + chainId, + recipient, + settlementHash, + }); + + return { amountRaw, fromBlock: blockNumber }; +} + +/** + * Submits the second-leg batch and waits for every child transaction to + * confirm. + * + * @param options - Submit options. + * @param options.chainId - Chain to submit the batch on. + * @param options.from - Account submitting the batch. + * @param options.messenger - Controller messenger. + * @param options.nestedTransactions - Calls to submit. + * @param options.sourceAmountRaw - Settled amount in raw units, for logging. + * @param options.sponsored - Whether gas is sponsored. + * @param options.transaction - Parent transaction meta. + * @returns Hash of the final submitted child transaction. + */ +async function submitBatch({ + chainId, + from, + messenger, + nestedTransactions, + sourceAmountRaw, + sponsored, + transaction, +}: { + chainId: Hex; + from: Hex; + messenger: TransactionPayControllerMessenger; + nestedTransactions: NestedTransactionMetadata[]; + sourceAmountRaw: string; + sponsored: boolean; + transaction: TransactionMeta; +}): Promise<{ transactionHash: Hex }> { + const transactionId = transaction.id; + const networkClientId = getNetworkClientId(messenger, chainId); + const transactionIds: string[] = []; + + const { end } = collectTransactionIds(chainId, from, messenger, (id) => { + transactionIds.push(id); + + updateTransaction( + { + transactionId, + messenger, + note: 'Add required transaction ID from second leg submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + }); + + const logContext = { + chainId, + from, + nestedTransactionCount: nestedTransactions.length, + networkClientId, + sourceAmountRaw, + transactionId, + }; + + log('Submitting second leg', logContext); + + try { + await messenger.call('TransactionController:addTransactionBatch', { + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from, + isGasFeeSponsored: sponsored, + isInternal: true, + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + skipInitialGasEstimate: true, + transactions: nestedTransactions.map((nestedTransaction, index) => ({ + params: { + data: nestedTransaction.data, + to: nestedTransaction.to, + value: nestedTransaction.value ?? '0x0', + }, + type: + index === 0 + ? (nestedTransaction.type ?? TransactionType.tokenMethodApprove) + : TransactionType.contractInteraction, + })), + }); + } finally { + end(); + } + + log('Submitted second leg', { ...logContext, transactionIds }); + + if (!transactionIds.length) { + throw new Error('No transactions submitted'); + } + + await Promise.all( + transactionIds.map((id) => waitForTransactionConfirmed(id, messenger)), + ); + + const hash = getTransaction(transactionIds.slice(-1)[0], messenger)?.hash; + + if (!hash) { + throw new Error('Missing transaction hash'); + } + + log('Confirmed second leg', { ...logContext, hash, transactionIds }); + + return { transactionHash: hash as Hex }; +} diff --git a/tsc-suppressions.json b/tsc-suppressions.json index 7ef9e39345c..f71604a254c 100644 --- a/tsc-suppressions.json +++ b/tsc-suppressions.json @@ -1190,7 +1190,7 @@ "count": 2 }, "TS2741": { - "count": 168 + "count": 167 } }, "packages/transaction-pay-controller/src/strategy/relay/relay-submit-execute.test.ts": { @@ -1226,11 +1226,14 @@ } }, "packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts": { + "TS2345": { + "count": 1 + }, "TS2352": { - "count": 2 + "count": 7 }, "TS2741": { - "count": 39 + "count": 56 } }, "packages/transaction-pay-controller/src/strategy/server/server-submit.test.ts": { @@ -1247,11 +1250,6 @@ "count": 6 } }, - "packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts": { - "TS2352": { - "count": 1 - } - }, "packages/transaction-pay-controller/src/utils/provider.test.ts": { "TS2353": { "count": 1 @@ -1260,6 +1258,11 @@ "count": 1 } }, + "packages/transaction-pay-controller/src/utils/second-leg.test.ts": { + "TS2352": { + "count": 1 + } + }, "packages/transaction-pay-controller/src/utils/validation.test.ts": { "TS2322": { "count": 1