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 chain-agnostic Pay source metadata using CAIP-10 account and CAIP-19 asset identifiers without changing legacy EVM Pay fields ([#10272](https://github.com/MetaMask/core/pull/10272))

### Changed

- Bump `uuid` from `^8.3.2` to `^9.0.1` ([#10117](https://github.com/MetaMask/core/pull/10117))
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export type {
LegacyGasFeeEstimates,
Log,
MetamaskPayMetadata,
MetamaskPaySource,
NestedTransactionMetadata,
NestedTransactionUpdate,
PublishBatchHook,
Expand Down
14 changes: 13 additions & 1 deletion packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { AccessList } from '@ethereumjs/tx';
import type { AccountsController } from '@metamask/accounts-controller';
import type { GasFeeState } from '@metamask/gas-fee-controller';
import type { NetworkClientId } from '@metamask/network-controller';
import type { Hex, Json } from '@metamask/utils';
import type { CaipAccountId, CaipAssetType, Hex, Json } from '@metamask/utils';
import type { Operation } from 'fast-json-patch';

import type { TransactionControllerMessenger } from './TransactionController.js';
Expand Down Expand Up @@ -2158,6 +2158,15 @@ export type AssetsFiatValues = {
sending?: string;
};

/** Chain-agnostic source metadata for a MetaMask Pay transaction. */
export type MetamaskPaySource = {
/** Canonical CAIP-10 identity of the source account. */
sourceAccountId: CaipAccountId;

/** Canonical CAIP-19 identity of the source asset. */
sourceAssetId: CaipAssetType;
};

/** Metadata specific to the MetaMask Pay feature. */
export type MetamaskPayMetadata = {
/** Total fee from any bridge transactions, in fiat currency. */
Expand All @@ -2180,6 +2189,9 @@ export type MetamaskPayMetadata = {
*/
isPostQuote?: boolean;

/** Chain-agnostic payment source metadata. */
source?: MetamaskPaySource;

/** Total network fee in fiat currency, including the original and bridge transactions. */
networkFeeFiat?: string;

Expand Down
4 changes: 2 additions & 2 deletions packages/transaction-pay-controller/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ The high level interaction with the `TransactionPayController` is as follows:

## State

State is grouped according to the associated transaction ID in the `transactionData` property.
Transient state is grouped according to the associated transaction ID in the `transactionData` property. It includes required tokens, the selected payment token, retrieved quotes, and calculated totals, and is not persisted across restarts.

This transaction specific data includes any required tokens, selected payment token, retrieved quotes, and calculated totals.
Chain-agnostic source account and asset metadata is stored only on the persisted target transaction in `metamaskPay.source`. The CAIP-10 account and CAIP-19 asset identify their source chain without placing non-EVM identifiers in legacy EVM-only fields. Execution correlation and recovery checkpoints are not part of ordinary Pay selection state.
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 validated chain-agnostic Pay source metadata to persisted transaction records while keeping ordinary Pay selection state transient ([#10272](https://github.com/MetaMask/core/pull/10272))

### Changed

- Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@

import type { TransactionPayController } from './TransactionPayController.js';

/**
* Persists chain-agnostic Pay source metadata on the target transaction.
*
* The CAIP-10 account and CAIP-19 asset must identify the same chain.
* Legacy EVM-only Pay metadata is preserved unchanged.
*
* @param request - Pay source metadata and target transaction ID.
* @param request.source - Chain-agnostic payment source metadata.
* @param request.transactionId - ID of the target transaction.
*/
export type TransactionPayControllerSetPaySourceAction = {
type: `TransactionPayController:setPaySource`;
handler: TransactionPayController['setPaySource'];
};

/**
* Sets the transaction configuration.
*
Expand Down Expand Up @@ -141,6 +156,7 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = {
* Union of all TransactionPayController action types.
*/
export type TransactionPayControllerMethodActions =
| TransactionPayControllerSetPaySourceAction
| TransactionPayControllerSetTransactionConfigAction
| TransactionPayControllerUpdatePaymentTokenAction
| TransactionPayControllerUpdateFiatPaymentAction
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* eslint-disable no-new */

import { deriveStateFromMetadata } from '@metamask/base-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import type { Hex } from '@metamask/utils';
import type { CaipAccountId, CaipAssetType, Hex } from '@metamask/utils';

import { updateFiatPayment } from './actions/update-fiat-payment.js';
import { updatePaymentToken } from './actions/update-payment-token.js';
Expand All @@ -12,6 +13,7 @@ import { getMessengerMock } from './tests/messenger-mock.js';
import type {
TransactionPayControllerMessenger,
TransactionPayControllerOptions,
TransactionPaySource,
TransactionPaySourceAmount,
UpdateTransactionDataCallback,
} from './types.js';
Expand All @@ -22,6 +24,7 @@ import {
getTransaction,
subscribeAssetChanges,
subscribeTransactionChanges,
updateTransaction,
} from './utils/transaction.js';

jest.mock('./actions/update-fiat-payment');
Expand All @@ -36,13 +39,20 @@ const TRANSACTION_ID_MOCK = '123-456';
const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta;
const TOKEN_ADDRESS_MOCK = '0xabc' as Hex;
const CHAIN_ID_MOCK = '0x1' as Hex;
const SOLANA_PAY_SOURCE_MOCK: TransactionPaySource = {
sourceAccountId:
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId,
sourceAssetId:
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as CaipAssetType,
};
describe('TransactionPayController', () => {
const updateFiatPaymentMock = jest.mocked(updateFiatPayment);
const updatePaymentTokenMock = jest.mocked(updatePaymentToken);
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
deriveFiatAssetForFiatPayment,
);
const getTransactionMock = jest.mocked(getTransaction);
const updateTransactionMock = jest.mocked(updateTransaction);
const updateSourceAmountsMock = jest.mocked(updateSourceAmounts);
const updateQuotesMock = jest.mocked(updateQuotes);
const subscribeTransactionChangesMock = jest.mocked(
Expand Down Expand Up @@ -104,6 +114,127 @@ describe('TransactionPayController', () => {
const getControllerState = subscribeAssetChangesMock.mock.calls[0][1];
expect(getControllerState()).toBe(controller.state);
});

it('keeps ordinary Pay selection state transient', () => {
const controller = createController({
state: {
transactionData: {
[TRANSACTION_ID_MOCK]: {
isLoading: true,
tokens: [],
},
},
},
});

expect(
deriveStateFromMetadata(
controller.state,
controller.metadata,
'persist',
),
).toStrictEqual({});
});
});

describe('setPaySource', () => {
it('persists the source only on the target transaction record', () => {
const controller = createController();

controller.setPaySource({
transactionId: TRANSACTION_ID_MOCK,
source: SOLANA_PAY_SOURCE_MOCK,
});

expect(controller.state).toStrictEqual({ transactionData: {} });
expect(updateTransactionMock).toHaveBeenCalledWith(
{
transactionId: TRANSACTION_ID_MOCK,
messenger,
note: 'Set transaction pay source',
},
expect.any(Function),
);

const updateTransactionCallback = updateTransactionMock.mock.calls[0][1];
const transaction = {
metamaskPay: {
chainId: CHAIN_ID_MOCK,
sourceHash: '0xabc' as Hex,
tokenAddress: TOKEN_ADDRESS_MOCK,
},
} as TransactionMeta;

updateTransactionCallback(transaction);

expect(transaction.metamaskPay).toStrictEqual({
chainId: CHAIN_ID_MOCK,
source: SOLANA_PAY_SOURCE_MOCK,
sourceHash: '0xabc',
tokenAddress: TOKEN_ADDRESS_MOCK,
});
});

it('does not project Solana identifiers into legacy EVM-only fields', () => {
const controller = createController();

controller.setPaySource({
transactionId: TRANSACTION_ID_MOCK,
source: SOLANA_PAY_SOURCE_MOCK,
});

const updateTransactionCallback = updateTransactionMock.mock.calls[0][1];
const transaction = {} as TransactionMeta;

updateTransactionCallback(transaction);

expect(transaction.metamaskPay).toStrictEqual({
source: SOLANA_PAY_SOURCE_MOCK,
});
});

it('rejects source account and asset identifiers from different chains', () => {
const controller = createController();

expect(() =>
controller.setPaySource({
transactionId: TRANSACTION_ID_MOCK,
source: {
...SOLANA_PAY_SOURCE_MOCK,
sourceAssetId: 'eip155:1/slip44:60' as CaipAssetType,
},
}),
).toThrow('Pay source account and asset must use the same chain');

expect(updateTransactionMock).not.toHaveBeenCalled();
});

it('validates source identifiers before updating the transaction', () => {
const controller = createController();

expect(() =>
controller.setPaySource({
transactionId: TRANSACTION_ID_MOCK,
source: {
...SOLANA_PAY_SOURCE_MOCK,
sourceAccountId: 'invalid' as CaipAccountId,
},
}),
).toThrow('Invalid CAIP account ID');

expect(updateTransactionMock).not.toHaveBeenCalled();
});

it('is callable via messenger action handler', () => {
createController();

messenger.call('TransactionPayController:setPaySource', {
transactionId: TRANSACTION_ID_MOCK,
source: SOLANA_PAY_SOURCE_MOCK,
});

expect(updateTransactionMock).toHaveBeenCalledTimes(1);
});
});

describe('updatePaymentToken', () => {
Expand Down Expand Up @@ -994,6 +1125,36 @@ describe('TransactionPayController', () => {
controller.state.transactionData[TRANSACTION_ID_MOCK],
).toBeUndefined();
});

it('removes only controller-owned transient data', () => {
const controller = createController();
controller.setPaySource({
transactionId: TRANSACTION_ID_MOCK,
source: SOLANA_PAY_SOURCE_MOCK,
});

const transaction = {} as TransactionMeta;
updateTransactionMock.mock.calls[0][1](transaction);

controller.updatePaymentToken({
transactionId: TRANSACTION_ID_MOCK,
tokenAddress: TOKEN_ADDRESS_MOCK,
chainId: CHAIN_ID_MOCK,
});
const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1];
updateTransactionData(TRANSACTION_ID_MOCK, () => undefined);

const removeTransactionDataCallback =
subscribeTransactionChangesMock.mock.calls[0][2];
removeTransactionDataCallback(TRANSACTION_ID_MOCK);

expect(
controller.state.transactionData[TRANSACTION_ID_MOCK],
).toBeUndefined();
expect(transaction.metamaskPay?.source).toStrictEqual(
SOLANA_PAY_SOURCE_MOCK,
);
});
});

describe('fiat token selection', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { StateMetadata } from '@metamask/base-controller';
import { BaseController } from '@metamask/base-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { parseCaipAccountId, parseCaipAssetType } from '@metamask/utils';
import type { Draft } from 'immer';
import { noop } from 'lodash-es';

Expand All @@ -18,6 +19,7 @@ import type {
GetDelegationTransactionCallback,
GetPaymentOverrideDataCallback,
PolymarketCallbacks,
SetPaySourceRequest,
TransactionConfig,
TransactionConfigCallback,
TransactionData,
Expand All @@ -34,6 +36,7 @@ import { updateSourceAmounts } from './utils/source-amounts.js';
import {
subscribeAssetChanges,
subscribeTransactionChanges,
updateTransaction,
} from './utils/transaction.js';

const MESSENGER_EXPOSED_METHODS = [
Expand All @@ -44,6 +47,7 @@ const MESSENGER_EXPOSED_METHODS = [
'getStrategy',
'polymarketGetDepositWalletAddress',
'polymarketSubmitDepositWalletBatch',
'setPaySource',
'setTransactionConfig',
'updateFiatPayment',
'updatePaymentToken',
Expand Down Expand Up @@ -140,6 +144,37 @@ export class TransactionPayController extends BaseController<
});
}

/**
* Persists chain-agnostic Pay source metadata on the target transaction.
*
* The CAIP-10 account and CAIP-19 asset must identify the same chain.
* Legacy EVM-only Pay metadata is preserved unchanged.
*
* @param request - Pay source metadata and target transaction ID.
* @param request.source - Chain-agnostic payment source metadata.
* @param request.transactionId - ID of the target transaction.
*/
setPaySource({ transactionId, source }: SetPaySourceRequest): void {
const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId;
const assetChainId = parseCaipAssetType(source.sourceAssetId).chainId;

if (accountChainId !== assetChainId) {
throw new Error('Pay source account and asset must use the same chain');
}

updateTransaction(
{
transactionId,
messenger: this.messenger,
note: 'Set transaction pay source',
},
(transaction) => {
transaction.metamaskPay ??= {};
transaction.metamaskPay.source = { ...source };
},
);
}

/**
* Sets the transaction configuration.
*
Expand Down
Loading