diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4d1e2c817a..f1710ec782 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add generic signature steps to the server pay strategy, supporting EIP-712 sign-then-POST flows ([#9051](https://github.com/MetaMask/core/pull/9051)) + - Trigger quote refresh when `txParams.to` or `requiredAssets` changes on a transaction, in addition to the existing `txParams.data` trigger - Make fiat order polling interval and timeout remotely configurable via `confirmations_pay_fiat.orderPollIntervalMs` and `confirmations_pay_fiat.orderPollTimeoutMs` feature flags ([#9090](https://github.com/MetaMask/core/pull/9090)) ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts index 118401aa3e..facb01c49e 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.test.ts @@ -141,5 +141,25 @@ describe('strategy/server/perps', () => { expect(result).toBe(baseRequest); }); + + it('rewrites source chain, token, and amount when isHyperliquidSource is true', () => { + const withdrawRequest: QuoteRequest = { + ...baseRequest, + isHyperliquidSource: true, + sourceChainId: '0xa4b1', + sourceTokenAmount: '100000000', + }; + + const result = normalizeServerPerpsRequest( + withdrawRequest, + innocuousTransaction, + ); + + expect(result.sourceChainId).toBe(CHAIN_ID_HYPERCORE); + expect(result.sourceTokenAddress).toBe( + SERVER_HYPERCORE_USDC_PERPS_ADDRESS, + ); + expect(result.sourceTokenAmount).toBe('1000000'); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/server/perps.ts b/packages/transaction-pay-controller/src/strategy/server/perps.ts index 90cb6332be..d4f71fe730 100644 --- a/packages/transaction-pay-controller/src/strategy/server/perps.ts +++ b/packages/transaction-pay-controller/src/strategy/server/perps.ts @@ -65,14 +65,22 @@ export function isServerPerpsDepositRequest( * 6 decimals); HyperCore expects an 8-decimal amount, so the target amount is * shifted accordingly. * + * Also handles the perps-withdraw direction: when `request.isHyperliquidSource` + * is set the source is rewritten to the HyperCore sentinel and the amount is + * shifted from 8 to 6 decimals. + * * @param request - Quote request from the transaction-pay controller. * @param transaction - Parent transaction whose calldata is inspected. - * @returns Normalized request, or the original request if not a perps deposit. + * @returns Normalized request, or the original request if not a perps flow. */ export function normalizeServerPerpsRequest( request: QuoteRequest, transaction: Pick, ): QuoteRequest { + if (request.isHyperliquidSource) { + return normalizePerpsWithdrawRequest(request); + } + if (!isServerPerpsDepositRequest(request, transaction)) { return request; } @@ -87,6 +95,17 @@ export function normalizeServerPerpsRequest( }; } +function normalizePerpsWithdrawRequest(request: QuoteRequest): QuoteRequest { + return { + ...request, + sourceChainId: CHAIN_ID_HYPERCORE, + sourceTokenAddress: SERVER_HYPERCORE_USDC_PERPS_ADDRESS, + sourceTokenAmount: new BigNumber(request.sourceTokenAmount) + .shiftedBy(USDC_DECIMALS - HYPERCORE_USDC_DECIMALS) + .toFixed(0), + }; +} + function transactionDataReferencesBridge( transaction: Pick, ): boolean { 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 a838fe3563..26ce8dc7f6 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 @@ -1,7 +1,11 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { CHAIN_ID_HYPERCORE, TransactionPayStrategy } from '../../constants'; +import { + CHAIN_ID_HYPERCORE, + PaymentOverride, + TransactionPayStrategy, +} from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { GetDelegationTransactionCallback, @@ -90,6 +94,7 @@ const FULFILLED_RESULT_MOCK = { }, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, @@ -124,7 +129,12 @@ describe('server-quotes', () => { const fetchServerQuoteMock = jest.mocked(fetchServerQuote); const getSlippageMock = jest.mocked(getSlippage); const isEIP7702ChainMock = jest.mocked(isEIP7702Chain); - const { getDelegationTransactionMock, messenger } = getMessengerMock(); + const { + getControllerStateMock, + getDelegationTransactionMock, + getPaymentOverrideDataMock, + messenger, + } = getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); @@ -497,6 +507,7 @@ describe('server-quotes', () => { gasless: false, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, maxFeePerGas: '0x1', @@ -627,6 +638,7 @@ describe('server-quotes', () => { ...NON_GASLESS_RESULT_MOCK.quote, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, @@ -685,6 +697,7 @@ describe('server-quotes', () => { ...NON_GASLESS_RESULT_MOCK.quote, steps: [ { + type: 'transaction' as const, chainId: 1, data: '0xdef' as Hex, to: '0x4560000000000000000000000000000000000000' as Hex, @@ -711,5 +724,249 @@ describe('server-quotes', () => { }), ); }); + + it('zeroes source network fees when quote has no steps and is not gasless', async () => { + fetchServerQuoteMock.mockResolvedValue({ + results: [ + { + ...FULFILLED_RESULT_MOCK, + quote: { + ...FULFILLED_RESULT_MOCK.quote, + gasless: false, + steps: [], + }, + }, + ], + }); + + const result = await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: TRANSACTION_META_MOCK, + }); + + expect(result[0].fees.sourceNetwork.estimate).toStrictEqual( + expect.objectContaining({ raw: '0' }), + ); + }); + }); + + describe('processMoneyAccountPostQuote', () => { + const OVERRIDE_CALL_MOCK = { + data: '0xoverride' as Hex, + to: '0xcccc000000000000000000000000000000000000' as Hex, + value: '0x0' as Hex, + }; + + beforeEach(() => { + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_META_MOCK.id]: { + tokens: [{ amountHuman: '1.5' }], + }, + }, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: undefined, + } as never); + }); + + it('adds override calls and transfer call to server quote body when isPostQuote + MoneyAccount', async () => { + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + calls: expect.arrayContaining([ + expect.objectContaining({ to: OVERRIDE_CALL_MOCK.to }), + ]), + }), + undefined, + ); + }); + + it('falls back to request.from as recipient when getPaymentOverrideData returns no recipient', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: undefined, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + 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, + }), + ]), + }), + undefined, + ); + }); + + it('attaches authorizationList when getPaymentOverrideData returns one', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [OVERRIDE_CALL_MOCK], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: [ + { + address: '0xaaaa000000000000000000000000000000000000' as Hex, + chainId: '0x1' as Hex, + nonce: '0x1' as Hex, + r: '0xr' as Hex, + s: '0xs' as Hex, + yParity: '0x1' as Hex, + }, + ], + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + authorizationList: expect.arrayContaining([ + expect.objectContaining({ + address: '0xaaaa000000000000000000000000000000000000', + }), + ]), + }), + undefined, + ); + }); + + it('falls back to 0 amount when transactionData has no tokens', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith( + expect.objectContaining({ amount: '0' }), + ); + }); + + it('defaults override call value to 0x0 when call.value is undefined', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [ + { + to: '0xcccc000000000000000000000000000000000000' as Hex, + data: '0xdata' as Hex, + }, + ], + recipient: TOKEN_TRANSFER_RECIPIENT_MOCK, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + calls: expect.arrayContaining([ + expect.objectContaining({ + to: '0xcccc000000000000000000000000000000000000', + value: '0x0', + }), + ]), + }), + undefined, + ); + }); + + it('skips override when getPaymentOverrideData returns empty calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + recipient: undefined, + authorizationList: undefined, + } as never); + + await getServerQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + isPostQuote: true, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(fetchServerQuoteMock).toHaveBeenCalledWith( + messenger, + expect.not.objectContaining({ calls: 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 7bb2d527d4..3b71e75d6d 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -1,11 +1,18 @@ import { Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + AuthorizationList, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; -import { CHAIN_ID_HYPERCORE, TransactionPayStrategy } from '../../constants'; +import { + CHAIN_ID_HYPERCORE, + PaymentOverride, + TransactionPayStrategy, +} from '../../constants'; import { projectLogger } from '../../logger'; import type { PayStrategyGetQuotesRequest, @@ -38,7 +45,7 @@ import type { ServerQuote, ServerQuoteRequest, ServerQuoteResult, - ServerQuoteStep, + ServerTransactionStep, } from './types'; import { ServerTradeType } from './types'; @@ -65,6 +72,12 @@ type SourceNetworkCost = Pick< maxPriorityFeePerGas: string | undefined; }; +function isTransactionStep( + step: ServerQuote['steps'][number], +): step is ServerTransactionStep { + return step.type === 'transaction'; +} + /** * Fetch server intents-api quotes and normalize them into Transaction Pay quotes. * @@ -138,6 +151,7 @@ async function buildServerQuoteRequest( from, isMaxAmount, isPostQuote, + paymentOverride, sourceChainId, sourceTokenAddress, sourceTokenAmount, @@ -146,7 +160,10 @@ async function buildServerQuoteRequest( targetTokenAddress, } = normalizedRequest; - const useExactInput = (isMaxAmount ?? false) || (isPostQuote ?? false); + const useExactInput = + (isMaxAmount ?? false) || + (isPostQuote ?? false) || + Boolean(normalizedRequest.isHyperliquidSource); const singleData = getSingleTransactionData(transaction); const isHypercore = targetChainId === CHAIN_ID_HYPERCORE; const isTokenTransfer = @@ -158,8 +175,11 @@ async function buildServerQuoteRequest( recipient = decodeTransferRecipient(singleData); } + const isHypercoreSource = sourceChainId === CHAIN_ID_HYPERCORE; const supportsGasless = - accountSupports7702 && isEIP7702Chain(messenger, sourceChainId); + !isHypercoreSource && + accountSupports7702 && + isEIP7702Chain(messenger, sourceChainId); const body: ServerQuoteRequest = { source: { chainId: Number(sourceChainId), token: sourceTokenAddress }, @@ -181,10 +201,18 @@ async function buildServerQuoteRequest( hasNoData || isTokenTransfer || isHypercore || + isHypercoreSource || (isPostQuote ?? false) || (isMaxAmount ?? false); - if (!skipDelegation) { + if (isPostQuote && paymentOverride === PaymentOverride.MoneyAccount) { + await processMoneyAccountPostQuote( + transaction, + normalizedRequest, + body, + messenger, + ); + } else if (!skipDelegation) { const delegation = await messenger.call( 'TransactionPayController:getDelegationTransaction', { transaction }, @@ -204,25 +232,89 @@ async function buildServerQuoteRequest( ]; if (delegation.authorizationList?.length) { - body.authorizationList = delegation.authorizationList.map((entry) => ({ - address: entry.address, - chainId: Number(entry.chainId), - nonce: Number(entry.nonce), - r: entry.r as Hex, - s: entry.s as Hex, - yParity: Number(entry.yParity), - })); + body.authorizationList = normalizeAuthorizationList( + delegation.authorizationList, + ); } } return body; } +function normalizeAuthorizationList( + authorizationList: AuthorizationList, +): NonNullable { + return authorizationList.map((entry) => ({ + address: entry.address, + chainId: Number(entry.chainId), + nonce: Number(entry.nonce), + r: entry.r as Hex, + s: entry.s as Hex, + yParity: Number(entry.yParity), + })); +} + +async function processMoneyAccountPostQuote( + transaction: TransactionMeta, + request: QuoteRequest, + body: ServerQuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { transactionData: transactionDataList } = messenger.call( + 'TransactionPayController:getState', + ); + + const transactionData = transactionDataList[transaction.id]; + const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; + + const { + calls: overrideCalls, + recipient, + authorizationList, + } = await messenger.call('TransactionPayController:getPaymentOverrideData', { + amount: amountHuman, + transaction, + transactionData, + }); + + if (!overrideCalls.length) { + log('No payment override calls for money account post-quote'); + return; + } + + const fundingRecipient = recipient ?? request.from; + + body.tradeType = ServerTradeType.ExactInput; + body.amount = request.sourceTokenAmount; + + body.calls = [ + { + data: buildTransferData(fundingRecipient, request.sourceTokenAmount), + to: request.targetTokenAddress, + value: '0x0', + }, + ...overrideCalls.map((call) => ({ + data: call.data as Hex, + to: call.to as Hex, + value: call.value ?? '0x0', + })), + ]; + + if (authorizationList?.length) { + body.authorizationList = normalizeAuthorizationList(authorizationList); + } + + log('Added money account post-quote calls to server quote body', { + callCount: overrideCalls.length, + }); +} + function shouldRequestQuote(quoteRequest: QuoteRequest): boolean { return ( quoteRequest.targetAmountMinimum !== '0' || Boolean(quoteRequest.isPostQuote) || - Boolean(quoteRequest.isMaxAmount) + Boolean(quoteRequest.isMaxAmount) || + Boolean(quoteRequest.isHyperliquidSource) ); } @@ -233,11 +325,13 @@ async function normalizeQuote( ): Promise> { const { quote } = result; const { gasless } = quote; + const transactionSteps = quote.steps.filter(isTransactionStep); + const isSignatureOnly = transactionSteps.length === 0; const sourceNetwork = await calculateSourceNetworkCost({ - gasless, + gasless: gasless || isSignatureOnly, messenger, quoteRequest, - steps: quote.steps, + steps: transactionSteps, }); const sourceFiatRate = getTokenFiatRate( @@ -323,7 +417,7 @@ async function calculateSourceNetworkCost({ gasless: boolean; messenger: TransactionPayControllerMessenger; quoteRequest: QuoteRequest; - steps: ServerQuoteStep[]; + steps: ServerTransactionStep[]; }): Promise { const noFees = { estimate: ZERO_AMOUNT, @@ -339,11 +433,6 @@ async function calculateSourceNetworkCost({ return noFees; } - if (steps.length === 0) { - log('No quote steps; zeroing source network fees'); - return noFees; - } - const { from, sourceChainId, sourceTokenAddress } = quoteRequest; const firstStep = steps[0]; const chainIdHex = toHex(firstStep.chainId); @@ -452,7 +541,7 @@ async function calculateSourceNetworkCost({ } function stepToGasTransaction( - step: ServerQuoteStep, + step: ServerTransactionStep, from: Hex, ): QuoteGasTransaction { return { 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 a1991fdb21..4c129d43ba 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 @@ -1,8 +1,10 @@ +import { successfulFetch } from '@metamask/controller-utils'; import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { cloneDeep } from 'lodash'; +import { PaymentOverride } from '../../constants'; import { getMessengerMock } from '../../tests/messenger-mock'; import type { PayStrategyExecuteRequest, @@ -12,6 +14,7 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags'; +import { getLiveTokenBalance } from '../../utils/token'; import { collectTransactionIds, getTransaction, @@ -20,9 +23,17 @@ import { import { getServerStatus, submitServerIntent } from './server-api'; import { submitServerQuotes } from './server-submit'; import { ServerProviderName, ServerStatus } from './types'; -import type { ServerQuote } from './types'; +import type { ServerQuote, ServerSignatureStep } from './types'; +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); jest.mock('../../utils/feature-flags'); +jest.mock('../../utils/token', () => ({ + ...jest.requireActual('../../utils/token'), + getLiveTokenBalance: jest.fn(), +})); jest.mock('../../utils/transaction', () => ({ ...jest.requireActual('../../utils/transaction'), collectTransactionIds: jest.fn(), @@ -61,14 +72,27 @@ const ORIGINAL_QUOTE_MOCK: ServerQuote = { maxPriorityFeePerGas: '500000000', }, duration: 30, + fees: { metamask: '0', provider: '0', subsidized: false }, gasless: true, id: 'server-intent-id', - input: { formatted: '1.23', raw: '1230000' }, - output: { formatted: '1', raw: '1000000' }, + input: { + chainId: 137, + decimals: 6, + formatted: '1.23', + raw: '1230000', + token: '0x5555555555555555555555555555555555555555' as Hex, + }, + output: { + chainId: 1, + decimals: 6, + formatted: '1', + raw: '1000000', + token: '0x6666666666666666666666666666666666666666' as Hex, + }, provider: ServerProviderName.Relay, - providerFeeUsd: '0.01', steps: [ { + type: 'transaction' as const, chainId: 137, data: '0xstepdata' as Hex, to: '0x4444444444444444444444444444444444444444' as Hex, @@ -122,6 +146,7 @@ const DELEGATION_MOCK = { }; describe('submitServerQuotes', () => { + const getLiveTokenBalanceMock = jest.mocked(getLiveTokenBalance); const getServerPollingIntervalMock = jest.mocked(getServerPollingInterval); const getServerPollingTimeoutMock = jest.mocked(getServerPollingTimeout); const getServerStatusMock = jest.mocked(getServerStatus); @@ -131,7 +156,9 @@ describe('submitServerQuotes', () => { addTransactionMock: addTxMock, addTransactionBatchMock: addTxBatchMock, findNetworkClientIdByChainIdMock, + getControllerStateMock, getDelegationTransactionMock, + getPaymentOverrideDataMock, getTransactionControllerStateMock, messenger, updateTransactionMock, @@ -154,6 +181,7 @@ describe('submitServerQuotes', () => { findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); getDelegationTransactionMock.mockResolvedValue(DELEGATION_MOCK); + getLiveTokenBalanceMock.mockResolvedValue('999999999999999999'); getServerPollingIntervalMock.mockReturnValue(0); getServerPollingTimeoutMock.mockReturnValue(undefined); getServerStatusMock.mockResolvedValue({ @@ -365,6 +393,38 @@ describe('submitServerQuotes', () => { expect(addTxBatchMock).not.toHaveBeenCalled(); }); + it('falls back to 0x / 0x0 for undefined data and value in override calls', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + transactions: [], + } as never); + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [{ to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex }], + recipient: undefined, + authorizationList: undefined, + } as never); + + const req = { + ...request, + quotes: [ + { + ...cloneDeep(QUOTE_MOCK), + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }, + ], + }; + + await submitServerQuotes(req); + + expect(submitServerIntentMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ data: DELEGATION_MOCK.data }), + ); + }); + it('continues polling when getServerStatus throws a network error', async () => { getServerStatusMock .mockRejectedValueOnce(new Error('network error')) @@ -435,6 +495,7 @@ describe('submitServerQuotes', () => { steps: [ ORIGINAL_QUOTE_MOCK.steps[0], { + type: 'transaction' as const, chainId: 137, data: '0xseconddata' as Hex, to: '0x9999999999999999999999999999999999999999' as Hex, @@ -591,6 +652,7 @@ describe('submitServerQuotes', () => { steps: [ ORIGINAL_QUOTE_MOCK.steps[0], { + type: 'transaction' as const, chainId: 137, data: '0xseconddata' as Hex, to: '0x9999999999999999999999999999999999999999' as Hex, @@ -609,4 +671,406 @@ describe('submitServerQuotes', () => { ); }); }); + + describe('signature steps', () => { + const SIGNATURE_MOCK = `0x${'a'.repeat(64)}${'b'.repeat(64)}1c`; + const SIGNATURE_STEP_MOCK: ServerSignatureStep = { + type: 'signature' as const, + sign: { + domain: { name: 'Test', chainId: 137 }, + types: { Order: [{ name: 'amount', type: 'uint256' }] }, + primaryType: 'Order', + value: { amount: '1000' }, + }, + post: { + endpoint: 'https://api.example.com/sign', + method: 'POST', + body: { orderId: 'abc' }, + signatureFormat: 'queryParam' as const, + }, + }; + + const signTypedMessageMock = jest.fn(); + const successfulFetchMock = jest.mocked(successfulFetch); + + // Register the handler once — can't re-register after the first test. + // The outer beforeEach resets mock implementations so we re-apply in + // our own beforeEach which runs after the outer one. + beforeAll(() => { + messenger.registerActionHandler( + 'KeyringController:signTypedMessage' as never, + signTypedMessageMock as never, + ); + }); + + beforeEach(() => { + signTypedMessageMock.mockResolvedValue(SIGNATURE_MOCK); + successfulFetchMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ success: true }), + } as never); + }); + + const buildSignatureRequest = ( + overrides: Partial = {}, + ): PayStrategyExecuteRequest => { + const quote = cloneDeep(QUOTE_MOCK); + quote.original = { + ...quote.original, + steps: [SIGNATURE_STEP_MOCK], + gasless: true, + ...overrides, + }; + return { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + }; + + it('signs and POSTs signature step with queryParam format', async () => { + await submitServerQuotes(buildSignatureRequest()); + + expect(successfulFetchMock).toHaveBeenCalledWith( + `${SIGNATURE_STEP_MOCK.post.endpoint}?signature=${SIGNATURE_MOCK}`, + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('signs and POSTs signature step with rsv format', async () => { + successfulFetchMock.mockResolvedValue({ + json: jest.fn().mockResolvedValue({ status: 'ok' }), + } as never); + + const rsvStep: ServerSignatureStep = { + ...SIGNATURE_STEP_MOCK, + post: { ...SIGNATURE_STEP_MOCK.post, signatureFormat: 'rsv' as const }, + }; + + await submitServerQuotes(buildSignatureRequest({ steps: [rsvStep] })); + + expect(successfulFetchMock).toHaveBeenCalledWith( + SIGNATURE_STEP_MOCK.post.endpoint, + expect.objectContaining({ + body: expect.stringContaining('"signature"'), + }), + ); + }); + + it('throws when rsv POST response status is not ok', async () => { + successfulFetchMock.mockResolvedValue({ + json: jest + .fn() + .mockResolvedValue({ status: 'error', message: 'rejected' }), + } as never); + + const rsvStep: ServerSignatureStep = { + ...SIGNATURE_STEP_MOCK, + post: { ...SIGNATURE_STEP_MOCK.post, signatureFormat: 'rsv' as const }, + }; + + await expect( + submitServerQuotes(buildSignatureRequest({ steps: [rsvStep] })), + ).rejects.toThrow('Signature step rejected by server'); + }); + + it('throws when signature POST fails', async () => { + successfulFetchMock.mockRejectedValue(new Error('network error')); + + await expect(submitServerQuotes(buildSignatureRequest())).rejects.toThrow( + 'Signature step POST failed: network error', + ); + }); + + it('no-ops transaction phase when gasless quote has only signature steps', async () => { + await submitServerQuotes(buildSignatureRequest()); + + expect(addTxMock).not.toHaveBeenCalled(); + expect(addTxBatchMock).not.toHaveBeenCalled(); + expect(submitServerIntentMock).not.toHaveBeenCalled(); + expect(getServerStatusMock).toHaveBeenCalled(); + }); + + it('does not throw for a signature-only non-gasless quote (no transaction steps)', async () => { + const quote = cloneDeep(QUOTE_MOCK); + quote.original.steps = [SIGNATURE_STEP_MOCK]; + quote.original.gasless = false; + + successfulFetchMock.mockResolvedValue({ + json: async () => ({}), + ok: true, + } as Response); + + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).resolves.not.toThrow(); + }); + }); + + describe('validateSourceBalance', () => { + it('throws when source balance is insufficient', async () => { + jest.mocked(getLiveTokenBalance).mockResolvedValue('0'); + + const quote = cloneDeep(QUOTE_MOCK); + quote.original.gasless = false; + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).rejects.toThrow( + 'Insufficient source token balance', + ); + }); + + it('wraps getLiveTokenBalance errors with context message', async () => { + jest + .mocked(getLiveTokenBalance) + .mockRejectedValue(new Error('RPC timeout')); + + const quote = cloneDeep(QUOTE_MOCK); + quote.original.gasless = false; + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + + await expect(submitServerQuotes(req)).rejects.toThrow( + 'Cannot validate payment token balance - RPC timeout', + ); + }); + }); + + describe('buildTransactionParams', () => { + const buildRequest = ( + overrides: Partial> = {}, + ): PayStrategyExecuteRequest => { + const quote = { ...cloneDeep(QUOTE_MOCK), ...overrides }; + quote.original.gasless = false; + return { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [quote], + transaction: cloneDeep(TRANSACTION_META_MOCK), + }; + }; + + beforeEach(() => { + jest + .mocked(collectTransactionIds) + .mockImplementation((_chainId, _from, _messenger, onTransactionId) => { + onTransactionId('submitted-tx-id'); + return { end: jest.fn() }; + }); + jest.mocked(getTransaction).mockReturnValue({ + hash: '0xsubmitted' as Hex, + } as TransactionMeta); + jest.mocked(waitForTransactionConfirmed).mockResolvedValue(undefined); + addTxMock.mockResolvedValue({ + result: Promise.resolve('0xsubmitted' as Hex), + } as never); + getControllerStateMock.mockReturnValue({ + transactionData: {}, + transactions: [], + } as never); + }); + + it('skips prepend when paymentOverride returns empty calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + recipient: undefined, + authorizationList: undefined, + } as never); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }), + ); + + // No calls prepended — relay step submitted directly via TransactionController + expect(addTxMock).toHaveBeenCalled(); + }); + + it('prepends payment override calls before relay steps', async () => { + const overrideCall = { + data: '0xoverridedata' as Hex, + to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex, + value: '0x0' as Hex, + }; + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [overrideCall], + recipient: undefined, + authorizationList: undefined, + } as never); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + paymentOverride: PaymentOverride.MoneyAccount, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ to: overrideCall.to }), + }), + ]), + }), + ); + }); + + it('prepends original tx before relay steps in post-quote flow (no account override)', async () => { + const transaction = cloneDeep(TRANSACTION_META_MOCK); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: ORIGINAL_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + gasless: false, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + to: transaction.txParams.to, + maxFeePerGas: expect.any(String), + maxPriorityFeePerGas: expect.any(String), + }), + }), + ]), + }), + ); + }); + + it('prepends original tx with undefined fee caps when client has no fee estimates', async () => { + const transaction = cloneDeep(TRANSACTION_META_MOCK); + + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: ORIGINAL_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + client: { + ...ORIGINAL_QUOTE_MOCK.client, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }, + gasless: false, + }, + }), + ); + + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + to: transaction.txParams.to, + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + }), + }), + ]), + }), + ); + }); + + it('prepends delegation tx before relay steps in post-quote flow with account override', async () => { + await submitServerQuotes( + buildRequest({ + request: { + ...QUOTE_MOCK.request, + from: QUOTE_FROM_MOCK, + isPostQuote: true, + }, + original: { + ...ORIGINAL_QUOTE_MOCK, + gasless: false, + }, + }), + ); + + expect(getDelegationTransactionMock).toHaveBeenCalled(); + expect(addTxBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + transactions: expect.arrayContaining([ + expect.objectContaining({ + params: expect.objectContaining({ + to: DELEGATION_MOCK.to, + maxFeePerGas: expect.any(String), + maxPriorityFeePerGas: expect.any(String), + }), + }), + ]), + }), + ); + }); + + it('resolves effective type through batch transaction to nested perps type', async () => { + const batchTransaction: TransactionMeta = { + ...TRANSACTION_META_MOCK, + type: 'batch' as never, + nestedTransactions: [ + { + type: 'relayPerpsDeposit' as never, + to: '0x1111111111111111111111111111111111111111' as Hex, + data: '0x', + }, + ], + }; + + const req: PayStrategyExecuteRequest = { + accountSupports7702: true, + isSmartTransaction: (): boolean => false, + messenger, + quotes: [ + { + ...cloneDeep(QUOTE_MOCK), + original: { ...ORIGINAL_QUOTE_MOCK, gasless: false }, + }, + ], + transaction: batchTransaction, + }; + + await submitServerQuotes(req); + + expect(addTxMock).toHaveBeenCalled(); + }); + }); }); 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 2407cb4c75..973e63facf 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-submit.ts @@ -1,7 +1,13 @@ -import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; +import { + ORIGIN_METAMASK, + successfulFetch, + toHex, +} from '@metamask/controller-utils'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; import type { TransactionMeta, TransactionParams, + TransactionType, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -17,16 +23,24 @@ import { getServerPollingInterval, getServerPollingTimeout, } from '../../utils/feature-flags'; +import { getNetworkClientId } from '../../utils/provider'; +import { + getLiveTokenBalance, + normalizeTokenAddress, + TokenAddressTarget, +} from '../../utils/token'; import { collectTransactionIds, getTransaction, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; +import { RELAY_DEPOSIT_TYPES } from '../relay/constants'; import { getServerStatus, submitServerIntent } from './server-api'; import type { ServerQuote, - ServerQuoteStep, + ServerSignatureStep, + ServerTransactionStep, ServerStatusResponse, ServerSubmitRequest, } from './types'; @@ -34,6 +48,26 @@ import { ServerStatus } from './types'; const log = createModuleLogger(projectLogger, 'server-strategy'); +const DOMAIN_FIELD_MAP: Record = { + name: { name: 'name', type: 'string' }, + version: { name: 'version', type: 'string' }, + chainId: { name: 'chainId', type: 'uint256' }, + verifyingContract: { name: 'verifyingContract', type: 'address' }, + salt: { name: 'salt', type: 'bytes32' }, +}; + +function isSignatureStep( + step: ServerQuote['steps'][number], +): step is ServerSignatureStep { + return step.type === 'signature'; +} + +function isTransactionStep( + step: ServerQuote['steps'][number], +): step is ServerTransactionStep { + return step.type === 'transaction'; +} + /** * Submits server intent quotes. * @@ -78,12 +112,18 @@ async function executeSingleServerQuote( }, ); - if (quote.original.gasless) { - await submitViaServerExecute(quote, messenger, transaction); - } else { - await submitViaTransactionController(quote, messenger, transaction); + // Phase 1: off-chain signature steps (e.g. Relay authorize, HyperLiquid deposit). + // Use quote.request.from (resolved accountOverride) not transaction.txParams.from. + const signatureSteps = quote.original.steps.filter(isSignatureStep); + + for (const step of signatureSteps) { + await submitSignatureStep(step, quote.request.from, messenger); } + // Phase 2: on-chain transaction steps (if any). + await submitTransactionSteps(quote, messenger, transaction); + + // Phase 3: poll until the intent is confirmed on the target chain. const targetHash = await waitForServerCompletion( quote.original, messenger, @@ -106,21 +146,446 @@ async function executeSingleServerQuote( return { transactionHash: targetHash }; } -async function submitViaServerExecute( +/** + * Submit the on-chain transaction steps for a server quote. + * + * Validates the source balance, builds the complete set of params (including + * any post-quote or payment-override prepends), then dispatches to the + * gasless execute path or TransactionController depending on the quote. + * + * No-ops when the quote has no transaction steps (signature-only flows). + * + * @param quote - Server quote. + * @param messenger - Controller messenger. + * @param transaction - Original transaction meta. + */ +async function submitTransactionSteps( quote: TransactionPayQuote, messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, ): Promise { - const { from, sourceChainId } = quote.request; - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', - sourceChainId, + const transactionSteps = quote.original.steps.filter(isTransactionStep); + + if (transactionSteps.length === 0) { + // Signature-only quotes (all steps are signature steps, no transaction + // steps) have nothing to submit on-chain even when gasless is false. + const hasSignatureSteps = quote.original.steps.some(isSignatureStep); + if (!quote.original.gasless && !hasSignatureSteps) { + throw new Error('Server quote has no steps to submit'); + } + return; + } + + const { isHyperliquidSource, isPostQuote, paymentOverride } = quote.request; + + // Skip balance check for HyperLiquid source flows (no on-chain debit), + // post-quote flows (funds come from the Safe after the original tx executes), + // and payment-override flows (funds are supplied by the override account). + if (!isHyperliquidSource && !isPostQuote && !paymentOverride) { + await validateSourceBalance(quote, messenger); + } + + const allParams = await buildTransactionParams( + quote, + transactionSteps, + transaction, + messenger, ); - const nestedTransactions = quote.original.steps.map((step) => ({ + if (quote.original.gasless) { + await submitViaServerExecute(quote, allParams, messenger, transaction); + } else { + await submitViaTransactionController( + quote, + allParams, + messenger, + transaction, + ); + } +} + +/** + * Build the complete flat array of TransactionParams for on-chain submission. + * + * Converts server transaction steps to params, then prepends any additional + * calls required by the payment override or post-quote flow. The returned + * array is ready to pass directly to submitViaServerExecute or + * submitViaTransactionController. + * + * @param quote - Server quote. + * @param transactionSteps - Transaction steps from the quote (pre-filtered). + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Complete ordered array of TransactionParams for submission. + */ +async function buildTransactionParams( + quote: TransactionPayQuote, + transactionSteps: ServerTransactionStep[], + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const { from, isPostQuote, paymentOverride } = quote.request; + const { gasLimits, maxFeePerGas, maxPriorityFeePerGas } = + quote.original.client; + const originalType = getEffectiveTransactionType(transaction); + + const relayParams = transactionSteps.map((step, i) => + transactionStepToParams( + step, + i, + transactionSteps.length, + from, + gasLimits, + maxFeePerGas, + maxPriorityFeePerGas, + originalType, + ), + ); + + if (paymentOverride) { + return prependPaymentOverrideParams( + relayParams, + quote, + transaction, + messenger, + ); + } + + if (isPostQuote && transaction.txParams.to) { + return prependPostQuoteParams(relayParams, quote, transaction, messenger); + } + + return relayParams; +} + +/** + * Converts a single server transaction step to TransactionParams. + * + * @param step - The transaction step. + * @param index - Zero-based position within the transaction steps array. + * @param totalSteps - Total number of transaction steps (for type mapping). + * @param from - Sender address. + * @param gasLimits - Per-step gas limits from client-side estimation. + * @param clientMaxFeePerGas - Client-side max fee per gas fallback. + * @param clientMaxPriorityFeePerGas - Client-side max priority fee per gas fallback. + * @param originalType - Effective type of the parent transaction. + * @returns Normalized TransactionParams for this step. + */ +function transactionStepToParams( + step: ServerTransactionStep, + index: number, + totalSteps: number, + from: Hex, + gasLimits: number[], + clientMaxFeePerGas: string | undefined, + clientMaxPriorityFeePerGas: string | undefined, + originalType: TransactionMeta['type'], +): TransactionParams { + let gas: Hex | undefined; + const gasLimit = gasLimits[index]; + + if (gasLimit) { + gas = toHex(gasLimit); + } else if (step.gasLimit) { + gas = toHex(step.gasLimit); + } + + const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; + const resolvedMaxPriorityFeePerGas = + step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; + + const params: TransactionParams = { data: step.data, + from, + gas, + maxFeePerGas: resolvedMaxFeePerGas + ? toHex(resolvedMaxFeePerGas) + : undefined, + maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas + ? toHex(resolvedMaxPriorityFeePerGas) + : undefined, to: step.to, - value: step.value as Hex, + type: getTransactionType(index, totalSteps, originalType), + value: toHex(step.value), + }; + + log('Built transaction params for step', { + index, + step: { + gasLimit: step.gasLimit, + maxFeePerGas: step.maxFeePerGas, + maxPriorityFeePerGas: step.maxPriorityFeePerGas, + value: step.value, + }, + params: { + gas: params.gas, + maxFeePerGas: params.maxFeePerGas, + maxPriorityFeePerGas: params.maxPriorityFeePerGas, + type: params.type, + value: params.value, + }, + }); + + return params; +} + +/** + * Determine the relay deposit transaction type for a step at the given index. + * + * Single-step quotes always use the deposit type. In multi-step quotes the + * first step is an approval and subsequent steps are deposits — matching the + * Relay strategy's convention. + * + * @param index - Zero-based index of the step within the transaction step array. + * @param totalSteps - Total number of transaction steps. + * @param originalType - Effective type of the parent transaction. + * @returns The mapped TransactionType for this step. + */ +function getTransactionType( + index: number, + totalSteps: number, + originalType: TransactionMeta['type'], +): TransactionType { + const depositType = getRelayDepositType(originalType); + + if (totalSteps === 1) { + return depositType; + } + + return index === 0 ? ('tokenMethodApprove' as TransactionType) : depositType; +} + +/** + * Get the relay deposit transaction type based on the parent transaction type. + * + * @param originalType - Type of the parent transaction. + * @returns The mapped relay deposit type, or `relayDeposit` as a fallback. + */ +function getRelayDepositType( + originalType: TransactionMeta['type'], +): TransactionType { + return ( + (originalType && RELAY_DEPOSIT_TYPES[originalType]) ?? + ('relayDeposit' as TransactionType) + ); +} + +/** + * Get the effective transaction type, resolving through batch-type parent + * transactions to find the nested perps/predict type. + * + * @param transaction - The transaction metadata. + * @returns The resolved type from nested transactions, or the top-level type. + */ +function getEffectiveTransactionType( + transaction: TransactionMeta, +): TransactionMeta['type'] { + if (transaction.type !== ('batch' as TransactionType)) { + return transaction.type; + } + + const nestedType = transaction.nestedTransactions?.find( + (tx) => tx.type && RELAY_DEPOSIT_TYPES[tx.type] !== undefined, + )?.type; + + return nestedType ?? transaction.type; +} + +/** + * Prepend payment override calls before the relay transaction steps. + * + * For money-account payment override flows the override account supplies + * the source funds. The override transactions must be batched ahead of the + * relay deposit steps. + * + * @param relayParams - Already-built relay step params. + * @param quote - Server quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Combined params with override calls prepended. + */ +async function prependPaymentOverrideParams( + relayParams: TransactionParams[], + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + const { calls: overrideCalls } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: quote.sourceAmount.human, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + if (!overrideCalls.length) { + log('No payment override calls to prepend'); + return relayParams; + } + + log('Prepending payment override calls', { count: overrideCalls.length }); + + return [...(overrideCalls as TransactionParams[]), ...relayParams]; +} + +/** + * Prepend the original transaction (or a delegation-wrapped version) before + * the relay deposit steps for post-quote flows. + * + * In post-quote flows the source tokens are held in the Safe and only become + * available after the original transaction executes as part of the batch. + * When an accountOverride is active the override account cannot directly + * execute the original call, so it is wrapped in a delegation transaction. + * + * @param relayParams - Already-built relay step params. + * @param quote - Server quote. + * @param transaction - Original transaction meta. + * @param messenger - Controller messenger. + * @returns Combined params with the original tx prepended. + */ +async function prependPostQuoteParams( + relayParams: TransactionParams[], + quote: TransactionPayQuote, + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const hasAccountOverride = + quote.request.from.toLowerCase() !== + (transaction.txParams.from as Hex).toLowerCase(); + + const { maxFeePerGas, maxPriorityFeePerGas } = quote.original.client; + + let prependedParams: TransactionParams; + + if (hasAccountOverride) { + prependedParams = await buildDelegatedOriginalParams( + transaction, + messenger, + ); + } else { + prependedParams = { + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams; + } + + // Ensure the prepended tx carries the same fee caps as the relay steps so + // it isn't submitted with undefined maxFeePerGas in a non-7702 batch. + prependedParams.maxFeePerGas = maxFeePerGas ? toHex(maxFeePerGas) : undefined; + prependedParams.maxPriorityFeePerGas = maxPriorityFeePerGas + ? toHex(maxPriorityFeePerGas) + : undefined; + + log('Prepending post-quote original tx', { hasAccountOverride }); + + return [prependedParams, ...relayParams]; +} + +/** + * Build TransactionParams for a delegation that redeems the original + * post-quote transaction on behalf of the override account. + * + * @param transaction - Original transaction meta to be redeemed. + * @param messenger - Controller messenger. + * @returns Transaction params for the delegation tx. + */ +async function buildDelegatedOriginalParams( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + + log('Delegation result for post-quote original tx', delegation); + + return { + data: delegation.data, + from: transaction.txParams.from as Hex, + to: delegation.to, + value: delegation.value, + }; +} + +/** + * Validate that the user's source token balance covers the quote's required + * source amount before submitting on-chain. + * + * Reads the live balance via RPC rather than the cached state so it reflects + * any concurrent spends. Throws fast with a clear error instead of letting + * the on-chain transaction revert. + * + * @param quote - Server quote containing the required source amount. + * @param messenger - Controller messenger. + */ +async function validateSourceBalance( + quote: TransactionPayQuote, + messenger: TransactionPayControllerMessenger, +): Promise { + const { from, sourceChainId, sourceTokenAddress } = quote.request; + + const normalizedSourceTokenAddress = normalizeTokenAddress( + sourceTokenAddress, + sourceChainId, + TokenAddressTarget.MetaMask, + ); + + let currentBalance: string; + + try { + currentBalance = await getLiveTokenBalance( + messenger, + from, + sourceChainId, + normalizedSourceTokenAddress, + ); + } catch (error) { + throw new Error( + `Cannot validate payment token balance - ${(error as Error).message}`, + ); + } + + const requiredAmount = new BigNumber(quote.sourceAmount.raw); + const balance = new BigNumber(currentBalance); + + log('Validating source balance', { + from, + sourceChainId, + sourceTokenAddress, + currentBalance, + requiredAmount: requiredAmount.toString(10), + }); + + if (balance.isLessThan(requiredAmount)) { + throw new Error( + `Insufficient source token balance for server deposit. ` + + `Required: ${requiredAmount.toString(10)}, ` + + `Available: ${balance.toString(10)}`, + ); + } +} + +async function submitViaServerExecute( + quote: TransactionPayQuote, + allParams: TransactionParams[], + messenger: TransactionPayControllerMessenger, + transaction: TransactionMeta, +): Promise { + const { from, sourceChainId } = quote.request; + const networkClientId = getNetworkClientId(messenger, sourceChainId); + + const nestedTransactions = allParams.map((params) => ({ + data: (params.data ?? '0x') as Hex, + to: params.to as Hex, + value: (params.value ?? '0x0') as Hex, })); const sourceCallTransaction = { @@ -173,26 +638,14 @@ async function submitViaServerExecute( async function submitViaTransactionController( quote: TransactionPayQuote, + allParams: TransactionParams[], messenger: TransactionPayControllerMessenger, transaction: TransactionMeta, ): Promise { const { from, sourceChainId, sourceTokenAddress } = quote.request; - const { steps } = quote.original; - - if (steps.length === 0) { - throw new Error('Server quote has no steps to submit'); - } - - const networkClientId = messenger.call( - 'NetworkController:findNetworkClientIdByChainId', - sourceChainId, - ); + const { gasLimits, is7702 } = quote.original.client; - const { gasLimits, is7702, maxFeePerGas, maxPriorityFeePerGas } = - quote.original.client; - const transactionParams = steps.map((step, i) => - stepToParams(step, from, gasLimits[i], maxFeePerGas, maxPriorityFeePerGas), - ); + const networkClientId = getNetworkClientId(messenger, sourceChainId); const gasFeeToken = quote.fees.isSourceGasFeeToken ? sourceTokenAddress : undefined; @@ -202,8 +655,8 @@ async function submitViaTransactionController( gasFeeToken, is7702, networkClientId, + paramCount: allParams.length, sourceChainId, - stepCount: steps.length, }); const transactionIds: string[] = []; @@ -228,7 +681,7 @@ async function submitViaTransactionController( ); try { - if (transactionParams.length === 1) { + if (allParams.length === 1) { const addTransactionOptions = { gasFeeToken, isInternal: true, @@ -238,23 +691,24 @@ async function submitViaTransactionController( }; log('Calling addTransaction', { - params: transactionParams[0], + params: allParams[0], options: addTransactionOptions, }); await messenger.call( 'TransactionController:addTransaction', - transactionParams[0], + allParams[0], addTransactionOptions, ); } else { const gasLimit7702 = is7702 ? toHex(gasLimits[0]) : undefined; - const batchTransactions = transactionParams.map((params, i) => { - const gas = (gasLimit7702 ?? - (gasLimits[i] === undefined ? undefined : params.gas)) as - | Hex - | undefined; + const batchTransactions = allParams.map((params) => { + // params.gas was already resolved correctly by transactionStepToParams + // (indexed by relay-step position), so use it directly. Indexing + // allParams position into gasLimits would be wrong when payment- + // override or post-quote params are prepended. + const gas = (gasLimit7702 ?? params.gas) as Hex | undefined; return { params: { @@ -265,6 +719,7 @@ async function submitViaTransactionController( to: params.to as Hex, value: params.value as Hex, }, + type: params.type as TransactionType | undefined, }; }); @@ -326,54 +781,103 @@ async function submitViaTransactionController( } } -function stepToParams( - step: ServerQuoteStep, +async function submitSignatureStep( + step: ServerSignatureStep, from: Hex, - gasLimit?: number, - clientMaxFeePerGas?: string, - clientMaxPriorityFeePerGas?: string, -): TransactionParams { - let gas: Hex | undefined; - if (gasLimit) { - gas = toHex(gasLimit); - } else if (step.gasLimit) { - gas = toHex(step.gasLimit); - } - - const resolvedMaxFeePerGas = step.maxFeePerGas ?? clientMaxFeePerGas; - const resolvedMaxPriorityFeePerGas = - step.maxPriorityFeePerGas ?? clientMaxPriorityFeePerGas; + messenger: TransactionPayControllerMessenger, +): Promise { + const { sign, post } = step; - const params = { - data: step.data, - from, - gas, - maxFeePerGas: resolvedMaxFeePerGas - ? toHex(resolvedMaxFeePerGas) - : undefined, - maxPriorityFeePerGas: resolvedMaxPriorityFeePerGas - ? toHex(resolvedMaxPriorityFeePerGas) - : undefined, - to: step.to, - value: toHex(step.value), + const typedData = { + domain: sign.domain, + types: { + ...sign.types, + EIP712Domain: deriveEIP712DomainType(sign.domain), + }, + primaryType: sign.primaryType, + message: sign.value, }; - log('Step to params', { - step: { - gasLimit: step.gasLimit, - maxFeePerGas: step.maxFeePerGas, - maxPriorityFeePerGas: step.maxPriorityFeePerGas, - value: step.value, - }, - params: { - gas: params.gas, - maxFeePerGas: params.maxFeePerGas, - maxPriorityFeePerGas: params.maxPriorityFeePerGas, - value: params.value, - }, + log('Signing typed data for signature step', { + primaryType: sign.primaryType, }); - return params; + const signature = await messenger.call( + 'KeyringController:signTypedMessage', + { from, data: JSON.stringify(typedData) }, + SignTypedDataVersion.V4, + ); + + await postSignatureStepResult( + post.endpoint, + post.method, + post.body, + signature, + post.signatureFormat, + ); +} + +async function postSignatureStepResult( + endpoint: string, + method: string, + body: Record, + signature: string, + signatureFormat: 'queryParam' | 'rsv', +): Promise { + let url = endpoint; + let postBody: Record; + + if (signatureFormat === 'queryParam') { + url = `${endpoint}?signature=${signature}`; + postBody = body; + } else { + // eslint-disable-next-line id-length + const r = signature.slice(0, 66); + // eslint-disable-next-line id-length + const s = `0x${signature.slice(66, 130)}`; + // eslint-disable-next-line id-length + const v = parseInt(signature.slice(130, 132), 16); + postBody = { ...body, signature: { r, s, v } }; + } + + log('Posting signature step result', { url, signatureFormat }); + + let result: unknown; + + try { + const response = await successfulFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(postBody), + }); + + result = await response.json(); + } catch (error) { + throw new Error(`Signature step POST failed: ${(error as Error).message}`); + } + + log('Signature step POST response', result); + + // For rsv-format steps (HyperLiquid exchange endpoint) the response body + // carries an explicit status field. Validate it here so a failed deposit + // throws immediately rather than silently proceeding to the polling phase. + if (signatureFormat === 'rsv') { + const status = (result as { status?: string })?.status; + + if (status !== 'ok') { + throw new Error( + `Signature step rejected by server: ${JSON.stringify(result)}`, + ); + } + } +} + +function deriveEIP712DomainType( + domain: Record, +): { name: string; type: string }[] { + return Object.keys(DOMAIN_FIELD_MAP) + .filter((key) => Object.prototype.hasOwnProperty.call(domain, key)) + .map((key) => DOMAIN_FIELD_MAP[key]); } async function waitForServerCompletion( diff --git a/packages/transaction-pay-controller/src/strategy/server/types.ts b/packages/transaction-pay-controller/src/strategy/server/types.ts index 5aad191f86..2148705eb8 100644 --- a/packages/transaction-pay-controller/src/strategy/server/types.ts +++ b/packages/transaction-pay-controller/src/strategy/server/types.ts @@ -21,8 +21,8 @@ export type ServerQuoteAmount = { formatted: string; }; -/** A single on-chain step returned by the server quote endpoint. */ -export type ServerQuoteStep = { +export type ServerTransactionStep = { + type: 'transaction'; chainId: number; to: Hex; data: Hex; @@ -32,6 +32,24 @@ export type ServerQuoteStep = { maxPriorityFeePerGas?: string; }; +export type ServerSignatureStep = { + type: 'signature'; + sign: { + domain: Record; + types: Record; + primaryType: string; + value: Record; + }; + post: { + endpoint: string; + method: string; + body: Record; + signatureFormat: 'queryParam' | 'rsv'; + }; +}; + +export type ServerStep = ServerTransactionStep | ServerSignatureStep; + /** A call to include in the quote request (for delegation flows). */ export type ServerCall = { to: Hex; @@ -78,7 +96,7 @@ export type ServerQuotePayload = { output: ServerQuoteAmount; fees: ServerQuoteFees; duration: number; - steps: ServerQuoteStep[]; + steps: ServerStep[]; gasless: boolean; }; @@ -117,7 +135,7 @@ export type ServerQuote = { fees: ServerQuoteFees; duration: number; client: ServerQuoteClient; - steps: ServerQuoteStep[]; + steps: ServerStep[]; gasless: boolean; }; diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts index 6189adb600..18317fed27 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.test.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -149,6 +149,103 @@ describe('Transaction Utils', () => { expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); }); + it('updates state when txParams.to changes', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: '0xnewrecipient' as Hex, + }, + }, + ], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); + }); + + it('updates state when requiredAssets changes', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + requiredAssets: [ + { address: '0xtoken' as Hex, chainId: '0x1' as Hex }, + ], + }, + ], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); + }); + + it('does not update state when txParams.data, txParams.to, and requiredAssets are unchanged', () => { + const updateTransactionDataMock = jest.fn(); + + subscribeTransactionChanges(messenger, updateTransactionDataMock, noop); + + publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + publish( + 'TransactionController:stateChange', + { + transactions: [ + { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.submitted, + }, + ], + } as TransactionControllerState, + [], + ); + + // Only the initial new-transaction event triggers the update + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + }); + it.each(FINALIZED_STATUSES)( 'removes state if transaction status is %s', (status) => { diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts index 2394906505..3e3ebf0fdc 100644 --- a/packages/transaction-pay-controller/src/utils/transaction.ts +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -79,7 +79,10 @@ export function subscribeTransactionChanges( return ( previousTransaction && - previousTransaction?.txParams.data !== tx.txParams.data + (previousTransaction?.txParams.data !== tx.txParams.data || + previousTransaction?.txParams.to !== tx.txParams.to || + JSON.stringify(previousTransaction?.requiredAssets) !== + JSON.stringify(tx.requiredAssets)) ); });