Skip to content
Merged
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
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -2239,7 +2239,7 @@
},
"packages/transaction-pay-controller/src/utils/transaction.ts": {
"no-restricted-syntax": {
"count": 2
"count": 6
}
},
"packages/user-operation-controller/src/UserOperationController.test.ts": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metamask/core-monorepo",
"version": "965.0.0",
"version": "966.0.0",
"private": true,
"description": "Monorepo for packages shared between MetaMask clients",
"repository": {
Expand Down
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]

### 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))

## [65.2.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,107 @@ describe('updateFirstTimeInteraction', () => {
from: '0xfrom',
});
});

it('extracts recipient from ERC721 safeTransferFrom (3-arg) method data', async () => {
const transactionMetaWithData = {
...mockTransactionMeta,
txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' },
type: TransactionType.tokenMethodSafeTransferFrom,
};

mockDecodeTransactionData.mockReturnValue({
name: 'safeTransferFrom',
args: { from: '0xfrom', to: '0xrecipient', tokenId: '0x1' },
} as unknown as ReturnType<typeof decodeTransactionData>);
mockGetAccountAddressRelationship.mockResolvedValue({ count: 0 });

await updateFirstTimeInteraction({
existingTransactions: [],
getTransaction: mockGetTransaction,
isFirstTimeInteractionEnabled: mockIsFirstTimeInteractionEnabled,
trace: mockTrace,
transactionMeta: transactionMetaWithData,
updateTransaction: mockUpdateTransactionInternal,
});

expect(mockValidateParamTo).toHaveBeenCalledWith('0xrecipient');
expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith({
chainId: 1,
to: '0xrecipient',
from: '0xfrom',
});
});

it('extracts recipient from ERC721 safeTransferFrom (4-arg with bytes) method data', async () => {
const transactionMetaWithData = {
...mockTransactionMeta,
txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' },
type: TransactionType.tokenMethodSafeTransferFrom,
};

mockDecodeTransactionData.mockReturnValue({
name: 'safeTransferFrom',
args: {
from: '0xfrom',
to: '0xrecipient',
tokenId: '0x1',
data: '0x',
},
} as unknown as ReturnType<typeof decodeTransactionData>);
mockGetAccountAddressRelationship.mockResolvedValue({ count: 0 });

await updateFirstTimeInteraction({
existingTransactions: [],
getTransaction: mockGetTransaction,
isFirstTimeInteractionEnabled: mockIsFirstTimeInteractionEnabled,
trace: mockTrace,
transactionMeta: transactionMetaWithData,
updateTransaction: mockUpdateTransactionInternal,
});

expect(mockValidateParamTo).toHaveBeenCalledWith('0xrecipient');
expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith({
chainId: 1,
to: '0xrecipient',
from: '0xfrom',
});
});

it('extracts recipient from ERC1155 safeTransferFrom method data', async () => {
const transactionMetaWithData = {
...mockTransactionMeta,
txParams: { ...mockTransactionMeta.txParams, data: '0xabcdef' },
type: TransactionType.tokenMethodSafeTransferFrom,
};

mockDecodeTransactionData.mockReturnValue({
name: 'safeTransferFrom',
args: {
from: '0xfrom',
to: '0xrecipient',
id: '0x1',
amount: '0x1',
data: '0x',
},
} as unknown as ReturnType<typeof decodeTransactionData>);
mockGetAccountAddressRelationship.mockResolvedValue({ count: 0 });

await updateFirstTimeInteraction({
existingTransactions: [],
getTransaction: mockGetTransaction,
isFirstTimeInteractionEnabled: mockIsFirstTimeInteractionEnabled,
trace: mockTrace,
transactionMeta: transactionMetaWithData,
updateTransaction: mockUpdateTransactionInternal,
});

expect(mockValidateParamTo).toHaveBeenCalledWith('0xrecipient');
expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith({
chainId: 1,
to: '0xrecipient',
from: '0xfrom',
});
});
});

