diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 56fa6fb9d65..f1d9847d095 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -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)) diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index b7b17038a97..f0dd613ead1 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -89,6 +89,7 @@ export type { LegacyGasFeeEstimates, Log, MetamaskPayMetadata, + MetamaskPaySource, NestedTransactionMetadata, NestedTransactionUpdate, PublishBatchHook, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 83b19561560..180900f3d67 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -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'; @@ -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. */ @@ -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; diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index 1b7cc23602e..a3a66e013f5 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -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. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d0587708279..0870802e091 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -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)) diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 14a91436fa2..2495a408164 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -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. * @@ -141,6 +156,7 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { * Union of all TransactionPayController action types. */ export type TransactionPayControllerMethodActions = + | TransactionPayControllerSetPaySourceAction | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index e0650bfe7a6..0d1a03852ab 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -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'; @@ -12,6 +13,7 @@ import { getMessengerMock } from './tests/messenger-mock.js'; import type { TransactionPayControllerMessenger, TransactionPayControllerOptions, + TransactionPaySource, TransactionPaySourceAmount, UpdateTransactionDataCallback, } from './types.js'; @@ -22,6 +24,7 @@ import { getTransaction, subscribeAssetChanges, subscribeTransactionChanges, + updateTransaction, } from './utils/transaction.js'; jest.mock('./actions/update-fiat-payment'); @@ -36,6 +39,12 @@ 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); @@ -43,6 +52,7 @@ describe('TransactionPayController', () => { 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( @@ -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', () => { @@ -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', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 60e47e74567..c86f7553d1e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -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'; @@ -18,6 +19,7 @@ import type { GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, PolymarketCallbacks, + SetPaySourceRequest, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -34,6 +36,7 @@ import { updateSourceAmounts } from './utils/source-amounts.js'; import { subscribeAssetChanges, subscribeTransactionChanges, + updateTransaction, } from './utils/transaction.js'; const MESSENGER_EXPOSED_METHODS = [ @@ -44,6 +47,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getStrategy', 'polymarketGetDepositWalletAddress', 'polymarketSubmitDepositWalletBatch', + 'setPaySource', 'setTransactionConfig', 'updateFiatPayment', 'updatePaymentToken', @@ -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. * diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index f89d3983ca6..552d61d843d 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -7,6 +7,10 @@ export type { GetBalanceResponse, GetPaymentOverrideDataRequest, GetPaymentOverrideDataResponse, + PolymarketCallbacks, + QuoteErrorInfo, + QuoteErrorReason, + SetPaySourceRequest, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -19,13 +23,11 @@ export type { TransactionPayControllerMessenger, TransactionPayControllerOptions, TransactionPayControllerState, - PolymarketCallbacks, - QuoteErrorInfo, - QuoteErrorReason, TransactionPayControllerStateChangeEvent, TransactionPaymentToken, TransactionPayQuote, TransactionPayRequiredToken, + TransactionPaySource, TransactionPaySourceAmount, TransactionPayTotals, UpdateFiatPaymentRequest, @@ -38,6 +40,7 @@ export type { TransactionPayControllerGetStrategyAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, + TransactionPayControllerSetPaySourceAction, TransactionPayControllerSetTransactionConfigAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d029e1e85b7..9cf4d48e84d 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -44,6 +44,7 @@ import type { import type { BatchTransaction, BatchTransactionParams, + MetamaskPaySource, TransactionControllerAddTransactionAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, @@ -312,9 +313,21 @@ export type TransactionPayControllerOptions = { state?: Partial; }; +/** Chain-agnostic source metadata for a MetaMask Pay transaction. */ +export type TransactionPaySource = MetamaskPaySource; + +/** Request to persist Pay source metadata on a target transaction. */ +export type SetPaySourceRequest = { + /** Chain-agnostic payment source metadata. */ + source: TransactionPaySource; + + /** ID of the target TransactionController transaction. */ + transactionId: string; +}; + /** State of the TransactionPayController. */ export type TransactionPayControllerState = { - /** State relating to each transaction, keyed by transaction ID. */ + /** Transient state relating to each transaction, keyed by transaction ID. */ transactionData: Record; }; diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 71c582f8fe8..67480f402fa 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -1,7 +1,7 @@ import { TransactionStatus } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { BatchTransaction } from '@metamask/transaction-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { CaipAccountId, CaipAssetType, Hex, Json } from '@metamask/utils'; import { cloneDeep } from 'lodash-es'; import { TransactionPayStrategy } from '../constants.js'; @@ -967,16 +967,37 @@ describe('Quotes Utils', () => { const transactionMetaMock = {} as TransactionMeta; updateTransactionMock.mock.calls[0][1](transactionMetaMock); - expect(transactionMetaMock).toMatchObject({ - metamaskPay: { - bridgeFeeFiat: TOTALS_MOCK.fees.provider.usd, - chainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, - networkFeeFiat: TOTALS_MOCK.fees.sourceNetwork.estimate.usd, - strategy: TransactionPayStrategy.Across, - targetFiat: TOTALS_MOCK.targetAmount.usd, - tokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, - totalFiat: TOTALS_MOCK.total.usd, - }, + expect(transactionMetaMock.metamaskPay).toStrictEqual({ + bridgeFeeFiat: TOTALS_MOCK.fees.provider.usd, + chainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, + isPostQuote: undefined, + networkFeeFiat: TOTALS_MOCK.fees.sourceNetwork.estimate.usd, + strategy: TransactionPayStrategy.Across, + targetFiat: TOTALS_MOCK.targetAmount.usd, + tokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, + totalFiat: TOTALS_MOCK.total.usd, + }); + }); + + it('preserves chain-agnostic source metadata when updating EVM compatibility metadata', async () => { + await run(); + + const source = { + sourceAccountId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId, + sourceAssetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, + }; + const transactionMetaMock = { + metamaskPay: { source }, + } as TransactionMeta; + + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock.metamaskPay).toMatchObject({ + chainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, + source, + tokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, }); }); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index b12e45816ca..5426b892b2d 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -276,10 +276,13 @@ function syncTransaction({ tx.isExternalSign = hasQuotes; } + const source = tx.metamaskPay?.source; + tx.metamaskPay = { bridgeFeeFiat: totals.fees.provider.usd, chainId: paymentToken?.chainId, isPostQuote, + ...(source && { source }), networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, strategy, targetFiat: totals.targetAmount.usd,