Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-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]

### Added

- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8593](https://github.com/MetaMask/core/pull/8593))

## [64.4.0]

### Changed
Expand Down
5 changes: 5 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,11 @@ export enum TransactionType {
*/
predictAcrossDeposit = 'predictAcrossDeposit',

/**
* Withdraw funds for Across quote via Predict.
*/
predictAcrossWithdraw = 'predictAcrossWithdraw',

/**
* Buy a position via Predict.
*
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add Gas Station support for Across source transactions when native balance is insufficient ([#8588](https://github.com/MetaMask/core/pull/8588))
- Add Across support for post-quote Predict withdraw flows ([#8593](https://github.com/MetaMask/core/pull/8593))

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,59 @@ describe('AcrossStrategy', () => {
).toBe(true);
});

it('supports post-quote predict withdraw requests', () => {
const strategy = new AcrossStrategy();
expect(
strategy.supports({
...baseRequest,
transaction: {
...TRANSACTION_META_MOCK,
nestedTransactions: [{ type: TransactionType.predictWithdraw }],
txParams: {
...TRANSACTION_META_MOCK.txParams,
data: '0x12345678' as Hex,
to: '0xdef' as Hex,
},
} as TransactionMeta,
requests: [
{
from: '0xabc' as Hex,
isPostQuote: true,
sourceBalanceRaw: '100',
sourceChainId: '0x1' as Hex,
sourceTokenAddress: '0xabc' as Hex,
sourceTokenAmount: '100',
targetAmountMinimum: '0',
targetChainId: '0x2' as Hex,
targetTokenAddress: '0xdef' as Hex,
},
],
}),
).toBe(true);
});

it('does not support post-quote requests outside predict withdraw', () => {
const strategy = new AcrossStrategy();
expect(
strategy.supports({
...baseRequest,
requests: [
{
from: '0xabc' as Hex,
isPostQuote: true,
sourceBalanceRaw: '100',
sourceChainId: '0x1' as Hex,
sourceTokenAddress: '0xabc' as Hex,
sourceTokenAmount: '100',
targetAmountMinimum: '0',
targetChainId: '0x2' as Hex,
targetTokenAddress: '0xdef' as Hex,
},
],
}),
).toBe(false);
});

it('returns false for unsupported perps deposits', () => {
const strategy = new AcrossStrategy();
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
TransactionPayQuote,
} from '../../types';
import { getPayStrategiesConfig } from '../../utils/feature-flags';
import { isPredictWithdrawTransaction } from '../../utils/transaction';
import { getAcrossDestination } from './across-actions';
import { getAcrossQuotes } from './across-quotes';
import { submitAcrossQuotes } from './across-submit';
Expand Down Expand Up @@ -61,6 +62,10 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
}

return actionableRequests.every((singleRequest) => {
if (singleRequest.isPostQuote) {
return isPredictWithdrawTransaction(request.transaction);
}

try {
getAcrossDestination(request.transaction, singleRequest);
return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ const TRANSACTION_META_MOCK = {
from: FROM_MOCK,
},
} as TransactionMeta;
const PREDICT_WITHDRAW_TRANSACTION_MOCK = {
txParams: {
from: FROM_MOCK,
},
nestedTransactions: [{ type: TransactionType.predictWithdraw }],
} as TransactionMeta;

const QUOTE_REQUEST_MOCK: QuoteRequest = {
from: FROM_MOCK,
Expand Down Expand Up @@ -324,6 +330,120 @@ describe('Across Quotes', () => {
expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount);
});

it('uses exactInput trade type without destination actions for post-quote predict withdraws', async () => {
const refundTo = '0x5afe000000000000000000000000000000000001' as Hex;

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

await getAcrossQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
refundTo,
targetAmountMinimum: '0',
},
],
transaction: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK,
txParams: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams,
data: '0x12345678' as Hex,
to: '0x000000000000000000000000000000000000dEaD' as Hex,
},
} as TransactionMeta,
});

const [url] = successfulFetchMock.mock.calls[0];
const params = new URL(url as string).searchParams;

expect(params.get('tradeType')).toBe('exactInput');
expect(params.get('amount')).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount);
expect(params.get('refundAddress')).toBe(refundTo);
expect(getRequestBody().actions).toStrictEqual([]);
});

it('ignores invalid original transaction gas for post-quote predict withdraws', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
targetAmountMinimum: '0',
},
],
transaction: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK,
txParams: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams,
gas: '0x0',
to: '0x000000000000000000000000000000000000dEaD' as Hex,
},
} as TransactionMeta,
});

expect(result[0].original.metamask.gasLimits).toStrictEqual([
{
estimate: 21000,
max: 21000,
},
]);
});

it('adds original transaction gas to EIP-7702 gas limits for post-quote predict withdraws', async () => {
estimateGasBatchMock.mockResolvedValue({
gasLimits: [51000],
});

successfulFetchMock.mockResolvedValue({
json: async () => ({
...QUOTE_MOCK,
approvalTxns: [
{
chainId: 1,
data: '0xaaaa' as Hex,
to: '0xapprove1' as Hex,
},
],
}),
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
targetAmountMinimum: '0',
},
],
transaction: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK,
txParams: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams,
gas: '0x5208',
to: '0x000000000000000000000000000000000000dEaD' as Hex,
},
} as TransactionMeta,
});

expect(result[0].original.metamask.gasLimits).toStrictEqual([
{
estimate: 72000,
max: 72000,
},
]);
expect(result[0].original.metamask.is7702).toBe(true);
});

it('re-quotes max amount quotes after reserving source token for gas fee token', async () => {
const adjustedSourceAmount = '999999999999999900';

Expand Down Expand Up @@ -376,6 +496,60 @@ describe('Across Quotes', () => {
expect(result[0].fees.isSourceGasFeeToken).toBe(true);
});

it('re-quotes post-quote predict withdraws after reserving source token for gas fee token', async () => {
const adjustedSourceAmount = '999999999999999900';
const refundTo = '0x5afe000000000000000000000000000000000001' as Hex;

getTokenBalanceMock.mockReturnValue('0');
isEIP7702ChainMock.mockReturnValue(true);
getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]);

successfulFetchMock
.mockResolvedValueOnce({
json: async () => QUOTE_MOCK,
} as Response)
.mockResolvedValueOnce({
json: async () => ({
...QUOTE_MOCK,
inputAmount: adjustedSourceAmount,
}),
} as Response);

const result = await getAcrossQuotes({
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
isPostQuote: true,
refundTo,
targetAmountMinimum: '0',
},
],
transaction: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK,
txParams: {
...PREDICT_WITHDRAW_TRANSACTION_MOCK.txParams,
gas: '0x5208',
to: '0x000000000000000000000000000000000000dEaD' as Hex,
},
} as TransactionMeta,
});

expect(successfulFetchMock).toHaveBeenCalledTimes(2);
expect(getGasFeeTokensMock).toHaveBeenCalledWith(
expect.objectContaining({
from: refundTo,
}),
);

const [phase2Url] = successfulFetchMock.mock.calls[1];
expect(new URL(phase2Url as string).searchParams.get('amount')).toBe(
adjustedSourceAmount,
);
expect(result[0].sourceAmount.raw).toBe(adjustedSourceAmount);
expect(result[0].fees.isSourceGasFeeToken).toBe(true);
});

it('falls back to phase 1 max amount quote when adjusted quote is not affordable', async () => {
getTokenBalanceMock.mockReturnValue('0');
isEIP7702ChainMock.mockReturnValue(true);
Expand Down
Loading
Loading