describe('existing transaction check', () => {
Expand Down Expand Up @@ -402,6 +503,116 @@ describe('updateFirstTimeInteraction', () => {
expect(mockGetAccountAddressRelationship).not.toHaveBeenCalled();
expect(mockUpdateTransactionInternal).not.toHaveBeenCalled();
});

it('proceeds for ERC721 safeTransferFrom when existing tx has same NFT contract but different decoded recipient', async () => {
const tokenContract = '0xcontract';
const currentRecipient = '0xrecipientA';
const otherRecipient = '0xrecipientB';

const currentTx: TransactionMeta = {
...mockTransactionMeta,
id: 'current-tx',
type: TransactionType.tokenMethodSafeTransferFrom,
txParams: {
...mockTransactionMeta.txParams,
to: tokenContract,
data: '0xdata',
},
} as unknown as TransactionMeta;

const existingTxSameContract: TransactionMeta = {
...mockTransactionMeta,
id: 'existing-tx',
type: TransactionType.tokenMethodSafeTransferFrom,
txParams: {
...mockTransactionMeta.txParams,
from: '0xfrom',
to: tokenContract,
data: '0xother',
},
} as unknown as TransactionMeta;

mockDecodeTransactionData
.mockReturnValueOnce({
name: 'safeTransferFrom',
args: {
from: '0xfrom',
to: currentRecipient,
tokenId: '0x1',
},
} as unknown as ReturnType<typeof decodeTransactionData>)
.mockReturnValueOnce({
name: 'safeTransferFrom',
args: {
from: '0xfrom',
to: otherRecipient,
tokenId: '0x2',
},
} as unknown as ReturnType<typeof decodeTransactionData>);

mockGetAccountAddressRelationship.mockResolvedValue({ count: 0 });

await updateFirstTimeInteraction({
existingTransactions: [existingTxSameContract],
getTransaction: mockGetTransaction,
isFirstTimeInteractionEnabled: mockIsFirstTimeInteractionEnabled,
trace: mockTrace,
transactionMeta: currentTx,
updateTransaction: mockUpdateTransactionInternal,
});

expect(mockGetAccountAddressRelationship).toHaveBeenCalledWith({
chainId: 1,
to: currentRecipient,
from: '0xfrom',
});
expect(mockUpdateTransactionInternal).toHaveBeenCalled();
});

it('returns early for ERC721 safeTransferFrom when existing tx has same effective recipient (decoded)', async () => {
const tokenContract = '0xcontract';
const recipient = '0xrecipientA';

const currentTx: TransactionMeta = {
...mockTransactionMeta,
id: 'current-tx',
type: TransactionType.tokenMethodSafeTransferFrom,
txParams: {
...mockTransactionMeta.txParams,
to: tokenContract,
data: '0xdata',
},
} as unknown as TransactionMeta;

const existingTxSameRecipient: TransactionMeta = {
...mockTransactionMeta,
id: 'existing-tx',
type: TransactionType.tokenMethodSafeTransferFrom,
txParams: {
...mockTransactionMeta.txParams,
from: '0xfrom',
to: tokenContract,
data: '0xother',
},
} as unknown as TransactionMeta;

mockDecodeTransactionData.mockReturnValue({
name: 'safeTransferFrom',
args: { from: '0xfrom', to: recipient, tokenId: '0x1' },
} as unknown as ReturnType<typeof decodeTransactionData>);

await updateFirstTimeInteraction({
existingTransactions: [existingTxSameRecipient],
getTransaction: mockGetTransaction,
isFirstTimeInteractionEnabled: mockIsFirstTimeInteractionEnabled,
trace: mockTrace,
transactionMeta: currentTx,
updateTransaction: mockUpdateTransactionInternal,
});

expect(mockGetAccountAddressRelationship).not.toHaveBeenCalled();
expect(mockUpdateTransactionInternal).not.toHaveBeenCalled();
});
});

