Skip to content
Merged
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Estimate relay transactions separately and combine with original transaction gas at quote time ([#7933](https://github.com/MetaMask/core/pull/7933))

## [15.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -671,16 +671,11 @@ describe('Relay Quotes Utils', () => {
expect(body.amount).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount);
});

it('includes original transaction in gas estimation for post-quote', async () => {
it('estimates only relay transactions for post-quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

estimateGasBatchMock.mockResolvedValue({
totalGasLimit: 100000,
gasLimits: [21000, 79000],
});

const postQuoteTransaction = {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
Expand All @@ -705,44 +700,54 @@ describe('Relay Quotes Utils', () => {
transaction: postQuoteTransaction,
});

expect(estimateGasBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
transactions: [
expect.objectContaining({
data: '0xaaa',
to: '0x9',
}),
expect.objectContaining({
data: QUOTE_MOCK.steps[0].items[0].data.data,
to: QUOTE_MOCK.steps[0].items[0].data.to,
}),
],
}),
);
// Original transaction should NOT be included in gas estimation.
// Only relay step params are estimated.
expect(estimateGasBatchMock).not.toHaveBeenCalled();
});

it('defaults data to 0x when original transaction has no data for post-quote', async () => {
it('prepends original transaction gas to relay gas limits for post-quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

estimateGasBatchMock.mockResolvedValue({
totalGasLimit: 100000,
gasLimits: [21000, 79000],
getGasBufferMock.mockReturnValue(1);

const result = await getRelayQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
targetAmountMinimum: '0',
isPostQuote: true,
},
],
transaction: {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
txParams: {
from: FROM_MOCK,
to: '0x9' as Hex,
data: '0xaaa' as Hex,
gas: '0x13498',
value: '0',
},
} as TransactionMeta,
});

const postQuoteTransaction = {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
txParams: {
from: FROM_MOCK,
to: '0x9' as Hex,
gas: '79000',
value: '0',
},
} as TransactionMeta;
// Original tx gas (0x13498 = 79000) prepended, relay gas (21000) from params
expect(result[0].original.metamask.gasLimits).toStrictEqual([
79000, 21000,
]);
});

await getRelayQuotes({
it('prefers nestedTransactions gas over txParams.gas for post-quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

getGasBufferMock.mockReturnValue(1);

const result = await getRelayQuotes({
messenger,
requests: [
{
Expand All @@ -751,31 +756,56 @@ describe('Relay Quotes Utils', () => {
isPostQuote: true,
},
],
transaction: postQuoteTransaction,
transaction: {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
txParams: {
from: FROM_MOCK,
to: '0x9' as Hex,
data: '0xaaa' as Hex,
gas: '0x13498',
value: '0',
},
nestedTransactions: [{ gas: '0xC350' }],
} as TransactionMeta,
});

expect(estimateGasBatchMock).toHaveBeenCalledWith(
expect.objectContaining({
transactions: expect.arrayContaining([
expect.objectContaining({
data: '0x',
to: '0x9',
}),
]),
}),
);
// nestedTransactions gas (0xC350 = 50000) used instead of txParams.gas (79000)
expect(result[0].original.metamask.gasLimits).toStrictEqual([
50000, 21000,
]);
});

it('includes all gas limits for post-quote including original transaction', async () => {
it('adds original transaction gas to EIP-7702 combined gas limit for post-quote', async () => {
const multiStepQuote = {
...QUOTE_MOCK,
steps: [
{
...QUOTE_MOCK.steps[0],
items: [
QUOTE_MOCK.steps[0].items[0],
{
...QUOTE_MOCK.steps[0].items[0],
data: {
...QUOTE_MOCK.steps[0].items[0].data,
gas: '30000',
},
},
],
},
],
} as RelayQuote;

successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
json: async () => multiStepQuote,
} as never);

getGasBufferMock.mockReturnValue(1);

// EIP-7702: batch returns single combined gas for multiple relay txs
estimateGasBatchMock.mockResolvedValue({
totalGasLimit: 100000,
gasLimits: [21000, 79000],
totalGasLimit: 51000,
gasLimits: [51000],
});

const result = await getRelayQuotes({
Expand All @@ -794,16 +824,94 @@ describe('Relay Quotes Utils', () => {
from: FROM_MOCK,
to: '0x9' as Hex,
data: '0xaaa' as Hex,
gas: '79000',
gas: '0x13498',
value: '0',
},
} as TransactionMeta,
});

// gasLimits should include both the original tx and relay-step limits
expect(result[0].original.metamask.gasLimits).toStrictEqual([
21000, 79000,
]);
// EIP-7702: original tx gas (79000) added to combined relay gas (51000)
expect(result[0].original.metamask.gasLimits).toStrictEqual([130000]);
});

it('skips original transaction gas when txParams.gas is missing for post-quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

const result = await getRelayQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
targetAmountMinimum: '0',
isPostQuote: true,
},
],
transaction: {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
txParams: {
from: FROM_MOCK,
to: '0x9' as Hex,
data: '0xaaa' as Hex,
value: '0',
},
} as TransactionMeta,
});

// No gas on txParams or nestedTransactions — only relay gas limits
expect(result[0].original.metamask.gasLimits).toStrictEqual([21000]);
});

it('preserves estimate vs limit distinction when using fallback gas for post-quote', async () => {
// Use a quote whose relay step has NO gas param so the single-path
// estimation is attempted; make it fail to trigger the fallback path
// where estimate (900 000) != max (1 500 000).
const noGasQuote = cloneDeep(QUOTE_MOCK);
delete noGasQuote.steps[0].items[0].data.gas;

successfulFetchMock.mockResolvedValue({
json: async () => noGasQuote,
} as never);

estimateGasMock.mockRejectedValue(new Error('Estimation failed'));

await getRelayQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
targetAmountMinimum: '0',
isPostQuote: true,
},
],
transaction: {
...TRANSACTION_META_MOCK,
chainId: '0x1' as Hex,
txParams: {
from: FROM_MOCK,
to: '0x9' as Hex,
data: '0xaaa' as Hex,
gas: '0x13498', // 79 000
value: '0',
},
} as TransactionMeta,
});

// Fallback: estimate=900000, max=1500000.
// With originalTxGas=79000 added independently:
// estimate call should receive 900000+79000 = 979000
// max call should receive 1500000+79000 = 1579000
const estimateCall = calculateGasCostMock.mock.calls.find(
([args]) => !args.isMax,
);
const maxCall = calculateGasCostMock.mock.calls.find(
([args]) => args.isMax,
);

expect(estimateCall?.[0].gas).toBe(979000);
expect(maxCall?.[0].gas).toBe(1579000);
});

it('does not prepend original transaction for post-quote when txParams.to is missing', async () => {
Expand Down
Loading
Loading