Skip to content
Open
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))

### Changed

- Trigger the first-time-interaction warning correctly for `safeTransferFrom` token transfers by including `TransactionType.tokenMethodSafeTransferFrom` in the effective-recipient decoding logic ([#8723](https://github.com/MetaMask/core/pull/8723))
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 @@ -845,6 +845,11 @@ export enum TransactionType {
*/
predictAcrossDeposit = 'predictAcrossDeposit',

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

/**
* Buy a position via Predict.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,36 @@ describe('Gas Fee Tokens Utils', () => {
expect(request.transaction.txParams.nonce).toBeUndefined();
});

it('sets external sign when native token is excluded for fees', async () => {
request.transaction.excludeNativeTokenForFee = true;
request.transaction.isGasFeeTokenIgnoredIfBalance = false;
request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
request.transaction.gasFeeTokens = [];
request.transaction.isExternalSign = false;
request.transaction.txParams.nonce = '0x1';

jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([
{
tokenAddress: TOKEN_ADDRESS_1_MOCK,
} as GasFeeToken,
]);

await checkGasFeeTokenBeforePublish(request);

jest
.mocked(request.updateTransaction)
.mock.calls[0][1](request.transaction);

expect(isNativeBalanceSufficientForGasMock).not.toHaveBeenCalled();
expect(request.fetchGasFeeTokens).toHaveBeenCalledWith(
expect.objectContaining({
isExternalSign: true,
}),
);
expect(request.transaction.isExternalSign).toBe(true);
expect(request.transaction.txParams.nonce).toBeUndefined();
});

it('removes selected gas fee token if native balance sufficient', async () => {
request.transaction.isGasFeeTokenIgnoredIfBalance = true;
request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
Expand All @@ -503,7 +533,7 @@ describe('Gas Fee Tokens Utils', () => {
expect(request.updateTransaction).not.toHaveBeenCalled();
});

it('does nothing if not ignoring gas fee token when native balance sufficient', async () => {
it('does nothing if not ignoring gas fee token and native token is allowed for fees', async () => {
request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK;
request.transaction.isGasFeeTokenIgnoredIfBalance = false;

Expand Down
41 changes: 25 additions & 16 deletions packages/transaction-controller/src/utils/gas-fee-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,31 +146,40 @@ export async function checkGasFeeTokenBeforePublish({
fn: (tx: TransactionMeta) => void,
) => void;
}): Promise<void> {
const { isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken } = transaction;
const {
excludeNativeTokenForFee,
isGasFeeTokenIgnoredIfBalance,
selectedGasFeeToken,
} = transaction;

if (!selectedGasFeeToken || !isGasFeeTokenIgnoredIfBalance) {
if (
!selectedGasFeeToken ||
(!isGasFeeTokenIgnoredIfBalance && !excludeNativeTokenForFee)
) {
return;
}

log('Checking gas fee token before publish', { selectedGasFeeToken });

const hasNativeBalance = await isNativeBalanceSufficientForGas(
transaction,
messenger,
networkClientId,
);

if (hasNativeBalance) {
log(
'Ignoring gas fee token before publish due to sufficient native balance',
if (!excludeNativeTokenForFee) {
const hasNativeBalance = await isNativeBalanceSufficientForGas(
transaction,
messenger,
networkClientId,
);

updateTransaction(transaction.id, (tx) => {
tx.isExternalSign = false;
tx.selectedGasFeeToken = undefined;
});
if (hasNativeBalance) {
log(
'Ignoring gas fee token before publish due to sufficient native balance',
);

return;
updateTransaction(transaction.id, (tx) => {
tx.isExternalSign = false;
tx.selectedGasFeeToken = undefined;
});

return;
}
}

const gasFeeTokens = await fetchGasFeeTokens({
Expand Down
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]

### Added

- Add Across support for post-quote Predict withdraw flows ([#8593](https://github.com/MetaMask/core/pull/8593))

## [22.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Hex } from '@metamask/utils';

import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM } from '../../constants';
import type {
PayStrategyCheckQuoteSupportRequest,
PayStrategyExecuteRequest,
PayStrategyGetQuotesRequest,
TransactionPayQuote,
Expand Down Expand Up @@ -56,6 +57,25 @@ describe('AcrossStrategy', () => {
],
} as PayStrategyGetQuotesRequest;

const quoteWithAuthorizationList = {
request: {
...baseRequest.requests[0],
},
original: {
metamask: {
gasLimits: [],
is7702: true,
requiresAuthorizationList: true,
},
quote: {},
request: {
actions: [],
amount: '100',
tradeType: 'exactInput',
},
},
} as TransactionPayQuote<AcrossQuote>;

beforeEach(() => {
jest.resetAllMocks();
getPayStrategiesConfigMock.mockReturnValue({
Expand Down Expand Up @@ -197,6 +217,60 @@ describe('AcrossStrategy', () => {
).toBe(true);
});

it('supports post-quote predict withdraw requests with source-chain authorization lists', () => {
const strategy = new AcrossStrategy();
expect(
strategy.supports({
...baseRequest,
transaction: {
...TRANSACTION_META_MOCK,
nestedTransactions: [{ type: TransactionType.predictWithdraw }],
txParams: {
...TRANSACTION_META_MOCK.txParams,
authorizationList: [{ address: '0xabc' as Hex }],
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 Expand Up @@ -297,6 +371,79 @@ describe('AcrossStrategy', () => {
expect(result).toBe(false);
});

it('supports source 7702 authorization lists for Predict withdraw post-quote quotes without Across actions', () => {
const strategy = new AcrossStrategy();
const request = {
messenger,
quotes: [
{
...quoteWithAuthorizationList,
request: {
...quoteWithAuthorizationList.request,
isPostQuote: true,
},
},
],
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.predictWithdraw,
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(true);
});

it('does not support first-time 7702 authorization lists for non-post-quote Across quotes', () => {
const strategy = new AcrossStrategy();
const request = {
messenger,
quotes: [quoteWithAuthorizationList],
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.predictWithdraw,
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(false);
});

it('does not support first-time 7702 authorization lists when the Across quote has embedded destination actions', () => {
const strategy = new AcrossStrategy();
const request = {
messenger,
quotes: [
{
...quoteWithAuthorizationList,
request: {
...quoteWithAuthorizationList.request,
isPostQuote: true,
},
original: {
...quoteWithAuthorizationList.original,
request: {
...quoteWithAuthorizationList.original.request,
actions: [
{
args: [],
functionSignature: 'function transfer(address,uint256)',
isNativeTransfer: false,
target: '0xdef' as Hex,
value: '0',
},
],
},
},
},
],
transaction: {
...TRANSACTION_META_MOCK,
type: TransactionType.predictWithdraw,
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(false);
});

it('supports 7702 quotes that do not require an authorization list', () => {
const strategy = new AcrossStrategy();
const quote = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ 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';
import { hasUnsupportedTransactionAuthorizationList } from './authorization-list';
import { isSupportedAcrossPerpsDepositRequest } from './perps';
import { isAcrossQuoteRequest } from './requests';
import type { AcrossQuote } from './types';
Expand Down Expand Up @@ -52,15 +54,20 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
}
}

// Across cannot submit EIP-7702 authorization lists. This pre-quote check
// catches transactions where the authorization list is already present.
// First-time 7702 upgrades discovered during gas planning are handled in
// `checkQuoteSupport` below.
if (request.transaction.txParams?.authorizationList?.length) {
if (
hasUnsupportedTransactionAuthorizationList(
request.transaction,
actionableRequests,
)
) {
return false;
}

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

try {
getAcrossDestination(request.transaction, singleRequest);
return true;
Expand All @@ -76,9 +83,26 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
// Gas planning can discover that TransactionController would add an
// authorization list for a first-time 7702 upgrade. `is7702` alone is not a
// blocker because it also covers already-upgraded accounts.
return !request.quotes.some(
const requiresAuthorizationList = request.quotes.some(
(quote) => quote.original.metamask.requiresAuthorizationList,
);

if (!requiresAuthorizationList) {
return true;
}

if (!isPredictWithdrawTransaction(request.transaction)) {
return false;
}

// A first-time 7702 authorization list is acceptable here only because it is
// attached to MetaMask's source-chain batch transaction. It must not be
// smuggled into Across destination post-swap actions.
return request.quotes.every(
(quote) =>
quote.request.isPostQuote === true &&
quote.original.request.actions.length === 0,
);
}

async getQuotes(
Expand Down
Loading
Loading