describe('API integration', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import { validateParamTo } from './validation';
const TOKEN_TRANSFER_TYPES = [
TransactionType.tokenMethodTransfer,
TransactionType.tokenMethodTransferFrom,
TransactionType.tokenMethodSafeTransferFrom,
];

/**
* Returns the effective recipient for first-time-interaction checks (decoded from data for token transfers).
* Used when comparing existing transactions so we match by actual recipient, not txParams.to (contract for ERC20).
* Used when comparing existing transactions so we match by actual recipient, not txParams.to (the token
* contract for ERC20/ERC721/ERC1155 transfer methods).
*
* @param tx - Transaction meta with txParams and type
* @returns Effective recipient address, or undefined
Expand Down Expand Up @@ -99,7 +101,8 @@ export async function updateFirstTimeInteraction({
);

// Skip API call only if we already have a tx with same from and same effective recipient (e.g. duplicate or pending).
// For ERC20, effective recipient is decoded from data; using txParams.to would wrongly match any send of that token.
// For token transfers (ERC20/ERC721/ERC1155), effective recipient is decoded from data; using txParams.to
// would wrongly match any send of the same token contract.
if (existingTransaction) {
return;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [22.0.0]

### Changed

- **BREAKING:** Re-parse required tokens when asset state changes ([#8714](https://github.com/MetaMask/core/pull/8714))
- Adds `AssetsControllerStateChangeEvent`, `CurrencyRateStateChange`, `TokenRatesControllerStateChangeEvent`, and `TokensControllerStateChangeEvent` to `AllowedEvents`.
- Consumers must grant these events when creating the controller messenger.
- Bump `@metamask/gas-fee-controller` from `^26.1.1` to `^26.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722))
- Bump `@metamask/transaction-controller` from `^65.1.0` to `^65.2.0` ([#8722](https://github.com/MetaMask/core/pull/8722))

Expand Down Expand Up @@ -788,7 +793,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release ([#6820](https://github.com/MetaMask/core/pull/6820))

[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.1.0...HEAD
[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.0...HEAD
[22.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.1.0...@metamask/transaction-pay-controller@22.0.0
[21.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@21.0.0...@metamask/transaction-pay-controller@21.1.0
[21.0.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.2.0...@metamask/transaction-pay-controller@21.0.0
[20.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@20.1.0...@metamask/transaction-pay-controller@20.2.0
Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-pay-controller/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metamask/transaction-pay-controller",
"version": "21.1.0",
"version": "22.0.0",
"description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask",
"keywords": [
"Ethereum",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import type {
import { getStrategyOrder } from './utils/feature-flags';
import { updateQuotes } from './utils/quotes';
import { updateSourceAmounts } from './utils/source-amounts';
import { getTransaction, pollTransactionChanges } from './utils/transaction';
import {
getTransaction,
subscribeAssetChanges,
subscribeTransactionChanges,
} from './utils/transaction';

jest.mock('./actions/update-fiat-payment');
jest.mock('./actions/update-payment-token');
Expand All @@ -41,7 +45,10 @@ describe('TransactionPayController', () => {
const getTransactionMock = jest.mocked(getTransaction);
const updateSourceAmountsMock = jest.mocked(updateSourceAmounts);
const updateQuotesMock = jest.mocked(updateQuotes);
const pollTransactionChangesMock = jest.mocked(pollTransactionChanges);
const subscribeTransactionChangesMock = jest.mocked(
subscribeTransactionChanges,
);
const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges);
const getStrategyOrderMock = jest.mocked(getStrategyOrder);
let messenger: TransactionPayControllerMessenger;
let getKeyringControllerStateMock: jest.Mock;
Expand Down Expand Up @@ -80,6 +87,21 @@ describe('TransactionPayController', () => {
updateQuotesMock.mockResolvedValue(true);
});

describe('constructor', () => {
it('subscribes to rate changes for in-flight retry', () => {
const controller = createController();

expect(subscribeAssetChangesMock).toHaveBeenCalledWith(
messenger,
expect.any(Function),
expect.any(Function),
);

const getControllerState = subscribeAssetChangesMock.mock.calls[0][1];
expect(getControllerState()).toBe(controller.state);
});
});

describe('updatePaymentToken', () => {
it('calls util', () => {
createController().updatePaymentToken({
Expand Down Expand Up @@ -674,7 +696,7 @@ describe('TransactionPayController', () => {
).toBeDefined();

const removeTransactionDataCallback =
pollTransactionChangesMock.mock.calls[0][2];
subscribeTransactionChangesMock.mock.calls[0][2];

removeTransactionDataCallback(TRANSACTION_ID_MOCK);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import type {
import { getStrategyOrder } from './utils/feature-flags';
import { updateQuotes } from './utils/quotes';
import { updateSourceAmounts } from './utils/source-amounts';
import { getTransaction, pollTransactionChanges } from './utils/transaction';
import {
getTransaction,
subscribeAssetChanges,
subscribeTransactionChanges,
} from './utils/transaction';

const MESSENGER_EXPOSED_METHODS = [
'getDelegationTransaction',
Expand Down Expand Up @@ -87,12 +91,18 @@ export class TransactionPayController extends BaseController<
MESSENGER_EXPOSED_METHODS,
);

pollTransactionChanges(
subscribeTransactionChanges(
messenger,
this.#updateTransactionData.bind(this),
this.#removeTransactionData.bind(this),
);

subscribeAssetChanges(
messenger,
() => this.state,
this.#updateTransactionData.bind(this),
);

// eslint-disable-next-line no-new
new QuoteRefresher({
getStrategies: this.#getStrategiesWithFallback.bind(this),
Expand Down
Loading
Loading