From 3cfae695052ecf837767d3691bbcbefab0e79010 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 14 Sep 2026 14:09:53 +0100 Subject: [PATCH 1/5] feat(transaction-pay-controller): add one-attempt Solana Pay flow --- packages/transaction-controller/CHANGELOG.md | 2 +- packages/transaction-controller/src/index.ts | 6 +- packages/transaction-controller/src/types.ts | 68 ++- .../ARCHITECTURE.md | 8 +- .../transaction-pay-controller/CHANGELOG.md | 3 +- ...actionPayController-method-action-types.ts | 75 ++- .../src/TransactionPayController.test.ts | 504 +++++++++++++++++- .../src/TransactionPayController.ts | 447 +++++++++++++++- .../helpers/TransactionPayPublishHook.test.ts | 25 + .../src/helpers/TransactionPayPublishHook.ts | 20 + .../transaction-pay-controller/src/index.ts | 13 +- .../src/strategy/relay/constants.ts | 5 +- .../src/strategy/relay/relay-api.test.ts | 56 +- .../src/strategy/relay/relay-api.ts | 52 +- .../src/strategy/relay/solana-pay.test.ts | 309 +++++++++++ .../src/strategy/relay/solana-pay.ts | 206 +++++++ .../src/strategy/relay/types.ts | 52 ++ .../src/tests/messenger-mock.ts | 11 + .../transaction-pay-controller/src/types.ts | 109 +++- 19 files changed, 1939 insertions(+), 32 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f1d9847d095..937bec455a0 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Add versioned, chain-agnostic Pay intent metadata for persisting CAIP source identity, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. ### Changed diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index f0dd613ead1..085dd90ebc1 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -88,8 +88,12 @@ export type { IsAtomicBatchSupportedResultEntry, LegacyGasFeeEstimates, Log, + MetamaskPayExecution, + MetamaskPayIntent, MetamaskPayMetadata, - MetamaskPaySource, + MetamaskPayProviderNotificationStatus, + MetamaskPayRelayStatus, + MetamaskPaySourceStatus, NestedTransactionMetadata, NestedTransactionUpdate, PublishBatchHook, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 180900f3d67..134e3b2bd61 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2158,13 +2158,77 @@ export type AssetsFiatValues = { sending?: string; }; -/** Chain-agnostic source metadata for a MetaMask Pay transaction. */ -export type MetamaskPaySource = { +/** Status observed independently for a MetaMask Pay source transaction. */ +export type MetamaskPaySourceStatus = + | 'not-started' + | 'attempting' + | 'submitted' + | 'pending' + | 'confirmed' + | 'failed' + | 'unknown'; + +/** Status observed independently for MetaMask Pay Relay settlement. */ +export type MetamaskPayRelayStatus = + | 'not-started' + | 'pending' + | 'success' + | 'failure' + | 'refund' + | 'unknown'; + +/** Status of MetaMask Pay provider notification delivery. */ +export type MetamaskPayProviderNotificationStatus = + | 'not-started' + | 'pending' + | 'succeeded' + | 'failed'; + +/** Durable checkpoints for one source submission attempt. */ +export type MetamaskPayExecution = { + /** Status observed independently for the source-chain transaction. */ + sourceStatus: MetamaskPaySourceStatus; + + /** Status observed independently for Relay settlement. */ + relayStatus: MetamaskPayRelayStatus; + + /** Status of the optional Relay transaction-index notification. */ + providerNotificationStatus: MetamaskPayProviderNotificationStatus; +}; + +/** + * Durable MetaMask Pay intent for a chain-agnostic payment source. + * + * The target transaction is the TransactionController record containing this + * intent, or the key of this intent in TransactionPayController state. + */ +export type MetamaskPayIntent = { + /** Schema version of the persisted intent. */ + version: 1; + /** Canonical CAIP-10 identity of the source account. */ sourceAccountId: CaipAccountId; /** Canonical CAIP-19 identity of the source asset. */ sourceAssetId: CaipAssetType; + + /** Explicit CAIP-2 identity of the source chain. */ + sourceChainId: CaipChainId; + + /** Provider request ID used to reconcile execution after restart. */ + requestId?: string; + + /** Chain-native source transaction identifier, such as a Solana signature. */ + sourceTransactionId?: string; + + /** Durable one-attempt execution checkpoints. */ + execution?: MetamaskPayExecution; + + /** Destination transaction identifier observed from Relay settlement. */ + targetTransactionId?: string; + + /** Provider failure classification observed during reconciliation. */ + relayFailureReason?: string; }; /** Metadata specific to the MetaMask Pay feature. */ diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index a3a66e013f5..7bc0abf4a7a 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -47,6 +47,10 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay) The resulting transaction deposits the necessary funds (on the source network), then a Relayer on the target chain immediately transfers the necessary funds and optionally executes any requested call data. +For a Solana source, the controller uses Relay `/quote/v2` and validates the returned instruction and address-lookup-table payload instead of treating it as an EVM transaction. The client callback compiles that payload with current Solana network data and invokes its Snap `signAndSendTransaction` boundary once. Core persists an `attempting` checkpoint before the callback and never automatically invokes it again after a callback rejection or restart. + +Solana source status, Relay settlement, and the best-effort Relay transaction-index notification are separate observations. `reconcileSolanaPay` and `recoverSolanaPay` only observe persisted attempts. `retrySolanaPayNotification` retries only provider notification and cannot resubmit the source transaction. + ## Lifecycle The high level interaction with the `TransactionPayController` is as follows: @@ -70,4 +74,6 @@ The high level interaction with the `TransactionPayController` is as follows: 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. -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. +Durable chain-agnostic source identity, execution checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. + +The full Solana quote is transient. Once the source attempt starts, recovery uses only the persisted Relay request ID and source signature. A missing signature after an ambiguous callback remains `unknown`; quote refresh, notification retry, status reconciliation, and source resubmission are deliberately not interchangeable operations. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 0870802e091..216bc2ab9e1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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)) +- Add a persisted, versioned Pay intent model with canonical CAIP source identity, one-attempt execution checkpoints, and provider correlation, mirrored to transaction metadata for restart recovery. +- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, instruction and lookup-table handoff, one Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. ### Changed 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 2495a408164..42b279ad282 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -20,6 +20,74 @@ export type TransactionPayControllerSetPaySourceAction = { handler: TransactionPayController['setPaySource']; }; +/** + * Fetches and stores an executable Relay /quote/v2 Solana quote. + * + * The provider request ID is persisted before the quote can be published; + * the full instruction payload remains transient because recovery only + * observes an existing attempt and never resubmits it. + * + * @param request - Source amount and EVM destination details. + * @returns The Relay Solana quote for client display and confirmation. + */ +export type TransactionPayControllerGetSolanaPayQuoteAction = { + type: `TransactionPayController:getSolanaPayQuote`; + handler: TransactionPayController['getSolanaPayQuote']; +}; + +/** + * Performs at most one client-owned Solana sign-and-broadcast attempt. + * + * The `attempting` checkpoint is persisted before invoking the callback. Any + * callback rejection is treated as ambiguous and becomes `unknown`; neither + * this method nor recovery will invoke the callback again. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest independent source, notification, and Relay statuses. + */ +export type TransactionPayControllerSubmitSolanaPayAction = { + type: `TransactionPayController:submitSolanaPay`; + handler: TransactionPayController['submitSolanaPay']; +}; + +/** + * Retries only Relay's transaction-index notification. + * + * This method never invokes the signing callback and notification failure + * does not alter source or settlement observations. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest durable status. + */ +export type TransactionPayControllerRetrySolanaPayNotificationAction = { + type: `TransactionPayController:retrySolanaPayNotification`; + handler: TransactionPayController['retrySolanaPayNotification']; +}; + +/** + * Reconciles source-chain and Relay status without signing, notifying, or + * resubmitting. It can recover a source signature from Relay after callback + * loss and persists each observation for the next restart. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest durable status. + */ +export type TransactionPayControllerReconcileSolanaPayAction = { + type: `TransactionPayController:reconcileSolanaPay`; + handler: TransactionPayController['reconcileSolanaPay']; +}; + +/** + * Resumes observation for all persisted, non-terminal Solana Pay intents. + * Source submission and provider notification are intentionally excluded. + * + * @returns Latest statuses keyed by target transaction ID. + */ +export type TransactionPayControllerRecoverSolanaPayAction = { + type: `TransactionPayController:recoverSolanaPay`; + handler: TransactionPayController['recoverSolanaPay']; +}; + /** * Sets the transaction configuration. * @@ -156,7 +224,12 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { * Union of all TransactionPayController action types. */ export type TransactionPayControllerMethodActions = - | TransactionPayControllerSetPaySourceAction + | TransactionPayControllerSetPayIntentAction + | TransactionPayControllerGetSolanaPayQuoteAction + | TransactionPayControllerSubmitSolanaPayAction + | TransactionPayControllerRetrySolanaPayNotificationAction + | TransactionPayControllerReconcileSolanaPayAction + | TransactionPayControllerRecoverSolanaPayAction | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 0d1a03852ab..8e067e0c405 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -9,6 +9,12 @@ import { updatePaymentToken } from './actions/update-payment-token.js'; import { PaymentOverride, TransactionPayStrategy } from './constants.js'; import { TransactionPayController } from './index.js'; import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils.js'; +import { + fetchRelaySolanaQuote, + getRelayStatus, + notifyRelayTransaction, +} from './strategy/relay/relay-api.js'; +import type { RelaySolanaQuote } from './strategy/relay/types.js'; import { getMessengerMock } from './tests/messenger-mock.js'; import type { TransactionPayControllerMessenger, @@ -30,6 +36,7 @@ import { jest.mock('./actions/update-fiat-payment'); jest.mock('./actions/update-payment-token'); jest.mock('./strategy/fiat/utils'); +jest.mock('./strategy/relay/relay-api'); jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); @@ -39,18 +46,90 @@ 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, +const SOLANA_CHAIN_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; +const SOLANA_ACCOUNT_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId; +const SOLANA_ASSET_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as CaipAssetType; +const SOLANA_TRANSACTION_ID = + '5KtPn3tE7mWcMgvBvXhXxF9xQmP9xNj7wT6oYxj3BbJpfK2p5yhx2S1QnW8gQwJm7U3F5v9hQmXk8b2mJ7dP4c'; +const SOLANA_PAY_INTENT_MOCK: TransactionPayIntent = { + version: 1, + sourceAccountId: SOLANA_ACCOUNT_ID, + sourceAssetId: SOLANA_ASSET_ID, + sourceChainId: SOLANA_CHAIN_ID, + requestId: 'relay-request-123', + sourceTransactionId: SOLANA_TRANSACTION_ID, }; + +const SOLANA_QUOTE_MOCK: RelaySolanaQuote = { + requestId: 'relay-request-123', + details: { + currencyIn: { + amount: '1000000', + amountFormatted: '1', + amountUsd: '1', + currency: { chainId: 792703809, decimals: 6 }, + }, + currencyOut: { + amount: '999000', + amountFormatted: '0.999', + amountUsd: '0.999', + currency: { chainId: 42161, decimals: 6 }, + minimumAmount: '995000', + }, + timeEstimate: 15, + totalImpact: { usd: '0.001' }, + }, + fees: { relayer: { amountUsd: '0.001' } }, + steps: [ + { + id: 'deposit', + kind: 'transaction', + requestId: 'relay-request-123', + items: [ + { + check: { + endpoint: + 'https://api.relay.link/intents/status/v3?requestId=relay-request-123', + method: 'GET', + }, + data: { + chainId: 792703809, + instructions: [ + { + programId: '11111111111111111111111111111111', + keys: [ + { + pubkey: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + isSigner: true, + isWritable: true, + }, + ], + data: '02000000', + }, + ], + addressLookupTableAddresses: [ + 'HZaWndaNWHFDd9Dhk5PQcJmR4aLQv3Y5nVf2gQmMEz2x', + ], + }, + status: 'incomplete', + }, + ], + }, + ], +}; + describe('TransactionPayController', () => { const updateFiatPaymentMock = jest.mocked(updateFiatPayment); const updatePaymentTokenMock = jest.mocked(updatePaymentToken); const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); + const fetchRelaySolanaQuoteMock = jest.mocked(fetchRelaySolanaQuote); + const getRelayStatusMock = jest.mocked(getRelayStatus); + const notifyRelayTransactionMock = jest.mocked(notifyRelayTransaction); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); @@ -99,6 +178,16 @@ describe('TransactionPayController', () => { getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); updateQuotesMock.mockResolvedValue(true); + fetchRelaySolanaQuoteMock.mockResolvedValue(SOLANA_QUOTE_MOCK); + notifyRelayTransactionMock.mockResolvedValue(); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'pending', + txHashes: [], + updatedAt: 1, + }); }); describe('constructor', () => { @@ -237,6 +326,411 @@ describe('TransactionPayController', () => { }); }); + describe('Solana Pay', () => { + const GET_QUOTE_REQUEST = { + amount: '1000000', + destinationChainId: '0xa4b1' as Hex, + destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex, + recipient: '0x1234567890123456789012345678901234567890' as Hex, + transactionId: TRANSACTION_ID_MOCK, + }; + + function getUnquotedIntent(): TransactionPayIntent { + return { + version: 1, + sourceAccountId: SOLANA_ACCOUNT_ID, + sourceAssetId: SOLANA_ASSET_ID, + sourceChainId: SOLANA_CHAIN_ID, + }; + } + + it('rejects quote requests without a persisted Solana intent', async () => { + const controller = createController(); + + await expect( + controller.getSolanaPayQuote(GET_QUOTE_REQUEST), + ).rejects.toThrow('Solana Pay intent missing'); + }); + + it('fetches a production Relay quote and persists its request checkpoint', async () => { + const controller = createController({ + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + + const quote = await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + originChainId: 792703809, + originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + tradeType: 'EXACT_INPUT', + }), + ); + expect(quote).toBe(SOLANA_QUOTE_MOCK); + expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ + requestId: 'relay-request-123', + execution: { + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + sourceStatus: 'not-started', + }, + }); + expect( + controller.state.transactionData[TRANSACTION_ID_MOCK].solanaPayQuote, + ).toBe(SOLANA_QUOTE_MOCK); + }); + + it('rejects submission when the transient quote is unavailable', async () => { + const controller = createController({ + solana: { + getTransactionStatus: jest.fn(), + signAndSendTransaction: jest.fn(), + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...getUnquotedIntent(), + requestId: 'relay-request-123', + execution: { + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + sourceStatus: 'not-started', + }, + }, + }, + }, + }); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Solana Pay quote'); + }); + + it('rejects submission when Solana callbacks are not configured', async () => { + const controller = createController({ + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Solana callbacks missing'); + }); + + it('invokes the sign-and-send callback once and never resubmits', async () => { + const signAndSendTransaction = jest.fn().mockResolvedValue({ + transactionId: SOLANA_TRANSACTION_ID, + }); + const getTransactionStatus = jest.fn().mockResolvedValue('pending'); + const controller = createController({ + solana: { getTransactionStatus, signAndSendTransaction }, + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + expect(signAndSendTransaction).toHaveBeenCalledWith({ + accountId: SOLANA_ACCOUNT_ID, + requestId: 'relay-request-123', + scope: SOLANA_CHAIN_ID, + transaction: SOLANA_QUOTE_MOCK.steps[0].items[0].data, + }); + expect( + controller.state.payIntents[TRANSACTION_ID_MOCK].sourceTransactionId, + ).toBe(SOLANA_TRANSACTION_ID); + }); + + it('persists an ambiguous callback rejection and never resubmits', async () => { + const signAndSendTransaction = jest + .fn() + .mockRejectedValue(new Error('Snap callback lost')); + const controller = createController({ + solana: { + getTransactionStatus: jest.fn(), + signAndSendTransaction, + }, + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [], + originChainId: 792703809, + status: 'waiting', + txHashes: [], + updatedAt: 1, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + const firstStatus = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + const recoveredStatus = + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(firstStatus.sourceStatus).toBe('unknown'); + expect(recoveredStatus.sourceStatus).toBe('unknown'); + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + await expect( + controller.getSolanaPayQuote(GET_QUOTE_REQUEST), + ).rejects.toThrow('Solana source attempt already started'); + expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledTimes(1); + }); + + it('rejects notification retry without stable request and source correlation', async () => { + const controller = createController({ + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + + await expect( + controller.retrySolanaPayNotification(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Solana notification correlation'); + }); + + it('retries provider notification without resubmitting', async () => { + const signAndSendTransaction = jest.fn().mockResolvedValue({ + transactionId: SOLANA_TRANSACTION_ID, + }); + const controller = createController({ + solana: { + getTransactionStatus: jest.fn().mockResolvedValue('pending'), + signAndSendTransaction, + }, + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + notifyRelayTransactionMock.mockRejectedValueOnce( + new Error('index unavailable'), + ); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + const submissionStatus = + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + const notificationStatus = + await controller.retrySolanaPayNotification(TRANSACTION_ID_MOCK); + + expect(submissionStatus.providerNotificationStatus).toBe('failed'); + expect(notificationStatus.providerNotificationStatus).toBe('succeeded'); + expect(notifyRelayTransactionMock).toHaveBeenCalledTimes(2); + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + }); + + it('marks unavailable source and Relay observations as unknown', async () => { + const controller = createController({ + solana: { + getTransactionStatus: jest + .fn() + .mockRejectedValue(new Error('RPC unavailable')), + signAndSendTransaction: jest.fn(), + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'submitted', + }, + }, + }, + }, + }); + getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.relayStatus).toBe('unknown'); + expect(status.sourceStatus).toBe('unknown'); + }); + + it('does not erase a terminal Relay observation when status is unavailable', async () => { + const controller = createController({ + solana: { + getTransactionStatus: jest.fn().mockResolvedValue('pending'), + signAndSendTransaction: jest.fn(), + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'pending', + }, + }, + }, + }, + }); + getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.relayStatus).toBe('success'); + expect(status.sourceStatus).toBe('pending'); + }); + + it('rejects reconciliation without Relay correlation', async () => { + const controller = createController({ + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Relay request ID'); + }); + + it('recovers a callback-lost source signature and reconciles both status axes', async () => { + const signAndSendTransaction = jest.fn(); + const controller = createController({ + solana: { + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + signAndSendTransaction, + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...getUnquotedIntent(), + requestId: 'relay-request-123', + execution: { + providerNotificationStatus: 'not-started', + relayStatus: 'pending', + sourceStatus: 'attempting', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + + const statuses = await controller.recoverSolanaPay(); + + expect(statuses[TRANSACTION_ID_MOCK]).toMatchObject({ + relayStatus: 'success', + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, + targetTransactionId: '0xtarget', + }); + expect(signAndSendTransaction).not.toHaveBeenCalled(); + + const completionCall = updateTransactionMock.mock.calls.find( + ([request]) => request.note === 'Complete Solana pay intent', + ); + const transaction = {} as TransactionMeta; + completionCall?.[1](transaction); + expect(transaction.isIntentComplete).toBe(true); + }); + + it('skips intents that cannot or no longer need recovery', async () => { + const controller = createController({ + state: { + payIntents: { + 'non-solana': { + ...getUnquotedIntent(), + sourceChainId: 'eip155:1' as CaipChainId, + }, + 'missing-request': getUnquotedIntent(), + terminal: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'succeeded', + relayStatus: 'refund', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + + expect(await controller.recoverSolanaPay()).toStrictEqual({}); + expect(getRelayStatusMock).not.toHaveBeenCalled(); + }); + + it('preserves source failure separately from pending Relay settlement', async () => { + const controller = createController({ + solana: { + getTransactionStatus: jest.fn().mockResolvedValue('failed'), + signAndSendTransaction: jest.fn(), + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'submitted', + }, + }, + }, + }, + }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.sourceStatus).toBe('failed'); + expect(status.relayStatus).toBe('pending'); + }); + + it('preserves Relay failure separately from confirmed source submission', async () => { + const controller = createController({ + solana: { + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + signAndSendTransaction: jest.fn(), + }, + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'submitted', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + failReason: 'DESTINATION_TX_FAILED', + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'failure', + txHashes: [], + updatedAt: 1, + }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.sourceStatus).toBe('confirmed'); + expect(status.relayStatus).toBe('failure'); + expect(status.failureReason).toBe('DESTINATION_TX_FAILED'); + }); + }); + describe('updatePaymentToken', () => { it('calls util', () => { createController().updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c86f7553d1e..7e1309d2863 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -13,13 +13,29 @@ import { TransactionPayStrategy, } from './constants.js'; import { QuoteRefresher } from './helpers/QuoteRefresher.js'; +import { RELAY_SOLANA_CHAIN_ID } from './strategy/relay/constants.js'; +import { + fetchRelaySolanaQuote, + getRelayStatus, + notifyRelayTransaction, +} from './strategy/relay/relay-api.js'; +import { + buildRelaySolanaQuoteRequest, + getInitialSolanaPayExecution, + getRelaySolanaTransaction, + mapRelayStatus, +} from './strategy/relay/solana-pay.js'; import type { GetAmountDataCallback, GetBalanceCallback, GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, + GetSolanaPayQuoteRequest, PolymarketCallbacks, - SetPaySourceRequest, + SetPayIntentRequest, + SolanaPayCallbacks, + SolanaPayQuote, + SolanaPayStatus, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -27,6 +43,7 @@ import type { TransactionPayFiatOptions, TransactionPayControllerOptions, TransactionPayControllerState, + TransactionPayIntent, UpdateFiatPaymentRequest, UpdatePaymentTokenRequest, } from './types.js'; @@ -44,11 +61,16 @@ const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', 'getFiatOptions', 'getPaymentOverrideData', + 'getSolanaPayQuote', 'getStrategy', 'polymarketGetDepositWalletAddress', + 'reconcileSolanaPay', + 'recoverSolanaPay', + 'retrySolanaPayNotification', 'polymarketSubmitDepositWalletBatch', 'setPaySource', 'setTransactionConfig', + 'submitSolanaPay', 'updateFiatPayment', 'updatePaymentToken', ] as const; @@ -66,6 +88,31 @@ const getDefaultState = (): TransactionPayControllerState => ({ transactionData: {}, }); +type PromiseResult = + | { status: 'fulfilled'; value: Value } + | { status: 'rejected' }; + +function observePromise( + promise: Promise, +): Promise> { + return promise.then( + (value) => ({ status: 'fulfilled', value }), + () => ({ status: 'rejected' }), + ); +} + +function getSolanaPayExecution( + intent: TransactionPayIntent, +): NonNullable { + return intent.execution ?? getInitialSolanaPayExecution(); +} + +function isTerminalRelayStatus( + status: NonNullable['relayStatus'], +): boolean { + return ['success', 'failure', 'refund'].includes(status); +} + export class TransactionPayController extends BaseController< typeof CONTROLLER_NAME, TransactionPayControllerState, @@ -91,6 +138,8 @@ export class TransactionPayController extends BaseController< readonly #polymarket?: PolymarketCallbacks; + readonly #solana?: SolanaPayCallbacks; + constructor({ fiatOptions, getAmountData, @@ -101,6 +150,7 @@ export class TransactionPayController extends BaseController< getStrategies, messenger, polymarket, + solana, state, }: TransactionPayControllerOptions) { super({ @@ -118,6 +168,7 @@ export class TransactionPayController extends BaseController< this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; this.#polymarket = polymarket; + this.#solana = solana; this.messenger.registerMethodActionHandlers( this, @@ -154,25 +205,332 @@ export class TransactionPayController extends BaseController< * @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; + setPayIntent({ transactionId, intent }: SetPayIntentRequest): void { + this.#persistPayIntent(transactionId, intent, 'Set transaction pay intent'); + } - if (accountChainId !== assetChainId) { - throw new Error('Pay source account and asset must use the same chain'); + /** + * Fetches and stores an executable Relay /quote/v2 Solana quote. + * + * The provider request ID is persisted before the quote can be published; + * the full instruction payload remains transient because recovery only + * observes an existing attempt and never resubmits it. + * + * @param request - Source amount and EVM destination details. + * @returns The Relay Solana quote for client display and confirmation. + */ + async getSolanaPayQuote( + request: GetSolanaPayQuoteRequest, + ): Promise { + const intent = this.#requireSolanaPayIntent(request.transactionId); + + if (getSolanaPayExecution(intent).sourceStatus !== 'not-started') { + throw new Error( + 'TransactionPayController: Solana source attempt already started', + ); } - updateTransaction( + const quoteRequest = buildRelaySolanaQuoteRequest(intent, request); + const quote = await fetchRelaySolanaQuote(this.messenger, quoteRequest); + + getRelaySolanaTransaction(quote); + + this.#persistPayIntent( + request.transactionId, { - transactionId, - messenger: this.messenger, - note: 'Set transaction pay source', + ...intent, + execution: getInitialSolanaPayExecution(), + relayFailureReason: undefined, + requestId: quote.requestId, + sourceTransactionId: undefined, + targetTransactionId: undefined, }, - (transaction) => { - transaction.metamaskPay ??= {}; - transaction.metamaskPay.source = { ...source }; + 'Set Solana pay quote checkpoint', + ); + + this.#updateTransactionData(request.transactionId, (transactionData) => { + transactionData.solanaPayQuote = quote; + }); + + return quote; + } + + /** + * Performs at most one client-owned Solana sign-and-broadcast attempt. + * + * The `attempting` checkpoint is persisted before invoking the callback. Any + * callback rejection is treated as ambiguous and becomes `unknown`; neither + * this method nor recovery will invoke the callback again. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest independent source, notification, and Relay statuses. + */ + async submitSolanaPay(transactionId: string): Promise { + const intent = this.#requireSolanaPayIntent(transactionId); + const execution = getSolanaPayExecution(intent); + + if (execution.sourceStatus !== 'not-started') { + return await this.reconcileSolanaPay(transactionId); + } + + const quote = this.state.transactionData[transactionId]?.solanaPayQuote; + + if (!quote || quote.requestId !== intent.requestId) { + throw new Error('TransactionPayController: Missing Solana Pay quote'); + } + + const solana = this.#requireSolanaCallbacks(); + const transaction = getRelaySolanaTransaction(quote); + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + sourceStatus: 'attempting', + }, + }), + 'Start Solana source attempt', + ); + + const submission = await observePromise( + solana.signAndSendTransaction({ + accountId: intent.sourceAccountId, + requestId: intent.requestId, + scope: intent.sourceChainId, + transaction, + }), + ); + + if (submission.status === 'rejected') { + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + sourceStatus: 'unknown', + }, + }), + 'Mark Solana source attempt ambiguous', + ); + return this.#getSolanaPayStatus(transactionId); + } + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + providerNotificationStatus: 'pending', + sourceStatus: 'submitted', + }, + sourceTransactionId: submission.value.transactionId, + }), + 'Record Solana source transaction', + ); + + await this.retrySolanaPayNotification(transactionId); + return await this.reconcileSolanaPay(transactionId); + } + + /** + * Retries only Relay's transaction-index notification. + * + * This method never invokes the signing callback and notification failure + * does not alter source or settlement observations. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest durable status. + */ + async retrySolanaPayNotification( + transactionId: string, + ): Promise { + const intent = this.#requireSolanaPayIntent(transactionId); + + if (!intent.requestId || !intent.sourceTransactionId) { + throw new Error( + 'TransactionPayController: Missing Solana notification correlation', + ); + } + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + providerNotificationStatus: 'pending', + }, + }), + 'Start Relay source notification', + ); + + const notification = await observePromise( + notifyRelayTransaction({ + chainId: String(RELAY_SOLANA_CHAIN_ID), + requestId: intent.requestId, + txHash: intent.sourceTransactionId, + }), + ); + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + providerNotificationStatus: + notification.status === 'fulfilled' ? 'succeeded' : 'failed', + }, + }), + 'Record Relay source notification result', + ); + + return this.#getSolanaPayStatus(transactionId); + } + + /** + * Reconciles source-chain and Relay status without signing, notifying, or + * resubmitting. It can recover a source signature from Relay after callback + * loss and persists each observation for the next restart. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns The latest durable status. + */ + async reconcileSolanaPay(transactionId: string): Promise { + const initialIntent = this.#requireSolanaPayIntent(transactionId); + + if (!initialIntent.requestId) { + throw new Error('TransactionPayController: Missing Relay request ID'); + } + + const relayResult = await observePromise( + getRelayStatus(initialIntent.requestId), + ); + + this.#updatePayIntent( + transactionId, + (current) => { + if (relayResult.status === 'rejected') { + const execution = getSolanaPayExecution(current); + return { + ...current, + execution: { + ...execution, + relayStatus: isTerminalRelayStatus(execution.relayStatus) + ? execution.relayStatus + : 'unknown', + }, + }; + } + + const [observedSourceTransactionId] = relayResult.value.inTxHashes; + const sourceTransactionId = + current.sourceTransactionId ?? observedSourceTransactionId; + const [targetTransactionId] = relayResult.value.txHashes.slice(-1); + + return { + ...current, + execution: { + ...getSolanaPayExecution(current), + relayStatus: mapRelayStatus(relayResult.value.status), + ...(!sourceTransactionId && + getSolanaPayExecution(current).sourceStatus === 'attempting' && { + sourceStatus: 'unknown' as const, + }), + }, + relayFailureReason: + relayResult.value.failReason ?? relayResult.value.refundFailReason, + sourceTransactionId, + targetTransactionId, + }; }, + 'Reconcile Relay Solana status', ); + + const intent = this.#requireSolanaPayIntent(transactionId); + const { sourceStatus } = getSolanaPayExecution(intent); + + if ( + intent.sourceTransactionId && + sourceStatus !== 'confirmed' && + sourceStatus !== 'failed' + ) { + const sourceResult = await observePromise( + this.#requireSolanaCallbacks().getTransactionStatus({ + accountId: intent.sourceAccountId, + scope: intent.sourceChainId, + transactionId: intent.sourceTransactionId, + }), + ); + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + sourceStatus: + sourceResult.status === 'fulfilled' + ? sourceResult.value + : 'unknown', + }, + }), + 'Reconcile Solana source status', + ); + } + + const status = this.#getSolanaPayStatus(transactionId); + + if ( + status.sourceStatus === 'confirmed' && + status.relayStatus === 'success' + ) { + updateTransaction( + { + transactionId, + messenger: this.messenger, + note: 'Complete Solana pay intent', + }, + (transaction) => { + transaction.isIntentComplete = true; + }, + ); + } + + return status; + } + + /** + * Resumes observation for all persisted, non-terminal Solana Pay intents. + * Source submission and provider notification are intentionally excluded. + * + * @returns Latest statuses keyed by target transaction ID. + */ + async recoverSolanaPay(): Promise> { + const results: Record = {}; + + for (const [transactionId, intent] of Object.entries( + this.state.payIntents, + )) { + const execution = getSolanaPayExecution(intent); + + if ( + !intent.sourceChainId.startsWith('solana:') || + !intent.requestId || + (['confirmed', 'failed'].includes(execution.sourceStatus) && + isTerminalRelayStatus(execution.relayStatus)) + ) { + continue; + } + + results[transactionId] = await this.reconcileSolanaPay(transactionId); + } + + return results; } /** @@ -350,6 +708,69 @@ export class TransactionPayController extends BaseController< return this.#polymarket; } + #requireSolanaCallbacks(): SolanaPayCallbacks { + if (!this.#solana) { + throw new Error('TransactionPayController: Solana callbacks missing'); + } + return this.#solana; + } + + #requireSolanaPayIntent(transactionId: string): TransactionPayIntent { + const intent = this.state.payIntents[transactionId]; + + if (!intent?.sourceChainId.startsWith('solana:')) { + throw new Error('TransactionPayController: Solana Pay intent missing'); + } + + return intent; + } + + #getSolanaPayStatus(transactionId: string): SolanaPayStatus { + const intent = this.#requireSolanaPayIntent(transactionId); + const execution = getSolanaPayExecution(intent); + + return { + failureReason: intent.relayFailureReason, + providerNotificationStatus: execution.providerNotificationStatus, + relayStatus: execution.relayStatus, + requestId: intent.requestId as string, + sourceStatus: execution.sourceStatus, + sourceTransactionId: intent.sourceTransactionId, + targetTransactionId: intent.targetTransactionId, + }; + } + + #updatePayIntent( + transactionId: string, + updateIntent: (intent: TransactionPayIntent) => TransactionPayIntent, + note: string, + ): void { + const intent = this.#requireSolanaPayIntent(transactionId); + this.#persistPayIntent(transactionId, updateIntent(intent), note); + } + + #persistPayIntent( + transactionId: string, + intent: TransactionPayIntent, + note: string, + ): void { + updateTransaction( + { + transactionId, + messenger: this.messenger, + note, + }, + (transaction) => { + transaction.metamaskPay ??= {}; + transaction.metamaskPay.intent = { ...intent }; + }, + ); + + this.update((state) => { + state.payIntents[transactionId] = { ...intent }; + }); + } + #removeTransactionData(transactionId: string): void { this.update((state) => { delete state.transactionData[transactionId]; diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index 31b3bea72e3..fbbb9e0f326 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -2,6 +2,7 @@ import type { PublishHookResult, TransactionMeta, } from '@metamask/transaction-controller'; +import type { CaipChainId } from '@metamask/utils'; import { TransactionPayStrategy } from '../index.js'; import { getMessengerMock } from '../tests/messenger-mock.js'; @@ -35,6 +36,7 @@ describe('TransactionPayPublishHook', () => { getControllerStateMock, getKeyringControllerStateMock, getTransactionControllerStateMock, + submitSolanaPayMock, updateTransactionMock, } = getMessengerMock(); @@ -88,6 +90,29 @@ describe('TransactionPayPublishHook', () => { }); }); + it('routes a persisted Solana intent through the controller submission boundary', async () => { + getControllerStateMock.mockReturnValue({ + payIntents: { + [TRANSACTION_META_MOCK.id]: { + version: 1, + sourceAccountId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + sourceChainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + }, + }, + transactionData: {}, + } as TransactionPayControllerState); + + const result = await runHook(); + + expect(submitSolanaPayMock).toHaveBeenCalledTimes(1); + expect(submitSolanaPayMock).toHaveBeenCalledWith(TRANSACTION_META_MOCK.id); + expect(executeMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: undefined }); + }); + it('executes strategy with quotes', async () => { await runHook(); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index cf57a00494e..319ea81a096 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -65,6 +65,26 @@ export class TransactionPayPublishHook { ); const transactionData = controllerState.transactionData?.[transactionId]; + const payIntent = controllerState.payIntents?.[transactionId]; + + if (payIntent?.sourceChainId.startsWith('solana:')) { + updateTransaction( + { + transactionId, + messenger: this.#messenger, + note: 'Set submittedTime at Solana pay publish hook start', + }, + (transaction) => { + transaction.submittedTime = new Date().getTime(); + }, + ); + + await this.#messenger.call( + 'TransactionPayController:submitSolanaPay', + transactionId, + ); + return EMPTY_RESULT; + } // No-op quotes mark direct routes and cannot be executed by any strategy. const quotes = ( diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 552d61d843d..e94328a3ddd 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -5,12 +5,18 @@ export type { GetBalanceCallback, GetBalanceRequest, GetBalanceResponse, + GetSolanaPayQuoteRequest, + GetSolanaPayTransactionStatusRequest, GetPaymentOverrideDataRequest, GetPaymentOverrideDataResponse, PolymarketCallbacks, QuoteErrorInfo, QuoteErrorReason, - SetPaySourceRequest, + SetPayIntentRequest, + SolanaPayCallbacks, + SolanaPayQuote, + SolanaPaySignAndSendTransactionRequest, + SolanaPayStatus, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -37,11 +43,16 @@ export type { TransactionPayControllerGetAmountDataAction, TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetFiatOptionsAction, + TransactionPayControllerGetSolanaPayQuoteAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerReconcileSolanaPayAction, + TransactionPayControllerRecoverSolanaPayAction, + TransactionPayControllerRetrySolanaPayNotificationAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetPaySourceAction, TransactionPayControllerSetTransactionConfigAction, + TransactionPayControllerSubmitSolanaPayAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index ab47df2db18..81a3f6564c3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -9,8 +9,11 @@ export const FALLBACK_HASH = '0x0' as Hex; export const RELAY_URL_BASE = 'https://api.relay.link'; export const RELAY_AUTHORIZE_URL = `${RELAY_URL_BASE}/authorize`; export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`; -export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; +export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote/v2`; export const RELAY_STATUS_URL = `${RELAY_URL_BASE}/intents/status/v3`; +export const RELAY_TRANSACTIONS_INDEX_URL = `${RELAY_URL_BASE}/transactions/index`; +export const RELAY_SOLANA_CHAIN_ID = 792703809; +export const RELAY_SOLANA_NATIVE_CURRENCY = '11111111111111111111111111111111'; export const HYPERLIQUID_URL_BASE = 'https://api.hyperliquid.xyz'; export const HYPERLIQUID_INFO_URL = `${HYPERLIQUID_URL_BASE}/info`; export const HYPERLIQUID_EXCHANGE_URL = `${HYPERLIQUID_URL_BASE}/exchange`; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts index ad7d4a5bc38..8a83aef8c6c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts @@ -1,12 +1,14 @@ import type { FeatureFlags } from '../../utils/feature-flags.js'; import { getFeatureFlags } from '../../utils/feature-flags.js'; -import { RELAY_STATUS_URL } from './constants.js'; +import { RELAY_STATUS_URL, RELAY_TRANSACTIONS_INDEX_URL } from './constants.js'; import { fetchRelayQuote, + fetchRelaySolanaQuote, getRelayStatus, + notifyRelayTransaction, submitRelayExecute, } from './relay-api.js'; -import type { RelayQuoteRequest } from './types.js'; +import type { RelayQuoteRequest, RelaySolanaQuoteRequest } from './types.js'; jest.mock('../../utils/feature-flags'); @@ -138,6 +140,56 @@ describe('relay-api', () => { }); }); + describe('fetchRelaySolanaQuote', () => { + const SOLANA_QUOTE_REQUEST_MOCK: RelaySolanaQuoteRequest = { + amount: '1000000', + destinationChainId: 42161, + destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + originChainId: 792703809, + originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + recipient: '0x1234567890123456789012345678901234567890', + refundTo: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + tradeType: 'EXACT_INPUT', + user: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + }; + + it('posts the Solana request to the configured /quote/v2 URL', async () => { + mockOkResponse({ requestId: 'relay-request-123', steps: [] }); + + const quote = await fetchRelaySolanaQuote( + MESSENGER_MOCK, + SOLANA_QUOTE_REQUEST_MOCK, + ); + + expect(fetchMock).toHaveBeenCalledWith(QUOTE_URL_MOCK, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(SOLANA_QUOTE_REQUEST_MOCK), + signal: undefined, + }); + expect(quote.request).toStrictEqual(SOLANA_QUOTE_REQUEST_MOCK); + }); + }); + + describe('notifyRelayTransaction', () => { + it('posts stable request and source transaction correlation', async () => { + mockOkResponse({}); + const request = { + chainId: '792703809', + requestId: 'relay-request-123', + txHash: 'solana-signature-123', + }; + + await notifyRelayTransaction(request); + + expect(fetchMock).toHaveBeenCalledWith(RELAY_TRANSACTIONS_INDEX_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + }); + }); + describe('submitRelayExecute', () => { const EXECUTE_REQUEST_MOCK = { executionKind: 'rawCalls' as const, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts index 836ba83159b..402243bceec 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts @@ -1,11 +1,13 @@ import type { TransactionPayControllerMessenger } from '../../types.js'; import { getFeatureFlags } from '../../utils/feature-flags.js'; -import { RELAY_STATUS_URL } from './constants.js'; +import { RELAY_STATUS_URL, RELAY_TRANSACTIONS_INDEX_URL } from './constants.js'; import type { RelayExecuteRequest, RelayExecuteResponse, RelayQuote, RelayQuoteRequest, + RelaySolanaQuote, + RelaySolanaQuoteRequest, RelayStatusResponse, } from './types.js'; @@ -37,6 +39,54 @@ export async function fetchRelayQuote( return quote; } +/** + * Fetch an executable Solana quote from Relay /quote/v2. + * + * @param messenger - Controller messenger. + * @param body - Solana quote request. + * @param signal - Optional abort signal. + * @returns The validated-at-consumption quote response with its request attached. + */ +export async function fetchRelaySolanaQuote( + messenger: TransactionPayControllerMessenger, + body: RelaySolanaQuoteRequest, + signal?: AbortSignal, +): Promise { + const { relayQuoteUrl } = getFeatureFlags(messenger); + const response = await relayFetch(relayQuoteUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }); + const quote = (await response.json()) as RelaySolanaQuote; + quote.request = body; + return quote; +} + +/** + * Notifies Relay about a wallet-broadcast source transaction. + * + * This operation is deliberately separate from signing/broadcasting so it can + * be retried without ever resubmitting the source transaction. + * + * @param request - Stable source transaction correlation. + * @param request.chainId - Relay numeric source chain ID. + * @param request.requestId - Relay request correlation ID. + * @param request.txHash - Base58 source transaction signature. + */ +export async function notifyRelayTransaction(request: { + chainId: string; + requestId: string; + txHash: string; +}): Promise { + await relayFetch(RELAY_TRANSACTIONS_INDEX_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); +} + /** * Submit a gasless transaction via the Relay /execute endpoint. * diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts new file mode 100644 index 00000000000..80fc25fb1ae --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -0,0 +1,309 @@ +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Hex, +} from '@metamask/utils'; + +import type { TransactionPayIntent } from '../../types.js'; +import { + buildRelaySolanaQuoteRequest, + getRelaySolanaTransaction, + mapRelayStatus, +} from './solana-pay.js'; +import type { RelaySolanaQuote, RelaySolanaTransaction } from './types.js'; + +const SOLANA_CHAIN_ID = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; +const SOLANA_ACCOUNT = + `${SOLANA_CHAIN_ID}:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z` as CaipAccountId; +const SOLANA_USDC = + `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as CaipAssetType; +const SOLANA_NATIVE = `${SOLANA_CHAIN_ID}/slip44:501` as CaipAssetType; +const SOLANA_NATIVE_RELAY_ADDRESS = '11111111111111111111111111111111'; + +function getIntent(sourceAssetId: CaipAssetType): TransactionPayIntent { + return { + version: 1, + sourceAccountId: SOLANA_ACCOUNT, + sourceAssetId, + sourceChainId: SOLANA_CHAIN_ID, + }; +} + +function getQuote(): RelaySolanaQuote { + return { + requestId: 'relay-request-123', + details: { + currencyIn: { + amount: '1000000', + amountFormatted: '1', + amountUsd: '1', + currency: { chainId: 792703809, decimals: 6 }, + }, + currencyOut: { + amount: '999000', + amountFormatted: '0.999', + amountUsd: '0.999', + currency: { chainId: 42161, decimals: 6 }, + minimumAmount: '995000', + }, + timeEstimate: 15, + totalImpact: { usd: '0.001' }, + }, + fees: { relayer: { amountUsd: '0.001' } }, + steps: [ + { + id: 'deposit', + kind: 'transaction', + requestId: 'relay-request-123', + items: [ + { + status: 'incomplete', + check: { + endpoint: + 'https://api.relay.link/intents/status/v3?requestId=relay-request-123', + method: 'GET', + }, + data: { + chainId: 792703809, + instructions: [ + { + programId: '11111111111111111111111111111111', + keys: [ + { + pubkey: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + isSigner: true, + isWritable: true, + }, + ], + data: '02000000', + }, + ], + addressLookupTableAddresses: [ + 'HZaWndaNWHFDd9Dhk5PQcJmR4aLQv3Y5nVf2gQmMEz2x', + ], + }, + }, + ], + }, + ], + }; +} + +describe('Solana Relay Pay', () => { + describe('buildRelaySolanaQuoteRequest', () => { + it('builds an exact-input /quote/v2 request for an SPL token', () => { + expect( + buildRelaySolanaQuoteRequest(getIntent(SOLANA_USDC), { + amount: '1000000', + destinationChainId: '0xa4b1' as Hex, + destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }), + ).toStrictEqual({ + amount: '1000000', + destinationChainId: 42161, + destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + originChainId: 792703809, + originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + recipient: '0x1234567890123456789012345678901234567890', + refundTo: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + tradeType: 'EXACT_INPUT', + user: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + }); + }); + + it('maps the CAIP native SOL asset to the Relay native sentinel', () => { + const result = buildRelaySolanaQuoteRequest(getIntent(SOLANA_NATIVE), { + amount: '100000000', + destinationChainId: '0x89' as Hex, + destinationCurrency: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }); + + expect(result.originCurrency).toBe(SOLANA_NATIVE_RELAY_ADDRESS); + }); + + it('rejects unsupported Solana networks', () => { + const intent = { + ...getIntent(SOLANA_USDC), + sourceChainId: 'solana:devnet' as CaipChainId, + }; + + expect(() => + buildRelaySolanaQuoteRequest(intent, { + amount: '1', + destinationChainId: '0x1' as Hex, + destinationCurrency: '0x1234567890123456789012345678901234567890', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }), + ).toThrow('Unsupported Solana source chain'); + }); + + it('rejects a source account from a different chain', () => { + const intent = { + ...getIntent(SOLANA_USDC), + sourceAccountId: 'solana:other:account' as CaipAccountId, + }; + + expect(() => + buildRelaySolanaQuoteRequest(intent, { + amount: '1', + destinationChainId: '0x1' as Hex, + destinationCurrency: '0x1234567890123456789012345678901234567890', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }), + ).toThrow('Solana source account does not match source chain'); + }); + + it('rejects a source asset from a different chain', () => { + const intent = { + ...getIntent(SOLANA_USDC), + sourceAssetId: 'solana:other/token:mint' as CaipAssetType, + }; + + expect(() => + buildRelaySolanaQuoteRequest(intent, { + amount: '1', + destinationChainId: '0x1' as Hex, + destinationCurrency: '0x1234567890123456789012345678901234567890', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }), + ).toThrow('Solana source asset does not match source chain'); + }); + + it('rejects unsupported Solana CAIP asset namespaces', () => { + const intent = getIntent( + `${SOLANA_CHAIN_ID}/erc20:0x1234` as CaipAssetType, + ); + + expect(() => + buildRelaySolanaQuoteRequest(intent, { + amount: '1', + destinationChainId: '0x1' as Hex, + destinationCurrency: '0x1234567890123456789012345678901234567890', + recipient: '0x1234567890123456789012345678901234567890', + transactionId: 'transaction-1', + }), + ).toThrow('Unsupported Solana source asset'); + }); + }); + + describe('getRelaySolanaTransaction', () => { + it('returns the instruction and lookup-table handoff', () => { + expect(getRelaySolanaTransaction(getQuote())).toStrictEqual( + getQuote().steps[0].items[0].data, + ); + }); + + it('accepts a valid instruction handoff without lookup tables', () => { + const quote = getQuote(); + delete quote.steps[0].items[0].data.addressLookupTableAddresses; + + expect(getRelaySolanaTransaction(quote)).toStrictEqual({ + chainId: 792703809, + instructions: quote.steps[0].items[0].data.instructions, + }); + }); + + it('rejects a quote without a provider request ID', () => { + const quote = getQuote(); + quote.requestId = ''; + quote.steps[0].requestId = ''; + + expect(() => getRelaySolanaTransaction(quote)).toThrow( + 'Invalid Relay Solana transaction payload', + ); + }); + + it('rejects a step with mismatched request correlation', () => { + const quote = getQuote(); + quote.steps[0].requestId = 'other-request'; + + expect(() => getRelaySolanaTransaction(quote)).toThrow( + 'Invalid Relay Solana transaction payload', + ); + }); + + it('rejects an EVM transaction item instead of casting it as Solana', () => { + const quote = getQuote(); + quote.steps[0].items[0].data = { + chainId: 792703809, + to: '0x1234', + data: '0x', + } as unknown as RelaySolanaTransaction; + + expect(() => getRelaySolanaTransaction(quote)).toThrow( + 'Invalid Relay Solana transaction payload', + ); + }); + + it.each([ + null, + { chainId: 1, instructions: [] }, + { chainId: 792703809, instructions: [] }, + { + chainId: 792703809, + instructions: [null], + }, + { + chainId: 792703809, + instructions: [{ programId: '', data: 'not-hex', keys: [] }], + }, + { + chainId: 792703809, + instructions: [ + { + programId: 'program', + data: '00', + keys: [null], + }, + ], + }, + { + chainId: 792703809, + instructions: [ + { + programId: 'program', + data: '00', + keys: [], + }, + ], + addressLookupTableAddresses: [1], + }, + ])('rejects malformed transaction data %#', (data) => { + const quote = getQuote(); + quote.steps[0].items[0].data = data as unknown as RelaySolanaTransaction; + + expect(() => getRelaySolanaTransaction(quote)).toThrow( + 'Invalid Relay Solana transaction payload', + ); + }); + + it('rejects malformed instruction keys', () => { + const quote = getQuote(); + const transaction = quote.steps[0].items[0].data; + transaction.instructions[0].keys[0].isSigner = 'true' as never; + + expect(() => getRelaySolanaTransaction(quote)).toThrow( + 'Invalid Relay Solana transaction payload', + ); + }); + }); + + it.each([ + ['success', 'success'], + ['failure', 'failure'], + ['refund', 'refund'], + ['refunded', 'refund'], + ['waiting', 'pending'], + ] as const)('maps Relay status %s to %s', (relayStatus, expected) => { + expect(mapRelayStatus(relayStatus)).toBe(expected); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts new file mode 100644 index 00000000000..5df1251689e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -0,0 +1,206 @@ +import type { + MetamaskPayExecution, + MetamaskPayRelayStatus, +} from '@metamask/transaction-controller'; + +import type { + GetSolanaPayQuoteRequest, + TransactionPayIntent, +} from '../../types.js'; +import { + RELAY_SOLANA_CHAIN_ID, + RELAY_SOLANA_NATIVE_CURRENCY, +} from './constants.js'; +import type { + RelaySolanaQuote, + RelaySolanaQuoteRequest, + RelaySolanaTransaction, + RelayStatus, +} from './types.js'; + +const SOLANA_MAINNET_CAIP_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const SOLANA_NATIVE_ASSET_REFERENCE = 'slip44:501'; +const SOLANA_TOKEN_ASSET_NAMESPACE = 'token:'; +const HEX_BYTES_REGEX = /^(?:[\da-f]{2})*$/iu; + +/** + * Builds the Relay /quote/v2 request for a persisted Solana Pay intent. + * + * @param intent - Persisted chain-agnostic source identity. + * @param request - Destination and amount selected by the client. + * @returns A Relay Solana exact-input request. + */ +export function buildRelaySolanaQuoteRequest( + intent: TransactionPayIntent, + request: GetSolanaPayQuoteRequest, +): RelaySolanaQuoteRequest { + if (intent.sourceChainId !== SOLANA_MAINNET_CAIP_CHAIN_ID) { + throw new Error(`Unsupported Solana source chain: ${intent.sourceChainId}`); + } + + const sourceAccount = getSourceAccount(intent); + + return { + amount: request.amount, + destinationChainId: Number(request.destinationChainId), + destinationCurrency: request.destinationCurrency, + originChainId: RELAY_SOLANA_CHAIN_ID, + originCurrency: getOriginCurrency(intent), + recipient: request.recipient, + refundTo: sourceAccount, + tradeType: 'EXACT_INPUT', + user: sourceAccount, + }; +} + +/** + * Extracts and validates the single Relay Solana transaction handoff. + * + * Relay leaves item data untyped in its public schema. Runtime validation here + * prevents EVM transaction items or malformed instruction keys from reaching + * the client callback. + * + * @param quote - Relay /quote/v2 response. + * @returns Instructions and lookup-table addresses for client compilation. + */ +export function getRelaySolanaTransaction( + quote: RelaySolanaQuote, +): RelaySolanaTransaction { + const transactionItems = quote.steps.flatMap((step) => step.items); + const hasInvalidStep = quote.steps.some( + (step) => step.kind !== 'transaction' || step.requestId !== quote.requestId, + ); + + if ( + !quote.requestId || + hasInvalidStep || + transactionItems.length !== 1 || + !isRelaySolanaTransaction(transactionItems[0].data) + ) { + throw new Error('Invalid Relay Solana transaction payload'); + } + + return transactionItems[0].data; +} + +/** + * Returns the durable initial execution checkpoints for an executable quote. + * + * @returns Initial one-attempt status axes. + */ +export function getInitialSolanaPayExecution(): MetamaskPayExecution { + return { + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + sourceStatus: 'not-started', + }; +} + +/** + * Maps the provider lifecycle onto the independent durable Relay axis. + * + * @param status - Status returned by Relay. + * @returns Durable Relay observation. + */ +export function mapRelayStatus(status: RelayStatus): MetamaskPayRelayStatus { + if (status === 'success') { + return 'success'; + } + + if (status === 'failure') { + return 'failure'; + } + + if (status === 'refund' || status === 'refunded') { + return 'refund'; + } + + return 'pending'; +} + +function getSourceAccount(intent: TransactionPayIntent): string { + const prefix = `${intent.sourceChainId}:`; + + if (!intent.sourceAccountId.startsWith(prefix)) { + throw new Error('Solana source account does not match source chain'); + } + + return intent.sourceAccountId.slice(prefix.length); +} + +function getOriginCurrency(intent: TransactionPayIntent): string { + const prefix = `${intent.sourceChainId}/`; + + if (!intent.sourceAssetId.startsWith(prefix)) { + throw new Error('Solana source asset does not match source chain'); + } + + const assetReference = intent.sourceAssetId.slice(prefix.length); + + if (assetReference === SOLANA_NATIVE_ASSET_REFERENCE) { + return RELAY_SOLANA_NATIVE_CURRENCY; + } + + if (assetReference.startsWith(SOLANA_TOKEN_ASSET_NAMESPACE)) { + return assetReference.slice(SOLANA_TOKEN_ASSET_NAMESPACE.length); + } + + throw new Error(`Unsupported Solana source asset: ${intent.sourceAssetId}`); +} + +function isRelaySolanaTransaction( + value: unknown, +): value is RelaySolanaTransaction { + if (!isRecord(value)) { + return false; + } + + if ( + value.chainId !== RELAY_SOLANA_CHAIN_ID || + !Array.isArray(value.instructions) || + value.instructions.length === 0 || + !value.instructions.every(isRelaySolanaInstruction) + ) { + return false; + } + + return ( + value.addressLookupTableAddresses === undefined || + (Array.isArray(value.addressLookupTableAddresses) && + value.addressLookupTableAddresses.every(isNonEmptyString)) + ); +} + +function isRelaySolanaInstruction(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + + return ( + isNonEmptyString(value.programId) && + typeof value.data === 'string' && + HEX_BYTES_REGEX.test(value.data) && + Array.isArray(value.keys) && + value.keys.every(isRelaySolanaInstructionKey) + ); +} + +function isRelaySolanaInstructionKey(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + + return ( + isNonEmptyString(value.pubkey) && + typeof value.isSigner === 'boolean' && + typeof value.isWritable === 'boolean' + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index ccb8356225c..a7b5b83abf0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -33,6 +33,36 @@ export type RelayQuoteRequest = { }; }; +export type RelaySolanaQuoteRequest = { + amount: string; + destinationChainId: number; + destinationCurrency: Hex; + originChainId: number; + originCurrency: string; + recipient: Hex; + refundTo: string; + tradeType: 'EXACT_INPUT'; + user: string; +}; + +export type RelaySolanaInstructionKey = { + pubkey: string; + isSigner: boolean; + isWritable: boolean; +}; + +export type RelaySolanaInstruction = { + programId: string; + keys: RelaySolanaInstructionKey[]; + data: string; +}; + +export type RelaySolanaTransaction = { + chainId: number; + instructions: RelaySolanaInstruction[]; + addressLookupTableAddresses?: string[]; +}; + export type RelayQuote = { details: { currencyIn: { @@ -157,6 +187,24 @@ export type RelayHyperliquidDepositStep = { depositAddress?: string; }; +export type RelaySolanaQuote = Pick & { + requestId: string; + request?: RelaySolanaQuoteRequest; + steps: { + id: string; + items: { + check: { + endpoint: string; + method: 'GET' | 'POST'; + }; + data: RelaySolanaTransaction; + status: 'complete' | 'incomplete'; + }[]; + kind: 'transaction'; + requestId: string; + }[]; +}; + type RelayQuoteMetamaskBase = { isExecute?: boolean; isMaxGasStation?: boolean; @@ -222,6 +270,10 @@ export type RelayStatusResponse = { updatedAt: number; originChainId: number; destinationChainId: number; + quoteCreatedAt?: number; + failReason?: string; + refundFailReason?: string; + details?: Record; }; export type RelayCompletionOutcome = { diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 96fd295e4be..44acb555c22 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -31,6 +31,7 @@ import type { TransactionPayControllerGetPaymentOverrideDataAction, TransactionPayControllerGetStrategyAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, + TransactionPayControllerSubmitSolanaPayAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, } from '../TransactionPayController-method-action-types.js'; import type { TransactionPayControllerGetStateAction } from '../types.js'; @@ -129,6 +130,10 @@ export function getMessengerMock({ TransactionPayControllerPolymarketGetDepositWalletAddressAction['handler'] > = jest.fn(); + const submitSolanaPayMock: jest.MockedFn< + TransactionPayControllerSubmitSolanaPayAction['handler'] + > = jest.fn(); + const polymarketSubmitDepositWalletBatchMock: jest.MockedFn< TransactionPayControllerPolymarketSubmitDepositWalletBatchAction['handler'] > = jest.fn(); @@ -269,6 +274,11 @@ export function getMessengerMock({ polymarketGetDepositWalletAddressMock, ); + messenger.registerActionHandler( + 'TransactionPayController:submitSolanaPay', + submitSolanaPayMock, + ); + messenger.registerActionHandler( 'TransactionPayController:polymarketSubmitDepositWalletBatch', polymarketSubmitDepositWalletBatchMock, @@ -336,6 +346,7 @@ export function getMessengerMock({ polymarketSubmitDepositWalletBatchMock, publish, simulateTransactionsMock, + submitSolanaPayMock, updateTransactionMock, }; } diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 9cf4d48e84d..07baa2e5cb8 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -44,7 +44,9 @@ import type { import type { BatchTransaction, BatchTransactionParams, - MetamaskPaySource, + MetamaskPayIntent, + MetamaskPayRelayStatus, + MetamaskPaySourceStatus, TransactionControllerAddTransactionAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, @@ -52,7 +54,7 @@ import type { TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { CaipAccountId, CaipChainId, Hex, Json } from '@metamask/utils'; import type { Draft } from 'immer'; import type { @@ -60,6 +62,10 @@ import type { PaymentOverride, TransactionPayStrategy, } from './constants.js'; +import type { + RelaySolanaQuote, + RelaySolanaTransaction, +} from './strategy/relay/types.js'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types.js'; export type AllowedActions = @@ -277,6 +283,99 @@ export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ 'Money Keyring', ]; +/** Request to fetch an executable Solana-source Relay quote. */ +export type GetSolanaPayQuoteRequest = { + /** Source amount in atomic units. */ + amount: string; + + /** Destination EVM chain ID. */ + destinationChainId: Hex; + + /** Destination EVM token address. */ + destinationCurrency: Hex; + + /** Destination EVM account. */ + recipient: Hex; + + /** Target TransactionController transaction ID. */ + transactionId: string; +}; + +/** Relay /quote/v2 response containing an SVM instruction handoff. */ +export type SolanaPayQuote = RelaySolanaQuote; + +/** Request passed to the client-owned Solana sign-and-broadcast boundary. */ +export type SolanaPaySignAndSendTransactionRequest = { + /** Canonical source account used by the Snap client request. */ + accountId: CaipAccountId; + + /** Relay correlation ID. */ + requestId: string; + + /** Canonical Solana chain scope used by the Snap client request. */ + scope: CaipChainId; + + /** Relay instructions and LUT addresses to compile with a fresh blockhash. */ + transaction: RelaySolanaTransaction; +}; + +/** Request used to observe a previously submitted Solana transaction. */ +export type GetSolanaPayTransactionStatusRequest = { + /** Canonical source account. */ + accountId: CaipAccountId; + + /** Canonical Solana chain scope. */ + scope: CaipChainId; + + /** Base58 Solana transaction signature. */ + transactionId: string; +}; + +/** Client-owned Solana operations required by TransactionPayController. */ +export type SolanaPayCallbacks = { + /** + * Compiles the Relay instruction/LUT handoff, then invokes + * `SnapController:handleRequest` with `signAndSendTransaction` exactly once. + * The callback returns the base58 transaction signature. + */ + signAndSendTransaction: ( + request: SolanaPaySignAndSendTransactionRequest, + ) => Promise<{ transactionId: string }>; + + /** Observes source-chain status without submitting or resubmitting. */ + getTransactionStatus: ( + request: GetSolanaPayTransactionStatusRequest, + ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; +}; + +/** Independent durable observations returned by reconciliation. */ +export type SolanaPayStatus = { + /** Provider failure classification, when supplied by Relay. */ + failureReason?: string; + + /** Status of the Relay transaction-index notification. */ + providerNotificationStatus: + | 'not-started' + | 'pending' + | 'succeeded' + | 'failed'; + + /** Relay request ID. */ + requestId: string; + + /** Relay settlement status. */ + relayStatus: MetamaskPayRelayStatus; + + /** Source-chain observation status. */ + sourceStatus: MetamaskPaySourceStatus; + + /** Base58 source transaction signature, when known. */ + sourceTransactionId?: string; + + /** Destination transaction identifier, when Relay reports one. */ + targetTransactionId?: string; +}; + /** Options for the TransactionPayController. */ export type TransactionPayControllerOptions = { /** Optional callback to re-encode nested transaction calldata for a given amount. */ @@ -309,6 +408,9 @@ export type TransactionPayControllerOptions = { /** Callbacks for the Polymarket relayer; required only for the Polymarket deposit-wallet flow. */ polymarket?: PolymarketCallbacks; + /** Client-owned Solana compilation, Snap submission, and observation operations. */ + solana?: SolanaPayCallbacks; + /** Initial state of the controller. */ state?: Partial; }; @@ -402,6 +504,9 @@ export type TransactionData = { /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; + /** Transient executable Relay /quote/v2 response for a Solana source. */ + solanaPayQuote?: SolanaPayQuote; + /** Most relevant structured validation error from the latest quote attempt. */ quoteError?: QuoteErrorInfo; From 33b2264e27e48793b2e6cc09095c621b9d615361 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 14 Sep 2026 15:43:01 +0100 Subject: [PATCH 2/5] fix(transaction-pay-controller): harden Solana client boundary --- packages/transaction-controller/CHANGELOG.md | 3 +- ...ansactionController-method-action-types.ts | 12 + .../src/TransactionController.test.ts | 150 ++++ .../src/TransactionController.ts | 90 +- .../helpers/PendingTransactionTracker.test.ts | 14 + .../src/helpers/PendingTransactionTracker.ts | 3 +- .../src/hooks/CollectPublishHook.test.ts | 31 +- .../src/hooks/CollectPublishHook.ts | 14 +- .../ExtraTransactionsPublishHook.test.ts | 15 + .../src/hooks/ExtraTransactionsPublishHook.ts | 13 +- packages/transaction-controller/src/index.ts | 5 + packages/transaction-controller/src/types.ts | 116 ++- .../transaction-controller/src/utils/batch.ts | 8 +- .../ARCHITECTURE.md | 8 +- .../transaction-pay-controller/CHANGELOG.md | 3 +- ...actionPayController-method-action-types.ts | 6 +- .../src/TransactionPayController.test.ts | 818 +++++++++++++++++- .../src/TransactionPayController.ts | 364 ++++++-- .../helpers/TransactionPayPublishHook.test.ts | 74 +- .../src/helpers/TransactionPayPublishHook.ts | 14 +- .../transaction-pay-controller/src/index.ts | 6 + .../src/strategy/relay/constants.ts | 3 +- .../src/strategy/relay/relay-api.test.ts | 4 +- .../src/strategy/relay/relay-api.ts | 4 +- .../src/strategy/relay/solana-pay.test.ts | 174 +++- .../src/strategy/relay/solana-pay.ts | 189 +++- .../src/tests/messenger-mock.ts | 22 + .../transaction-pay-controller/src/types.ts | 178 +++- .../src/utils/feature-flags.test.ts | 5 + .../src/utils/feature-flags.ts | 7 + 30 files changed, 2179 insertions(+), 174 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 937bec455a0..ac54ba80a25 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add versioned, chain-agnostic Pay intent metadata for persisting CAIP source identity, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. +- Add versioned, chain-agnostic Pay intent metadata for persisting CAIP source identity, discriminated outcomes, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. +- Add externally handled publish results so non-EVM hooks can suppress EVM RPC fallback while preserving submitted, rejected, failed, and confirmed transaction lifecycle events. ### Changed diff --git a/packages/transaction-controller/src/TransactionController-method-action-types.ts b/packages/transaction-controller/src/TransactionController-method-action-types.ts index 2be0525c5d5..3c36c4d4ba6 100644 --- a/packages/transaction-controller/src/TransactionController-method-action-types.ts +++ b/packages/transaction-controller/src/TransactionController-method-action-types.ts @@ -190,6 +190,17 @@ export type TransactionControllerWipeTransactionsAction = { handler: TransactionController['wipeTransactions']; }; +/** + * Marks an existing externally handled transaction as confirmed and emits + * the standard lifecycle events. + * + * @param transactionId - Existing transaction ID. + */ +export type TransactionControllerConfirmTransactionAction = { + type: `TransactionController:confirmTransaction`; + handler: TransactionController['confirmTransaction']; +}; + /** * Adds external provided transaction to state as confirmed transaction. * @@ -459,6 +470,7 @@ export type TransactionControllerMethodActions = | TransactionControllerFailTransactionAction | TransactionControllerUpdateSecurityAlertResponseAction | TransactionControllerWipeTransactionsAction + | TransactionControllerConfirmTransactionAction | TransactionControllerConfirmExternalTransactionAction | TransactionControllerUpdateTransactionGasFeesAction | TransactionControllerUpdatePreviousGasParamsAction diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index b32d8f575f5..6163069b159 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -6408,6 +6408,116 @@ describe('TransactionController', () => { expect(sendRawTransactionCalls).toHaveLength(1); }); + it.each(['submitted', 'ambiguous'] as const)( + 'marks an externally handled %s publication submitted without EVM fallback', + async (outcome) => { + const { controller } = setupController({ + options: { + hooks: { + publish: async () => ({ externallyHandled: true, outcome }), + }, + }, + messengerOptions: { + addTransactionApprovalRequest: { + state: 'approved', + }, + }, + }); + + const { result } = await controller.addTransaction(paramsMock, { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }); + + await result; + + expect(controller.state.transactions[0]).toMatchObject({ + hash: undefined, + isExternalPublish: true, + status: TransactionStatus.submitted, + }); + expect( + rpcRequestMock.mock.calls.filter( + ([request]) => request.method === 'eth_sendRawTransaction', + ), + ).toHaveLength(0); + }, + ); + + it('emits rejection lifecycle for an externally handled user rejection', async () => { + const { controller, messenger } = setupController({ + options: { + hooks: { + publish: async () => ({ + externallyHandled: true, + outcome: 'user-rejected', + }), + }, + }, + messengerOptions: { + addTransactionApprovalRequest: { + state: 'approved', + }, + }, + }); + const listener = jest.fn(); + messenger.subscribe( + 'TransactionController:transactionRejected', + listener, + ); + + const { result } = await controller.addTransaction(paramsMock, { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }); + await result.catch(() => undefined); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + transactionMeta: expect.objectContaining({ + status: TransactionStatus.rejected, + }), + }), + ); + expect(controller.state.transactions).toHaveLength(0); + }); + + it('emits failure lifecycle for guaranteed external non-submission', async () => { + const { controller, messenger } = setupController({ + options: { + hooks: { + publish: async () => ({ + error: 'Snap preparation failed', + externallyHandled: true, + outcome: 'not-submitted', + }), + }, + }, + messengerOptions: { + addTransactionApprovalRequest: { + state: 'approved', + }, + }, + }); + const listener = jest.fn(); + messenger.subscribe('TransactionController:transactionFailed', listener); + + const { result } = await controller.addTransaction(paramsMock, { + networkClientId: NETWORK_CLIENT_ID_MOCK, + }); + await expect(result).rejects.toThrow('Snap preparation failed'); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'Snap preparation failed', + transactionMeta: expect.objectContaining({ + status: TransactionStatus.failed, + }), + }), + ); + expect(controller.state.transactions[0].status).toBe( + TransactionStatus.failed, + ); + }); + it('submits to publish hook with final transaction meta', async () => { const publishHook = jest .fn() @@ -6550,6 +6660,46 @@ describe('TransactionController', () => { }); }); + describe('confirmTransaction', () => { + const submittedTransactionMock = { + ...TRANSACTION_META_MOCK, + status: TransactionStatus.submitted as const, + } as unknown as TransactionMeta; + + it('marks an externally handled transaction confirmed and publishes lifecycle events', () => { + const confirmedListener = jest.fn(); + const statusUpdatedListener = jest.fn(); + const finishedListener = jest.fn(); + const { controller, messenger } = setupController({ + options: { + state: { transactions: [submittedTransactionMock] }, + }, + }); + messenger.subscribe( + 'TransactionController:transactionConfirmed', + confirmedListener, + ); + messenger.subscribe( + 'TransactionController:transactionStatusUpdated', + statusUpdatedListener, + ); + messenger.subscribe( + 'TransactionController:transactionFinished', + finishedListener, + ); + + controller.confirmTransaction(submittedTransactionMock.id); + controller.confirmTransaction(submittedTransactionMock.id); + + expect(controller.state.transactions[0].status).toBe( + TransactionStatus.confirmed, + ); + expect(confirmedListener).toHaveBeenCalledTimes(1); + expect(statusUpdatedListener).toHaveBeenCalledTimes(1); + expect(finishedListener).toHaveBeenCalledTimes(1); + }); + }); + describe('updateSecurityAlertResponse', () => { it('add securityAlertResponse to transaction meta', async () => { const transactionMeta = TRANSACTION_META_MOCK; diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 778d5f56e36..85180ee4893 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -687,6 +687,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'approveTransactionsWithSameNonce', 'clearUnapprovedTransactions', 'confirmExternalTransaction', + 'confirmTransaction', 'emulateNewTransaction', 'emulateTransactionUpdate', 'estimateGas', @@ -1772,6 +1773,41 @@ export class TransactionController extends BaseController< return this.#getTransactionOrThrow(transactionID); } + /** + * Marks an existing externally handled transaction as confirmed and emits + * the standard lifecycle events. + * + * @param transactionId - Existing transaction ID. + */ + confirmTransaction(transactionId: string): void { + const transactionMeta = this.#getTransactionOrThrow(transactionId); + + if (transactionMeta.status === TransactionStatus.confirmed) { + return; + } + + const updatedTransactionMeta = this.#updateTransactionInternal( + { transactionId }, + (draftTransactionMeta) => { + draftTransactionMeta.status = TransactionStatus.confirmed; + }, + ); + + this.#onTransactionStatusChange(updatedTransactionMeta); + this.messenger.publish( + `${controllerName}:transactionConfirmed`, + updatedTransactionMeta, + ); + this.messenger.publish( + `${controllerName}:transactionFinished`, + updatedTransactionMeta, + ); + this.#internalEvents.emit( + `${transactionId}:finished`, + updatedTransactionMeta, + ); + } + /** * Adds external provided transaction to state as confirmed transaction. * @@ -3057,6 +3093,12 @@ export class TransactionController extends BaseController< resultCallbacks?.success(); return finalMeta.hash as string; + case TransactionStatus.rejected: { + const error = finalMeta.error as Error; + resultCallbacks?.error(error); + throw error; + } + default: { const internalError = rpcErrors.internal( `MetaMask Tx Signature: Unknown problem: ${JSON.stringify( @@ -3206,10 +3248,25 @@ export class TransactionController extends BaseController< publishHook = extraTransactionsPublishHook.getHook(); } - const { transactionHash: hash } = await publishHook( - transactionMeta, - rawTx ?? '0x', - ); + const publishResult = await publishHook(transactionMeta, rawTx ?? '0x'); + const { transactionHash: hash } = publishResult; + + if (publishResult.externallyHandled) { + if (publishResult.outcome === 'user-rejected') { + this.#rejectTransaction(transactionId); + return ApprovalState.NotApproved; + } + + if (publishResult.outcome === 'not-submitted') { + this.#failTransaction( + transactionMeta, + new Error( + publishResult.error ?? 'External transaction was not submitted', + ), + ); + return ApprovalState.NotApproved; + } + } // eslint-disable-next-line require-atomic-updates transactionMeta = this.#updateTransactionInternal( @@ -3218,6 +3275,7 @@ export class TransactionController extends BaseController< }, (draftTxMeta) => { draftTxMeta.hash = hash; + draftTxMeta.isExternalPublish = publishResult.externallyHandled; draftTxMeta.status = TransactionStatus.submitted; draftTxMeta.submittedTime ??= new Date().getTime(); if (shouldUpdatePreTxBalance) { @@ -4469,27 +4527,31 @@ export class TransactionController extends BaseController< transactionMeta: TransactionMeta, signedTx: string, ): Promise { - let transactionHash: string | undefined; + let result: PublishHookResult = {}; await this.#trace( { name: 'Publish', parentContext: traceContext }, async () => { const publishHook = publishHookOverride ?? this.#publish; - ({ transactionHash } = await publishHook(transactionMeta, signedTx)); + result = await publishHook(transactionMeta, signedTx); - // eslint-disable-next-line require-atomic-updates - transactionHash ??= await this.#publishTransaction({ - ...transactionMeta, - networkClientId, - rawTx: signedTx, - }); + if (!result.externallyHandled && !result.transactionHash) { + // eslint-disable-next-line require-atomic-updates + result = { + transactionHash: await this.#publishTransaction({ + ...transactionMeta, + networkClientId, + rawTx: signedTx, + }), + }; + } }, ); - log('Publish successful', transactionHash); + log('Publish successful', result); - return { transactionHash }; + return result; } async #getGasFeeTokens(transaction: TransactionMeta): Promise<{ diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index 105ab87dfa0..af5f791a953 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -223,6 +223,20 @@ describe('PendingTransactionTracker', () => { ); }); + it('does not poll externally published transactions as EVM transactions', () => { + pendingTransactionTracker = new PendingTransactionTracker(options); + options.getTransactions.mockReturnValue( + freeze( + [{ ...TRANSACTION_SUBMITTED_MOCK, isExternalPublish: true }], + true, + ), + ); + + pendingTransactionTracker.startIfPendingTransactions(); + + expect(transactionPoller.start).not.toHaveBeenCalled(); + }); + it('does nothing if listener already added', () => { pendingTransactionTracker = new PendingTransactionTracker(options); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index 80b0918635c..467dcffe290 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -562,7 +562,8 @@ export class PendingTransactionTracker { tx.status === TransactionStatus.submitted && !tx.verifiedOnBlockchain && !tx.isUserOperation && - !tx.isStateOnly, + !tx.isStateOnly && + !tx.isExternalPublish, ); } diff --git a/packages/transaction-controller/src/hooks/CollectPublishHook.test.ts b/packages/transaction-controller/src/hooks/CollectPublishHook.test.ts index 23bf3f27ac8..9cf39df2dfa 100644 --- a/packages/transaction-controller/src/hooks/CollectPublishHook.test.ts +++ b/packages/transaction-controller/src/hooks/CollectPublishHook.test.ts @@ -76,7 +76,10 @@ describe('CollectPublishHook', () => { SIGNED_TX_2_MOCK, ); - collectHook.success([TRANSACTION_HASH_MOCK, TRANSACTION_HASH_2_MOCK]); + collectHook.success([ + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, + ]); const result1 = await publishPromise1; const result2 = await publishPromise2; @@ -99,7 +102,10 @@ describe('CollectPublishHook', () => { SIGNED_TX_MOCK, ); - collectHook.success([TRANSACTION_HASH_MOCK, TRANSACTION_HASH_2_MOCK]); + collectHook.success([ + { transactionHash: TRANSACTION_HASH_MOCK }, + { transactionHash: TRANSACTION_HASH_2_MOCK }, + ]); const result1 = await publishPromise1; const result2 = await publishPromise2; @@ -108,7 +114,22 @@ describe('CollectPublishHook', () => { expect(result2.transactionHash).toBe(TRANSACTION_HASH_2_MOCK); }); - it('throws if transaction hash count does not match hook call count', () => { + it('propagates an externally handled result', async () => { + const collectHook = new CollectPublishHook(1); + const publishPromise = collectHook.getHook()( + TRANSACTION_META_MOCK, + SIGNED_TX_MOCK, + ); + + collectHook.success([{ externallyHandled: true, outcome: 'submitted' }]); + + expect(await publishPromise).toStrictEqual({ + externallyHandled: true, + outcome: 'submitted', + }); + }); + + it('throws if publish result count does not match hook call count', () => { const collectHook = new CollectPublishHook(2); const publishHook = collectHook.getHook(); @@ -121,8 +142,8 @@ describe('CollectPublishHook', () => { }); expect(() => { - collectHook.success([TRANSACTION_HASH_MOCK]); - }).toThrow('Transaction hash count mismatch'); + collectHook.success([{ transactionHash: TRANSACTION_HASH_MOCK }]); + }).toThrow('Publish result count mismatch'); }); }); diff --git a/packages/transaction-controller/src/hooks/CollectPublishHook.ts b/packages/transaction-controller/src/hooks/CollectPublishHook.ts index 5e15e8fd4b9..23a8663acb5 100644 --- a/packages/transaction-controller/src/hooks/CollectPublishHook.ts +++ b/packages/transaction-controller/src/hooks/CollectPublishHook.ts @@ -53,20 +53,18 @@ export class CollectPublishHook { /** * Resolve all publish promises with the provided transaction hashes. * - * @param transactionHashes - The transaction hashes to pass to the original publish promises. + * @param publishResults - Results to pass to the original publish promises. */ - success(transactionHashes: Hex[]): void { - log('Success', { transactionHashes }); + success(publishResults: PublishHookResult[]): void { + log('Success', { publishResults }); - if (transactionHashes.length !== this.#transactionCount) { - throw new Error('Transaction hash count mismatch'); + if (publishResults.length !== this.#transactionCount) { + throw new Error('Publish result count mismatch'); } for (let i = 0; i < this.#results.length; i++) { const result = this.#results[i]; - const transactionHash = transactionHashes[i]; - - result.promise.resolve({ transactionHash }); + result.promise.resolve(publishResults[i]); } } diff --git a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts index 6ab008acffd..22bd76ab3ef 100644 --- a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts +++ b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.test.ts @@ -151,6 +151,21 @@ describe('ExtraTransactionsPublishHook', () => { }); }); + it('propagates externally handled publication through the batch composer', async () => { + const hook = createHook().getHook(); + const hookPromise = hook(TRANSACTION_META_MOCK, SIGNED_TRANSACTION_MOCK); + const onPublish = + addTransactionBatchMock.mock.calls[0][0].transactions[0] + .existingTransaction?.onPublish; + + onPublish?.({ externallyHandled: true, outcome: 'submitted' }); + + expect(await hookPromise).toStrictEqual({ + externallyHandled: true, + outcome: 'submitted', + }); + }); + it('rejects if addTransactionBatch throws', async () => { addTransactionBatchMock.mockImplementation(() => { throw new Error('Test error'); diff --git a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts index 79d21a48206..959d8335b15 100644 --- a/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts +++ b/packages/transaction-controller/src/hooks/ExtraTransactionsPublishHook.ts @@ -76,13 +76,10 @@ export class ExtraTransactionsPublishHook { const signedTransaction = signedTx as Hex; const resultPromise = createDeferredPromise(); - const onPublish = ({ - newSignature, - transactionHash, - }: { - newSignature?: Hex; - transactionHash?: string; - }): void => { + const onPublish = ( + result: PublishHookResult & { newSignature?: Hex }, + ): void => { + const { newSignature } = result; if (newSignature) { const latestTransactionMeta = this.#getTransaction(transactionId); @@ -98,7 +95,7 @@ export class ExtraTransactionsPublishHook { return; } - resultPromise.resolve({ transactionHash }); + resultPromise.resolve(result); }; const firstParams: BatchTransactionParams = { diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 085dd90ebc1..451f1888aef 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -27,6 +27,7 @@ export type { TransactionControllerAddTransactionAction, TransactionControllerAddTransactionBatchAction, TransactionControllerConfirmExternalTransactionAction, + TransactionControllerConfirmTransactionAction, TransactionControllerEmulateNewTransactionAction, TransactionControllerEmulateTransactionUpdateAction, TransactionControllerEstimateGasAction, @@ -75,6 +76,7 @@ export type { DefaultGasEstimates, FeeMarketEIP1559Values, FeeMarketGasFeeEstimateForLevel, + ExternallyHandledPublishOutcome, FeeMarketGasFeeEstimates, GasFeeEstimates, GasFeeToken, @@ -89,11 +91,14 @@ export type { LegacyGasFeeEstimates, Log, MetamaskPayExecution, + MetamaskPayFollowUpStatus, MetamaskPayIntent, MetamaskPayMetadata, + MetamaskPayOutcome, MetamaskPayProviderNotificationStatus, MetamaskPayRelayStatus, MetamaskPaySourceStatus, + MetamaskPaySubmissionOutcome, NestedTransactionMetadata, NestedTransactionUpdate, PublishBatchHook, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 134e3b2bd61..3b9e8b1e871 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -271,6 +271,9 @@ export type TransactionMeta = { */ isExternalSign?: boolean; + /** Whether publication and confirmation are owned by an external handler. */ + isExternalPublish?: boolean; + /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; @@ -1799,13 +1802,12 @@ export type TransactionBatchSingleRequest = { id: string; /** Optional callback to be invoked once the transaction is published. */ - onPublish?: (request: { - /** Updated signature for the transaction, if applicable. */ - newSignature?: Hex; - - /** Hash of the transaction on the network. */ - transactionHash?: string; - }) => void; + onPublish?: ( + request: PublishHookResult & { + /** Updated signature for the transaction, if applicable. */ + newSignature?: Hex; + }, + ) => void; /** Signed transaction data. */ signedTransaction: Hex; @@ -1969,15 +1971,35 @@ export type UpdateCustodialTransactionRequest = { type?: TransactionEnvelopeType; }; -/** - * Data returned from custom logic to publish a transaction. - */ -export type PublishHookResult = { - /** - * The hash of the transaction on the network. - */ - transactionHash?: string; -}; +/** Outcome of a transaction published by an external non-EVM handler. */ +export type ExternallyHandledPublishOutcome = + | 'submitted' + | 'user-rejected' + | 'not-submitted' + | 'ambiguous'; + +/** Data returned from custom logic to publish a transaction. */ +export type PublishHookResult = + | { + /** External handling was not used; the EVM hash may suppress fallback. */ + externallyHandled?: false; + + /** Hash of the transaction on the network. */ + transactionHash?: string; + } + | { + /** Prevents fallback to `eth_sendRawTransaction`. */ + externallyHandled: true; + + /** Explicit result of the external publication attempt. */ + outcome: ExternallyHandledPublishOutcome; + + /** Stable error detail for guaranteed non-submission. */ + error?: string; + + /** Externally handled publication does not require an EVM hash. */ + transactionHash?: never; + }; /** * Custom logic to publish a transaction. @@ -2021,10 +2043,7 @@ export type PublishBatchHookRequest = { export type PublishBatchHookResult = | { /** Result data for each transaction in the batch. */ - results: { - /** Hash of the transaction on the network. */ - transactionHash: Hex; - }[]; + results: PublishHookResult[]; } | undefined; @@ -2166,6 +2185,8 @@ export type MetamaskPaySourceStatus = | 'pending' | 'confirmed' | 'failed' + | 'user-rejected' + | 'not-submitted' | 'unknown'; /** Status observed independently for MetaMask Pay Relay settlement. */ @@ -2184,6 +2205,43 @@ export type MetamaskPayProviderNotificationStatus = | 'succeeded' | 'failed'; +/** Durable, monotonic business outcome for a MetaMask Pay intent. */ +export type MetamaskPayOutcome = + | { type: 'not-started' } + | { type: 'attempting' } + | { type: 'submitted' } + | { type: 'user-rejected' } + | { + type: 'source-failed'; + guaranteedNotSubmitted: boolean; + reason?: string; + } + | { type: 'relay-failed'; reason?: string } + | { type: 'refunded'; reason?: string } + | { type: 'follow-up-failed' } + | { type: 'unknown'; phase: 'source' | 'relay' | 'follow-up' } + | { type: 'succeeded' }; + +/** Durable result of a single externally handled submission callback. */ +export type MetamaskPaySubmissionOutcome = + | 'submitted' + | 'user-rejected' + | 'not-submitted' + | 'ambiguous'; + +/** Status of the required non-atomic sponsored follow-up transaction. */ +export type MetamaskPayFollowUpStatus = + | 'not-required' + | 'not-started' + | 'attempting' + | 'submitted' + | 'pending' + | 'confirmed' + | 'failed' + | 'user-rejected' + | 'not-submitted' + | 'unknown'; + /** Durable checkpoints for one source submission attempt. */ export type MetamaskPayExecution = { /** Status observed independently for the source-chain transaction. */ @@ -2194,6 +2252,12 @@ export type MetamaskPayExecution = { /** Status of the optional Relay transaction-index notification. */ providerNotificationStatus: MetamaskPayProviderNotificationStatus; + + /** Result returned by the single source submission callback. */ + submissionOutcome?: MetamaskPaySubmissionOutcome; + + /** Status of a required non-atomic sponsored follow-up. */ + followUpStatus?: MetamaskPayFollowUpStatus; }; /** @@ -2221,14 +2285,26 @@ export type MetamaskPayIntent = { /** Chain-native source transaction identifier, such as a Solana signature. */ sourceTransactionId?: string; + /** Stable source submission failure or interruption detail. */ + sourceFailureReason?: string; + /** Durable one-attempt execution checkpoints. */ execution?: MetamaskPayExecution; + /** Core-derived business outcome used by lifecycle consumers. */ + outcome?: MetamaskPayOutcome; + /** Destination transaction identifier observed from Relay settlement. */ targetTransactionId?: string; /** Provider failure classification observed during reconciliation. */ relayFailureReason?: string; + + /** Whether Relay success must be followed by a sponsored target submission. */ + requiresNonAtomicFollowUp?: boolean; + + /** Transaction ID returned by the sponsored follow-up callback. */ + followUpTransactionId?: string; }; /** Metadata specific to the MetaMask Pay feature. */ diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 77f040f7c60..c4cbbb1c8f0 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -757,14 +757,10 @@ async function addTransactionBatchWithHook( throw new Error('Publish batch hook did not return a result'); } - const transactionHashes = result.results.map( - ({ transactionHash }) => transactionHash, - ); - - collectHook.success(transactionHashes); + collectHook.success(result.results); resultCallbacks?.success(); - log('Completed batch transaction with hook', transactionHashes); + log('Completed batch transaction with hook', result.results); return { batchId, diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index 7bc0abf4a7a..0308523262d 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -47,9 +47,11 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay) The resulting transaction deposits the necessary funds (on the source network), then a Relayer on the target chain immediately transfers the necessary funds and optionally executes any requested call data. -For a Solana source, the controller uses Relay `/quote/v2` and validates the returned instruction and address-lookup-table payload instead of treating it as an EVM transaction. The client callback compiles that payload with current Solana network data and invokes its Snap `signAndSendTransaction` boundary once. Core persists an `attempting` checkpoint before the callback and never automatically invokes it again after a callback rejection or restart. +For a Solana source, the controller uses a separate Relay `/quote/v2` endpoint while existing EVM Relay traffic remains on `/quote`. Core validates the instruction and address-lookup-table payload rather than treating it as an EVM transaction. A client callback compiles that payload with current Solana RPC data and returns an opaque prepared transaction, finalized network and priority fees, rent debit and rent-exemption requirement, source/native balances, and a preparation ID. Core normalizes the source amount, retained reserve, and affordability decision, then binds the one signing callback to that exact preparation. -Solana source status, Relay settlement, and the best-effort Relay transaction-index notification are separate observations. `reconcileSolanaPay` and `recoverSolanaPay` only observe persisted attempts. `retrySolanaPayNotification` retries only provider notification and cannot resubmit the source transaction. +The signing callback returns one of four explicit outcomes: submitted, user-rejected, guaranteed not-submitted, or ambiguous. Core persists an `attempting` checkpoint before invoking it and never automatically invokes it again. Only the ambiguous result enters source `unknown`; an unexpected callback rejection remains `attempting` for safe diagnosis. + +Solana source status, Relay settlement, the best-effort Relay transaction-index notification, and any required non-atomic Money Account follow-up are separate durable observations. `retrySolanaPayNotification` retries only provider notification and cannot resubmit the source transaction. `recoverSolanaPay` owns the non-signing polling loop and drives the parent transaction through standard submitted, rejected, failed, and confirmed lifecycle events. A required sponsored follow-up is checkpointed before its one callback attempt and must confirm before the intent becomes complete. ## Lifecycle @@ -74,6 +76,6 @@ The high level interaction with the `TransactionPayController` is as follows: 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. -Durable chain-agnostic source identity, execution checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. +Durable chain-agnostic source identity, discriminated business outcome, execution and follow-up checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. The full Solana quote is transient. Once the source attempt starts, recovery uses only the persisted Relay request ID and source signature. A missing signature after an ambiguous callback remains `unknown`; quote refresh, notification retry, status reconciliation, and source resubmission are deliberately not interchangeable operations. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 216bc2ab9e1..9242810833b 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a persisted, versioned Pay intent model with canonical CAIP source identity, one-attempt execution checkpoints, and provider correlation, mirrored to transaction metadata for restart recovery. -- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, instruction and lookup-table handoff, one Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. +- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, instruction and lookup-table preparation, normalized fee/rent/reserve affordability, one discriminated Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. +- Add durable non-atomic Money Account follow-up checkpoints and eventful parent transaction outcomes for success, rejection, source failure, Relay failure, refund, and unknown status. ### Changed 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 42b279ad282..178806a8a9e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -38,9 +38,9 @@ export type TransactionPayControllerGetSolanaPayQuoteAction = { /** * Performs at most one client-owned Solana sign-and-broadcast attempt. * - * The `attempting` checkpoint is persisted before invoking the callback. Any - * callback rejection is treated as ambiguous and becomes `unknown`; neither - * this method nor recovery will invoke the callback again. + * The `attempting` checkpoint is persisted before invoking the callback. + * The callback must resolve with a discriminated completion outcome; only an + * explicit ambiguous outcome becomes `unknown`. No outcome is resubmitted. * * @param transactionId - Target TransactionController transaction ID. * @returns The latest independent source, notification, and Relay statuses. diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 8e067e0c405..044e56b1ed4 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -18,12 +18,17 @@ import type { RelaySolanaQuote } from './strategy/relay/types.js'; import { getMessengerMock } from './tests/messenger-mock.js'; import type { TransactionPayControllerMessenger, + SolanaPayCallbacks, TransactionPayControllerOptions, TransactionPaySource, TransactionPaySourceAmount, UpdateTransactionDataCallback, } from './types.js'; -import { getStrategyOrder } from './utils/feature-flags.js'; +import { + getRelayPollingInterval, + getRelayPollingTimeout, + getStrategyOrder, +} from './utils/feature-flags.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -62,6 +67,21 @@ const SOLANA_PAY_INTENT_MOCK: TransactionPayIntent = { requestId: 'relay-request-123', sourceTransactionId: SOLANA_TRANSACTION_ID, }; +const SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK: TransactionPayIntent = { + ...SOLANA_PAY_INTENT_MOCK, + outcome: { type: 'submitted' }, +}; + +const SOLANA_PREFLIGHT_MOCK = { + preparedTransaction: 'base64-transaction', + preparationId: 'preparation-123', + nativeBalanceRaw: '100000', + networkFeeRaw: '5000', + priorityFeeRaw: '1000', + rentDebitRaw: '2000', + rentExemptionRequirementRaw: '3000', + sourceBalanceRaw: '2000000', +}; const SOLANA_QUOTE_MOCK: RelaySolanaQuote = { requestId: 'relay-request-123', @@ -130,6 +150,8 @@ describe('TransactionPayController', () => { const fetchRelaySolanaQuoteMock = jest.mocked(fetchRelaySolanaQuote); const getRelayStatusMock = jest.mocked(getRelayStatus); const notifyRelayTransactionMock = jest.mocked(notifyRelayTransaction); + const getRelayPollingIntervalMock = jest.mocked(getRelayPollingInterval); + const getRelayPollingTimeoutMock = jest.mocked(getRelayPollingTimeout); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); @@ -140,7 +162,10 @@ describe('TransactionPayController', () => { const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); let messenger: TransactionPayControllerMessenger; + let confirmTransactionMock: jest.Mock; + let failTransactionMock: jest.Mock; let getKeyringControllerStateMock: jest.Mock; + let getRemoteFeatureFlagControllerStateMock: jest.Mock; /** * Create a TransactionPayController. @@ -163,7 +188,33 @@ describe('TransactionPayController', () => { const mocks = getMessengerMock({ skipRegister: true }); messenger = mocks.messenger; + confirmTransactionMock = mocks.confirmTransactionMock; + failTransactionMock = mocks.failTransactionMock; getKeyringControllerStateMock = mocks.getKeyringControllerStateMock; + getRemoteFeatureFlagControllerStateMock = + mocks.getRemoteFeatureFlagControllerStateMock; + + messenger.registerActionHandler( + 'TransactionController:confirmTransaction', + confirmTransactionMock, + ); + messenger.registerActionHandler( + 'TransactionController:failTransaction', + failTransactionMock, + ); + messenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + getRemoteFeatureFlagControllerStateMock, + ); + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { pollingInterval: 1, pollingTimeout: 100 }, + }, + }, + }, + }); getKeyringControllerStateMock.mockReturnValue({ isUnlocked: true, @@ -177,6 +228,8 @@ describe('TransactionPayController', () => { }); getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + getRelayPollingIntervalMock.mockReturnValue(1); + getRelayPollingTimeoutMock.mockReturnValue(100); updateQuotesMock.mockResolvedValue(true); fetchRelaySolanaQuoteMock.mockResolvedValue(SOLANA_QUOTE_MOCK); notifyRelayTransactionMock.mockResolvedValue(); @@ -235,7 +288,9 @@ describe('TransactionPayController', () => { source: SOLANA_PAY_SOURCE_MOCK, }); - expect(controller.state).toStrictEqual({ transactionData: {} }); + expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( + SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, + ); expect(updateTransactionMock).toHaveBeenCalledWith( { transactionId: TRANSACTION_ID_MOCK, @@ -258,7 +313,7 @@ describe('TransactionPayController', () => { expect(transaction.metamaskPay).toStrictEqual({ chainId: CHAIN_ID_MOCK, - source: SOLANA_PAY_SOURCE_MOCK, + intent: SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, sourceHash: '0xabc', tokenAddress: TOKEN_ADDRESS_MOCK, }); @@ -278,11 +333,28 @@ describe('TransactionPayController', () => { updateTransactionCallback(transaction); expect(transaction.metamaskPay).toStrictEqual({ - source: SOLANA_PAY_SOURCE_MOCK, + intent: SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, }); }); - it('rejects source account and asset identifiers from different chains', () => { + it('preserves a non-Solana intent without adding Solana outcome policy', () => { + const controller = createController(); + const intent: TransactionPayIntent = { + version: 1, + sourceAccountId: + 'eip155:1:0x1234567890123456789012345678901234567890' as CaipAccountId, + sourceAssetId: 'eip155:1/slip44:60' as CaipAssetType, + sourceChainId: 'eip155:1' as CaipChainId, + }; + + controller.setPayIntent({ transactionId: TRANSACTION_ID_MOCK, intent }); + + expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( + intent, + ); + }); + + it('is callable via messenger action handler', () => { const controller = createController(); expect(() => @@ -322,7 +394,9 @@ describe('TransactionPayController', () => { source: SOLANA_PAY_SOURCE_MOCK, }); - expect(updateTransactionMock).toHaveBeenCalledTimes(1); + expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( + SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, + ); }); }); @@ -344,6 +418,20 @@ describe('TransactionPayController', () => { }; } + function getSolanaCallbacks( + overrides: Partial = {}, + ): SolanaPayCallbacks { + return { + getPreflight: jest.fn().mockResolvedValue(SOLANA_PREFLIGHT_MOCK), + getTransactionStatus: jest.fn().mockResolvedValue('pending'), + signAndSendTransaction: jest.fn().mockResolvedValue({ + outcome: 'submitted', + transactionId: SOLANA_TRANSACTION_ID, + }), + ...overrides, + }; + } + it('rejects quote requests without a persisted Solana intent', async () => { const controller = createController(); @@ -353,7 +441,9 @@ describe('TransactionPayController', () => { }); it('fetches a production Relay quote and persists its request checkpoint', async () => { + const solana = getSolanaCallbacks(); const controller = createController({ + solana, state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, @@ -369,7 +459,22 @@ describe('TransactionPayController', () => { tradeType: 'EXACT_INPUT', }), ); - expect(quote).toBe(SOLANA_QUOTE_MOCK); + expect(solana.getPreflight).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: SOLANA_ACCOUNT_ID, + requestId: 'relay-request-123', + sourceAmountRaw: '1000000', + sourceAssetId: SOLANA_ASSET_ID, + transaction: SOLANA_QUOTE_MOCK.steps[0].items[0].data, + }), + ); + expect(quote).toStrictEqual({ + preflight: expect.objectContaining({ + retainedReserveRaw: '11000', + sourceAmountRaw: '1000000', + }), + providerQuote: SOLANA_QUOTE_MOCK, + }); expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ requestId: 'relay-request-123', execution: { @@ -380,15 +485,60 @@ describe('TransactionPayController', () => { }); expect( controller.state.transactionData[TRANSACTION_ID_MOCK].solanaPayQuote, - ).toBe(SOLANA_QUOTE_MOCK); + ).toStrictEqual(quote); }); - it('rejects submission when the transient quote is unavailable', async () => { + it('rejects a Relay response whose canonical source amount changed', async () => { + const providerQuote = { + ...SOLANA_QUOTE_MOCK, + details: { + ...SOLANA_QUOTE_MOCK.details, + currencyIn: { + ...SOLANA_QUOTE_MOCK.details.currencyIn, + amount: '999999', + }, + }, + }; + fetchRelaySolanaQuoteMock.mockResolvedValue(providerQuote); + const solana = getSolanaCallbacks(); const controller = createController({ - solana: { - getTransactionStatus: jest.fn(), - signAndSendTransaction: jest.fn(), + solana, + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + + await expect( + controller.getSolanaPayQuote(GET_QUOTE_REQUEST), + ).rejects.toThrow('Relay Solana source amount mismatch'); + expect(solana.getPreflight).not.toHaveBeenCalled(); + }); + + it('enforces Core affordability policy before the signing boundary', async () => { + const signAndSendTransaction = jest.fn(); + const controller = createController({ + solana: getSolanaCallbacks({ + getPreflight: jest.fn().mockResolvedValue({ + ...SOLANA_PREFLIGHT_MOCK, + sourceBalanceRaw: '1', + }), + signAndSendTransaction, + }), + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Solana source is not affordable'); + expect(signAndSendTransaction).not.toHaveBeenCalled(); + }); + + it('rejects submission when the transient quote is unavailable', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -409,26 +559,29 @@ describe('TransactionPayController', () => { ).rejects.toThrow('Missing Solana Pay quote'); }); - it('rejects submission when Solana callbacks are not configured', async () => { + it('rejects quote preflight when Solana callbacks are not configured', async () => { const controller = createController({ state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); await expect( - controller.submitSolanaPay(TRANSACTION_ID_MOCK), + controller.getSolanaPayQuote(GET_QUOTE_REQUEST), ).rejects.toThrow('Solana callbacks missing'); }); it('invokes the sign-and-send callback once and never resubmits', async () => { const signAndSendTransaction = jest.fn().mockResolvedValue({ + outcome: 'submitted', transactionId: SOLANA_TRANSACTION_ID, }); const getTransactionStatus = jest.fn().mockResolvedValue('pending'); const controller = createController({ - solana: { getTransactionStatus, signAndSendTransaction }, + solana: getSolanaCallbacks({ + getTransactionStatus, + signAndSendTransaction, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, @@ -441,24 +594,26 @@ describe('TransactionPayController', () => { expect(signAndSendTransaction).toHaveBeenCalledTimes(1); expect(signAndSendTransaction).toHaveBeenCalledWith({ accountId: SOLANA_ACCOUNT_ID, + preparedTransaction: 'base64-transaction', + preparationId: 'preparation-123', requestId: 'relay-request-123', scope: SOLANA_CHAIN_ID, - transaction: SOLANA_QUOTE_MOCK.steps[0].items[0].data, }); expect( controller.state.payIntents[TRANSACTION_ID_MOCK].sourceTransactionId, ).toBe(SOLANA_TRANSACTION_ID); }); - it('persists an ambiguous callback rejection and never resubmits', async () => { - const signAndSendTransaction = jest - .fn() - .mockRejectedValue(new Error('Snap callback lost')); + it('persists an explicit ambiguous outcome and never resubmits', async () => { + const signAndSendTransaction = jest.fn().mockResolvedValue({ + outcome: 'ambiguous', + reason: 'Snap callback lost', + }); const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn(), signAndSendTransaction, - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, @@ -486,6 +641,52 @@ describe('TransactionPayController', () => { expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledTimes(1); }); + it.each([ + ['user-rejected', 'user-rejected'], + ['not-submitted', 'not-submitted'], + ] as const)( + 'persists guaranteed %s without entering unknown observation', + async (outcome, expectedStatus) => { + const signAndSendTransaction = jest.fn().mockResolvedValue({ outcome }); + const controller = createController({ + solana: getSolanaCallbacks({ signAndSendTransaction }), + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + const status = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.sourceStatus).toBe(expectedStatus); + expect(status.submissionOutcome).toBe(outcome); + expect(getRelayStatusMock).not.toHaveBeenCalled(); + expect(notifyRelayTransactionMock).not.toHaveBeenCalled(); + }, + ); + + it('does not classify an undiscriminated callback rejection as unknown', async () => { + const signAndSendTransaction = jest + .fn() + .mockRejectedValue(new Error('callback contract violation')); + const controller = createController({ + solana: getSolanaCallbacks({ signAndSendTransaction }), + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + }, + }); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('callback contract violation'); + + expect( + controller.state.payIntents[TRANSACTION_ID_MOCK].execution, + ).toMatchObject({ sourceStatus: 'attempting' }); + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + }); + it('rejects notification retry without stable request and source correlation', async () => { const controller = createController({ state: { @@ -500,13 +701,14 @@ describe('TransactionPayController', () => { it('retries provider notification without resubmitting', async () => { const signAndSendTransaction = jest.fn().mockResolvedValue({ + outcome: 'submitted', transactionId: SOLANA_TRANSACTION_ID, }); const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('pending'), signAndSendTransaction, - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, @@ -529,12 +731,12 @@ describe('TransactionPayController', () => { it('marks unavailable source and Relay observations as unknown', async () => { const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest .fn() .mockRejectedValue(new Error('RPC unavailable')), signAndSendTransaction: jest.fn(), - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -549,19 +751,25 @@ describe('TransactionPayController', () => { }, }); getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); expect(status.relayStatus).toBe('unknown'); expect(status.sourceStatus).toBe('unknown'); + expect(confirmTransactionMock).not.toHaveBeenCalled(); + expect(failTransactionMock).not.toHaveBeenCalled(); }); it('does not erase a terminal Relay observation when status is unavailable', async () => { const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('pending'), signAndSendTransaction: jest.fn(), - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -598,10 +806,10 @@ describe('TransactionPayController', () => { it('recovers a callback-lost source signature and reconciles both status axes', async () => { const signAndSendTransaction = jest.fn(); const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), signAndSendTransaction, - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -624,6 +832,10 @@ describe('TransactionPayController', () => { txHashes: ['0xtarget'], updatedAt: 1, }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); const statuses = await controller.recoverSolanaPay(); @@ -641,6 +853,87 @@ describe('TransactionPayController', () => { const transaction = {} as TransactionMeta; completionCall?.[1](transaction); expect(transaction.isIntentComplete).toBe(true); + expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); + }); + + it('keeps observing a pending intent until it reaches a terminal outcome', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + getRelayStatusMock + .mockResolvedValueOnce({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'pending', + txHashes: [], + updatedAt: 1, + }) + .mockResolvedValueOnce({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 2, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const statuses = await controller.recoverSolanaPay(); + + expect(statuses[TRANSACTION_ID_MOCK].outcome).toStrictEqual({ + type: 'succeeded', + }); + expect(getRelayStatusMock).toHaveBeenCalledTimes(2); + }); + + it('returns the latest non-terminal observation at the recovery timeout', async () => { + getRelayPollingTimeoutMock.mockReturnValue(1); + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'pending', + txHashes: [], + updatedAt: 1, + }); + const statuses = await controller.recoverSolanaPay(); + + expect(statuses[TRANSACTION_ID_MOCK].outcome).toStrictEqual({ + type: 'submitted', + }); }); it('skips intents that cannot or no longer need recovery', async () => { @@ -660,6 +953,24 @@ describe('TransactionPayController', () => { sourceStatus: 'confirmed', }, }, + 'terminal-rejection': { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + sourceStatus: 'user-rejected', + }, + }, + 'terminal-follow-up': { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'confirmed', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, }, }, }); @@ -670,10 +981,10 @@ describe('TransactionPayController', () => { it('preserves source failure separately from pending Relay settlement', async () => { const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('failed'), signAndSendTransaction: jest.fn(), - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -688,18 +999,29 @@ describe('TransactionPayController', () => { }, }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); expect(status.sourceStatus).toBe('failed'); expect(status.relayStatus).toBe('pending'); + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ + message: 'Solana source transaction failed', + }), + ); }); it('preserves Relay failure separately from confirmed source submission', async () => { const controller = createController({ - solana: { + solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), signAndSendTransaction: jest.fn(), - }, + }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { @@ -723,11 +1045,437 @@ describe('TransactionPayController', () => { updatedAt: 1, }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); expect(status.sourceStatus).toBe('confirmed'); expect(status.relayStatus).toBe('failure'); expect(status.failureReason).toBe('DESTINATION_TX_FAILED'); + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ message: 'DESTINATION_TX_FAILED' }), + ); + }); + + it.each([ + ['failure', 'Relay settlement failed'], + ['refund', 'Relay settlement refunded'], + ] as const)( + 'uses a stable parent failure for Relay %s without provider detail', + async (relayStatus, expectedMessage) => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: relayStatus, + txHashes: [], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ message: expectedMessage }), + ); + }, + ); + + it('emits a distinct parent failure for a Relay refund', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + refundFailReason: 'ORDER_REFUNDED', + status: 'refund', + txHashes: [], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.relayStatus).toBe('refund'); + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ message: 'ORDER_REFUNDED' }), + ); + }); + + it('completes only after a required one-shot non-atomic follow-up confirms', async () => { + const submitNonAtomicFollowUp = jest.fn().mockResolvedValue({ + outcome: 'submitted', + transactionId: 'follow-up-123', + }); + const getNonAtomicFollowUpStatus = jest + .fn() + .mockResolvedValue('confirmed'); + const controller = createController({ + solana: getSolanaCallbacks({ + getNonAtomicFollowUpStatus, + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + submitNonAtomicFollowUp, + }), + state: { + payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, + transactionData: { + [TRANSACTION_ID_MOCK]: { + atomic: false, + isLoading: false, + paymentOverride: PaymentOverride.MoneyAccount, + tokens: [], + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + const transaction = { + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta; + getTransactionMock.mockReturnValue(transaction); + await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + const status = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(submitNonAtomicFollowUp).toHaveBeenCalledTimes(1); + expect(getNonAtomicFollowUpStatus).toHaveBeenCalledWith({ + transaction, + transactionId: 'follow-up-123', + }); + expect(status.followUpStatus).toBe('confirmed'); + expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); + }); + + it('fails the parent when a required follow-up is guaranteed not submitted', async () => { + const controller = createController({ + solana: getSolanaCallbacks({ + submitNonAtomicFollowUp: jest + .fn() + .mockResolvedValue({ outcome: 'not-submitted' }), + }), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-started', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.outcome).toStrictEqual({ type: 'follow-up-failed' }); + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ + message: 'Solana pay non-atomic follow-up failed', + }), + ); + }); + + it('keeps an ambiguous follow-up observation-only and incomplete', async () => { + const controller = createController({ + solana: getSolanaCallbacks({ + submitNonAtomicFollowUp: jest + .fn() + .mockResolvedValue({ outcome: 'ambiguous' }), + }), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-started', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.outcome).toStrictEqual({ + phase: 'follow-up', + type: 'unknown', + }); + expect(confirmTransactionMock).not.toHaveBeenCalled(); + expect(failTransactionMock).not.toHaveBeenCalled(); + }); + + it('rejects a required follow-up when the target transaction is missing', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-started', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Target transaction missing'); + }); + + it('requires the non-atomic follow-up submission callback', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'not-started', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Non-atomic follow-up callback missing'); + }); + + it('requires the non-atomic follow-up status callback', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'submitted', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + followUpTransactionId: 'follow-up-123', + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Non-atomic follow-up status callback missing'); + }); + + it('records follow-up status unavailability without resubmitting', async () => { + const getNonAtomicFollowUpStatus = jest + .fn() + .mockRejectedValue(new Error('RPC unavailable')); + const submitNonAtomicFollowUp = jest.fn(); + const controller = createController({ + solana: getSolanaCallbacks({ + getNonAtomicFollowUpStatus, + submitNonAtomicFollowUp, + }), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'submitted', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + followUpTransactionId: 'follow-up-123', + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.followUpStatus).toBe('unknown'); + expect(submitNonAtomicFollowUp).not.toHaveBeenCalled(); + }); + + it('does not repeat or complete an interrupted non-atomic follow-up', async () => { + const submitNonAtomicFollowUp = jest.fn(); + const controller = createController({ + solana: getSolanaCallbacks({ submitNonAtomicFollowUp }), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + execution: { + followUpStatus: 'attempting', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + requiresNonAtomicFollowUp: true, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.followUpStatus).toBe('attempting'); + expect(submitNonAtomicFollowUp).not.toHaveBeenCalled(); + expect(confirmTransactionMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 7e1309d2863..13fa8a5ed29 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -10,6 +10,7 @@ import { updatePaymentToken } from './actions/update-payment-token.js'; import { CONTROLLER_NAME, isTransactionPayStrategy, + PaymentOverride, TransactionPayStrategy, } from './constants.js'; import { QuoteRefresher } from './helpers/QuoteRefresher.js'; @@ -21,9 +22,11 @@ import { } from './strategy/relay/relay-api.js'; import { buildRelaySolanaQuoteRequest, + deriveSolanaPayOutcome, getInitialSolanaPayExecution, getRelaySolanaTransaction, mapRelayStatus, + normalizeSolanaPayPreflight, } from './strategy/relay/solana-pay.js'; import type { GetAmountDataCallback, @@ -47,10 +50,15 @@ import type { UpdateFiatPaymentRequest, UpdatePaymentTokenRequest, } from './types.js'; -import { getStrategyOrder } from './utils/feature-flags.js'; +import { + getRelayPollingInterval, + getRelayPollingTimeout, + getStrategyOrder, +} from './utils/feature-flags.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { + getTransaction, subscribeAssetChanges, subscribeTransactionChanges, updateTransaction, @@ -104,7 +112,10 @@ function observePromise( function getSolanaPayExecution( intent: TransactionPayIntent, ): NonNullable { - return intent.execution ?? getInitialSolanaPayExecution(); + return ( + intent.execution ?? + getInitialSolanaPayExecution(intent.requiresNonAtomicFollowUp) + ); } function isTerminalRelayStatus( @@ -113,6 +124,37 @@ function isTerminalRelayStatus( return ['success', 'failure', 'refund'].includes(status); } +function isTerminalSolanaPayOutcome(status: SolanaPayStatus): boolean { + return [ + 'succeeded', + 'user-rejected', + 'source-failed', + 'relay-failed', + 'refunded', + 'follow-up-failed', + ].includes(status.outcome.type); +} + +function getSolanaPayFailure(status: SolanaPayStatus): string | undefined { + if (status.outcome.type === 'source-failed') { + return status.outcome.reason ?? 'Solana source transaction failed'; + } + + if (status.outcome.type === 'relay-failed') { + return status.outcome.reason ?? 'Relay settlement failed'; + } + + if (status.outcome.type === 'refunded') { + return status.outcome.reason ?? 'Relay settlement refunded'; + } + + if (status.outcome.type === 'follow-up-failed') { + return 'Solana pay non-atomic follow-up failed'; + } + + return undefined; +} + export class TransactionPayController extends BaseController< typeof CONTROLLER_NAME, TransactionPayControllerState, @@ -231,25 +273,57 @@ export class TransactionPayController extends BaseController< } const quoteRequest = buildRelaySolanaQuoteRequest(intent, request); - const quote = await fetchRelaySolanaQuote(this.messenger, quoteRequest); + const providerQuote = await fetchRelaySolanaQuote( + this.messenger, + quoteRequest, + ); + const transaction = getRelaySolanaTransaction(providerQuote); + + if (providerQuote.details.currencyIn.amount !== quoteRequest.amount) { + throw new Error( + 'TransactionPayController: Relay Solana source amount mismatch', + ); + } - getRelaySolanaTransaction(quote); + const preflightData = await this.#requireSolanaCallbacks().getPreflight({ + accountId: intent.sourceAccountId, + requestId: providerQuote.requestId, + scope: intent.sourceChainId, + sourceAmountRaw: quoteRequest.amount, + sourceAssetId: intent.sourceAssetId, + transaction, + }); + const quote: SolanaPayQuote = { + preflight: normalizeSolanaPayPreflight( + intent, + quoteRequest.amount, + preflightData, + ), + providerQuote, + }; + const transactionData = this.state.transactionData[request.transactionId]; + const requiresNonAtomicFollowUp = + transactionData?.atomic === false && + transactionData.paymentOverride === PaymentOverride.MoneyAccount; this.#persistPayIntent( request.transactionId, { ...intent, - execution: getInitialSolanaPayExecution(), + execution: getInitialSolanaPayExecution(requiresNonAtomicFollowUp), + followUpTransactionId: undefined, relayFailureReason: undefined, - requestId: quote.requestId, + requestId: providerQuote.requestId, + requiresNonAtomicFollowUp, + sourceFailureReason: undefined, sourceTransactionId: undefined, targetTransactionId: undefined, }, 'Set Solana pay quote checkpoint', ); - this.#updateTransactionData(request.transactionId, (transactionData) => { - transactionData.solanaPayQuote = quote; + this.#updateTransactionData(request.transactionId, (data) => { + data.solanaPayQuote = quote; }); return quote; @@ -258,9 +332,9 @@ export class TransactionPayController extends BaseController< /** * Performs at most one client-owned Solana sign-and-broadcast attempt. * - * The `attempting` checkpoint is persisted before invoking the callback. Any - * callback rejection is treated as ambiguous and becomes `unknown`; neither - * this method nor recovery will invoke the callback again. + * The `attempting` checkpoint is persisted before invoking the callback. + * The callback must resolve with a discriminated completion outcome; only an + * explicit ambiguous outcome becomes `unknown`. No outcome is resubmitted. * * @param transactionId - Target TransactionController transaction ID. * @returns The latest independent source, notification, and Relay statuses. @@ -275,12 +349,17 @@ export class TransactionPayController extends BaseController< const quote = this.state.transactionData[transactionId]?.solanaPayQuote; - if (!quote || quote.requestId !== intent.requestId) { + if (!quote || quote.providerQuote.requestId !== intent.requestId) { throw new Error('TransactionPayController: Missing Solana Pay quote'); } + if (!quote.preflight.affordability.isAffordable) { + throw new Error( + 'TransactionPayController: Solana source is not affordable', + ); + } + const solana = this.#requireSolanaCallbacks(); - const transaction = getRelaySolanaTransaction(quote); this.#updatePayIntent( transactionId, @@ -294,28 +373,37 @@ export class TransactionPayController extends BaseController< 'Start Solana source attempt', ); - const submission = await observePromise( - solana.signAndSendTransaction({ - accountId: intent.sourceAccountId, - requestId: intent.requestId, - scope: intent.sourceChainId, - transaction, - }), - ); + const submission = await solana.signAndSendTransaction({ + accountId: intent.sourceAccountId, + preparedTransaction: quote.preflight.preparedTransaction, + preparationId: quote.preflight.preparationId, + requestId: intent.requestId, + scope: intent.sourceChainId, + }); - if (submission.status === 'rejected') { + if (submission.outcome !== 'submitted') { + const sourceStatus = + submission.outcome === 'ambiguous' ? 'unknown' : submission.outcome; this.#updatePayIntent( transactionId, (current) => ({ ...current, execution: { ...getSolanaPayExecution(current), - sourceStatus: 'unknown', + sourceStatus, + submissionOutcome: submission.outcome, }, + sourceFailureReason: + submission.outcome === 'user-rejected' + ? undefined + : submission.reason, }), - 'Mark Solana source attempt ambiguous', + `Record Solana source outcome: ${submission.outcome}`, ); - return this.#getSolanaPayStatus(transactionId); + + return submission.outcome === 'ambiguous' + ? await this.reconcileSolanaPay(transactionId) + : this.#getSolanaPayStatus(transactionId); } this.#updatePayIntent( @@ -326,8 +414,9 @@ export class TransactionPayController extends BaseController< ...getSolanaPayExecution(current), providerNotificationStatus: 'pending', sourceStatus: 'submitted', + submissionOutcome: 'submitted', }, - sourceTransactionId: submission.value.transactionId, + sourceTransactionId: submission.transactionId, }), 'Record Solana source transaction', ); @@ -437,10 +526,6 @@ export class TransactionPayController extends BaseController< execution: { ...getSolanaPayExecution(current), relayStatus: mapRelayStatus(relayResult.value.status), - ...(!sourceTransactionId && - getSolanaPayExecution(current).sourceStatus === 'attempting' && { - sourceStatus: 'unknown' as const, - }), }, relayFailureReason: relayResult.value.failReason ?? relayResult.value.refundFailReason, @@ -483,24 +568,10 @@ export class TransactionPayController extends BaseController< ); } - const status = this.#getSolanaPayStatus(transactionId); - - if ( - status.sourceStatus === 'confirmed' && - status.relayStatus === 'success' - ) { - updateTransaction( - { - transactionId, - messenger: this.messenger, - note: 'Complete Solana pay intent', - }, - (transaction) => { - transaction.isIntentComplete = true; - }, - ); - } + await this.#advanceNonAtomicFollowUp(transactionId); + const status = this.#getSolanaPayStatus(transactionId); + this.#updateSolanaParentLifecycle(transactionId, status); return status; } @@ -518,16 +589,27 @@ export class TransactionPayController extends BaseController< )) { const execution = getSolanaPayExecution(intent); + const isFollowUpTerminal = + !intent.requiresNonAtomicFollowUp || + ['confirmed', 'failed', 'user-rejected', 'not-submitted'].includes( + execution.followUpStatus ?? 'not-started', + ); + const isRecoveryComplete = + ['user-rejected', 'not-submitted'].includes(execution.sourceStatus) || + (['confirmed', 'failed'].includes(execution.sourceStatus) && + isTerminalRelayStatus(execution.relayStatus) && + (execution.relayStatus !== 'success' || isFollowUpTerminal)); + if ( !intent.sourceChainId.startsWith('solana:') || !intent.requestId || - (['confirmed', 'failed'].includes(execution.sourceStatus) && - isTerminalRelayStatus(execution.relayStatus)) + isRecoveryComplete ) { continue; } - results[transactionId] = await this.reconcileSolanaPay(transactionId); + results[transactionId] = + await this.#recoverSolanaPayIntent(transactionId); } return results; @@ -701,6 +783,175 @@ export class TransactionPayController extends BaseController< return this.#requirePolymarket().submitDepositWalletBatch(...args); } + async #recoverSolanaPayIntent( + transactionId: string, + ): Promise { + const startTime = Date.now(); + + while (true) { + const status = await this.reconcileSolanaPay(transactionId); + + if (isTerminalSolanaPayOutcome(status)) { + return status; + } + + const timeout = getRelayPollingTimeout(this.messenger); + + if (timeout && Date.now() - startTime >= timeout) { + return status; + } + + await new Promise((resolve) => + setTimeout(resolve, getRelayPollingInterval(this.messenger)), + ); + } + } + + async #advanceNonAtomicFollowUp(transactionId: string): Promise { + let intent = this.#requireSolanaPayIntent(transactionId); + let execution = getSolanaPayExecution(intent); + + if ( + !intent.requiresNonAtomicFollowUp || + execution.relayStatus !== 'success' || + execution.sourceStatus !== 'confirmed' + ) { + return; + } + + const transaction = getTransaction(transactionId, this.messenger); + + if (!transaction) { + throw new Error('TransactionPayController: Target transaction missing'); + } + + if (execution.followUpStatus === 'not-started') { + const submitFollowUp = + this.#requireSolanaCallbacks().submitNonAtomicFollowUp; + + if (!submitFollowUp) { + throw new Error( + 'TransactionPayController: Non-atomic follow-up callback missing', + ); + } + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + followUpStatus: 'attempting', + }, + }), + 'Start Solana pay non-atomic follow-up', + ); + + const result = await submitFollowUp({ + requestId: intent.requestId as string, + relayTransactionId: intent.targetTransactionId, + transaction, + }); + const followUpStatus = + result.outcome === 'submitted' ? 'submitted' : result.outcome; + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + followUpStatus: + followUpStatus === 'ambiguous' ? 'unknown' : followUpStatus, + }, + followUpTransactionId: + result.outcome === 'submitted' ? result.transactionId : undefined, + }), + `Record Solana pay follow-up outcome: ${result.outcome}`, + ); + + intent = this.#requireSolanaPayIntent(transactionId); + execution = getSolanaPayExecution(intent); + } + + if ( + intent.followUpTransactionId && + (execution.followUpStatus === 'submitted' || + execution.followUpStatus === 'pending' || + execution.followUpStatus === 'unknown') + ) { + const getFollowUpStatus = + this.#requireSolanaCallbacks().getNonAtomicFollowUpStatus; + + if (!getFollowUpStatus) { + throw new Error( + 'TransactionPayController: Non-atomic follow-up status callback missing', + ); + } + + const result = await observePromise( + getFollowUpStatus({ + transaction, + transactionId: intent.followUpTransactionId, + }), + ); + + this.#updatePayIntent( + transactionId, + (current) => ({ + ...current, + execution: { + ...getSolanaPayExecution(current), + followUpStatus: + result.status === 'fulfilled' ? result.value : 'unknown', + }, + }), + 'Reconcile Solana pay non-atomic follow-up', + ); + } + } + + #updateSolanaParentLifecycle( + transactionId: string, + status: SolanaPayStatus, + ): void { + const transaction = getTransaction(transactionId, this.messenger); + + if (transaction?.status !== 'submitted') { + return; + } + + const failure = getSolanaPayFailure(status); + + if (failure) { + this.messenger.call( + 'TransactionController:failTransaction', + transactionId, + new Error(failure), + ); + return; + } + + if (status.outcome.type !== 'succeeded') { + return; + } + + updateTransaction( + { + transactionId, + messenger: this.messenger, + note: 'Complete Solana pay intent', + }, + (current) => { + current.isIntentComplete = true; + }, + ); + this.messenger.call( + 'TransactionController:confirmTransaction', + transactionId, + ); + } + #requirePolymarket(): PolymarketCallbacks { if (!this.#polymarket) { throw new Error('TransactionPayController: Polymarket callbacks missing'); @@ -731,10 +982,15 @@ export class TransactionPayController extends BaseController< return { failureReason: intent.relayFailureReason, + outcome: deriveSolanaPayOutcome(intent), providerNotificationStatus: execution.providerNotificationStatus, + followUpStatus: execution.followUpStatus, + followUpTransactionId: intent.followUpTransactionId, relayStatus: execution.relayStatus, requestId: intent.requestId as string, + sourceFailureReason: intent.sourceFailureReason, sourceStatus: execution.sourceStatus, + submissionOutcome: execution.submissionOutcome, sourceTransactionId: intent.sourceTransactionId, targetTransactionId: intent.targetTransactionId, }; @@ -754,6 +1010,10 @@ export class TransactionPayController extends BaseController< intent: TransactionPayIntent, note: string, ): void { + const persistedIntent = intent.sourceChainId.startsWith('solana:') + ? { ...intent, outcome: deriveSolanaPayOutcome(intent) } + : intent; + updateTransaction( { transactionId, @@ -762,12 +1022,12 @@ export class TransactionPayController extends BaseController< }, (transaction) => { transaction.metamaskPay ??= {}; - transaction.metamaskPay.intent = { ...intent }; + transaction.metamaskPay.intent = { ...persistedIntent }; }, ); this.update((state) => { - state.payIntents[transactionId] = { ...intent }; + state.payIntents[transactionId] = { ...persistedIntent }; }); } diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index fbbb9e0f326..8f1006ef4cb 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -7,6 +7,7 @@ import type { CaipChainId } from '@metamask/utils'; import { TransactionPayStrategy } from '../index.js'; import { getMessengerMock } from '../tests/messenger-mock.js'; import type { + SolanaPayStatus, TransactionPayControllerState, TransactionPayQuote, } from '../types.js'; @@ -105,14 +106,85 @@ describe('TransactionPayPublishHook', () => { transactionData: {}, } as TransactionPayControllerState); + submitSolanaPayMock.mockResolvedValue({ + outcome: { type: 'submitted' }, + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + requestId: 'relay-request-123', + sourceStatus: 'submitted', + submissionOutcome: 'submitted', + }); + const result = await runHook(); expect(submitSolanaPayMock).toHaveBeenCalledTimes(1); expect(submitSolanaPayMock).toHaveBeenCalledWith(TRANSACTION_META_MOCK.id); expect(executeMock).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ transactionHash: undefined }); + expect(result).toStrictEqual({ + externallyHandled: true, + outcome: 'submitted', + }); }); + it.each([ + ['user-rejected', undefined, undefined], + [ + 'not-submitted', + 'Snap rejected before broadcast', + 'Snap rejected before broadcast', + ], + ['not-submitted', undefined, 'Solana source transaction was not submitted'], + ['ambiguous', undefined, undefined], + [undefined, undefined, undefined], + ] as const)( + 'propagates externally handled %s outcome', + async (outcome, sourceFailureReason, expectedError) => { + const effectiveOutcome = outcome ?? 'ambiguous'; + getControllerStateMock.mockReturnValue({ + payIntents: { + [TRANSACTION_META_MOCK.id]: { + version: 1, + sourceAccountId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + sourceChainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + }, + }, + transactionData: {}, + } as TransactionPayControllerState); + + let payOutcome: SolanaPayStatus['outcome'] = { + phase: 'source', + type: 'unknown', + }; + if (effectiveOutcome === 'user-rejected') { + payOutcome = { type: 'user-rejected' }; + } else if (effectiveOutcome === 'not-submitted') { + payOutcome = { type: 'source-failed', guaranteedNotSubmitted: true }; + } + + submitSolanaPayMock.mockResolvedValue({ + outcome: payOutcome, + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + requestId: 'relay-request-123', + sourceFailureReason, + sourceStatus: + effectiveOutcome === 'ambiguous' ? 'unknown' : effectiveOutcome, + submissionOutcome: outcome, + }); + + const result = await runHook(); + + expect(result).toStrictEqual({ + ...(expectedError && { error: expectedError }), + externallyHandled: true, + outcome: effectiveOutcome, + }); + }, + ); + it('executes strategy with quotes', async () => { await runHook(); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index 319ea81a096..ce27893f48e 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -79,11 +79,21 @@ export class TransactionPayPublishHook { }, ); - await this.#messenger.call( + const status = await this.#messenger.call( 'TransactionPayController:submitSolanaPay', transactionId, ); - return EMPTY_RESULT; + const outcome = status.submissionOutcome ?? 'ambiguous'; + + return { + ...(outcome === 'not-submitted' && { + error: + status.sourceFailureReason ?? + 'Solana source transaction was not submitted', + }), + externallyHandled: true, + outcome, + }; } // No-op quotes mark direct routes and cannot be executed by any strategy. diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index e94328a3ddd..a678d87ee14 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -5,6 +5,8 @@ export type { GetBalanceCallback, GetBalanceRequest, GetBalanceResponse, + GetSolanaPayFollowUpStatusRequest, + GetSolanaPayPreflightRequest, GetSolanaPayQuoteRequest, GetSolanaPayTransactionStatusRequest, GetPaymentOverrideDataRequest, @@ -14,9 +16,13 @@ export type { QuoteErrorReason, SetPayIntentRequest, SolanaPayCallbacks, + SolanaPayFollowUpRequest, + SolanaPayPreflight, + SolanaPayPreflightData, SolanaPayQuote, SolanaPaySignAndSendTransactionRequest, SolanaPayStatus, + SolanaPaySubmissionResult, TransactionConfig, TransactionConfigCallback, TransactionData, diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts index 81a3f6564c3..6b07311ffaf 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -9,7 +9,8 @@ export const FALLBACK_HASH = '0x0' as Hex; export const RELAY_URL_BASE = 'https://api.relay.link'; export const RELAY_AUTHORIZE_URL = `${RELAY_URL_BASE}/authorize`; export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`; -export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote/v2`; +export const RELAY_QUOTE_URL = `${RELAY_URL_BASE}/quote`; +export const RELAY_SOLANA_QUOTE_URL = `${RELAY_URL_BASE}/quote/v2`; export const RELAY_STATUS_URL = `${RELAY_URL_BASE}/intents/status/v3`; export const RELAY_TRANSACTIONS_INDEX_URL = `${RELAY_URL_BASE}/transactions/index`; export const RELAY_SOLANA_CHAIN_ID = 792703809; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts index 8a83aef8c6c..56806d896c9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.test.ts @@ -30,6 +30,7 @@ const mockErrorResponse = (status: number, body: unknown): jest.SpyInstance => } as Response); const QUOTE_URL_MOCK = 'https://proxy.test/relay/quote'; +const SOLANA_QUOTE_URL_MOCK = 'https://proxy.test/relay/quote/v2'; const EXECUTE_URL_MOCK = 'https://proxy.test/relay/execute'; const MESSENGER_MOCK = {} as Parameters[0]; @@ -40,6 +41,7 @@ describe('relay-api', () => { getFeatureFlagsMock.mockReturnValue({ relayQuoteUrl: QUOTE_URL_MOCK, + relaySolanaQuoteUrl: SOLANA_QUOTE_URL_MOCK, relayExecuteUrl: EXECUTE_URL_MOCK, } as FeatureFlags); }); @@ -161,7 +163,7 @@ describe('relay-api', () => { SOLANA_QUOTE_REQUEST_MOCK, ); - expect(fetchMock).toHaveBeenCalledWith(QUOTE_URL_MOCK, { + expect(fetchMock).toHaveBeenCalledWith(SOLANA_QUOTE_URL_MOCK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(SOLANA_QUOTE_REQUEST_MOCK), diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts index 402243bceec..c2a635bba61 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-api.ts @@ -52,8 +52,8 @@ export async function fetchRelaySolanaQuote( body: RelaySolanaQuoteRequest, signal?: AbortSignal, ): Promise { - const { relayQuoteUrl } = getFeatureFlags(messenger); - const response = await relayFetch(relayQuoteUrl, { + const { relaySolanaQuoteUrl } = getFeatureFlags(messenger); + const response = await relayFetch(relaySolanaQuoteUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts index 80fc25fb1ae..8a3df620edb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -5,11 +5,16 @@ import type { Hex, } from '@metamask/utils'; -import type { TransactionPayIntent } from '../../types.js'; +import type { + SolanaPayPreflightData, + TransactionPayIntent, +} from '../../types.js'; import { buildRelaySolanaQuoteRequest, + deriveSolanaPayOutcome, getRelaySolanaTransaction, mapRelayStatus, + normalizeSolanaPayPreflight, } from './solana-pay.js'; import type { RelaySolanaQuote, RelaySolanaTransaction } from './types.js'; @@ -297,6 +302,173 @@ describe('Solana Relay Pay', () => { }); }); + describe('normalizeSolanaPayPreflight', () => { + const PREFLIGHT_DATA: SolanaPayPreflightData = { + preparedTransaction: 'base64-transaction', + preparationId: 'preparation-123', + nativeBalanceRaw: '100000', + networkFeeRaw: '5000', + priorityFeeRaw: '1000', + rentDebitRaw: '2000', + rentExemptionRequirementRaw: '3000', + sourceBalanceRaw: '2000000', + }; + + it('retains finalized fees and rent for an affordable SPL payment', () => { + expect( + normalizeSolanaPayPreflight( + getIntent(SOLANA_USDC), + '1000000', + PREFLIGHT_DATA, + ), + ).toStrictEqual({ + affordability: { + isAffordable: true, + nativeShortfallRaw: '0', + sourceShortfallRaw: '0', + }, + preparedTransaction: 'base64-transaction', + preparationId: 'preparation-123', + nativeBalanceRaw: '100000', + networkFeeRaw: '5000', + priorityFeeRaw: '1000', + rentDebitRaw: '2000', + rentExemptionRequirementRaw: '3000', + requiredNativeBalanceRaw: '11000', + requiredSourceBalanceRaw: '1000000', + retainedReserveRaw: '11000', + sourceAmountRaw: '1000000', + sourceBalanceRaw: '2000000', + totalFeeRaw: '6000', + }); + }); + + it('includes the retained reserve in native SOL affordability', () => { + expect( + normalizeSolanaPayPreflight(getIntent(SOLANA_NATIVE), '95000', { + ...PREFLIGHT_DATA, + nativeBalanceRaw: '100000', + sourceBalanceRaw: '100000', + }), + ).toMatchObject({ + affordability: { + isAffordable: false, + nativeShortfallRaw: '0', + sourceShortfallRaw: '6000', + }, + requiredNativeBalanceRaw: '106000', + requiredSourceBalanceRaw: '106000', + retainedReserveRaw: '11000', + }); + }); + + it('rejects inconsistent native SOL balance observations', () => { + expect(() => + normalizeSolanaPayPreflight(getIntent(SOLANA_NATIVE), '1', { + ...PREFLIGHT_DATA, + nativeBalanceRaw: '2', + sourceBalanceRaw: '1', + }), + ).toThrow('Invalid Solana preflight native balance mismatch'); + }); + + it('reports source and native shortfalls independently for SPL', () => { + expect( + normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + ...PREFLIGHT_DATA, + nativeBalanceRaw: '10000', + sourceBalanceRaw: '900000', + }), + ).toMatchObject({ + affordability: { + isAffordable: false, + nativeShortfallRaw: '1000', + sourceShortfallRaw: '100000', + }, + }); + }); + + it('rejects an unbound prepared transaction', () => { + expect(() => + normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + ...PREFLIGHT_DATA, + preparationId: '', + }), + ).toThrow('Invalid Solana preflight preparation'); + }); + + it.each([ + ['networkFeeRaw', '-1'], + ['priorityFeeRaw', '1.5'], + ['rentDebitRaw', 'abc'], + ['rentExemptionRequirementRaw', ''], + ['sourceBalanceRaw', '-2'], + ['nativeBalanceRaw', 'NaN'], + ] as const)('rejects invalid atomic input %s', (field, value) => { + expect(() => + normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + ...PREFLIGHT_DATA, + [field]: value, + }), + ).toThrow(`Invalid Solana preflight ${field}`); + }); + }); + + describe('deriveSolanaPayOutcome', () => { + it.each([ + [ + { + submissionOutcome: 'user-rejected', + sourceStatus: 'user-rejected', + }, + { type: 'user-rejected' }, + ], + [ + { + submissionOutcome: 'not-submitted', + sourceStatus: 'not-submitted', + }, + { guaranteedNotSubmitted: true, type: 'source-failed' }, + ], + [ + { sourceStatus: 'failed' }, + { guaranteedNotSubmitted: false, type: 'source-failed' }, + ], + [{ relayStatus: 'failure' }, { type: 'relay-failed' }], + [{ relayStatus: 'refund' }, { type: 'refunded' }], + [{ followUpStatus: 'failed' }, { type: 'follow-up-failed' }], + [ + { submissionOutcome: 'ambiguous', sourceStatus: 'unknown' }, + { phase: 'source', type: 'unknown' }, + ], + [{ relayStatus: 'unknown' }, { phase: 'relay', type: 'unknown' }], + [{ followUpStatus: 'unknown' }, { phase: 'follow-up', type: 'unknown' }], + [ + { + followUpStatus: 'confirmed', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + { type: 'succeeded' }, + ], + [{ sourceStatus: 'attempting' }, { type: 'attempting' }], + [{ sourceStatus: 'submitted' }, { type: 'submitted' }], + ] as const)('derives %# from durable axes', (overrides, expected) => { + const intent: TransactionPayIntent = { + ...getIntent(SOLANA_USDC), + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'not-started', + relayStatus: 'not-started', + sourceStatus: 'not-started', + ...overrides, + }, + }; + + expect(deriveSolanaPayOutcome(intent)).toMatchObject(expected); + }); + }); + it.each([ ['success', 'success'], ['failure', 'failure'], diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts index 5df1251689e..585a1482455 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -1,10 +1,14 @@ import type { MetamaskPayExecution, + MetamaskPayOutcome, MetamaskPayRelayStatus, } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; import type { GetSolanaPayQuoteRequest, + SolanaPayPreflight, + SolanaPayPreflightData, TransactionPayIntent, } from '../../types.js'; import { @@ -53,6 +57,83 @@ export function buildRelaySolanaQuoteRequest( }; } +/** + * Normalizes platform-provided Solana RPC observations into Core-owned policy. + * + * Native SOL must retain fees and rent in addition to the source amount. SPL + * sources evaluate token affordability separately from the native reserve. + * + * @param intent - Persisted source identity. + * @param sourceAmountRaw - Exact-input amount in atomic source units. + * @param data - Platform RPC and prepared-transaction observations. + * @returns Normalized client-consumable preflight. + */ +export function normalizeSolanaPayPreflight( + intent: TransactionPayIntent, + sourceAmountRaw: string, + data: SolanaPayPreflightData, +): SolanaPayPreflight { + if (!data.preparationId || !data.preparedTransaction) { + throw new Error('Invalid Solana preflight preparation'); + } + + const sourceAmount = getAtomicValue('sourceAmountRaw', sourceAmountRaw); + const sourceBalance = getAtomicValue( + 'sourceBalanceRaw', + data.sourceBalanceRaw, + ); + const nativeBalance = getAtomicValue( + 'nativeBalanceRaw', + data.nativeBalanceRaw, + ); + const networkFee = getAtomicValue('networkFeeRaw', data.networkFeeRaw); + const priorityFee = getAtomicValue('priorityFeeRaw', data.priorityFeeRaw); + const rentDebit = getAtomicValue('rentDebitRaw', data.rentDebitRaw); + const rentExemptionRequirement = getAtomicValue( + 'rentExemptionRequirementRaw', + data.rentExemptionRequirementRaw, + ); + const totalFee = networkFee.plus(priorityFee); + const retainedReserve = totalFee + .plus(rentDebit) + .plus(rentExemptionRequirement); + const isNative = intent.sourceAssetId.endsWith( + `/${SOLANA_NATIVE_ASSET_REFERENCE}`, + ); + + if (isNative && !sourceBalance.isEqualTo(nativeBalance)) { + throw new Error('Invalid Solana preflight native balance mismatch'); + } + + const requiredSourceBalance = isNative + ? sourceAmount.plus(retainedReserve) + : sourceAmount; + const requiredNativeBalance = isNative + ? requiredSourceBalance + : retainedReserve; + const sourceShortfall = BigNumber.maximum( + requiredSourceBalance.minus(sourceBalance), + 0, + ); + const nativeShortfall = isNative + ? new BigNumber(0) + : BigNumber.maximum(requiredNativeBalance.minus(nativeBalance), 0); + + return { + ...data, + affordability: { + isAffordable: sourceShortfall.isZero() && nativeShortfall.isZero(), + nativeShortfallRaw: nativeShortfall.toFixed(0), + sourceShortfallRaw: sourceShortfall.toFixed(0), + }, + requiredNativeBalanceRaw: requiredNativeBalance.toFixed(0), + requiredSourceBalanceRaw: requiredSourceBalance.toFixed(0), + retainedReserveRaw: retainedReserve.toFixed(0), + sourceAmountRaw: sourceAmount.toFixed(0), + totalFeeRaw: totalFee.toFixed(0), + }; +} + /** * Extracts and validates the single Relay Solana transaction handoff. * @@ -86,16 +167,112 @@ export function getRelaySolanaTransaction( /** * Returns the durable initial execution checkpoints for an executable quote. * + * @param requiresNonAtomicFollowUp - Whether target completion requires a sponsored follow-up. * @returns Initial one-attempt status axes. */ -export function getInitialSolanaPayExecution(): MetamaskPayExecution { +export function getInitialSolanaPayExecution( + requiresNonAtomicFollowUp = false, +): MetamaskPayExecution { return { + followUpStatus: requiresNonAtomicFollowUp ? 'not-started' : 'not-required', providerNotificationStatus: 'not-started', relayStatus: 'not-started', sourceStatus: 'not-started', }; } +/** + * Derives the durable business outcome from independent execution axes. + * + * @param intent - Persisted Pay intent and checkpoints. + * @returns Client-consumable outcome used for parent lifecycle transitions. + */ +export function deriveSolanaPayOutcome( + intent: TransactionPayIntent, +): MetamaskPayOutcome { + const execution = + intent.execution ?? + getInitialSolanaPayExecution(intent.requiresNonAtomicFollowUp); + const followUpStatus = execution.followUpStatus ?? 'not-required'; + + if (execution.submissionOutcome === 'user-rejected') { + return { type: 'user-rejected' }; + } + + if (execution.submissionOutcome === 'not-submitted') { + return { + guaranteedNotSubmitted: true, + reason: intent.sourceFailureReason, + type: 'source-failed', + }; + } + + if (execution.sourceStatus === 'failed') { + return { + guaranteedNotSubmitted: false, + reason: intent.sourceFailureReason, + type: 'source-failed', + }; + } + + if (execution.relayStatus === 'failure') { + return { reason: intent.relayFailureReason, type: 'relay-failed' }; + } + + if (execution.relayStatus === 'refund') { + return { reason: intent.relayFailureReason, type: 'refunded' }; + } + + if ( + followUpStatus === 'failed' || + followUpStatus === 'not-submitted' || + followUpStatus === 'user-rejected' + ) { + return { type: 'follow-up-failed' }; + } + + if ( + execution.sourceStatus === 'unknown' || + execution.submissionOutcome === 'ambiguous' + ) { + return { phase: 'source', type: 'unknown' }; + } + + if (execution.relayStatus === 'unknown') { + return { phase: 'relay', type: 'unknown' }; + } + + if (followUpStatus === 'unknown') { + return { phase: 'follow-up', type: 'unknown' }; + } + + const isFollowUpComplete = + followUpStatus === 'not-required' || followUpStatus === 'confirmed'; + + if ( + execution.sourceStatus === 'confirmed' && + execution.relayStatus === 'success' && + isFollowUpComplete + ) { + return { type: 'succeeded' }; + } + + if (execution.sourceStatus === 'attempting') { + return { type: 'attempting' }; + } + + if ( + execution.submissionOutcome === 'submitted' || + intent.sourceTransactionId !== undefined || + ['submitted', 'pending', 'confirmed'].includes(execution.sourceStatus) || + ['pending', 'success'].includes(execution.relayStatus) + ) { + return { type: 'submitted' }; + } + + return { type: 'not-started' }; +} + /** * Maps the provider lifecycle onto the independent durable Relay axis. * @@ -197,6 +374,16 @@ function isRelaySolanaInstructionKey(value: unknown): boolean { ); } +function getAtomicValue(name: string, value: string): BigNumber { + const amount = new BigNumber(value); + + if (!amount.isFinite() || !amount.isInteger() || amount.isNegative()) { + throw new Error(`Invalid Solana preflight ${name}: ${value}`); + } + + return amount; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index 44acb555c22..5ad22fe1f2f 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -18,7 +18,9 @@ import type { TransactionControllerAddTransactionAction, TransactionControllerAddTransactionBatchAction, TransactionControllerEstimateGasAction, + TransactionControllerConfirmTransactionAction, TransactionControllerEstimateGasBatchAction, + TransactionControllerFailTransactionAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, } from '@metamask/transaction-controller'; @@ -142,10 +144,18 @@ export function getMessengerMock({ TransactionControllerGetGasFeeTokensAction['handler'] > = jest.fn(); + const confirmTransactionMock: jest.MockedFn< + TransactionControllerConfirmTransactionAction['handler'] + > = jest.fn(); + const estimateGasMock: jest.MockedFn< TransactionControllerEstimateGasAction['handler'] > = jest.fn(); + const failTransactionMock: jest.MockedFn< + TransactionControllerFailTransactionAction['handler'] + > = jest.fn(); + const estimateGasBatchMock: jest.MockedFn< TransactionControllerEstimateGasBatchAction['handler'] > = jest.fn(); @@ -289,11 +299,21 @@ export function getMessengerMock({ getGasFeeTokensMock, ); + messenger.registerActionHandler( + 'TransactionController:confirmTransaction', + confirmTransactionMock, + ); + messenger.registerActionHandler( 'TransactionController:estimateGas', estimateGasMock, ); + messenger.registerActionHandler( + 'TransactionController:failTransaction', + failTransactionMock, + ); + messenger.registerActionHandler( 'TransactionController:estimateGasBatch', estimateGasBatchMock, @@ -321,7 +341,9 @@ export function getMessengerMock({ addTransactionMock, getAssetsControllerStateMock, addTransactionBatchMock, + confirmTransactionMock, estimateGasMock, + failTransactionMock, estimateGasBatchMock, findNetworkClientIdByChainIdMock, getAccountTrackerControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 07baa2e5cb8..9fb5503fbc4 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -38,7 +38,9 @@ import type { AuthorizationList, TransactionControllerAddTransactionBatchAction, TransactionControllerEstimateGasAction, + TransactionControllerConfirmTransactionAction, TransactionControllerEstimateGasBatchAction, + TransactionControllerFailTransactionAction, TransactionControllerUnapprovedTransactionAddedEvent, } from '@metamask/transaction-controller'; import type { @@ -54,7 +56,13 @@ import type { TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; -import type { CaipAccountId, CaipChainId, Hex, Json } from '@metamask/utils'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Hex, + Json, +} from '@metamask/utils'; import type { Draft } from 'immer'; import type { @@ -87,8 +95,10 @@ export type AllowedActions = | SentinelApiServiceActions | TransactionControllerAddTransactionAction | TransactionControllerAddTransactionBatchAction + | TransactionControllerConfirmTransactionAction | TransactionControllerEstimateGasAction | TransactionControllerEstimateGasBatchAction + | TransactionControllerFailTransactionAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction; @@ -301,24 +311,113 @@ export type GetSolanaPayQuoteRequest = { transactionId: string; }; -/** Relay /quote/v2 response containing an SVM instruction handoff. */ -export type SolanaPayQuote = RelaySolanaQuote; +/** Platform observations used by Core to normalize Solana affordability. */ +export type SolanaPayPreflightData = { + /** Opaque serialized transaction prepared by the platform callback. */ + preparedTransaction: string; + + /** Opaque identity binding submission to this exact preparation. */ + preparationId: string; + + /** Available native SOL balance in lamports. */ + nativeBalanceRaw: string; + + /** Finalized base network fee in lamports, excluding priority fee. */ + networkFeeRaw: string; + + /** Finalized priority fee in lamports. */ + priorityFeeRaw: string; + + /** Rent debited by the prepared transaction in lamports. */ + rentDebitRaw: string; + + /** Lamports that must remain to satisfy rent exemption. */ + rentExemptionRequirementRaw: string; + + /** Available balance of the selected source asset in atomic units. */ + sourceBalanceRaw: string; +}; + +/** Core-normalized Solana quote preflight policy. */ +export type SolanaPayPreflight = SolanaPayPreflightData & { + /** Affordability decision and exact atomic shortfalls. */ + affordability: { + isAffordable: boolean; + nativeShortfallRaw: string; + sourceShortfallRaw: string; + }; + + /** Native balance required by the transaction and retained reserve. */ + requiredNativeBalanceRaw: string; + + /** Source-asset balance required, including reserve for native SOL. */ + requiredSourceBalanceRaw: string; + + /** Fees and rent retained in addition to the source amount. */ + retainedReserveRaw: string; + + /** Exact-input source amount in atomic units. */ + sourceAmountRaw: string; + + /** Finalized base and priority fee total in lamports. */ + totalFeeRaw: string; +}; + +/** Client-consumable Relay quote and normalized Solana preflight. */ +export type SolanaPayQuote = { + /** Raw provider quote retained for execution and provider diagnostics. */ + providerQuote: RelaySolanaQuote; + + /** Core-owned normalized fee, rent, reserve, and affordability policy. */ + preflight: SolanaPayPreflight; +}; /** Request passed to the client-owned Solana sign-and-broadcast boundary. */ export type SolanaPaySignAndSendTransactionRequest = { /** Canonical source account used by the Snap client request. */ accountId: CaipAccountId; + /** Opaque serialized transaction returned by the preflight callback. */ + preparedTransaction: string; + + /** Opaque identity binding submission to the displayed preflight. */ + preparationId: string; + /** Relay correlation ID. */ requestId: string; /** Canonical Solana chain scope used by the Snap client request. */ scope: CaipChainId; +}; + +/** Request used to prepare and price a Relay Solana transaction. */ +export type GetSolanaPayPreflightRequest = { + /** Canonical source account. */ + accountId: CaipAccountId; + + /** Relay correlation ID. */ + requestId: string; + + /** Canonical Solana chain scope. */ + scope: CaipChainId; + + /** Canonical source asset whose balances are being evaluated. */ + sourceAssetId: CaipAssetType; - /** Relay instructions and LUT addresses to compile with a fresh blockhash. */ + /** Exact-input source amount in atomic units. */ + sourceAmountRaw: string; + + /** Relay instruction and LUT handoff to prepare with current RPC data. */ transaction: RelaySolanaTransaction; }; +/** Discriminated result of one client-owned submission callback invocation. */ +export type SolanaPaySubmissionResult = + | { outcome: 'submitted'; transactionId: string } + | { outcome: 'user-rejected' } + | { outcome: 'not-submitted'; reason?: string } + | { outcome: 'ambiguous'; reason?: string }; + /** Request used to observe a previously submitted Solana transaction. */ export type GetSolanaPayTransactionStatusRequest = { /** Canonical source account. */ @@ -331,25 +430,66 @@ export type GetSolanaPayTransactionStatusRequest = { transactionId: string; }; +/** Request for the required one-shot sponsored target follow-up. */ +export type SolanaPayFollowUpRequest = { + /** Relay request ID that settled the source payment. */ + requestId: string; + + /** Relay destination transaction ID, when available. */ + relayTransactionId?: string; + + /** Target transaction whose sponsored follow-up must be submitted. */ + transaction: TransactionMeta; +}; + +/** Request used to observe a sponsored non-atomic follow-up. */ +export type GetSolanaPayFollowUpStatusRequest = { + /** Target parent transaction. */ + transaction: TransactionMeta; + + /** Submitted follow-up transaction ID. */ + transactionId: string; +}; + /** Client-owned Solana operations required by TransactionPayController. */ export type SolanaPayCallbacks = { /** - * Compiles the Relay instruction/LUT handoff, then invokes - * `SnapController:handleRequest` with `signAndSendTransaction` exactly once. - * The callback returns the base58 transaction signature. + * Prepares the instruction/LUT handoff with current RPC data and returns raw + * fee, rent, and balance observations. Core owns normalization and policy. + */ + getPreflight: ( + request: GetSolanaPayPreflightRequest, + ) => Promise; + + /** + * Invokes `SnapController:handleRequest` with `signAndSendTransaction` + * exactly once and returns an explicit completion outcome. */ signAndSendTransaction: ( request: SolanaPaySignAndSendTransactionRequest, - ) => Promise<{ transactionId: string }>; + ) => Promise; /** Observes source-chain status without submitting or resubmitting. */ getTransactionStatus: ( request: GetSolanaPayTransactionStatusRequest, ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; + + /** Submits a required sponsored non-atomic follow-up exactly once. */ + submitNonAtomicFollowUp?: ( + request: SolanaPayFollowUpRequest, + ) => Promise; + + /** Observes a submitted sponsored follow-up without resubmitting it. */ + getNonAtomicFollowUpStatus?: ( + request: GetSolanaPayFollowUpStatusRequest, + ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; }; /** Independent durable observations returned by reconciliation. */ export type SolanaPayStatus = { + /** Core-derived durable business outcome. */ + outcome: NonNullable; + /** Provider failure classification, when supplied by Relay. */ failureReason?: string; @@ -369,6 +509,28 @@ export type SolanaPayStatus = { /** Source-chain observation status. */ sourceStatus: MetamaskPaySourceStatus; + /** Status of a required sponsored non-atomic follow-up. */ + followUpStatus?: + | 'not-required' + | 'not-started' + | 'attempting' + | 'submitted' + | 'pending' + | 'confirmed' + | 'failed' + | 'user-rejected' + | 'not-submitted' + | 'unknown'; + + /** Follow-up transaction ID, when submitted. */ + followUpTransactionId?: string; + + /** Durable completion outcome of the single submission callback. */ + submissionOutcome?: SolanaPaySubmissionResult['outcome']; + + /** Stable source submission failure or interruption detail. */ + sourceFailureReason?: string; + /** Base58 source transaction signature, when known. */ sourceTransactionId?: string; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 849bcba09b4..a79fd6e8107 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -15,6 +15,7 @@ import { DEFAULT_SERVER_BASE_URL, DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD, DEFAULT_RELAY_QUOTE_URL, + DEFAULT_RELAY_SOLANA_QUOTE_URL, DEFAULT_SLIPPAGE, getAssetsUnifyStateFeature, getFallbackGas, @@ -52,6 +53,7 @@ import * as featureFlagsModule from './feature-flags.js'; const GAS_FALLBACK_ESTIMATE_MOCK = 123; const GAS_FALLBACK_MAX_MOCK = 456; const RELAY_QUOTE_URL_MOCK = 'https://test.com/test'; +const RELAY_SOLANA_QUOTE_URL_MOCK = 'https://test.com/solana'; const RELAY_GAS_STATION_DISABLED_CHAINS_MOCK = ['0x1', '0x2']; const SLIPPAGE_MOCK = 0.01; const GAS_BUFFER_DEFAULT_MOCK = 1.5; @@ -104,6 +106,7 @@ describe('Feature Flags Utils', () => { max: DEFAULT_FALLBACK_GAS_MAX, }, relayQuoteUrl: DEFAULT_RELAY_QUOTE_URL, + relaySolanaQuoteUrl: DEFAULT_RELAY_SOLANA_QUOTE_URL, slippage: DEFAULT_SLIPPAGE, }); }); @@ -120,6 +123,7 @@ describe('Feature Flags Utils', () => { max: GAS_FALLBACK_MAX_MOCK, }, relayQuoteUrl: RELAY_QUOTE_URL_MOCK, + relaySolanaQuoteUrl: RELAY_SOLANA_QUOTE_URL_MOCK, slippage: SLIPPAGE_MOCK, }, }, @@ -135,6 +139,7 @@ describe('Feature Flags Utils', () => { max: GAS_FALLBACK_MAX_MOCK, }, relayQuoteUrl: RELAY_QUOTE_URL_MOCK, + relaySolanaQuoteUrl: RELAY_SOLANA_QUOTE_URL_MOCK, slippage: SLIPPAGE_MOCK, }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 349c69941a0..88b0f23a13d 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -24,6 +24,7 @@ import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, RELAY_QUOTE_URL, + RELAY_SOLANA_QUOTE_URL, } from '../strategy/relay/constants.js'; import { SERVER_POLLING_INTERVAL, @@ -45,6 +46,7 @@ export const DEFAULT_FALLBACK_GAS_ESTIMATE = 900000; export const DEFAULT_FALLBACK_GAS_MAX = 1500000; export const DEFAULT_RELAY_EXECUTE_URL = RELAY_EXECUTE_URL; export const DEFAULT_RELAY_QUOTE_URL = RELAY_QUOTE_URL; +export const DEFAULT_RELAY_SOLANA_QUOTE_URL = RELAY_SOLANA_QUOTE_URL; export const DEFAULT_RELAY_ORIGIN_GAS_OVERHEAD = '300000'; export const DEFAULT_SLIPPAGE = 0.005; export const DEFAULT_HYPERLIQUID_ACTIVATION_FEE_USD = 1; @@ -74,6 +76,7 @@ type FeatureFlagsRaw = { max?: number; }; relayQuoteUrl?: string; + relaySolanaQuoteUrl?: string; slippage?: number; slippageTokens?: Record>; strategyOrder?: string[]; @@ -159,6 +162,7 @@ export type FeatureFlags = { max: number; }; relayQuoteUrl: string; + relaySolanaQuoteUrl: string; slippage: number; }; @@ -521,6 +525,8 @@ export function getFeatureFlags( featureFlags.relayExecuteUrl ?? DEFAULT_RELAY_EXECUTE_URL; const relayQuoteUrl = featureFlags.relayQuoteUrl ?? DEFAULT_RELAY_QUOTE_URL; + const relaySolanaQuoteUrl = + featureFlags.relaySolanaQuoteUrl ?? DEFAULT_RELAY_SOLANA_QUOTE_URL; const relayDisabledGasStationChains = featureFlags.relayDisabledGasStationChains ?? []; @@ -535,6 +541,7 @@ export function getFeatureFlags( max, }, relayQuoteUrl, + relaySolanaQuoteUrl, slippage, }; From 161d8614fa4f9c25142e319d2390a4f64696e800 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 14 Sep 2026 17:50:06 +0100 Subject: [PATCH 3/5] fix(transaction-pay-controller): embed Solana product calls --- packages/transaction-controller/CHANGELOG.md | 2 +- packages/transaction-controller/src/types.ts | 14 +- .../ARCHITECTURE.md | 6 +- .../transaction-pay-controller/CHANGELOG.md | 2 +- ...actionPayController-method-action-types.ts | 2 +- .../src/TransactionPayController.test.ts | 288 ++++++++++++---- .../src/TransactionPayController.ts | 67 +++- .../helpers/TransactionPayPublishHook.test.ts | 8 +- .../src/strategy/relay/solana-pay.test.ts | 311 +++++++++++++----- .../src/strategy/relay/solana-pay.ts | 137 +++++++- .../src/strategy/relay/types.ts | 15 +- .../transaction-pay-controller/src/types.ts | 43 +-- .../src/utils/quotes.test.ts | 9 +- 13 files changed, 718 insertions(+), 186 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index ac54ba80a25..f3434c9fe37 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add versioned, chain-agnostic Pay intent metadata for persisting CAIP source identity, discriminated outcomes, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. +- Add versioned, chain-agnostic Pay intent metadata for persisting the wallet-local source account ID, CAIP source identity, atomic source amount, atomic product-route checkpoint, discriminated outcomes, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. - Add externally handled publish results so non-EVM hooks can suppress EVM RPC fallback while preserving submitted, rejected, failed, and confirmed transaction lifecycle events. ### Changed diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 3b9e8b1e871..8fbc070c5ac 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2268,7 +2268,13 @@ export type MetamaskPayExecution = { */ export type MetamaskPayIntent = { /** Schema version of the persisted intent. */ - version: 1; + version: 2; + + /** Wallet-local InternalAccount.id used by Snap requests. */ + sourceWalletAccountId: InternalAccount['id']; + + /** Immutable source amount in atomic units, finalized by Core at quote time. */ + sourceAmountRaw?: string; /** Canonical CAIP-10 identity of the source account. */ sourceAccountId: CaipAccountId; @@ -2300,6 +2306,12 @@ export type MetamaskPayIntent = { /** Provider failure classification observed during reconciliation. */ relayFailureReason?: string; + /** Whether the parent product action must execute atomically in the Relay route. */ + atomicProductActionRequired?: boolean; + + /** Whether the verified quote included the required atomic product action. */ + atomicProductActionIncluded?: boolean; + /** Whether Relay success must be followed by a sponsored target submission. */ requiresNonAtomicFollowUp?: boolean; diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index 0308523262d..b82a778ba22 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -47,7 +47,9 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay) The resulting transaction deposits the necessary funds (on the source network), then a Relayer on the target chain immediately transfers the necessary funds and optionally executes any requested call data. -For a Solana source, the controller uses a separate Relay `/quote/v2` endpoint while existing EVM Relay traffic remains on `/quote`. Core validates the instruction and address-lookup-table payload rather than treating it as an EVM transaction. A client callback compiles that payload with current Solana RPC data and returns an opaque prepared transaction, finalized network and priority fees, rent debit and rent-exemption requirement, source/native balances, and a preparation ID. Core normalizes the source amount, retained reserve, and affordability decision, then binds the one signing callback to that exact preparation. +For a Solana source, the controller uses a separate Relay `/quote/v2` endpoint while existing EVM Relay traffic remains on `/quote`. The persisted source snapshot distinguishes the wallet-local `InternalAccount.id` used by Snap requests from the CAIP-10 account identity, and records the CAIP asset/chain plus the provider-finalized atomic source amount. Core derives destination chain/currency, recipient, target minimum, and trade type from the parent transaction and Pay state. Atomic Perps/Predict routes use Relay `/quote/v2`'s verified `txs` field to include a target-token transfer and the delegated product call; non-atomic Money Account routes omit `txs` and retain their one-sponsored-follow-up boundary. + +Core validates the returned instruction and address-lookup-table payload rather than treating it as an EVM transaction. A client callback compiles that payload with current Solana RPC data and returns an opaque prepared transaction, finalized network and priority fees, rent debit and rent-exemption requirement, source/native balances, and a preparation ID. Core normalizes the source amount, retained reserve, and affordability decision, then binds the one signing callback to that exact preparation. The signing callback returns one of four explicit outcomes: submitted, user-rejected, guaranteed not-submitted, or ambiguous. Core persists an `attempting` checkpoint before invoking it and never automatically invokes it again. Only the ambiguous result enters source `unknown`; an unexpected callback rejection remains `attempting` for safe diagnosis. @@ -76,6 +78,6 @@ The high level interaction with the `TransactionPayController` is as follows: 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. -Durable chain-agnostic source identity, discriminated business outcome, execution and follow-up checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. +Durable wallet-local and chain-agnostic source identity, provider-finalized source amount, atomic-product-route inclusion, discriminated business outcome, execution and follow-up checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. The full Solana quote is transient. Once the source attempt starts, recovery uses only the persisted Relay request ID and source signature. A missing signature after an ambiguous callback remains `unknown`; quote refresh, notification retry, status reconciliation, and source resubmission are deliberately not interchangeable operations. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 9242810833b..b81196695a6 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add a persisted, versioned Pay intent model with canonical CAIP source identity, one-attempt execution checkpoints, and provider correlation, mirrored to transaction metadata for restart recovery. -- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, instruction and lookup-table preparation, normalized fee/rent/reserve affordability, one discriminated Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. +- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, Core-derived destination/trade policy and atomic Perps/Predict calls, instruction and lookup-table preparation, normalized fee/rent/reserve affordability, one discriminated Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. - Add durable non-atomic Money Account follow-up checkpoints and eventful parent transaction outcomes for success, rejection, source failure, Relay failure, refund, and unknown status. ### Changed 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 178806a8a9e..1ecc796b7f5 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -27,7 +27,7 @@ export type TransactionPayControllerSetPaySourceAction = { * the full instruction payload remains transient because recovery only * observes an existing attempt and never resubmits it. * - * @param request - Source amount and EVM destination details. + * @param request - Target transaction whose route Core derives. * @returns The Relay Solana quote for client display and confirmation. */ export type TransactionPayControllerGetSolanaPayQuoteAction = { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 044e56b1ed4..164971b15ed 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -1,6 +1,7 @@ /* eslint-disable no-new */ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { TransactionType } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAccountId, CaipAssetType, Hex } from '@metamask/utils'; @@ -20,7 +21,8 @@ import type { TransactionPayControllerMessenger, SolanaPayCallbacks, TransactionPayControllerOptions, - TransactionPaySource, + TransactionPayControllerState, + TransactionPayIntent, TransactionPaySourceAmount, UpdateTransactionDataCallback, } from './types.js'; @@ -60,12 +62,14 @@ const SOLANA_ASSET_ID = const SOLANA_TRANSACTION_ID = '5KtPn3tE7mWcMgvBvXhXxF9xQmP9xNj7wT6oYxj3BbJpfK2p5yhx2S1QnW8gQwJm7U3F5v9hQmXk8b2mJ7dP4c'; const SOLANA_PAY_INTENT_MOCK: TransactionPayIntent = { - version: 1, + version: 2, sourceAccountId: SOLANA_ACCOUNT_ID, + sourceAmountRaw: '1000000', sourceAssetId: SOLANA_ASSET_ID, sourceChainId: SOLANA_CHAIN_ID, requestId: 'relay-request-123', sourceTransactionId: SOLANA_TRANSACTION_ID, + sourceWalletAccountId: 'wallet-account-uuid', }; const SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK: TransactionPayIntent = { ...SOLANA_PAY_INTENT_MOCK, @@ -178,7 +182,7 @@ describe('TransactionPayController', () => { ): TransactionPayController { return new TransactionPayController({ ...options, - getDelegationTransaction: jest.fn(), + getDelegationTransaction: options.getDelegationTransaction ?? jest.fn(), messenger, }); } @@ -340,11 +344,13 @@ describe('TransactionPayController', () => { it('preserves a non-Solana intent without adding Solana outcome policy', () => { const controller = createController(); const intent: TransactionPayIntent = { - version: 1, + version: 2, sourceAccountId: 'eip155:1:0x1234567890123456789012345678901234567890' as CaipAccountId, + sourceAmountRaw: '1', sourceAssetId: 'eip155:1/slip44:60' as CaipAssetType, sourceChainId: 'eip155:1' as CaipChainId, + sourceWalletAccountId: 'evm-account-uuid', }; controller.setPayIntent({ transactionId: TRANSACTION_ID_MOCK, intent }); @@ -402,19 +408,80 @@ describe('TransactionPayController', () => { describe('Solana Pay', () => { const GET_QUOTE_REQUEST = { - amount: '1000000', - destinationChainId: '0xa4b1' as Hex, - destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex, - recipient: '0x1234567890123456789012345678901234567890' as Hex, transactionId: TRANSACTION_ID_MOCK, }; + const TARGET_TOKEN = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex; + + beforeEach(() => { + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + } as TransactionMeta); + }); function getUnquotedIntent(): TransactionPayIntent { return { - version: 1, + version: 2, sourceAccountId: SOLANA_ACCOUNT_ID, sourceAssetId: SOLANA_ASSET_ID, sourceChainId: SOLANA_CHAIN_ID, + sourceWalletAccountId: 'wallet-account-uuid', + }; + } + + function getQuoteState( + intent = getUnquotedIntent(), + ): Partial { + return { + payIntents: { [TRANSACTION_ID_MOCK]: intent }, + transactionData: { + [TRANSACTION_ID_MOCK]: { + isLoading: false, + sourceAmounts: [ + { + sourceAmountHuman: '1', + sourceAmountRaw: '1000000', + targetTokenAddress: TARGET_TOKEN, + }, + ], + tokens: [ + { + address: TARGET_TOKEN, + allowUnderMinimum: false, + amountFiat: '1', + amountHuman: '1', + amountRaw: '900000', + amountUsd: '1', + balanceFiat: '0', + balanceHuman: '0', + balanceRaw: '0', + balanceUsd: '0', + chainId: '0xa4b1' as Hex, + decimals: 6, + skipIfBalance: false, + symbol: 'USDC', + }, + ], + }, + }, + }; + } + + function getMoneyAccountQuoteState(): Partial { + const state = getQuoteState(); + const transactionData = state.transactionData?.[TRANSACTION_ID_MOCK]; + + return { + ...state, + transactionData: { + [TRANSACTION_ID_MOCK]: { + ...transactionData, + atomic: false, + paymentOverride: PaymentOverride.MoneyAccount, + }, + } as TransactionPayControllerState['transactionData'], }; } @@ -440,15 +507,26 @@ describe('TransactionPayController', () => { ).rejects.toThrow('Solana Pay intent missing'); }); - it('fetches a production Relay quote and persists its request checkpoint', async () => { - const solana = getSolanaCallbacks(); + it('rejects quote construction without production transaction data', async () => { const controller = createController({ - solana, + solana: getSolanaCallbacks(), state: { payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, }, }); + await expect( + controller.getSolanaPayQuote(GET_QUOTE_REQUEST), + ).rejects.toThrow('Transaction data missing'); + }); + + it('fetches a production Relay quote and persists its request checkpoint', async () => { + const solana = getSolanaCallbacks(); + const controller = createController({ + solana, + state: getQuoteState(), + }); + const quote = await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledWith( @@ -461,7 +539,8 @@ describe('TransactionPayController', () => { ); expect(solana.getPreflight).toHaveBeenCalledWith( expect.objectContaining({ - accountId: SOLANA_ACCOUNT_ID, + accountId: 'wallet-account-uuid', + caipAccountId: SOLANA_ACCOUNT_ID, requestId: 'relay-request-123', sourceAmountRaw: '1000000', sourceAssetId: SOLANA_ASSET_ID, @@ -474,6 +553,12 @@ describe('TransactionPayController', () => { sourceAmountRaw: '1000000', }), providerQuote: SOLANA_QUOTE_MOCK, + route: { + atomicProductActionIncluded: false, + recipient: '0x1234567890123456789012345678901234567890', + targetAmountMinimum: '900000', + tradeType: 'EXACT_INPUT', + }, }); expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ requestId: 'relay-request-123', @@ -486,6 +571,70 @@ describe('TransactionPayController', () => { expect( controller.state.transactionData[TRANSACTION_ID_MOCK].solanaPayQuote, ).toStrictEqual(quote); + expect(() => + controller.setPayIntent({ + transactionId: TRANSACTION_ID_MOCK, + intent: { + ...getUnquotedIntent(), + sourceWalletAccountId: 'different-wallet-account', + }, + }), + ).toThrow('Solana source snapshot is immutable'); + }); + + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])('derives and checkpoints atomic product calls for %s', async (type) => { + const getDelegationTransaction = jest.fn().mockResolvedValue({ + data: '0x1234' as Hex, + to: '0x9876543210987654321098765432109876543210' as Hex, + value: '0x0' as Hex, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + type, + } as TransactionMeta); + const controller = createController({ + getDelegationTransaction, + solana: getSolanaCallbacks(), + state: getQuoteState(), + }); + + const quote = await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + + expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledWith( + messenger, + expect.objectContaining({ + amount: '900000', + tradeType: 'EXACT_OUTPUT', + txs: expect.arrayContaining([ + expect.objectContaining({ to: TARGET_TOKEN }), + { + data: '0x1234', + to: '0x9876543210987654321098765432109876543210', + value: '0x0', + }, + ]), + }), + ); + expect(quote.route).toStrictEqual({ + atomicProductActionIncluded: true, + recipient: '0x1234567890123456789012345678901234567890', + targetAmountMinimum: '900000', + tradeType: 'EXACT_OUTPUT', + }); + expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ + atomicProductActionIncluded: true, + atomicProductActionRequired: true, + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + }); }); it('rejects a Relay response whose canonical source amount changed', async () => { @@ -503,9 +652,7 @@ describe('TransactionPayController', () => { const solana = getSolanaCallbacks(); const controller = createController({ solana, - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await expect( @@ -524,9 +671,7 @@ describe('TransactionPayController', () => { }), signAndSendTransaction, }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); @@ -561,9 +706,7 @@ describe('TransactionPayController', () => { it('rejects quote preflight when Solana callbacks are not configured', async () => { const controller = createController({ - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await expect( @@ -582,9 +725,7 @@ describe('TransactionPayController', () => { getTransactionStatus, signAndSendTransaction, }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); @@ -593,7 +734,8 @@ describe('TransactionPayController', () => { expect(signAndSendTransaction).toHaveBeenCalledTimes(1); expect(signAndSendTransaction).toHaveBeenCalledWith({ - accountId: SOLANA_ACCOUNT_ID, + accountId: 'wallet-account-uuid', + caipAccountId: SOLANA_ACCOUNT_ID, preparedTransaction: 'base64-transaction', preparationId: 'preparation-123', requestId: 'relay-request-123', @@ -614,9 +756,7 @@ describe('TransactionPayController', () => { getTransactionStatus: jest.fn(), signAndSendTransaction, }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, @@ -650,9 +790,7 @@ describe('TransactionPayController', () => { const signAndSendTransaction = jest.fn().mockResolvedValue({ outcome }); const controller = createController({ solana: getSolanaCallbacks({ signAndSendTransaction }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); @@ -671,9 +809,7 @@ describe('TransactionPayController', () => { .mockRejectedValue(new Error('callback contract violation')); const controller = createController({ solana: getSolanaCallbacks({ signAndSendTransaction }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); @@ -689,9 +825,7 @@ describe('TransactionPayController', () => { it('rejects notification retry without stable request and source correlation', async () => { const controller = createController({ - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await expect( @@ -709,9 +843,7 @@ describe('TransactionPayController', () => { getTransactionStatus: jest.fn().mockResolvedValue('pending'), signAndSendTransaction, }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); notifyRelayTransactionMock.mockRejectedValueOnce( new Error('index unavailable'), @@ -793,9 +925,7 @@ describe('TransactionPayController', () => { it('rejects reconciliation without Relay correlation', async () => { const controller = createController({ - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + state: getQuoteState(), }); await expect( @@ -805,15 +935,18 @@ describe('TransactionPayController', () => { it('recovers a callback-lost source signature and reconciles both status axes', async () => { const signAndSendTransaction = jest.fn(); + const getTransactionStatus = jest.fn().mockResolvedValue('confirmed'); const controller = createController({ solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + getTransactionStatus, signAndSendTransaction, }), state: { payIntents: { [TRANSACTION_ID_MOCK]: { ...getUnquotedIntent(), + atomicProductActionIncluded: true, + atomicProductActionRequired: true, requestId: 'relay-request-123', execution: { providerNotificationStatus: 'not-started', @@ -846,6 +979,12 @@ describe('TransactionPayController', () => { targetTransactionId: '0xtarget', }); expect(signAndSendTransaction).not.toHaveBeenCalled(); + expect(getTransactionStatus).toHaveBeenCalledWith({ + accountId: 'wallet-account-uuid', + caipAccountId: SOLANA_ACCOUNT_ID, + scope: SOLANA_CHAIN_ID, + transactionId: SOLANA_TRANSACTION_ID, + }); const completionCall = updateTransactionMock.mock.calls.find( ([request]) => request.note === 'Complete Solana pay intent', @@ -856,6 +995,48 @@ describe('TransactionPayController', () => { expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); }); + it('does not complete a product intent when its atomic action was omitted', async () => { + const controller = createController({ + solana: getSolanaCallbacks(), + state: { + payIntents: { + [TRANSACTION_ID_MOCK]: { + ...SOLANA_PAY_INTENT_MOCK, + atomicProductActionIncluded: false, + atomicProductActionRequired: true, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'pending', + sourceStatus: 'confirmed', + }, + }, + }, + }, + }); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + status: 'submitted', + } as TransactionMeta); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.outcome).toStrictEqual({ type: 'submitted' }); + expect(confirmTransactionMock).not.toHaveBeenCalled(); + expect(updateTransactionMock).not.toHaveBeenCalledWith( + expect.objectContaining({ note: 'Complete Solana pay intent' }), + expect.any(Function), + ); + }); + it('keeps observing a pending intent until it reaches a terminal outcome', async () => { const controller = createController({ solana: getSolanaCallbacks(), @@ -1159,17 +1340,7 @@ describe('TransactionPayController', () => { getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), submitNonAtomicFollowUp, }), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - transactionData: { - [TRANSACTION_ID_MOCK]: { - atomic: false, - isLoading: false, - paymentOverride: PaymentOverride.MoneyAccount, - tokens: [], - }, - }, - }, + state: getMoneyAccountQuoteState(), }); getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, @@ -1182,6 +1353,10 @@ describe('TransactionPayController', () => { const transaction = { id: TRANSACTION_ID_MOCK, status: 'submitted', + txParams: { + from: '0x1234567890123456789012345678901234567890', + }, + type: TransactionType.predictDeposit, } as TransactionMeta; getTransactionMock.mockReturnValue(transaction); await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); @@ -1314,6 +1489,7 @@ describe('TransactionPayController', () => { txHashes: ['0xtarget'], updatedAt: 1, }); + getTransactionMock.mockReturnValue(undefined); await expect( controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 13fa8a5ed29..934f46589a5 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -25,6 +25,7 @@ import { deriveSolanaPayOutcome, getInitialSolanaPayExecution, getRelaySolanaTransaction, + isSolanaPayProductTransaction, mapRelayStatus, normalizeSolanaPayPreflight, } from './strategy/relay/solana-pay.js'; @@ -248,6 +249,18 @@ export class TransactionPayController extends BaseController< * @param request.transactionId - ID of the target transaction. */ setPayIntent({ transactionId, intent }: SetPayIntentRequest): void { + const existing = this.state.payIntents[transactionId]; + + if ( + existing?.sourceChainId.startsWith('solana:') && + (existing.requestId || + getSolanaPayExecution(existing).sourceStatus !== 'not-started') + ) { + throw new Error( + 'TransactionPayController: Solana source snapshot is immutable', + ); + } + this.#persistPayIntent(transactionId, intent, 'Set transaction pay intent'); } @@ -258,7 +271,7 @@ export class TransactionPayController extends BaseController< * the full instruction payload remains transient because recovery only * observes an existing attempt and never resubmits it. * - * @param request - Source amount and EVM destination details. + * @param request - Target transaction whose route Core derives. * @returns The Relay Solana quote for client display and confirmation. */ async getSolanaPayQuote( @@ -272,49 +285,77 @@ export class TransactionPayController extends BaseController< ); } - const quoteRequest = buildRelaySolanaQuoteRequest(intent, request); + const transaction = getTransaction(request.transactionId, this.messenger); + const transactionData = this.state.transactionData[request.transactionId]; + + if (!transaction || !transactionData) { + throw new Error('TransactionPayController: Transaction data missing'); + } + + const quoteRequest = await buildRelaySolanaQuoteRequest( + intent, + transaction, + transactionData, + this.messenger, + ); const providerQuote = await fetchRelaySolanaQuote( this.messenger, quoteRequest, ); - const transaction = getRelaySolanaTransaction(providerQuote); + const sourceTransaction = getRelaySolanaTransaction(providerQuote); + const sourceAmountRaw = providerQuote.details.currencyIn.amount; - if (providerQuote.details.currencyIn.amount !== quoteRequest.amount) { + if ( + quoteRequest.tradeType === 'EXACT_INPUT' && + sourceAmountRaw !== quoteRequest.amount + ) { throw new Error( 'TransactionPayController: Relay Solana source amount mismatch', ); } const preflightData = await this.#requireSolanaCallbacks().getPreflight({ - accountId: intent.sourceAccountId, + accountId: intent.sourceWalletAccountId, + caipAccountId: intent.sourceAccountId, requestId: providerQuote.requestId, scope: intent.sourceChainId, - sourceAmountRaw: quoteRequest.amount, + sourceAmountRaw, sourceAssetId: intent.sourceAssetId, - transaction, + transaction: sourceTransaction, }); + const atomicProductActionIncluded = Boolean(quoteRequest.txs?.length); const quote: SolanaPayQuote = { preflight: normalizeSolanaPayPreflight( intent, - quoteRequest.amount, + sourceAmountRaw, preflightData, ), providerQuote, + route: { + atomicProductActionIncluded, + recipient: quoteRequest.recipient, + targetAmountMinimum: transactionData.tokens[0].amountRaw, + tradeType: quoteRequest.tradeType, + }, }; - const transactionData = this.state.transactionData[request.transactionId]; const requiresNonAtomicFollowUp = - transactionData?.atomic === false && + transactionData.atomic === false && transactionData.paymentOverride === PaymentOverride.MoneyAccount; this.#persistPayIntent( request.transactionId, { ...intent, + atomicProductActionIncluded, + atomicProductActionRequired: + isSolanaPayProductTransaction(transaction) && + !requiresNonAtomicFollowUp, execution: getInitialSolanaPayExecution(requiresNonAtomicFollowUp), followUpTransactionId: undefined, relayFailureReason: undefined, requestId: providerQuote.requestId, requiresNonAtomicFollowUp, + sourceAmountRaw, sourceFailureReason: undefined, sourceTransactionId: undefined, targetTransactionId: undefined, @@ -374,7 +415,8 @@ export class TransactionPayController extends BaseController< ); const submission = await solana.signAndSendTransaction({ - accountId: intent.sourceAccountId, + accountId: intent.sourceWalletAccountId, + caipAccountId: intent.sourceAccountId, preparedTransaction: quote.preflight.preparedTransaction, preparationId: quote.preflight.preparationId, requestId: intent.requestId, @@ -546,7 +588,8 @@ export class TransactionPayController extends BaseController< ) { const sourceResult = await observePromise( this.#requireSolanaCallbacks().getTransactionStatus({ - accountId: intent.sourceAccountId, + accountId: intent.sourceWalletAccountId, + caipAccountId: intent.sourceAccountId, scope: intent.sourceChainId, transactionId: intent.sourceTransactionId, }), diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index 8f1006ef4cb..bd21a5e3f2d 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -95,12 +95,14 @@ describe('TransactionPayPublishHook', () => { getControllerStateMock.mockReturnValue({ payIntents: { [TRANSACTION_META_MOCK.id]: { - version: 1, + version: 2, sourceAccountId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + sourceAmountRaw: '1000000', sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + sourceWalletAccountId: 'wallet-account-uuid', }, }, transactionData: {}, @@ -143,12 +145,14 @@ describe('TransactionPayPublishHook', () => { getControllerStateMock.mockReturnValue({ payIntents: { [TRANSACTION_META_MOCK.id]: { - version: 1, + version: 2, sourceAccountId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + sourceAmountRaw: '1000000', sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + sourceWalletAccountId: 'wallet-account-uuid', }, }, transactionData: {}, diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts index 8a3df620edb..15a9868746a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -1,3 +1,6 @@ +import { Interface } from '@ethersproject/abi'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAccountId, CaipAssetType, @@ -5,8 +8,11 @@ import type { Hex, } from '@metamask/utils'; +import { PaymentOverride } from '../../constants.js'; +import { getMessengerMock } from '../../tests/messenger-mock.js'; import type { SolanaPayPreflightData, + TransactionData, TransactionPayIntent, } from '../../types.js'; import { @@ -29,10 +35,12 @@ const SOLANA_NATIVE_RELAY_ADDRESS = '11111111111111111111111111111111'; function getIntent(sourceAssetId: CaipAssetType): TransactionPayIntent { return { - version: 1, + version: 2, sourceAccountId: SOLANA_ACCOUNT, + sourceAmountRaw: '1000000', sourceAssetId, sourceChainId: SOLANA_CHAIN_ID, + sourceWalletAccountId: 'wallet-account-uuid', }; } @@ -98,105 +106,238 @@ function getQuote(): RelaySolanaQuote { describe('Solana Relay Pay', () => { describe('buildRelaySolanaQuoteRequest', () => { - it('builds an exact-input /quote/v2 request for an SPL token', () => { - expect( - buildRelaySolanaQuoteRequest(getIntent(SOLANA_USDC), { - amount: '1000000', - destinationChainId: '0xa4b1' as Hex, - destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }), - ).toStrictEqual({ + const RECIPIENT = '0x1234567890123456789012345678901234567890' as Hex; + const TARGET_TOKEN = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex; + const TRANSACTION = { + id: 'transaction-1', + txParams: { from: RECIPIENT }, + } as TransactionMeta; + const TRANSACTION_DATA: TransactionData = { + isLoading: false, + sourceAmounts: [ + { + sourceAmountHuman: '1', + sourceAmountRaw: '1000000', + targetTokenAddress: TARGET_TOKEN, + }, + ], + tokens: [ + { + address: TARGET_TOKEN, + allowUnderMinimum: false, + amountFiat: '1', + amountHuman: '1', + amountRaw: '900000', + amountUsd: '1', + balanceFiat: '0', + balanceHuman: '0', + balanceRaw: '0', + balanceUsd: '0', + chainId: '0xa4b1' as Hex, + decimals: 6, + skipIfBalance: false, + symbol: 'USDC', + }, + ], + }; + const { getDelegationTransactionMock, messenger } = getMessengerMock(); + + beforeEach(() => { + getDelegationTransactionMock.mockResolvedValue({ + data: '0x1234', + to: '0x9876543210987654321098765432109876543210', + value: '0x0', + }); + }); + + it('derives an exact-input SPL route from Core transaction state', async () => { + const result = await buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + TRANSACTION, + TRANSACTION_DATA, + messenger, + ); + + expect(result).toStrictEqual({ amount: '1000000', destinationChainId: 42161, - destinationCurrency: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + destinationCurrency: TARGET_TOKEN, originChainId: 792703809, originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - recipient: '0x1234567890123456789012345678901234567890', + recipient: RECIPIENT, refundTo: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', tradeType: 'EXACT_INPUT', user: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', }); }); - it('maps the CAIP native SOL asset to the Relay native sentinel', () => { - const result = buildRelaySolanaQuoteRequest(getIntent(SOLANA_NATIVE), { - amount: '100000000', - destinationChainId: '0x89' as Hex, - destinationCurrency: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }); + it('maps the CAIP native SOL asset to the Relay native sentinel', async () => { + const result = await buildRelaySolanaQuoteRequest( + getIntent(SOLANA_NATIVE), + TRANSACTION, + TRANSACTION_DATA, + messenger, + ); expect(result.originCurrency).toBe(SOLANA_NATIVE_RELAY_ADDRESS); }); - it('rejects unsupported Solana networks', () => { - const intent = { - ...getIntent(SOLANA_USDC), - sourceChainId: 'solana:devnet' as CaipChainId, - }; + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])('embeds atomic product calls for %s', async (type) => { + const result = await buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + { ...TRANSACTION, type }, + TRANSACTION_DATA, + messenger, + ); - expect(() => - buildRelaySolanaQuoteRequest(intent, { - amount: '1', - destinationChainId: '0x1' as Hex, - destinationCurrency: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }), - ).toThrow('Unsupported Solana source chain'); + expect(result).toMatchObject({ + amount: '900000', + recipient: RECIPIENT, + tradeType: 'EXACT_OUTPUT', + txs: [ + { + data: new Interface([ + 'function transfer(address to, uint256 amount)', + ]).encodeFunctionData('transfer', [RECIPIENT, '900000']), + to: TARGET_TOKEN, + value: '0x0', + }, + { + data: '0x1234', + to: '0x9876543210987654321098765432109876543210', + value: '0x0', + }, + ], + }); }); - it('rejects a source account from a different chain', () => { - const intent = { - ...getIntent(SOLANA_USDC), - sourceAccountId: 'solana:other:account' as CaipAccountId, - }; + it('keeps Money Account explicitly non-atomic', async () => { + const result = await buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + { ...TRANSACTION, type: TransactionType.predictDeposit }, + { + ...TRANSACTION_DATA, + atomic: false, + paymentOverride: PaymentOverride.MoneyAccount, + }, + messenger, + ); - expect(() => - buildRelaySolanaQuoteRequest(intent, { - amount: '1', - destinationChainId: '0x1' as Hex, - destinationCurrency: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }), - ).toThrow('Solana source account does not match source chain'); + expect(result.tradeType).toBe('EXACT_INPUT'); + expect(result.txs).toBeUndefined(); + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); }); - it('rejects a source asset from a different chain', () => { - const intent = { - ...getIntent(SOLANA_USDC), - sourceAssetId: 'solana:other/token:mint' as CaipAssetType, - }; + it('includes normalized destination authorization with atomic calls', async () => { + getDelegationTransactionMock.mockResolvedValue({ + authorizationList: [ + { + address: RECIPIENT, + chainId: '0xa4b1', + nonce: '0x1', + r: '0x1', + s: '0x2', + yParity: '0x1', + }, + ], + data: '0x1234', + to: '0x9876543210987654321098765432109876543210', + value: '0x0', + }); - expect(() => - buildRelaySolanaQuoteRequest(intent, { - amount: '1', - destinationChainId: '0x1' as Hex, - destinationCurrency: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }), - ).toThrow('Solana source asset does not match source chain'); + const result = await buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + { ...TRANSACTION, type: TransactionType.predictDeposit }, + TRANSACTION_DATA, + messenger, + ); + + expect(result.authorizationList).toStrictEqual([ + { + address: RECIPIENT, + chainId: 42161, + nonce: 1, + r: '0x1', + s: '0x2', + yParity: 1, + }, + ]); }); - it('rejects unsupported Solana CAIP asset namespaces', () => { - const intent = getIntent( - `${SOLANA_CHAIN_ID}/erc20:0x1234` as CaipAssetType, - ); + it('rejects a product route that is non-atomic outside Money Account', async () => { + await expect( + buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + { ...TRANSACTION, type: TransactionType.predictDeposit }, + { ...TRANSACTION_DATA, atomic: false }, + messenger, + ), + ).rejects.toThrow('Unsupported non-atomic Solana Pay product route'); + }); - expect(() => - buildRelaySolanaQuoteRequest(intent, { - amount: '1', - destinationChainId: '0x1' as Hex, - destinationCurrency: '0x1234567890123456789012345678901234567890', - recipient: '0x1234567890123456789012345678901234567890', - transactionId: 'transaction-1', - }), - ).toThrow('Unsupported Solana source asset'); + it('rejects a route without a target token', async () => { + await expect( + buildRelaySolanaQuoteRequest( + getIntent(SOLANA_USDC), + TRANSACTION, + { ...TRANSACTION_DATA, tokens: [] }, + messenger, + ), + ).rejects.toThrow('Missing Solana Pay target token'); + }); + + it('rejects an exact-input route without a Core-derived source amount', async () => { + const intent = { ...getIntent(SOLANA_USDC), sourceAmountRaw: undefined }; + await expect( + buildRelaySolanaQuoteRequest( + intent, + TRANSACTION, + { ...TRANSACTION_DATA, sourceAmounts: undefined }, + messenger, + ), + ).rejects.toThrow('Missing Solana Pay source amount'); + }); + + it.each([ + [ + { + ...getIntent(SOLANA_USDC), + sourceChainId: 'solana:devnet' as CaipChainId, + }, + 'Unsupported Solana source chain', + ], + [ + { + ...getIntent(SOLANA_USDC), + sourceAccountId: 'solana:other:account' as CaipAccountId, + }, + 'Solana source account does not match source chain', + ], + [ + { + ...getIntent(SOLANA_USDC), + sourceAssetId: 'solana:other/token:mint' as CaipAssetType, + }, + 'Solana source asset does not match source chain', + ], + [ + getIntent(`${SOLANA_CHAIN_ID}/erc20:0x1234` as CaipAssetType), + 'Unsupported Solana source asset', + ], + ] as const)('rejects invalid source identity %#', async (intent, error) => { + await expect( + buildRelaySolanaQuoteRequest( + intent, + TRANSACTION, + TRANSACTION_DATA, + messenger, + ), + ).rejects.toThrow(error); }); }); @@ -467,6 +608,24 @@ describe('Solana Relay Pay', () => { expect(deriveSolanaPayOutcome(intent)).toMatchObject(expected); }); + + it('does not succeed when a required atomic product action was omitted', () => { + const intent: TransactionPayIntent = { + ...getIntent(SOLANA_USDC), + atomicProductActionIncluded: false, + atomicProductActionRequired: true, + execution: { + followUpStatus: 'not-required', + providerNotificationStatus: 'succeeded', + relayStatus: 'success', + sourceStatus: 'confirmed', + }, + }; + + expect(deriveSolanaPayOutcome(intent)).toStrictEqual({ + type: 'submitted', + }); + }); }); it.each([ diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts index 585a1482455..138488d879e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -1,14 +1,23 @@ +import { Interface } from '@ethersproject/abi'; +import { + hasTransactionType, + TransactionType, +} from '@metamask/transaction-controller'; import type { MetamaskPayExecution, MetamaskPayOutcome, MetamaskPayRelayStatus, + TransactionMeta, } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { PaymentOverride } from '../../constants.js'; import type { - GetSolanaPayQuoteRequest, SolanaPayPreflight, SolanaPayPreflightData, + TransactionData, + TransactionPayControllerMessenger, TransactionPayIntent, } from '../../types.js'; import { @@ -31,28 +40,74 @@ const HEX_BYTES_REGEX = /^(?:[\da-f]{2})*$/iu; * Builds the Relay /quote/v2 request for a persisted Solana Pay intent. * * @param intent - Persisted chain-agnostic source identity. - * @param request - Destination and amount selected by the client. - * @returns A Relay Solana exact-input request. + * @param transaction - Parent product transaction. + * @param transactionData - Core-owned Pay quote inputs. + * @param messenger - Controller messenger used for destination delegation. + * @returns A Relay Solana request derived entirely by Core. */ -export function buildRelaySolanaQuoteRequest( +export async function buildRelaySolanaQuoteRequest( intent: TransactionPayIntent, - request: GetSolanaPayQuoteRequest, -): RelaySolanaQuoteRequest { + transaction: TransactionMeta, + transactionData: TransactionData, + messenger: TransactionPayControllerMessenger, +): Promise { if (intent.sourceChainId !== SOLANA_MAINNET_CAIP_CHAIN_ID) { throw new Error(`Unsupported Solana source chain: ${intent.sourceChainId}`); } const sourceAccount = getSourceAccount(intent); + const target = transactionData.tokens[0]; + + if (!target) { + throw new Error('Missing Solana Pay target token'); + } + + const recipient = transaction.txParams.from as Hex; + const isMoneyAccount = + transactionData.atomic === false && + transactionData.paymentOverride === PaymentOverride.MoneyAccount; + const isAtomicProduct = + isSolanaPayProductTransaction(transaction) && !isMoneyAccount; + + if ( + isSolanaPayProductTransaction(transaction) && + transactionData.atomic === false && + !isMoneyAccount + ) { + throw new Error('Unsupported non-atomic Solana Pay product route'); + } + + const atomicDestination = isAtomicProduct + ? await buildAtomicProductTransactions( + transaction, + target.address, + target.amountRaw, + messenger, + ) + : undefined; + const txs = atomicDestination?.txs; + const tradeType = txs ? 'EXACT_OUTPUT' : 'EXACT_INPUT'; + const sourceAmountRaw = + transactionData.sourceAmounts?.[0]?.sourceAmountRaw ?? + intent.sourceAmountRaw; + + if (!txs && !sourceAmountRaw) { + throw new Error('Missing Solana Pay source amount'); + } return { - amount: request.amount, - destinationChainId: Number(request.destinationChainId), - destinationCurrency: request.destinationCurrency, + amount: txs ? target.amountRaw : (sourceAmountRaw as string), + ...(atomicDestination?.authorizationList && { + authorizationList: atomicDestination.authorizationList, + }), + destinationChainId: Number(target.chainId), + destinationCurrency: target.address, originChainId: RELAY_SOLANA_CHAIN_ID, originCurrency: getOriginCurrency(intent), - recipient: request.recipient, + recipient, refundTo: sourceAccount, - tradeType: 'EXACT_INPUT', + tradeType, + ...(txs && { txs }), user: sourceAccount, }; } @@ -248,11 +303,14 @@ export function deriveSolanaPayOutcome( const isFollowUpComplete = followUpStatus === 'not-required' || followUpStatus === 'confirmed'; + const isAtomicProductActionComplete = + !intent.atomicProductActionRequired || intent.atomicProductActionIncluded; if ( execution.sourceStatus === 'confirmed' && execution.relayStatus === 'success' && - isFollowUpComplete + isFollowUpComplete && + isAtomicProductActionComplete ) { return { type: 'succeeded' }; } @@ -374,6 +432,61 @@ function isRelaySolanaInstructionKey(value: unknown): boolean { ); } +export function isSolanaPayProductTransaction( + transaction: TransactionMeta, +): boolean { + return hasTransactionType(transaction, [ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ]); +} + +async function buildAtomicProductTransactions( + transaction: TransactionMeta, + targetTokenAddress: Hex, + targetAmountMinimum: string, + messenger: TransactionPayControllerMessenger, +): Promise<{ + authorizationList: RelaySolanaQuoteRequest['authorizationList']; + txs: NonNullable; +}> { + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + const recipient = transaction.txParams.from as Hex; + + return { + authorizationList: delegation.authorizationList?.map((authorization) => ({ + ...authorization, + chainId: Number(authorization.chainId), + nonce: Number(authorization.nonce), + r: authorization.r as Hex, + s: authorization.s as Hex, + yParity: Number(authorization.yParity), + })), + txs: [ + { + data: new Interface([ + 'function transfer(address to, uint256 amount)', + ]).encodeFunctionData('transfer', [ + recipient, + targetAmountMinimum, + ]) as Hex, + to: targetTokenAddress, + value: '0x0', + }, + { + data: delegation.data, + to: delegation.to, + value: delegation.value, + }, + ], + }; +} + function getAtomicValue(name: string, value: string): BigNumber { const amount = new BigNumber(value); diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts index a7b5b83abf0..b9d4b5ab600 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -35,13 +35,26 @@ export type RelayQuoteRequest = { export type RelaySolanaQuoteRequest = { amount: string; + authorizationList?: { + address: Hex; + chainId: number; + nonce: number; + r: Hex; + s: Hex; + yParity: number; + }[]; destinationChainId: number; destinationCurrency: Hex; originChainId: number; originCurrency: string; recipient: Hex; refundTo: string; - tradeType: 'EXACT_INPUT'; + tradeType: 'EXACT_INPUT' | 'EXACT_OUTPUT'; + txs?: { + to: Hex; + data: Hex; + value: Hex; + }[]; user: string; }; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 9fb5503fbc4..5fd1ab56339 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -295,19 +295,7 @@ export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ /** Request to fetch an executable Solana-source Relay quote. */ export type GetSolanaPayQuoteRequest = { - /** Source amount in atomic units. */ - amount: string; - - /** Destination EVM chain ID. */ - destinationChainId: Hex; - - /** Destination EVM token address. */ - destinationCurrency: Hex; - - /** Destination EVM account. */ - recipient: Hex; - - /** Target TransactionController transaction ID. */ + /** Target TransactionController transaction ID used to derive the route. */ transactionId: string; }; @@ -370,12 +358,23 @@ export type SolanaPayQuote = { /** Core-owned normalized fee, rent, reserve, and affordability policy. */ preflight: SolanaPayPreflight; + + /** Core-derived route policy; clients must not reconstruct these fields. */ + route: { + atomicProductActionIncluded: boolean; + recipient: Hex; + targetAmountMinimum: string; + tradeType: 'EXACT_INPUT' | 'EXACT_OUTPUT'; + }; }; /** Request passed to the client-owned Solana sign-and-broadcast boundary. */ export type SolanaPaySignAndSendTransactionRequest = { - /** Canonical source account used by the Snap client request. */ - accountId: CaipAccountId; + /** Wallet-local InternalAccount.id passed to the Snap request. */ + accountId: TransactionPayIntent['sourceWalletAccountId']; + + /** Canonical CAIP-10 source identity used for validation/diagnostics. */ + caipAccountId: CaipAccountId; /** Opaque serialized transaction returned by the preflight callback. */ preparedTransaction: string; @@ -392,8 +391,11 @@ export type SolanaPaySignAndSendTransactionRequest = { /** Request used to prepare and price a Relay Solana transaction. */ export type GetSolanaPayPreflightRequest = { - /** Canonical source account. */ - accountId: CaipAccountId; + /** Wallet-local InternalAccount.id used to prepare the Snap transaction. */ + accountId: TransactionPayIntent['sourceWalletAccountId']; + + /** Canonical CAIP-10 source identity. */ + caipAccountId: CaipAccountId; /** Relay correlation ID. */ requestId: string; @@ -420,8 +422,11 @@ export type SolanaPaySubmissionResult = /** Request used to observe a previously submitted Solana transaction. */ export type GetSolanaPayTransactionStatusRequest = { - /** Canonical source account. */ - accountId: CaipAccountId; + /** Wallet-local InternalAccount.id. */ + accountId: TransactionPayIntent['sourceWalletAccountId']; + + /** Canonical CAIP-10 source identity. */ + caipAccountId: CaipAccountId; /** Canonical Solana chain scope. */ scope: CaipChainId; diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 67480f402fa..ea3349dd1c3 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -982,12 +982,17 @@ describe('Quotes Utils', () => { it('preserves chain-agnostic source metadata when updating EVM compatibility metadata', async () => { await run(); - const source = { + const intent = { + version: 2, sourceAccountId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId, + sourceAmountRaw: '1000000', sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, - }; + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, + requestId: 'relay-request-123', + sourceWalletAccountId: 'wallet-account-uuid', + } as const; const transactionMetaMock = { metamaskPay: { source }, } as TransactionMeta; From c6b0cc879fbbdfd0fe73971878ca94f7b166dac4 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Mon, 14 Sep 2026 21:33:22 +0100 Subject: [PATCH 4/5] fix(transaction-pay-controller): preserve Solana source amount --- packages/transaction-controller/src/types.ts | 4 ++-- packages/transaction-pay-controller/ARCHITECTURE.md | 2 ++ packages/transaction-pay-controller/CHANGELOG.md | 1 + .../src/TransactionPayController.test.ts | 1 + .../src/strategy/relay/solana-pay.test.ts | 5 +++-- .../src/strategy/relay/solana-pay.ts | 9 ++++----- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 8fbc070c5ac..12d725c90ac 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2273,8 +2273,8 @@ export type MetamaskPayIntent = { /** Wallet-local InternalAccount.id used by Snap requests. */ sourceWalletAccountId: InternalAccount['id']; - /** Immutable source amount in atomic units, finalized by Core at quote time. */ - sourceAmountRaw?: string; + /** Immutable source amount in atomic units for the prepared execution. */ + sourceAmountRaw: string; /** Canonical CAIP-10 identity of the source account. */ sourceAccountId: CaipAccountId; diff --git a/packages/transaction-pay-controller/ARCHITECTURE.md b/packages/transaction-pay-controller/ARCHITECTURE.md index b82a778ba22..01550b2263e 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -49,6 +49,8 @@ The resulting transaction deposits the necessary funds (on the source network), For a Solana source, the controller uses a separate Relay `/quote/v2` endpoint while existing EVM Relay traffic remains on `/quote`. The persisted source snapshot distinguishes the wallet-local `InternalAccount.id` used by Snap requests from the CAIP-10 account identity, and records the CAIP asset/chain plus the provider-finalized atomic source amount. Core derives destination chain/currency, recipient, target minimum, and trade type from the parent transaction and Pay state. Atomic Perps/Predict routes use Relay `/quote/v2`'s verified `txs` field to include a target-token transfer and the delegated product call; non-atomic Money Account routes omit `txs` and retain their one-sponsored-follow-up boundary. +For exact-input Solana routes, the immutable v2 intent amount is authoritative; transient `transactionData.sourceAmounts` belongs to the legacy EVM payment-token path and is never used to override it. + Core validates the returned instruction and address-lookup-table payload rather than treating it as an EVM transaction. A client callback compiles that payload with current Solana RPC data and returns an opaque prepared transaction, finalized network and priority fees, rent debit and rent-exemption requirement, source/native balances, and a preparation ID. Core normalizes the source amount, retained reserve, and affordability decision, then binds the one signing callback to that exact preparation. The signing callback returns one of four explicit outcomes: submitted, user-rejected, guaranteed not-submitted, or ambiguous. Core persists an `attempting` checkpoint before invoking it and never automatically invokes it again. Only the ambiguous result enters source `unknown`; an unexpected callback rejection remains `attempting` for safe diagnosis. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index b81196695a6..5adb007be22 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Use the immutable Solana intent source amount for exact-input routes instead of stale EVM payment-token source amounts. - Detect nested `perpsDepositAndOrder` and `predictDepositAndOrder` transactions when selecting `EXACT_OUTPUT` Relay quotes ([#10222](https://github.com/MetaMask/core/pull/10222)) ## [28.0.2] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 164971b15ed..41775eb97b0 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -425,6 +425,7 @@ describe('TransactionPayController', () => { return { version: 2, sourceAccountId: SOLANA_ACCOUNT_ID, + sourceAmountRaw: '1000000', sourceAssetId: SOLANA_ASSET_ID, sourceChainId: SOLANA_CHAIN_ID, sourceWalletAccountId: 'wallet-account-uuid', diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts index 15a9868746a..cfdcf8c38a9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -216,9 +216,9 @@ describe('Solana Relay Pay', () => { }); }); - it('keeps Money Account explicitly non-atomic', async () => { + it('uses the immutable Solana intent amount for Money Account despite stale EVM source amounts', async () => { const result = await buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + { ...getIntent(SOLANA_USDC), sourceAmountRaw: '765432' }, { ...TRANSACTION, type: TransactionType.predictDeposit }, { ...TRANSACTION_DATA, @@ -228,6 +228,7 @@ describe('Solana Relay Pay', () => { messenger, ); + expect(result.amount).toBe('765432'); expect(result.tradeType).toBe('EXACT_INPUT'); expect(result.txs).toBeUndefined(); expect(getDelegationTransactionMock).not.toHaveBeenCalled(); diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts index 138488d879e..e9736c796bd 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -87,16 +87,15 @@ export async function buildRelaySolanaQuoteRequest( : undefined; const txs = atomicDestination?.txs; const tradeType = txs ? 'EXACT_OUTPUT' : 'EXACT_INPUT'; - const sourceAmountRaw = - transactionData.sourceAmounts?.[0]?.sourceAmountRaw ?? - intent.sourceAmountRaw; + const { sourceAmountRaw } = intent; + const amount = txs ? target.amountRaw : sourceAmountRaw; - if (!txs && !sourceAmountRaw) { + if (!amount) { throw new Error('Missing Solana Pay source amount'); } return { - amount: txs ? target.amountRaw : (sourceAmountRaw as string), + amount, ...(atomicDestination?.authorizationList && { authorizationList: atomicDestination.authorizationList, }), From 099cef50c3007cfbf1842d34632b5f92d8393b15 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 17 Sep 2026 15:59:55 +0100 Subject: [PATCH 5/5] refactor(transaction-pay-controller): narrow Solana execution state --- packages/transaction-controller/CHANGELOG.md | 4 +- .../src/TransactionController.test.ts | 24 +- .../src/TransactionController.ts | 9 +- packages/transaction-controller/src/index.ts | 16 +- packages/transaction-controller/src/types.ts | 180 +- .../ARCHITECTURE.md | 14 +- .../transaction-pay-controller/CHANGELOG.md | 8 +- ...actionPayController-method-action-types.ts | 63 +- .../src/TransactionPayController.test.ts | 1809 ++++++----------- .../src/TransactionPayController.ts | 651 +++--- .../helpers/TransactionPayPublishHook.test.ts | 91 +- .../src/helpers/TransactionPayPublishHook.ts | 4 +- .../transaction-pay-controller/src/index.ts | 7 +- .../src/strategy/relay/solana-pay.test.ts | 161 +- .../src/strategy/relay/solana-pay.ts | 189 +- .../transaction-pay-controller/src/types.ts | 209 +- .../src/utils/quotes.test.ts | 9 +- 17 files changed, 1401 insertions(+), 2047 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f3434c9fe37..0997bac84c2 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add versioned, chain-agnostic Pay intent metadata for persisting the wallet-local source account ID, CAIP source identity, atomic source amount, atomic product-route checkpoint, discriminated outcomes, one-attempt execution checkpoints, and provider correlation without changing legacy EVM Pay fields. -- Add externally handled publish results so non-EVM hooks can suppress EVM RPC fallback while preserving submitted, rejected, failed, and confirmed transaction lifecycle events. +- Add chain-agnostic Pay source metadata using CAIP-10 account and CAIP-19 asset identifiers, plus a phase-aware external Solana execution checkpoint, without changing legacy EVM Pay fields ([#10272](https://github.com/MetaMask/core/pull/10272), [#10273](https://github.com/MetaMask/core/pull/10273)) +- Add externally handled publish results and lifecycle finalization for hashless non-EVM parent transactions ([#10273](https://github.com/MetaMask/core/pull/10273)) ### Changed diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 6163069b159..c5c99a82d4f 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -6663,6 +6663,7 @@ describe('TransactionController', () => { describe('confirmTransaction', () => { const submittedTransactionMock = { ...TRANSACTION_META_MOCK, + isExternalPublish: true, status: TransactionStatus.submitted as const, } as unknown as TransactionMeta; @@ -6688,7 +6689,6 @@ describe('TransactionController', () => { finishedListener, ); - controller.confirmTransaction(submittedTransactionMock.id); controller.confirmTransaction(submittedTransactionMock.id); expect(controller.state.transactions[0].status).toBe( @@ -6698,6 +6698,28 @@ describe('TransactionController', () => { expect(statusUpdatedListener).toHaveBeenCalledTimes(1); expect(finishedListener).toHaveBeenCalledTimes(1); }); + + it.each([ + [TransactionStatus.submitted, false], + [TransactionStatus.confirmed, true], + [TransactionStatus.failed, true], + ])( + 'rejects status %s with external publication %s', + (status, isExternalPublish) => { + const transaction = { + ...submittedTransactionMock, + isExternalPublish, + status, + }; + const { controller } = setupController({ + options: { state: { transactions: [transaction] } }, + }); + + expect(() => controller.confirmTransaction(transaction.id)).toThrow( + 'Only submitted externally published transactions can be confirmed', + ); + }, + ); }); describe('updateSecurityAlertResponse', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index 85180ee4893..b2154f2daa6 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -1782,8 +1782,13 @@ export class TransactionController extends BaseController< confirmTransaction(transactionId: string): void { const transactionMeta = this.#getTransactionOrThrow(transactionId); - if (transactionMeta.status === TransactionStatus.confirmed) { - return; + if ( + transactionMeta.status !== TransactionStatus.submitted || + !transactionMeta.isExternalPublish + ) { + throw new Error( + 'Only submitted externally published transactions can be confirmed', + ); } const updatedTransactionMeta = this.#updateTransactionInternal( diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 451f1888aef..c528a4f6ea3 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -74,9 +74,9 @@ export type { BeforeSignHook, DappSuggestedGasFees, DefaultGasEstimates, + ExternallyHandledPublishOutcome, FeeMarketEIP1559Values, FeeMarketGasFeeEstimateForLevel, - ExternallyHandledPublishOutcome, FeeMarketGasFeeEstimates, GasFeeEstimates, GasFeeToken, @@ -90,15 +90,13 @@ export type { IsAtomicBatchSupportedResultEntry, LegacyGasFeeEstimates, Log, - MetamaskPayExecution, - MetamaskPayFollowUpStatus, - MetamaskPayIntent, MetamaskPayMetadata, - MetamaskPayOutcome, - MetamaskPayProviderNotificationStatus, - MetamaskPayRelayStatus, - MetamaskPaySourceStatus, - MetamaskPaySubmissionOutcome, + MetamaskPaySolanaExecution, + MetamaskPaySolanaFollowUpStatus, + MetamaskPaySolanaNotificationStatus, + MetamaskPaySolanaRelayStatus, + MetamaskPaySolanaSourceStatus, + MetamaskPaySource, NestedTransactionMetadata, NestedTransactionUpdate, PublishBatchHook, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 12d725c90ac..5f72c3d4b47 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -4,7 +4,13 @@ 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 { CaipAccountId, CaipAssetType, Hex, Json } from '@metamask/utils'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Hex, + Json, +} from '@metamask/utils'; import type { Operation } from 'fast-json-patch'; import type { TransactionControllerMessenger } from './TransactionController.js'; @@ -1981,23 +1987,13 @@ export type ExternallyHandledPublishOutcome = /** Data returned from custom logic to publish a transaction. */ export type PublishHookResult = | { - /** External handling was not used; the EVM hash may suppress fallback. */ externallyHandled?: false; - - /** Hash of the transaction on the network. */ transactionHash?: string; } | { - /** Prevents fallback to `eth_sendRawTransaction`. */ externallyHandled: true; - - /** Explicit result of the external publication attempt. */ outcome: ExternallyHandledPublishOutcome; - - /** Stable error detail for guaranteed non-submission. */ error?: string; - - /** Externally handled publication does not require an EVM hash. */ transactionHash?: never; }; @@ -2177,60 +2173,38 @@ export type AssetsFiatValues = { sending?: string; }; -/** Status observed independently for a MetaMask Pay source transaction. */ -export type MetamaskPaySourceStatus = - | 'not-started' - | 'attempting' - | 'submitted' +/** 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; +}; + +export type MetamaskPaySolanaSourceStatus = + | 'not-observed' | 'pending' | 'confirmed' | 'failed' - | 'user-rejected' - | 'not-submitted' | 'unknown'; -/** Status observed independently for MetaMask Pay Relay settlement. */ -export type MetamaskPayRelayStatus = - | 'not-started' +export type MetamaskPaySolanaRelayStatus = + | 'not-observed' | 'pending' | 'success' | 'failure' | 'refund' | 'unknown'; -/** Status of MetaMask Pay provider notification delivery. */ -export type MetamaskPayProviderNotificationStatus = - | 'not-started' +export type MetamaskPaySolanaNotificationStatus = + | 'not-ready' + | 'not-attempted' | 'pending' - | 'succeeded' - | 'failed'; - -/** Durable, monotonic business outcome for a MetaMask Pay intent. */ -export type MetamaskPayOutcome = - | { type: 'not-started' } - | { type: 'attempting' } - | { type: 'submitted' } - | { type: 'user-rejected' } - | { - type: 'source-failed'; - guaranteedNotSubmitted: boolean; - reason?: string; - } - | { type: 'relay-failed'; reason?: string } - | { type: 'refunded'; reason?: string } - | { type: 'follow-up-failed' } - | { type: 'unknown'; phase: 'source' | 'relay' | 'follow-up' } - | { type: 'succeeded' }; - -/** Durable result of a single externally handled submission callback. */ -export type MetamaskPaySubmissionOutcome = - | 'submitted' - | 'user-rejected' - | 'not-submitted' - | 'ambiguous'; + | 'success' + | 'failure'; -/** Status of the required non-atomic sponsored follow-up transaction. */ -export type MetamaskPayFollowUpStatus = +export type MetamaskPaySolanaFollowUpStatus = | 'not-required' | 'not-started' | 'attempting' @@ -2238,87 +2212,48 @@ export type MetamaskPayFollowUpStatus = | 'pending' | 'confirmed' | 'failed' - | 'user-rejected' - | 'not-submitted' | 'unknown'; -/** Durable checkpoints for one source submission attempt. */ -export type MetamaskPayExecution = { - /** Status observed independently for the source-chain transaction. */ - sourceStatus: MetamaskPaySourceStatus; - - /** Status observed independently for Relay settlement. */ - relayStatus: MetamaskPayRelayStatus; - - /** Status of the optional Relay transaction-index notification. */ - providerNotificationStatus: MetamaskPayProviderNotificationStatus; - - /** Result returned by the single source submission callback. */ - submissionOutcome?: MetamaskPaySubmissionOutcome; - - /** Status of a required non-atomic sponsored follow-up. */ - followUpStatus?: MetamaskPayFollowUpStatus; -}; - -/** - * Durable MetaMask Pay intent for a chain-agnostic payment source. - * - * The target transaction is the TransactionController record containing this - * intent, or the key of this intent in TransactionPayController state. - */ -export type MetamaskPayIntent = { - /** Schema version of the persisted intent. */ - version: 2; - +type MetamaskPaySolanaExecutionBase = { /** Wallet-local InternalAccount.id used by Snap requests. */ - sourceWalletAccountId: InternalAccount['id']; - - /** Immutable source amount in atomic units for the prepared execution. */ - sourceAmountRaw: string; + sourceWalletAccountId: string; - /** Canonical CAIP-10 identity of the source account. */ - sourceAccountId: CaipAccountId; - - /** Canonical CAIP-19 identity of the source asset. */ - sourceAssetId: CaipAssetType; - - /** Explicit CAIP-2 identity of the source chain. */ + /** Source chain derived from the persisted CAIP source metadata. */ sourceChainId: CaipChainId; - /** Provider request ID used to reconcile execution after restart. */ - requestId?: string; - - /** Chain-native source transaction identifier, such as a Solana signature. */ - sourceTransactionId?: string; - - /** Stable source submission failure or interruption detail. */ - sourceFailureReason?: string; - - /** Durable one-attempt execution checkpoints. */ - execution?: MetamaskPayExecution; + /** Immutable atomic source amount for this executable request. */ + sourceAmountRaw: string; - /** Core-derived business outcome used by lifecycle consumers. */ - outcome?: MetamaskPayOutcome; + /** Relay request correlation available once an executable quote exists. */ + requestId: string; - /** Destination transaction identifier observed from Relay settlement. */ + sourceStatus: MetamaskPaySolanaSourceStatus; + relayStatus: MetamaskPaySolanaRelayStatus; + notificationStatus: MetamaskPaySolanaNotificationStatus; + followUpStatus: MetamaskPaySolanaFollowUpStatus; + atomicProductActionRequired: boolean; + atomicProductActionIncluded: boolean; + requiresNonAtomicFollowUp: boolean; targetTransactionId?: string; - - /** Provider failure classification observed during reconciliation. */ - relayFailureReason?: string; - - /** Whether the parent product action must execute atomically in the Relay route. */ - atomicProductActionRequired?: boolean; - - /** Whether the verified quote included the required atomic product action. */ - atomicProductActionIncluded?: boolean; - - /** Whether Relay success must be followed by a sponsored target submission. */ - requiresNonAtomicFollowUp?: boolean; - - /** Transaction ID returned by the sponsored follow-up callback. */ followUpTransactionId?: string; + sourceFailureReason?: string; + relayFailureReason?: string; }; +/** Minimal durable checkpoint for one external Solana source execution. */ +export type MetamaskPaySolanaExecution = + | (MetamaskPaySolanaExecutionBase & { + phase: 'ready' | 'attempting' | 'user-rejected' | 'not-submitted'; + }) + | (MetamaskPaySolanaExecutionBase & { + phase: 'submitted'; + sourceTransactionId: string; + }) + | (MetamaskPaySolanaExecutionBase & { + phase: 'unknown'; + sourceTransactionId?: string; + }); + /** Metadata specific to the MetaMask Pay feature. */ export type MetamaskPayMetadata = { /** Total fee from any bridge transactions, in fiat currency. */ @@ -2344,6 +2279,9 @@ export type MetamaskPayMetadata = { /** Chain-agnostic payment source metadata. */ source?: MetamaskPaySource; + /** Durable external Solana execution checkpoint. */ + solanaExecution?: MetamaskPaySolanaExecution; + /** 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 01550b2263e..19db0a4326f 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -47,15 +47,11 @@ Quotes are retrieved from the [Relay API](https://docs.relay.link/what-is-relay) The resulting transaction deposits the necessary funds (on the source network), then a Relayer on the target chain immediately transfers the necessary funds and optionally executes any requested call data. -For a Solana source, the controller uses a separate Relay `/quote/v2` endpoint while existing EVM Relay traffic remains on `/quote`. The persisted source snapshot distinguishes the wallet-local `InternalAccount.id` used by Snap requests from the CAIP-10 account identity, and records the CAIP asset/chain plus the provider-finalized atomic source amount. Core derives destination chain/currency, recipient, target minimum, and trade type from the parent transaction and Pay state. Atomic Perps/Predict routes use Relay `/quote/v2`'s verified `txs` field to include a target-token transfer and the delegated product call; non-atomic Money Account routes omit `txs` and retain their one-sponsored-follow-up boundary. +Relay deprecates `/quote`, but the existing EVM integration remains on that endpoint to avoid changing its established contract in this work. Solana uses `/quote/v2`, the supported endpoint exercised by the verified integration fixtures. Relay's current public OpenAPI documents the generic quote request, including destination `txs`, but does not fully document the observed Solana instruction and lookup-table response. Core therefore structurally validates that integration-specific response before passing it to client-owned preparation. -For exact-input Solana routes, the immutable v2 intent amount is authoritative; transient `transactionData.sourceAmounts` belongs to the legacy EVM payment-token path and is never used to override it. +A Solana source broadcasts exactly once: Core persists `attempting` on the target transaction before crossing the Snap callback, and the publish hook returns `externallyHandled`. TransactionController suppresses EVM RPC fallback and EVM receipt polling for the hashless EVM parent. Status-only Pay reconciliation owns later source, Relay, and parent lifecycle transitions and never calls `signAndSendTransaction`. -Core validates the returned instruction and address-lookup-table payload rather than treating it as an EVM transaction. A client callback compiles that payload with current Solana RPC data and returns an opaque prepared transaction, finalized network and priority fees, rent debit and rent-exemption requirement, source/native balances, and a preparation ID. Core normalizes the source amount, retained reserve, and affordability decision, then binds the one signing callback to that exact preparation. - -The signing callback returns one of four explicit outcomes: submitted, user-rejected, guaranteed not-submitted, or ambiguous. Core persists an `attempting` checkpoint before invoking it and never automatically invokes it again. Only the ambiguous result enters source `unknown`; an unexpected callback rejection remains `attempting` for safe diagnosis. - -Solana source status, Relay settlement, the best-effort Relay transaction-index notification, and any required non-atomic Money Account follow-up are separate durable observations. `retrySolanaPayNotification` retries only provider notification and cannot resubmit the source transaction. `recoverSolanaPay` owns the non-signing polling loop and drives the parent transaction through standard submitted, rejected, failed, and confirmed lifecycle events. A required sponsored follow-up is checkpointed before its one callback attempt and must confirm before the intent becomes complete. +A non-atomic Money Account route may perform one sponsored destination follow-up after Relay success. That destination action has its own durable checkpoint and is not a source retry or source resubmission. ## Lifecycle @@ -80,6 +76,6 @@ The high level interaction with the `TransactionPayController` is as follows: 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. -Durable wallet-local and chain-agnostic source identity, provider-finalized source amount, atomic-product-route inclusion, discriminated business outcome, execution and follow-up checkpoints, source and target transaction identifiers, and provider correlation are stored as versioned intents in `payIntents`, keyed by the target transaction ID. Each intent is also mirrored to the target transaction's `metamaskPay.intent` metadata. This lets recovery correlate the persisted Pay state with the persisted transaction record without storing non-EVM identifiers in legacy EVM-only fields. +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. -The full Solana quote is transient. Once the source attempt starts, recovery uses only the persisted Relay request ID and source signature. A missing signature after an ambiguous callback remains `unknown`; quote refresh, notification retry, status reconciliation, and source resubmission are deliberately not interchangeable operations. +Once an executable Solana quote exists, the same target transaction becomes the single durable owner of `metamaskPay.solanaExecution`. This phase-aware checkpoint contains the immutable wallet account ID and source amount, derived source chain, Relay correlation, optional source signature, observation states, and any Money Account follow-up. `TransactionPayController.state` remains entirely transient and contains no duplicate execution record. diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5adb007be22..a83f71a21c1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a persisted, versioned Pay intent model with canonical CAIP source identity, one-attempt execution checkpoints, and provider correlation, mirrored to transaction metadata for restart recovery. -- Add a client-consumable Solana Pay path for Relay `/quote/v2` SOL/SPL quotes, Core-derived destination/trade policy and atomic Perps/Predict calls, instruction and lookup-table preparation, normalized fee/rent/reserve affordability, one discriminated Snap sign-and-send callback attempt, and restart-safe source and Relay status reconciliation. -- Add durable non-atomic Money Account follow-up checkpoints and eventful parent transaction outcomes for success, rejection, source failure, Relay failure, refund, and unknown status. +- Add validated chain-agnostic Pay source metadata and a phase-aware external Solana execution checkpoint to persisted transaction records while keeping ordinary Pay selection state transient ([#10272](https://github.com/MetaMask/core/pull/10272), [#10273](https://github.com/MetaMask/core/pull/10273)) +- Add the Relay `/quote/v2` Solana instruction/LUT path, normalized preflight, and one source sign-and-broadcast attempt with status-only recovery ([#10273](https://github.com/MetaMask/core/pull/10273)) +- Add the separate one-sponsored Money Account destination follow-up and atomic Perps/Predict destination calls ([#10273](https://github.com/MetaMask/core/pull/10273)) ### Changed @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Use the immutable Solana intent source amount for exact-input routes instead of stale EVM payment-token source amounts. +- Use the immutable Solana execution source amount for exact-input routes instead of transient EVM source-amount projections ([#10273](https://github.com/MetaMask/core/pull/10273)) - Detect nested `perpsDepositAndOrder` and `predictDepositAndOrder` transactions when selecting `EXACT_OUTPUT` Relay quotes ([#10222](https://github.com/MetaMask/core/pull/10222)) ## [28.0.2] 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 1ecc796b7f5..9575cf47de2 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -6,14 +6,11 @@ import type { TransactionPayController } from './TransactionPayController.js'; /** - * Persists chain-agnostic Pay source metadata on the target transaction. + * Persists validated chain-agnostic Pay source metadata on the 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. + * @param request - Pay source and target transaction ID. + * @param request.source - Validated CAIP source metadata. + * @param request.transactionId - Target transaction ID. */ export type TransactionPayControllerSetPaySourceAction = { type: `TransactionPayController:setPaySource`; @@ -21,14 +18,10 @@ export type TransactionPayControllerSetPaySourceAction = { }; /** - * Fetches and stores an executable Relay /quote/v2 Solana quote. - * - * The provider request ID is persisted before the quote can be published; - * the full instruction payload remains transient because recovery only - * observes an existing attempt and never resubmits it. + * Builds an executable Solana quote and its initial durable checkpoint. * - * @param request - Target transaction whose route Core derives. - * @returns The Relay Solana quote for client display and confirmation. + * @param request - Immutable source snapshot and target transaction ID. + * @returns Prepared Solana quote. */ export type TransactionPayControllerGetSolanaPayQuoteAction = { type: `TransactionPayController:getSolanaPayQuote`; @@ -40,7 +33,8 @@ export type TransactionPayControllerGetSolanaPayQuoteAction = { * * The `attempting` checkpoint is persisted before invoking the callback. * The callback must resolve with a discriminated completion outcome; only an - * explicit ambiguous outcome becomes `unknown`. No outcome is resubmitted. + * explicit ambiguous outcome becomes `unknown`. The source signing callback is + * never invoked again for this execution. * * @param transactionId - Target TransactionController transaction ID. * @returns The latest independent source, notification, and Relay statuses. @@ -51,26 +45,21 @@ export type TransactionPayControllerSubmitSolanaPayAction = { }; /** - * Retries only Relay's transaction-index notification. - * - * This method never invokes the signing callback and notification failure - * does not alter source or settlement observations. + * Notifies Relay indexing about an existing Solana signature only. * - * @param transactionId - Target TransactionController transaction ID. - * @returns The latest durable status. + * @param transactionId - Target transaction ID. + * @returns Latest durable status. */ -export type TransactionPayControllerRetrySolanaPayNotificationAction = { - type: `TransactionPayController:retrySolanaPayNotification`; - handler: TransactionPayController['retrySolanaPayNotification']; +export type TransactionPayControllerNotifyRelayOfSolanaTransactionAction = { + type: `TransactionPayController:notifyRelayOfSolanaTransaction`; + handler: TransactionPayController['notifyRelayOfSolanaTransaction']; }; /** - * Reconciles source-chain and Relay status without signing, notifying, or - * resubmitting. It can recover a source signature from Relay after callback - * loss and persists each observation for the next restart. + * Observes source and Relay status without signing or source resubmission. * - * @param transactionId - Target TransactionController transaction ID. - * @returns The latest durable status. + * @param transactionId - Target transaction ID. + * @returns Latest durable status. */ export type TransactionPayControllerReconcileSolanaPayAction = { type: `TransactionPayController:reconcileSolanaPay`; @@ -78,14 +67,14 @@ export type TransactionPayControllerReconcileSolanaPayAction = { }; /** - * Resumes observation for all persisted, non-terminal Solana Pay intents. - * Source submission and provider notification are intentionally excluded. + * Scans persisted TransactionController records and observes non-terminal + * Solana execution status. This method never signs, broadcasts, or notifies. * * @returns Latest statuses keyed by target transaction ID. */ -export type TransactionPayControllerRecoverSolanaPayAction = { - type: `TransactionPayController:recoverSolanaPay`; - handler: TransactionPayController['recoverSolanaPay']; +export type TransactionPayControllerRecoverSolanaPayStatusAction = { + type: `TransactionPayController:recoverSolanaPayStatus`; + handler: TransactionPayController['recoverSolanaPayStatus']; }; /** @@ -224,12 +213,12 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { * Union of all TransactionPayController action types. */ export type TransactionPayControllerMethodActions = - | TransactionPayControllerSetPayIntentAction + | TransactionPayControllerSetPaySourceAction | TransactionPayControllerGetSolanaPayQuoteAction | TransactionPayControllerSubmitSolanaPayAction - | TransactionPayControllerRetrySolanaPayNotificationAction + | TransactionPayControllerNotifyRelayOfSolanaTransactionAction | TransactionPayControllerReconcileSolanaPayAction - | TransactionPayControllerRecoverSolanaPayAction + | TransactionPayControllerRecoverSolanaPayStatusAction | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 41775eb97b0..5d508a708d6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -1,8 +1,10 @@ /* eslint-disable no-new */ import { deriveStateFromMetadata } from '@metamask/base-controller'; -import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + MetamaskPaySolanaExecution, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { CaipAccountId, CaipAssetType, Hex } from '@metamask/utils'; import { updateFiatPayment } from './actions/update-fiat-payment.js'; @@ -22,7 +24,7 @@ import type { SolanaPayCallbacks, TransactionPayControllerOptions, TransactionPayControllerState, - TransactionPayIntent, + TransactionPaySource, TransactionPaySourceAmount, UpdateTransactionDataCallback, } from './types.js'; @@ -53,29 +55,16 @@ 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_CHAIN_ID = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId; +const SOLANA_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; const SOLANA_ACCOUNT_ID = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId; + `${SOLANA_CHAIN_ID}:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z` as CaipAccountId; const SOLANA_ASSET_ID = - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as CaipAssetType; -const SOLANA_TRANSACTION_ID = - '5KtPn3tE7mWcMgvBvXhXxF9xQmP9xNj7wT6oYxj3BbJpfK2p5yhx2S1QnW8gQwJm7U3F5v9hQmXk8b2mJ7dP4c'; -const SOLANA_PAY_INTENT_MOCK: TransactionPayIntent = { - version: 2, + `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as CaipAssetType; +const SOLANA_PAY_SOURCE_MOCK: TransactionPaySource = { sourceAccountId: SOLANA_ACCOUNT_ID, - sourceAmountRaw: '1000000', sourceAssetId: SOLANA_ASSET_ID, - sourceChainId: SOLANA_CHAIN_ID, - requestId: 'relay-request-123', - sourceTransactionId: SOLANA_TRANSACTION_ID, - sourceWalletAccountId: 'wallet-account-uuid', }; -const SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK: TransactionPayIntent = { - ...SOLANA_PAY_INTENT_MOCK, - outcome: { type: 'submitted' }, -}; - +const SOLANA_TRANSACTION_ID = 'solana-signature-123'; const SOLANA_PREFLIGHT_MOCK = { preparedTransaction: 'base64-transaction', preparationId: 'preparation-123', @@ -86,7 +75,6 @@ const SOLANA_PREFLIGHT_MOCK = { rentExemptionRequirementRaw: '3000', sourceBalanceRaw: '2000000', }; - const SOLANA_QUOTE_MOCK: RelaySolanaQuote = { requestId: 'relay-request-123', details: { @@ -114,29 +102,16 @@ const SOLANA_QUOTE_MOCK: RelaySolanaQuote = { requestId: 'relay-request-123', items: [ { - check: { - endpoint: - 'https://api.relay.link/intents/status/v3?requestId=relay-request-123', - method: 'GET', - }, + check: { endpoint: '/status', method: 'GET' }, data: { chainId: 792703809, instructions: [ { programId: '11111111111111111111111111111111', - keys: [ - { - pubkey: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', - isSigner: true, - isWritable: true, - }, - ], - data: '02000000', + keys: [], + data: '00', }, ], - addressLookupTableAddresses: [ - 'HZaWndaNWHFDd9Dhk5PQcJmR4aLQv3Y5nVf2gQmMEz2x', - ], }, status: 'incomplete', }, @@ -144,7 +119,6 @@ const SOLANA_QUOTE_MOCK: RelaySolanaQuote = { }, ], }; - describe('TransactionPayController', () => { const updateFiatPaymentMock = jest.mocked(updateFiatPayment); const updatePaymentTokenMock = jest.mocked(updatePaymentToken); @@ -169,7 +143,7 @@ describe('TransactionPayController', () => { let confirmTransactionMock: jest.Mock; let failTransactionMock: jest.Mock; let getKeyringControllerStateMock: jest.Mock; - let getRemoteFeatureFlagControllerStateMock: jest.Mock; + let getTransactionControllerStateMock: jest.Mock; /** * Create a TransactionPayController. @@ -195,8 +169,7 @@ describe('TransactionPayController', () => { confirmTransactionMock = mocks.confirmTransactionMock; failTransactionMock = mocks.failTransactionMock; getKeyringControllerStateMock = mocks.getKeyringControllerStateMock; - getRemoteFeatureFlagControllerStateMock = - mocks.getRemoteFeatureFlagControllerStateMock; + getTransactionControllerStateMock = mocks.getTransactionControllerStateMock; messenger.registerActionHandler( 'TransactionController:confirmTransaction', @@ -207,18 +180,9 @@ describe('TransactionPayController', () => { failTransactionMock, ); messenger.registerActionHandler( - 'RemoteFeatureFlagController:getState', - getRemoteFeatureFlagControllerStateMock, + 'TransactionController:getState', + getTransactionControllerStateMock, ); - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - remoteFeatureFlags: { - confirmations_pay: { - payStrategies: { - relay: { pollingInterval: 1, pollingTimeout: 100 }, - }, - }, - }, - }); getKeyringControllerStateMock.mockReturnValue({ isUnlocked: true, @@ -236,7 +200,6 @@ describe('TransactionPayController', () => { getRelayPollingTimeoutMock.mockReturnValue(100); updateQuotesMock.mockResolvedValue(true); fetchRelaySolanaQuoteMock.mockResolvedValue(SOLANA_QUOTE_MOCK); - notifyRelayTransactionMock.mockResolvedValue(); getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, inTxHashes: [SOLANA_TRANSACTION_ID], @@ -245,6 +208,7 @@ describe('TransactionPayController', () => { txHashes: [], updatedAt: 1, }); + notifyRelayTransactionMock.mockResolvedValue(); }); describe('constructor', () => { @@ -292,9 +256,7 @@ describe('TransactionPayController', () => { source: SOLANA_PAY_SOURCE_MOCK, }); - expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( - SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, - ); + expect(controller.state).toStrictEqual({ transactionData: {} }); expect(updateTransactionMock).toHaveBeenCalledWith( { transactionId: TRANSACTION_ID_MOCK, @@ -317,7 +279,7 @@ describe('TransactionPayController', () => { expect(transaction.metamaskPay).toStrictEqual({ chainId: CHAIN_ID_MOCK, - intent: SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, + source: SOLANA_PAY_SOURCE_MOCK, sourceHash: '0xabc', tokenAddress: TOKEN_ADDRESS_MOCK, }); @@ -337,30 +299,11 @@ describe('TransactionPayController', () => { updateTransactionCallback(transaction); expect(transaction.metamaskPay).toStrictEqual({ - intent: SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, + source: SOLANA_PAY_SOURCE_MOCK, }); }); - it('preserves a non-Solana intent without adding Solana outcome policy', () => { - const controller = createController(); - const intent: TransactionPayIntent = { - version: 2, - sourceAccountId: - 'eip155:1:0x1234567890123456789012345678901234567890' as CaipAccountId, - sourceAmountRaw: '1', - sourceAssetId: 'eip155:1/slip44:60' as CaipAssetType, - sourceChainId: 'eip155:1' as CaipChainId, - sourceWalletAccountId: 'evm-account-uuid', - }; - - controller.setPayIntent({ transactionId: TRANSACTION_ID_MOCK, intent }); - - expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( - intent, - ); - }); - - it('is callable via messenger action handler', () => { + it('rejects source account and asset identifiers from different chains', () => { const controller = createController(); expect(() => @@ -400,53 +343,63 @@ describe('TransactionPayController', () => { source: SOLANA_PAY_SOURCE_MOCK, }); - expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toStrictEqual( - SOLANA_PAY_INTENT_WITH_OUTCOME_MOCK, - ); + expect(updateTransactionMock).toHaveBeenCalledTimes(1); }); }); - describe('Solana Pay', () => { - const GET_QUOTE_REQUEST = { - transactionId: TRANSACTION_ID_MOCK, - }; + describe('Solana execution', () => { const TARGET_TOKEN = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex; - beforeEach(() => { - getTransactionMock.mockReturnValue({ + function getSolanaCallbacks( + overrides: Partial = {}, + ): SolanaPayCallbacks { + return { + getPreflight: jest.fn().mockResolvedValue(SOLANA_PREFLIGHT_MOCK), + getTransactionStatus: jest.fn().mockResolvedValue('pending'), + signAndSendTransaction: jest.fn().mockResolvedValue({ + outcome: 'submitted', + transactionId: SOLANA_TRANSACTION_ID, + }), + ...overrides, + }; + } + + function getTransactionMeta(): TransactionMeta { + return { id: TRANSACTION_ID_MOCK, + metamaskPay: { source: SOLANA_PAY_SOURCE_MOCK }, + status: 'unapproved', txParams: { from: '0x1234567890123456789012345678901234567890', }, - } as TransactionMeta); - }); + } as TransactionMeta; + } - function getUnquotedIntent(): TransactionPayIntent { + function getExecution( + overrides: Partial = {}, + ): MetamaskPaySolanaExecution { return { - version: 2, - sourceAccountId: SOLANA_ACCOUNT_ID, + atomicProductActionIncluded: false, + atomicProductActionRequired: false, + followUpStatus: 'not-required', + notificationStatus: 'not-ready', + phase: 'ready', + relayStatus: 'not-observed', + requestId: 'relay-request-123', + requiresNonAtomicFollowUp: false, sourceAmountRaw: '1000000', - sourceAssetId: SOLANA_ASSET_ID, sourceChainId: SOLANA_CHAIN_ID, + sourceStatus: 'not-observed', sourceWalletAccountId: 'wallet-account-uuid', - }; + ...overrides, + } as MetamaskPaySolanaExecution; } - function getQuoteState( - intent = getUnquotedIntent(), - ): Partial { + function getControllerState(): Partial { return { - payIntents: { [TRANSACTION_ID_MOCK]: intent }, transactionData: { [TRANSACTION_ID_MOCK]: { isLoading: false, - sourceAmounts: [ - { - sourceAmountHuman: '1', - sourceAmountRaw: '1000000', - targetTokenAddress: TARGET_TOKEN, - }, - ], tokens: [ { address: TARGET_TOKEN, @@ -470,1189 +423,775 @@ describe('TransactionPayController', () => { }; } - function getMoneyAccountQuoteState(): Partial { - const state = getQuoteState(); - const transactionData = state.transactionData?.[TRANSACTION_ID_MOCK]; - - return { - ...state, - transactionData: { - [TRANSACTION_ID_MOCK]: { - ...transactionData, - atomic: false, - paymentOverride: PaymentOverride.MoneyAccount, - }, - } as TransactionPayControllerState['transactionData'], - }; - } - - function getSolanaCallbacks( - overrides: Partial = {}, - ): SolanaPayCallbacks { - return { - getPreflight: jest.fn().mockResolvedValue(SOLANA_PREFLIGHT_MOCK), - getTransactionStatus: jest.fn().mockResolvedValue('pending'), - signAndSendTransaction: jest.fn().mockResolvedValue({ - outcome: 'submitted', - transactionId: SOLANA_TRANSACTION_ID, - }), - ...overrides, - }; - } - - it('rejects quote requests without a persisted Solana intent', async () => { - const controller = createController(); - - await expect( - controller.getSolanaPayQuote(GET_QUOTE_REQUEST), - ).rejects.toThrow('Solana Pay intent missing'); - }); - - it('rejects quote construction without production transaction data', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { [TRANSACTION_ID_MOCK]: getUnquotedIntent() }, - }, + function applyTransactionUpdates(transaction: TransactionMeta): void { + getTransactionMock.mockReturnValue(transaction); + getTransactionControllerStateMock.mockReturnValue({ + transactions: [transaction], }); + updateTransactionMock.mockImplementation((_request, update) => { + update(transaction); + }); + } - await expect( - controller.getSolanaPayQuote(GET_QUOTE_REQUEST), - ).rejects.toThrow('Transaction data missing'); - }); - - it('fetches a production Relay quote and persists its request checkpoint', async () => { + it('stores the first durable checkpoint only on transaction metadata', async () => { + const transaction = getTransactionMeta(); const solana = getSolanaCallbacks(); + applyTransactionUpdates(transaction); const controller = createController({ solana, - state: getQuoteState(), + state: getControllerState(), }); - const quote = await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + const quote = await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); - expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledWith( - messenger, - expect.objectContaining({ - originChainId: 792703809, - originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', - tradeType: 'EXACT_INPUT', - }), - ); + expect(controller.state).not.toHaveProperty('payIntents'); + expect(transaction.metamaskPay?.solanaExecution).toMatchObject({ + phase: 'ready', + requestId: 'relay-request-123', + sourceAmountRaw: '1000000', + sourceChainId: SOLANA_CHAIN_ID, + sourceWalletAccountId: 'wallet-account-uuid', + }); expect(solana.getPreflight).toHaveBeenCalledWith( expect.objectContaining({ accountId: 'wallet-account-uuid', caipAccountId: SOLANA_ACCOUNT_ID, - requestId: 'relay-request-123', - sourceAmountRaw: '1000000', - sourceAssetId: SOLANA_ASSET_ID, - transaction: SOLANA_QUOTE_MOCK.steps[0].items[0].data, + scope: SOLANA_CHAIN_ID, }), ); - expect(quote).toStrictEqual({ - preflight: expect.objectContaining({ - retainedReserveRaw: '11000', - sourceAmountRaw: '1000000', - }), - providerQuote: SOLANA_QUOTE_MOCK, - route: { - atomicProductActionIncluded: false, - recipient: '0x1234567890123456789012345678901234567890', - targetAmountMinimum: '900000', - tradeType: 'EXACT_INPUT', - }, - }); - expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ - requestId: 'relay-request-123', - execution: { - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', - sourceStatus: 'not-started', - }, - }); - expect( - controller.state.transactionData[TRANSACTION_ID_MOCK].solanaPayQuote, - ).toStrictEqual(quote); - expect(() => - controller.setPayIntent({ - transactionId: TRANSACTION_ID_MOCK, - intent: { - ...getUnquotedIntent(), - sourceWalletAccountId: 'different-wallet-account', - }, - }), - ).toThrow('Solana source snapshot is immutable'); + expect(quote.route.targetAmountMinimum).toBe('900000'); }); it.each([ - TransactionType.perpsDeposit, - TransactionType.perpsDepositAndOrder, - TransactionType.predictDeposit, - TransactionType.predictDepositAndOrder, - ])('derives and checkpoints atomic product calls for %s', async (type) => { - const getDelegationTransaction = jest.fn().mockResolvedValue({ - data: '0x1234' as Hex, - to: '0x9876543210987654321098765432109876543210' as Hex, - value: '0x0' as Hex, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - txParams: { - from: '0x1234567890123456789012345678901234567890', - }, - type, - } as TransactionMeta); - const controller = createController({ - getDelegationTransaction, - solana: getSolanaCallbacks(), - state: getQuoteState(), - }); + ['atomic-product', undefined, true, false], + ['money-account', PaymentOverride.MoneyAccount, false, true], + ] as const)( + 'derives %s execution policy at quote time', + async ( + _name, + paymentOverride, + atomicProductActionIncluded, + requiresFollowUp, + ) => { + const transaction = getTransactionMeta(); + transaction.type = 'predictDeposit'; + applyTransactionUpdates(transaction); + const state = getControllerState(); + const transactionData = state.transactionData?.[TRANSACTION_ID_MOCK]; + if (!transactionData) { + throw new Error('Missing test transaction data'); + } + if (paymentOverride) { + transactionData.atomic = false; + transactionData.paymentOverride = paymentOverride; + } + const controller = createController({ + getDelegationTransaction: jest.fn().mockResolvedValue({ + data: '0x1234', + to: '0x9876543210987654321098765432109876543210', + value: '0x0', + }), + solana: getSolanaCallbacks(), + state, + }); - const quote = await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); + await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); - expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledWith( - messenger, - expect.objectContaining({ - amount: '900000', - tradeType: 'EXACT_OUTPUT', - txs: expect.arrayContaining([ - expect.objectContaining({ to: TARGET_TOKEN }), - { - data: '0x1234', - to: '0x9876543210987654321098765432109876543210', - value: '0x0', - }, - ]), - }), - ); - expect(quote.route).toStrictEqual({ - atomicProductActionIncluded: true, - recipient: '0x1234567890123456789012345678901234567890', - targetAmountMinimum: '900000', - tradeType: 'EXACT_OUTPUT', - }); - expect(controller.state.payIntents[TRANSACTION_ID_MOCK]).toMatchObject({ - atomicProductActionIncluded: true, - atomicProductActionRequired: true, + expect(transaction.metamaskPay?.solanaExecution).toMatchObject({ + atomicProductActionIncluded, + atomicProductActionRequired: !requiresFollowUp, + requiresNonAtomicFollowUp: requiresFollowUp, + }); + }, + ); + + it('checkpoints before one source broadcast and never signs during observation', async () => { + const transaction = getTransactionMeta(); + const signAndSendTransaction = jest.fn().mockImplementation(() => { + expect(transaction.metamaskPay?.solanaExecution?.phase).toBe( + 'attempting', + ); + return { + outcome: 'submitted', + transactionId: SOLANA_TRANSACTION_ID, + }; + }); + applyTransactionUpdates(transaction); + const controller = createController({ + solana: getSolanaCallbacks({ signAndSendTransaction }), + state: getControllerState(), + }); + await controller.getSolanaPayQuote({ sourceAmountRaw: '1000000', sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); + + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + expect(transaction.metamaskPay?.solanaExecution).toMatchObject({ + phase: 'submitted', + sourceTransactionId: SOLANA_TRANSACTION_ID, }); }); - it('rejects a Relay response whose canonical source amount changed', async () => { - const providerQuote = { - ...SOLANA_QUOTE_MOCK, - details: { - ...SOLANA_QUOTE_MOCK.details, - currencyIn: { - ...SOLANA_QUOTE_MOCK.details.currencyIn, - amount: '999999', - }, + it.each([ + ['user-rejected', 'user-rejected'], + ['not-submitted', 'not-submitted'], + ['ambiguous', 'unknown'], + ] as const)( + 'persists the discriminated %s source outcome as %s', + async (outcome, phase) => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + const controller = createController({ + solana: getSolanaCallbacks({ + signAndSendTransaction: jest.fn().mockResolvedValue({ outcome }), + }), + state: getControllerState(), + }); + await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); + if (outcome === 'ambiguous') { + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [], + originChainId: 792703809, + status: 'waiting', + txHashes: [], + updatedAt: 1, + }); + } + + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(transaction.metamaskPay?.solanaExecution?.phase).toBe(phase); + }, + ); + + it('notifies Relay indexing without invoking source submission', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: { + atomicProductActionIncluded: false, + atomicProductActionRequired: false, + followUpStatus: 'not-required', + notificationStatus: 'failure', + phase: 'submitted', + relayStatus: 'pending', + requestId: 'relay-request-123', + requiresNonAtomicFollowUp: false, + sourceAmountRaw: '1000000', + sourceChainId: SOLANA_CHAIN_ID, + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + sourceWalletAccountId: 'wallet-account-uuid', }, }; - fetchRelaySolanaQuoteMock.mockResolvedValue(providerQuote); - const solana = getSolanaCallbacks(); + const signAndSendTransaction = jest.fn(); + applyTransactionUpdates(transaction); const controller = createController({ - solana, - state: getQuoteState(), + solana: getSolanaCallbacks({ signAndSendTransaction }), }); - await expect( - controller.getSolanaPayQuote(GET_QUOTE_REQUEST), - ).rejects.toThrow('Relay Solana source amount mismatch'); - expect(solana.getPreflight).not.toHaveBeenCalled(); + const status = + await controller.notifyRelayOfSolanaTransaction(TRANSACTION_ID_MOCK); + + expect(notifyRelayTransactionMock).toHaveBeenCalledWith({ + chainId: '792703809', + requestId: 'relay-request-123', + txHash: SOLANA_TRANSACTION_ID, + }); + expect(signAndSendTransaction).not.toHaveBeenCalled(); + expect(status.notificationStatus).toBe('success'); }); - it('enforces Core affordability policy before the signing boundary', async () => { + it('recovers by scanning transaction metadata without signing', async () => { + const transaction = getTransactionMeta(); + transaction.status = 'submitted'; + transaction.isExternalPublish = true; + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: { + atomicProductActionIncluded: true, + atomicProductActionRequired: true, + followUpStatus: 'not-required', + notificationStatus: 'success', + phase: 'unknown', + relayStatus: 'pending', + requestId: 'relay-request-123', + requiresNonAtomicFollowUp: false, + sourceAmountRaw: '1000000', + sourceChainId: SOLANA_CHAIN_ID, + sourceStatus: 'unknown', + sourceWalletAccountId: 'wallet-account-uuid', + }, + }; const signAndSendTransaction = jest.fn(); + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); const controller = createController({ solana: getSolanaCallbacks({ - getPreflight: jest.fn().mockResolvedValue({ - ...SOLANA_PREFLIGHT_MOCK, - sourceBalanceRaw: '1', - }), + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), signAndSendTransaction, }), - state: getQuoteState(), }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - await expect( - controller.submitSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Solana source is not affordable'); + const statuses = await controller.recoverSolanaPayStatus(); + + expect(statuses[TRANSACTION_ID_MOCK].outcome).toBe('succeeded'); expect(signAndSendTransaction).not.toHaveBeenCalled(); + expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); }); - it('rejects submission when the transient quote is unavailable', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...getUnquotedIntent(), - requestId: 'relay-request-123', - execution: { - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', - sourceStatus: 'not-started', - }, - }, - }, - }, - }); - - await expect( - controller.submitSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Missing Solana Pay quote'); - }); + it('preserves a terminal Relay observation when status is unavailable', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + phase: 'submitted', + relayStatus: 'success', + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); + const controller = createController({ solana: getSolanaCallbacks() }); - it('rejects quote preflight when Solana callbacks are not configured', async () => { - const controller = createController({ - state: getQuoteState(), - }); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - await expect( - controller.getSolanaPayQuote(GET_QUOTE_REQUEST), - ).rejects.toThrow('Solana callbacks missing'); + expect(status.relayStatus).toBe('success'); }); - it('invokes the sign-and-send callback once and never resubmits', async () => { - const signAndSendTransaction = jest.fn().mockResolvedValue({ + it('keeps the Money Account destination follow-up separate from source submission', async () => { + const transaction = getTransactionMeta(); + transaction.status = 'submitted'; + transaction.isExternalPublish = true; + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'not-started', + notificationStatus: 'success', + phase: 'submitted', + relayStatus: 'success', + requiresNonAtomicFollowUp: true, + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + const submitNonAtomicFollowUp = jest.fn().mockResolvedValue({ outcome: 'submitted', - transactionId: SOLANA_TRANSACTION_ID, + transactionId: 'follow-up-123', + }); + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, }); - const getTransactionStatus = jest.fn().mockResolvedValue('pending'); const controller = createController({ solana: getSolanaCallbacks({ - getTransactionStatus, - signAndSendTransaction, + getNonAtomicFollowUpStatus: jest.fn().mockResolvedValue('confirmed'), + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + submitNonAtomicFollowUp, }), - state: getQuoteState(), }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - await controller.submitSolanaPay(TRANSACTION_ID_MOCK); - await controller.submitSolanaPay(TRANSACTION_ID_MOCK); - - expect(signAndSendTransaction).toHaveBeenCalledTimes(1); - expect(signAndSendTransaction).toHaveBeenCalledWith({ - accountId: 'wallet-account-uuid', - caipAccountId: SOLANA_ACCOUNT_ID, - preparedTransaction: 'base64-transaction', - preparationId: 'preparation-123', - requestId: 'relay-request-123', - scope: SOLANA_CHAIN_ID, - }); - expect( - controller.state.payIntents[TRANSACTION_ID_MOCK].sourceTransactionId, - ).toBe(SOLANA_TRANSACTION_ID); - }); - - it('persists an explicit ambiguous outcome and never resubmits', async () => { - const signAndSendTransaction = jest.fn().mockResolvedValue({ - outcome: 'ambiguous', - reason: 'Snap callback lost', - }); - const controller = createController({ - solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn(), - signAndSendTransaction, - }), - state: getQuoteState(), - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [], - originChainId: 792703809, - status: 'waiting', - txHashes: [], - updatedAt: 1, - }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - - const firstStatus = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); - const recoveredStatus = - await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - expect(firstStatus.sourceStatus).toBe('unknown'); - expect(recoveredStatus.sourceStatus).toBe('unknown'); - expect(signAndSendTransaction).toHaveBeenCalledTimes(1); - await expect( - controller.getSolanaPayQuote(GET_QUOTE_REQUEST), - ).rejects.toThrow('Solana source attempt already started'); - expect(fetchRelaySolanaQuoteMock).toHaveBeenCalledTimes(1); + expect(submitNonAtomicFollowUp).toHaveBeenCalledTimes(1); + expect(status.followUpStatus).toBe('confirmed'); + expect(status.outcome).toBe('succeeded'); }); it.each([ - ['user-rejected', 'user-rejected'], - ['not-submitted', 'not-submitted'], + ['ambiguous', 'unknown'], + ['not-submitted', 'failed'], ] as const)( - 'persists guaranteed %s without entering unknown observation', + 'records Money Account follow-up outcome %s as %s', async (outcome, expectedStatus) => { - const signAndSendTransaction = jest.fn().mockResolvedValue({ outcome }); + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'not-started', + phase: 'submitted', + relayStatus: 'success', + requiresNonAtomicFollowUp: true, + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: [], + updatedAt: 1, + }); const controller = createController({ - solana: getSolanaCallbacks({ signAndSendTransaction }), - state: getQuoteState(), + solana: getSolanaCallbacks({ + submitNonAtomicFollowUp: jest.fn().mockResolvedValue({ outcome }), + }), }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - const status = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - expect(status.sourceStatus).toBe(expectedStatus); - expect(status.submissionOutcome).toBe(outcome); - expect(getRelayStatusMock).not.toHaveBeenCalled(); - expect(notifyRelayTransactionMock).not.toHaveBeenCalled(); + expect(status.followUpStatus).toBe(expectedStatus); }, ); - it('does not classify an undiscriminated callback rejection as unknown', async () => { - const signAndSendTransaction = jest - .fn() - .mockRejectedValue(new Error('callback contract violation')); - const controller = createController({ - solana: getSolanaCallbacks({ signAndSendTransaction }), - state: getQuoteState(), - }); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - - await expect( - controller.submitSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('callback contract violation'); - - expect( - controller.state.payIntents[TRANSACTION_ID_MOCK].execution, - ).toMatchObject({ sourceStatus: 'attempting' }); - expect(signAndSendTransaction).toHaveBeenCalledTimes(1); - }); - - it('rejects notification retry without stable request and source correlation', async () => { - const controller = createController({ - state: getQuoteState(), - }); - - await expect( - controller.retrySolanaPayNotification(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Missing Solana notification correlation'); - }); - - it('retries provider notification without resubmitting', async () => { - const signAndSendTransaction = jest.fn().mockResolvedValue({ - outcome: 'submitted', - transactionId: SOLANA_TRANSACTION_ID, + it('records unavailable follow-up status as unknown', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'submitted', + followUpTransactionId: 'follow-up-123', + phase: 'submitted', + relayStatus: 'success', + requiresNonAtomicFollowUp: true, + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: [], + updatedAt: 1, }); const controller = createController({ solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn().mockResolvedValue('pending'), - signAndSendTransaction, + getNonAtomicFollowUpStatus: jest + .fn() + .mockRejectedValue(new Error('Unavailable')), }), - state: getQuoteState(), }); - notifyRelayTransactionMock.mockRejectedValueOnce( - new Error('index unavailable'), - ); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - const submissionStatus = - await controller.submitSolanaPay(TRANSACTION_ID_MOCK); - const notificationStatus = - await controller.retrySolanaPayNotification(TRANSACTION_ID_MOCK); + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - expect(submissionStatus.providerNotificationStatus).toBe('failed'); - expect(notificationStatus.providerNotificationStatus).toBe('succeeded'); - expect(notifyRelayTransactionMock).toHaveBeenCalledTimes(2); - expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + expect(status.followUpStatus).toBe('unknown'); }); - it('marks unavailable source and Relay observations as unknown', async () => { + it('records unavailable Relay, source, notification, and follow-up observations', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'submitted', + followUpTransactionId: 'follow-up-123', + notificationStatus: 'failure', + phase: 'submitted', + relayStatus: 'pending', + requiresNonAtomicFollowUp: true, + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); + notifyRelayTransactionMock.mockRejectedValue( + new Error('Index unavailable'), + ); const controller = createController({ solana: getSolanaCallbacks({ + getNonAtomicFollowUpStatus: jest + .fn() + .mockRejectedValue(new Error('Follow-up unavailable')), getTransactionStatus: jest .fn() - .mockRejectedValue(new Error('RPC unavailable')), - signAndSendTransaction: jest.fn(), + .mockRejectedValue(new Error('Source unavailable')), }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'submitted', - }, - }, - }, - }, }); - getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); + await controller.notifyRelayOfSolanaTransaction(TRANSACTION_ID_MOCK); const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + expect(status.notificationStatus).toBe('failure'); expect(status.relayStatus).toBe('unknown'); expect(status.sourceStatus).toBe('unknown'); - expect(confirmTransactionMock).not.toHaveBeenCalled(); - expect(failTransactionMock).not.toHaveBeenCalled(); + expect(status.followUpStatus).toBe('submitted'); }); - it('does not erase a terminal Relay observation when status is unavailable', async () => { - const controller = createController({ - solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn().mockResolvedValue('pending'), - signAndSendTransaction: jest.fn(), - }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'pending', - }, + it.each([ + ['missing-transaction', 'Transaction not found'], + ['missing-source', 'Solana Pay source missing'], + ['invalid-source', 'Invalid Solana Pay source'], + ['missing-execution', 'Solana execution missing'], + ['chain-mismatch', 'Solana execution chain mismatch'], + ])('rejects invalid persisted execution: %s', async (scenario, message) => { + const transaction = getTransactionMeta(); + + if (scenario === 'missing-transaction') { + getTransactionMock.mockReturnValue(undefined); + } else { + if (scenario === 'missing-source') { + transaction.metamaskPay = undefined; + } else if (scenario === 'invalid-source') { + transaction.metamaskPay = { + source: { + sourceAccountId: 'eip155:1:0x1234' as CaipAccountId, + sourceAssetId: 'eip155:1/slip44:60' as CaipAssetType, }, - }, - }, - }); - getRelayStatusMock.mockRejectedValue(new Error('Relay unavailable')); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + }; + } else if (scenario === 'chain-mismatch') { + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ sourceChainId: 'solana:other' }), + }; + } + applyTransactionUpdates(transaction); + } + + const controller = createController({ solana: getSolanaCallbacks() }); - expect(status.relayStatus).toBe('success'); - expect(status.sourceStatus).toBe('pending'); + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow(message); }); - it('rejects reconciliation without Relay correlation', async () => { + it('rejects quote and submission precondition failures', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); const controller = createController({ - state: getQuoteState(), + solana: getSolanaCallbacks(), + state: getControllerState(), }); await expect( - controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Missing Relay request ID'); + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Solana execution missing'); + + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution(), + }; + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }), + ).rejects.toThrow('Solana execution already exists'); + + transaction.metamaskPay.solanaExecution = getExecution({ + phase: 'submitted', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }); + expect( + await controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).toMatchObject({ phase: 'submitted' }); }); - it('recovers a callback-lost source signature and reconciles both status axes', async () => { - const signAndSendTransaction = jest.fn(); - const getTransactionStatus = jest.fn().mockResolvedValue('confirmed'); - const controller = createController({ - solana: getSolanaCallbacks({ - getTransactionStatus, - signAndSendTransaction, + it('rejects notification without a submitted signature', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution(), + }; + applyTransactionUpdates(transaction); + const controller = createController({ solana: getSolanaCallbacks() }); + + await expect( + controller.notifyRelayOfSolanaTransaction(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Solana notification correlation'); + }); + + it('rejects quote construction without transaction data', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + const controller = createController({ solana: getSolanaCallbacks() }); + + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...getUnquotedIntent(), - atomicProductActionIncluded: true, - atomicProductActionRequired: true, - requestId: 'relay-request-123', - execution: { - providerNotificationStatus: 'not-started', - relayStatus: 'pending', - sourceStatus: 'attempting', - }, - }, + ).rejects.toThrow('Transaction data missing'); + }); + + it('rejects a mismatched exact-input provider source amount', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + fetchRelaySolanaQuoteMock.mockResolvedValueOnce({ + ...SOLANA_QUOTE_MOCK, + details: { + ...SOLANA_QUOTE_MOCK.details, + currencyIn: { + ...SOLANA_QUOTE_MOCK.details.currencyIn, + amount: '999999', }, }, }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, + const controller = createController({ + solana: getSolanaCallbacks(), + state: getControllerState(), }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - const statuses = await controller.recoverSolanaPay(); + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }), + ).rejects.toThrow('source amount mismatch'); + }); - expect(statuses[TRANSACTION_ID_MOCK]).toMatchObject({ - relayStatus: 'success', - sourceStatus: 'confirmed', - sourceTransactionId: SOLANA_TRANSACTION_ID, - targetTransactionId: '0xtarget', + it('rejects unaffordable source submission', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + const controller = createController({ + solana: getSolanaCallbacks({ + getPreflight: jest.fn().mockResolvedValue({ + ...SOLANA_PREFLIGHT_MOCK, + sourceBalanceRaw: '1', + }), + }), + state: getControllerState(), }); - expect(signAndSendTransaction).not.toHaveBeenCalled(); - expect(getTransactionStatus).toHaveBeenCalledWith({ - accountId: 'wallet-account-uuid', - caipAccountId: SOLANA_ACCOUNT_ID, - scope: SOLANA_CHAIN_ID, - transactionId: SOLANA_TRANSACTION_ID, + await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, }); - const completionCall = updateTransactionMock.mock.calls.find( - ([request]) => request.note === 'Complete Solana pay intent', - ); - const transaction = {} as TransactionMeta; - completionCall?.[1](transaction); - expect(transaction.isIntentComplete).toBe(true); - expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('not affordable'); }); - it('does not complete a product intent when its atomic action was omitted', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - atomicProductActionIncluded: false, - atomicProductActionRequired: true, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'confirmed', - }, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); + it('rejects submission without its transient quote', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution(), + }; + applyTransactionUpdates(transaction); + const controller = createController({ solana: getSolanaCallbacks() }); - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Solana Pay quote'); + }); - expect(status.outcome).toStrictEqual({ type: 'submitted' }); - expect(confirmTransactionMock).not.toHaveBeenCalled(); - expect(updateTransactionMock).not.toHaveBeenCalledWith( - expect.objectContaining({ note: 'Complete Solana pay intent' }), - expect.any(Function), - ); + it('rejects preflight without Solana callbacks', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + const controller = createController({ state: getControllerState() }); + + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }), + ).rejects.toThrow('Solana callbacks missing'); }); - it('keeps observing a pending intent until it reaches a terminal outcome', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'confirmed', - }, - }, - }, - }, - }); - getRelayStatusMock - .mockResolvedValueOnce({ + it.each([ + ['source-failed', 'failed', 'pending', 'not-required'], + ['relay-failed', 'confirmed', 'failure', 'not-required'], + ['refunded', 'confirmed', 'refund', 'not-required'], + ['follow-up-failed', 'confirmed', 'success', 'failed'], + ] as const)( + 'fails an externally published parent for %s', + async (outcome, sourceStatus, relayStatus, followUpStatus) => { + const transaction = getTransactionMeta(); + transaction.status = 'submitted'; + transaction.isExternalPublish = true; + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus, + phase: 'submitted', + relayStatus, + sourceStatus, + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + const relayStatusByOutcome = { + 'follow-up-failed': 'success', + refunded: 'refund', + 'relay-failed': 'failure', + 'source-failed': 'pending', + } as const; + getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, inTxHashes: [SOLANA_TRANSACTION_ID], originChainId: 792703809, - status: 'pending', + status: relayStatusByOutcome[outcome], txHashes: [], updatedAt: 1, - }) - .mockResolvedValueOnce({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 2, }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); + const controller = createController({ + solana: getSolanaCallbacks({ + getTransactionStatus: jest.fn().mockResolvedValue(sourceStatus), + }), + }); - const statuses = await controller.recoverSolanaPay(); + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - expect(statuses[TRANSACTION_ID_MOCK].outcome).toStrictEqual({ - type: 'succeeded', - }); - expect(getRelayStatusMock).toHaveBeenCalledTimes(2); - }); + expect(failTransactionMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.any(Error), + ); + }, + ); + + it.each([false, true])( + 'does not finalize a non-terminal parent with external publication %s', + async (isExternalPublish) => { + const transaction = getTransactionMeta(); + transaction.status = 'submitted'; + transaction.isExternalPublish = isExternalPublish; + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + phase: 'submitted', + relayStatus: 'pending', + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + const controller = createController({ solana: getSolanaCallbacks() }); - it('returns the latest non-terminal observation at the recovery timeout', async () => { + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.outcome).toBe('submitted'); + expect(confirmTransactionMock).not.toHaveBeenCalled(); + expect(failTransactionMock).not.toHaveBeenCalled(); + }, + ); + + it('returns the latest pending status when recovery reaches its timeout', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + phase: 'submitted', + relayStatus: 'pending', + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); getRelayPollingTimeoutMock.mockReturnValue(1); - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'confirmed', - }, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'pending', - txHashes: [], - updatedAt: 1, - }); - const statuses = await controller.recoverSolanaPay(); - - expect(statuses[TRANSACTION_ID_MOCK].outcome).toStrictEqual({ - type: 'submitted', - }); - }); - - it('skips intents that cannot or no longer need recovery', async () => { - const controller = createController({ - state: { - payIntents: { - 'non-solana': { - ...getUnquotedIntent(), - sourceChainId: 'eip155:1' as CaipChainId, - }, - 'missing-request': getUnquotedIntent(), - terminal: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'succeeded', - relayStatus: 'refund', - sourceStatus: 'confirmed', - }, - }, - 'terminal-rejection': { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', - sourceStatus: 'user-rejected', - }, - }, - 'terminal-follow-up': { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'confirmed', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - - expect(await controller.recoverSolanaPay()).toStrictEqual({}); - expect(getRelayStatusMock).not.toHaveBeenCalled(); - }); - - it('preserves source failure separately from pending Relay settlement', async () => { - const controller = createController({ - solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn().mockResolvedValue('failed'), - signAndSendTransaction: jest.fn(), - }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'submitted', - }, - }, - }, - }, - }); - - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(status.sourceStatus).toBe('failed'); - expect(status.relayStatus).toBe('pending'); - expect(failTransactionMock).toHaveBeenCalledWith( - TRANSACTION_ID_MOCK, - expect.objectContaining({ - message: 'Solana source transaction failed', - }), - ); - }); - - it('preserves Relay failure separately from confirmed source submission', async () => { - const controller = createController({ - solana: getSolanaCallbacks({ - getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), - signAndSendTransaction: jest.fn(), - }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'submitted', - }, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - failReason: 'DESTINATION_TX_FAILED', - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'failure', - txHashes: [], - updatedAt: 1, - }); - - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(status.sourceStatus).toBe('confirmed'); - expect(status.relayStatus).toBe('failure'); - expect(status.failureReason).toBe('DESTINATION_TX_FAILED'); - expect(failTransactionMock).toHaveBeenCalledWith( - TRANSACTION_ID_MOCK, - expect.objectContaining({ message: 'DESTINATION_TX_FAILED' }), - ); - }); - - it.each([ - ['failure', 'Relay settlement failed'], - ['refund', 'Relay settlement refunded'], - ] as const)( - 'uses a stable parent failure for Relay %s without provider detail', - async (relayStatus, expectedMessage) => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'confirmed', - }, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: relayStatus, - txHashes: [], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(failTransactionMock).toHaveBeenCalledWith( - TRANSACTION_ID_MOCK, - expect.objectContaining({ message: expectedMessage }), - ); - }, - ); - - it('emits a distinct parent failure for a Relay refund', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'pending', - sourceStatus: 'confirmed', - }, - }, - }, - }, + getTransactionControllerStateMock.mockReturnValue({ + transactions: [transaction], }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - refundFailReason: 'ORDER_REFUNDED', - status: 'refund', - txHashes: [], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); + const controller = createController({ solana: getSolanaCallbacks() }); - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + const statuses = await controller.recoverSolanaPayStatus(); - expect(status.relayStatus).toBe('refund'); - expect(failTransactionMock).toHaveBeenCalledWith( - TRANSACTION_ID_MOCK, - expect.objectContaining({ message: 'ORDER_REFUNDED' }), - ); + expect(statuses[TRANSACTION_ID_MOCK].outcome).toBe('submitted'); }); - it('completes only after a required one-shot non-atomic follow-up confirms', async () => { - const submitNonAtomicFollowUp = jest.fn().mockResolvedValue({ - outcome: 'submitted', - transactionId: 'follow-up-123', - }); - const getNonAtomicFollowUpStatus = jest - .fn() - .mockResolvedValue('confirmed'); - const controller = createController({ - solana: getSolanaCallbacks({ - getNonAtomicFollowUpStatus, - getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), - submitNonAtomicFollowUp, - }), - state: getMoneyAccountQuoteState(), - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - const transaction = { - id: TRANSACTION_ID_MOCK, - status: 'submitted', - txParams: { - from: '0x1234567890123456789012345678901234567890', - }, - type: TransactionType.predictDeposit, - } as TransactionMeta; - getTransactionMock.mockReturnValue(transaction); - await controller.getSolanaPayQuote(GET_QUOTE_REQUEST); - - const status = await controller.submitSolanaPay(TRANSACTION_ID_MOCK); - - expect(submitNonAtomicFollowUp).toHaveBeenCalledTimes(1); - expect(getNonAtomicFollowUpStatus).toHaveBeenCalledWith({ - transaction, - transactionId: 'follow-up-123', - }); - expect(status.followUpStatus).toBe('confirmed'); - expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); - }); - - it('fails the parent when a required follow-up is guaranteed not submitted', async () => { - const controller = createController({ - solana: getSolanaCallbacks({ - submitNonAtomicFollowUp: jest - .fn() - .mockResolvedValue({ outcome: 'not-submitted' }), - }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-started', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(status.outcome).toStrictEqual({ type: 'follow-up-failed' }); - expect(failTransactionMock).toHaveBeenCalledWith( - TRANSACTION_ID_MOCK, - expect.objectContaining({ - message: 'Solana pay non-atomic follow-up failed', - }), - ); - }); - - it('keeps an ambiguous follow-up observation-only and incomplete', async () => { - const controller = createController({ - solana: getSolanaCallbacks({ - submitNonAtomicFollowUp: jest - .fn() - .mockResolvedValue({ outcome: 'ambiguous' }), + it('requires the Money Account follow-up submission callback', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'not-started', + phase: 'submitted', + relayStatus: 'success', + requiresNonAtomicFollowUp: true, + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-started', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(status.outcome).toStrictEqual({ - phase: 'follow-up', - type: 'unknown', - }); - expect(confirmTransactionMock).not.toHaveBeenCalled(); - expect(failTransactionMock).not.toHaveBeenCalled(); - }); - - it('rejects a required follow-up when the target transaction is missing', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-started', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue(undefined); - - await expect( - controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Target transaction missing'); - }); - - it('requires the non-atomic follow-up submission callback', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'not-started', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); + }; + applyTransactionUpdates(transaction); getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, inTxHashes: [SOLANA_TRANSACTION_ID], originChainId: 792703809, status: 'success', - txHashes: ['0xtarget'], + txHashes: [], updatedAt: 1, }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - + const controller = createController({ solana: getSolanaCallbacks() }); await expect( controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Non-atomic follow-up callback missing'); + ).rejects.toThrow('follow-up callback missing'); }); - it('requires the non-atomic follow-up status callback', async () => { - const controller = createController({ - solana: getSolanaCallbacks(), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'submitted', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - followUpTransactionId: 'follow-up-123', - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); + it('requires the Money Account follow-up status callback', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + followUpStatus: 'submitted', + followUpTransactionId: 'follow-up-123', + phase: 'submitted', + relayStatus: 'success', + requiresNonAtomicFollowUp: true, + sourceStatus: 'confirmed', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); getRelayStatusMock.mockResolvedValue({ destinationChainId: 42161, inTxHashes: [SOLANA_TRANSACTION_ID], originChainId: 792703809, status: 'success', - txHashes: ['0xtarget'], + txHashes: [], updatedAt: 1, }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); + const controller = createController({ solana: getSolanaCallbacks() }); await expect( controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), - ).rejects.toThrow('Non-atomic follow-up status callback missing'); - }); - - it('records follow-up status unavailability without resubmitting', async () => { - const getNonAtomicFollowUpStatus = jest - .fn() - .mockRejectedValue(new Error('RPC unavailable')); - const submitNonAtomicFollowUp = jest.fn(); - const controller = createController({ - solana: getSolanaCallbacks({ - getNonAtomicFollowUpStatus, - submitNonAtomicFollowUp, - }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'submitted', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - followUpTransactionId: 'follow-up-123', - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, - }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); - - expect(status.followUpStatus).toBe('unknown'); - expect(submitNonAtomicFollowUp).not.toHaveBeenCalled(); + ).rejects.toThrow('follow-up status callback missing'); }); - it('does not repeat or complete an interrupted non-atomic follow-up', async () => { - const submitNonAtomicFollowUp = jest.fn(); - const controller = createController({ - solana: getSolanaCallbacks({ submitNonAtomicFollowUp }), - state: { - payIntents: { - [TRANSACTION_ID_MOCK]: { - ...SOLANA_PAY_INTENT_MOCK, - execution: { - followUpStatus: 'attempting', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - requiresNonAtomicFollowUp: true, - }, - }, - }, - }); - getRelayStatusMock.mockResolvedValue({ - destinationChainId: 42161, - inTxHashes: [SOLANA_TRANSACTION_ID], - originChainId: 792703809, - status: 'success', - txHashes: ['0xtarget'], - updatedAt: 1, + it('skips records without execution and records already terminal', async () => { + const withoutExecution = getTransactionMeta(); + const terminal = getTransactionMeta(); + terminal.id = 'terminal'; + terminal.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ phase: 'user-rejected' }), + }; + getTransactionControllerStateMock.mockReturnValue({ + transactions: [withoutExecution, terminal], }); - getTransactionMock.mockReturnValue({ - id: TRANSACTION_ID_MOCK, - status: 'submitted', - } as TransactionMeta); - - const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + const controller = createController({ solana: getSolanaCallbacks() }); - expect(status.followUpStatus).toBe('attempting'); - expect(submitNonAtomicFollowUp).not.toHaveBeenCalled(); - expect(confirmTransactionMock).not.toHaveBeenCalled(); + expect(await controller.recoverSolanaPayStatus()).toStrictEqual({}); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 934f46589a5..6e7939d0615 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -1,6 +1,9 @@ import type { StateMetadata } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + MetamaskPaySolanaExecution, + TransactionMeta, +} from '@metamask/transaction-controller'; import { parseCaipAccountId, parseCaipAssetType } from '@metamask/utils'; import type { Draft } from 'immer'; import { noop } from 'lodash-es'; @@ -36,10 +39,11 @@ import type { GetPaymentOverrideDataCallback, GetSolanaPayQuoteRequest, PolymarketCallbacks, - SetPayIntentRequest, + SetPaySourceRequest, SolanaPayCallbacks, SolanaPayQuote, SolanaPayStatus, + SolanaPaySubmissionResult, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -47,7 +51,7 @@ import type { TransactionPayFiatOptions, TransactionPayControllerOptions, TransactionPayControllerState, - TransactionPayIntent, + TransactionPaySource, UpdateFiatPaymentRequest, UpdatePaymentTokenRequest, } from './types.js'; @@ -74,8 +78,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'getStrategy', 'polymarketGetDepositWalletAddress', 'reconcileSolanaPay', - 'recoverSolanaPay', - 'retrySolanaPayNotification', + 'recoverSolanaPayStatus', + 'notifyRelayOfSolanaTransaction', 'polymarketSubmitDepositWalletBatch', 'setPaySource', 'setTransactionConfig', @@ -110,19 +114,14 @@ function observePromise( ); } -function getSolanaPayExecution( - intent: TransactionPayIntent, -): NonNullable { - return ( - intent.execution ?? - getInitialSolanaPayExecution(intent.requiresNonAtomicFollowUp) - ); -} +function getExecutionSourceTransactionId( + execution: MetamaskPaySolanaExecution, +): string | undefined { + if (execution.phase !== 'submitted' && execution.phase !== 'unknown') { + return undefined; + } -function isTerminalRelayStatus( - status: NonNullable['relayStatus'], -): boolean { - return ['success', 'failure', 'refund'].includes(status); + return execution.sourceTransactionId; } function isTerminalSolanaPayOutcome(status: SolanaPayStatus): boolean { @@ -133,23 +132,66 @@ function isTerminalSolanaPayOutcome(status: SolanaPayStatus): boolean { 'relay-failed', 'refunded', 'follow-up-failed', - ].includes(status.outcome.type); + ].includes(status.outcome); +} + +function getSolanaPayStatus( + execution: MetamaskPaySolanaExecution, +): SolanaPayStatus { + let submissionOutcome: SolanaPayStatus['submissionOutcome']; + + if (execution.phase === 'submitted') { + submissionOutcome = 'submitted'; + } else if ( + execution.phase === 'user-rejected' || + execution.phase === 'not-submitted' + ) { + submissionOutcome = execution.phase; + } else if (execution.phase === 'unknown') { + submissionOutcome = 'ambiguous'; + } + + return { + followUpStatus: execution.followUpStatus, + followUpTransactionId: execution.followUpTransactionId, + notificationStatus: execution.notificationStatus, + outcome: deriveSolanaPayOutcome(execution), + phase: execution.phase, + relayFailureReason: execution.relayFailureReason, + relayStatus: execution.relayStatus, + requestId: execution.requestId, + sourceFailureReason: execution.sourceFailureReason, + sourceStatus: execution.sourceStatus, + sourceTransactionId: getExecutionSourceTransactionId(execution), + submissionOutcome, + targetTransactionId: execution.targetTransactionId, + }; +} + +function getFollowUpStatusFromSubmission( + outcome: SolanaPaySubmissionResult['outcome'], +): MetamaskPaySolanaExecution['followUpStatus'] { + if (outcome === 'submitted') { + return 'submitted'; + } + + return outcome === 'ambiguous' ? 'unknown' : 'failed'; } function getSolanaPayFailure(status: SolanaPayStatus): string | undefined { - if (status.outcome.type === 'source-failed') { - return status.outcome.reason ?? 'Solana source transaction failed'; + if (status.outcome === 'source-failed') { + return status.sourceFailureReason ?? 'Solana source transaction failed'; } - if (status.outcome.type === 'relay-failed') { - return status.outcome.reason ?? 'Relay settlement failed'; + if (status.outcome === 'relay-failed') { + return status.relayFailureReason ?? 'Relay settlement failed'; } - if (status.outcome.type === 'refunded') { - return status.outcome.reason ?? 'Relay settlement refunded'; + if (status.outcome === 'refunded') { + return status.relayFailureReason ?? 'Relay settlement refunded'; } - if (status.outcome.type === 'follow-up-failed') { + if (status.outcome === 'follow-up-failed') { return 'Solana pay non-atomic follow-up failed'; } @@ -239,61 +281,59 @@ 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. + * Persists validated chain-agnostic Pay source metadata on the transaction. * - * @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. + * @param request - Pay source and target transaction ID. + * @param request.source - Validated CAIP source metadata. + * @param request.transactionId - Target transaction ID. */ - setPayIntent({ transactionId, intent }: SetPayIntentRequest): void { - const existing = this.state.payIntents[transactionId]; + setPaySource({ transactionId, source }: SetPaySourceRequest): void { + const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId; + const assetChainId = parseCaipAssetType(source.sourceAssetId).chainId; - if ( - existing?.sourceChainId.startsWith('solana:') && - (existing.requestId || - getSolanaPayExecution(existing).sourceStatus !== 'not-started') - ) { - throw new Error( - 'TransactionPayController: Solana source snapshot is immutable', - ); + if (accountChainId !== assetChainId) { + throw new Error('Pay source account and asset must use the same chain'); } - this.#persistPayIntent(transactionId, intent, 'Set transaction pay intent'); + updateTransaction( + { + transactionId, + messenger: this.messenger, + note: 'Set transaction pay source', + }, + (transaction) => { + transaction.metamaskPay ??= {}; + transaction.metamaskPay.source = { ...source }; + }, + ); } /** - * Fetches and stores an executable Relay /quote/v2 Solana quote. - * - * The provider request ID is persisted before the quote can be published; - * the full instruction payload remains transient because recovery only - * observes an existing attempt and never resubmits it. + * Builds an executable Solana quote and its initial durable checkpoint. * - * @param request - Target transaction whose route Core derives. - * @returns The Relay Solana quote for client display and confirmation. + * @param request - Immutable source snapshot and target transaction ID. + * @returns Prepared Solana quote. */ async getSolanaPayQuote( request: GetSolanaPayQuoteRequest, ): Promise { - const intent = this.#requireSolanaPayIntent(request.transactionId); - - if (getSolanaPayExecution(intent).sourceStatus !== 'not-started') { - throw new Error( - 'TransactionPayController: Solana source attempt already started', - ); - } - - const transaction = getTransaction(request.transactionId, this.messenger); + const transaction = this.#requireTransaction(request.transactionId); + const source = this.#requireSolanaPaySource(transaction); const transactionData = this.state.transactionData[request.transactionId]; - if (!transaction || !transactionData) { + if (!transactionData) { throw new Error('TransactionPayController: Transaction data missing'); } + if (transaction.metamaskPay?.solanaExecution) { + throw new Error( + 'TransactionPayController: Solana execution already exists', + ); + } + const quoteRequest = await buildRelaySolanaQuoteRequest( - intent, + source, + request.sourceAmountRaw, transaction, transactionData, this.messenger, @@ -314,19 +354,34 @@ export class TransactionPayController extends BaseController< ); } + const sourceChainId = parseCaipAccountId(source.sourceAccountId).chainId; const preflightData = await this.#requireSolanaCallbacks().getPreflight({ - accountId: intent.sourceWalletAccountId, - caipAccountId: intent.sourceAccountId, + accountId: request.sourceWalletAccountId, + caipAccountId: source.sourceAccountId, requestId: providerQuote.requestId, - scope: intent.sourceChainId, + scope: sourceChainId, sourceAmountRaw, - sourceAssetId: intent.sourceAssetId, + sourceAssetId: source.sourceAssetId, transaction: sourceTransaction, }); const atomicProductActionIncluded = Boolean(quoteRequest.txs?.length); + const requiresNonAtomicFollowUp = + transactionData.atomic === false && + transactionData.paymentOverride === PaymentOverride.MoneyAccount; + const execution = getInitialSolanaPayExecution({ + atomicProductActionIncluded, + atomicProductActionRequired: + isSolanaPayProductTransaction(transaction) && + !requiresNonAtomicFollowUp, + requestId: providerQuote.requestId, + requiresNonAtomicFollowUp, + sourceAmountRaw, + sourceChainId, + sourceWalletAccountId: request.sourceWalletAccountId, + }); const quote: SolanaPayQuote = { preflight: normalizeSolanaPayPreflight( - intent, + source, sourceAmountRaw, preflightData, ), @@ -338,31 +393,12 @@ export class TransactionPayController extends BaseController< tradeType: quoteRequest.tradeType, }, }; - const requiresNonAtomicFollowUp = - transactionData.atomic === false && - transactionData.paymentOverride === PaymentOverride.MoneyAccount; - this.#persistPayIntent( + this.#persistSolanaExecution( request.transactionId, - { - ...intent, - atomicProductActionIncluded, - atomicProductActionRequired: - isSolanaPayProductTransaction(transaction) && - !requiresNonAtomicFollowUp, - execution: getInitialSolanaPayExecution(requiresNonAtomicFollowUp), - followUpTransactionId: undefined, - relayFailureReason: undefined, - requestId: providerQuote.requestId, - requiresNonAtomicFollowUp, - sourceAmountRaw, - sourceFailureReason: undefined, - sourceTransactionId: undefined, - targetTransactionId: undefined, - }, - 'Set Solana pay quote checkpoint', + execution, + 'Set executable Solana pay checkpoint', ); - this.#updateTransactionData(request.transactionId, (data) => { data.solanaPayQuote = quote; }); @@ -375,22 +411,22 @@ export class TransactionPayController extends BaseController< * * The `attempting` checkpoint is persisted before invoking the callback. * The callback must resolve with a discriminated completion outcome; only an - * explicit ambiguous outcome becomes `unknown`. No outcome is resubmitted. + * explicit ambiguous outcome becomes `unknown`. The source signing callback is + * never invoked again for this execution. * * @param transactionId - Target TransactionController transaction ID. * @returns The latest independent source, notification, and Relay statuses. */ async submitSolanaPay(transactionId: string): Promise { - const intent = this.#requireSolanaPayIntent(transactionId); - const execution = getSolanaPayExecution(intent); + const { execution, source } = this.#requireSolanaExecution(transactionId); - if (execution.sourceStatus !== 'not-started') { + if (execution.phase !== 'ready') { return await this.reconcileSolanaPay(transactionId); } const quote = this.state.transactionData[transactionId]?.solanaPayQuote; - if (!quote || quote.providerQuote.requestId !== intent.requestId) { + if (quote?.providerQuote.requestId !== execution.requestId) { throw new Error('TransactionPayController: Missing Solana Pay quote'); } @@ -400,46 +436,37 @@ export class TransactionPayController extends BaseController< ); } - const solana = this.#requireSolanaCallbacks(); - - this.#updatePayIntent( + this.#persistSolanaExecution( transactionId, - (current) => ({ - ...current, - execution: { - ...getSolanaPayExecution(current), - sourceStatus: 'attempting', - }, - }), - 'Start Solana source attempt', + { ...execution, phase: 'attempting' }, + 'Start one Solana source attempt', ); - const submission = await solana.signAndSendTransaction({ - accountId: intent.sourceWalletAccountId, - caipAccountId: intent.sourceAccountId, - preparedTransaction: quote.preflight.preparedTransaction, - preparationId: quote.preflight.preparationId, - requestId: intent.requestId, - scope: intent.sourceChainId, - }); + const submission = + await this.#requireSolanaCallbacks().signAndSendTransaction({ + accountId: execution.sourceWalletAccountId, + caipAccountId: source.sourceAccountId, + preparedTransaction: quote.preflight.preparedTransaction, + preparationId: quote.preflight.preparationId, + requestId: execution.requestId, + scope: execution.sourceChainId, + }); if (submission.outcome !== 'submitted') { - const sourceStatus = + const phase = submission.outcome === 'ambiguous' ? 'unknown' : submission.outcome; - this.#updatePayIntent( + this.#persistSolanaExecution( transactionId, - (current) => ({ - ...current, - execution: { - ...getSolanaPayExecution(current), - sourceStatus, - submissionOutcome: submission.outcome, - }, + { + ...execution, + phase, sourceFailureReason: submission.outcome === 'user-rejected' ? undefined : submission.reason, - }), + sourceStatus: + submission.outcome === 'ambiguous' ? 'unknown' : 'not-observed', + }, `Record Solana source outcome: ${submission.outcome}`, ); @@ -448,166 +475,153 @@ export class TransactionPayController extends BaseController< : this.#getSolanaPayStatus(transactionId); } - this.#updatePayIntent( + this.#persistSolanaExecution( transactionId, - (current) => ({ - ...current, - execution: { - ...getSolanaPayExecution(current), - providerNotificationStatus: 'pending', - sourceStatus: 'submitted', - submissionOutcome: 'submitted', - }, + { + ...execution, + notificationStatus: 'not-attempted', + phase: 'submitted', + sourceStatus: 'pending', sourceTransactionId: submission.transactionId, - }), - 'Record Solana source transaction', + }, + 'Record submitted Solana source transaction', ); - await this.retrySolanaPayNotification(transactionId); + await this.notifyRelayOfSolanaTransaction(transactionId); return await this.reconcileSolanaPay(transactionId); } /** - * Retries only Relay's transaction-index notification. - * - * This method never invokes the signing callback and notification failure - * does not alter source or settlement observations. + * Notifies Relay indexing about an existing Solana signature only. * - * @param transactionId - Target TransactionController transaction ID. - * @returns The latest durable status. + * @param transactionId - Target transaction ID. + * @returns Latest durable status. */ - async retrySolanaPayNotification( + async notifyRelayOfSolanaTransaction( transactionId: string, ): Promise { - const intent = this.#requireSolanaPayIntent(transactionId); + const { execution } = this.#requireSolanaExecution(transactionId); + const sourceTransactionId = getExecutionSourceTransactionId(execution); - if (!intent.requestId || !intent.sourceTransactionId) { + if (!sourceTransactionId) { throw new Error( 'TransactionPayController: Missing Solana notification correlation', ); } - this.#updatePayIntent( + this.#persistSolanaExecution( transactionId, - (current) => ({ - ...current, - execution: { - ...getSolanaPayExecution(current), - providerNotificationStatus: 'pending', - }, - }), - 'Start Relay source notification', + { ...execution, notificationStatus: 'pending' }, + 'Start Relay indexing notification', ); const notification = await observePromise( notifyRelayTransaction({ chainId: String(RELAY_SOLANA_CHAIN_ID), - requestId: intent.requestId, - txHash: intent.sourceTransactionId, + requestId: execution.requestId, + txHash: sourceTransactionId, }), ); - this.#updatePayIntent( + this.#updateSolanaExecution( transactionId, (current) => ({ ...current, - execution: { - ...getSolanaPayExecution(current), - providerNotificationStatus: - notification.status === 'fulfilled' ? 'succeeded' : 'failed', - }, + notificationStatus: + notification.status === 'fulfilled' ? 'success' : 'failure', }), - 'Record Relay source notification result', + 'Record Relay indexing notification result', ); return this.#getSolanaPayStatus(transactionId); } /** - * Reconciles source-chain and Relay status without signing, notifying, or - * resubmitting. It can recover a source signature from Relay after callback - * loss and persists each observation for the next restart. + * Observes source and Relay status without signing or source resubmission. * - * @param transactionId - Target TransactionController transaction ID. - * @returns The latest durable status. + * @param transactionId - Target transaction ID. + * @returns Latest durable status. */ async reconcileSolanaPay(transactionId: string): Promise { - const initialIntent = this.#requireSolanaPayIntent(transactionId); - - if (!initialIntent.requestId) { - throw new Error('TransactionPayController: Missing Relay request ID'); - } - + const { execution: initialExecution, source } = + this.#requireSolanaExecution(transactionId); const relayResult = await observePromise( - getRelayStatus(initialIntent.requestId), + getRelayStatus(initialExecution.requestId), ); - this.#updatePayIntent( + this.#updateSolanaExecution( transactionId, (current) => { if (relayResult.status === 'rejected') { - const execution = getSolanaPayExecution(current); return { ...current, - execution: { - ...execution, - relayStatus: isTerminalRelayStatus(execution.relayStatus) - ? execution.relayStatus - : 'unknown', - }, + relayStatus: ['success', 'failure', 'refund'].includes( + current.relayStatus, + ) + ? current.relayStatus + : 'unknown', }; } const [observedSourceTransactionId] = relayResult.value.inTxHashes; const sourceTransactionId = - current.sourceTransactionId ?? observedSourceTransactionId; + getExecutionSourceTransactionId(current) ?? + observedSourceTransactionId; const [targetTransactionId] = relayResult.value.txHashes.slice(-1); - - return { + const updated = { ...current, - execution: { - ...getSolanaPayExecution(current), - relayStatus: mapRelayStatus(relayResult.value.status), - }, relayFailureReason: relayResult.value.failReason ?? relayResult.value.refundFailReason, - sourceTransactionId, + relayStatus: mapRelayStatus(relayResult.value.status), targetTransactionId, }; + + return sourceTransactionId + ? { + ...updated, + notificationStatus: + current.notificationStatus === 'not-ready' + ? 'not-attempted' + : current.notificationStatus, + phase: 'submitted', + sourceStatus: + current.sourceStatus === 'not-observed' + ? 'pending' + : current.sourceStatus, + sourceTransactionId, + } + : updated; }, - 'Reconcile Relay Solana status', + 'Observe Relay Solana status', ); - const intent = this.#requireSolanaPayIntent(transactionId); - const { sourceStatus } = getSolanaPayExecution(intent); + const { execution } = this.#requireSolanaExecution(transactionId); + const sourceTransactionId = getExecutionSourceTransactionId(execution); if ( - intent.sourceTransactionId && - sourceStatus !== 'confirmed' && - sourceStatus !== 'failed' + sourceTransactionId && + execution.sourceStatus !== 'confirmed' && + execution.sourceStatus !== 'failed' ) { const sourceResult = await observePromise( this.#requireSolanaCallbacks().getTransactionStatus({ - accountId: intent.sourceWalletAccountId, - caipAccountId: intent.sourceAccountId, - scope: intent.sourceChainId, - transactionId: intent.sourceTransactionId, + accountId: execution.sourceWalletAccountId, + caipAccountId: source.sourceAccountId, + scope: execution.sourceChainId, + transactionId: sourceTransactionId, }), ); - this.#updatePayIntent( + this.#updateSolanaExecution( transactionId, (current) => ({ ...current, - execution: { - ...getSolanaPayExecution(current), - sourceStatus: - sourceResult.status === 'fulfilled' - ? sourceResult.value - : 'unknown', - }, + sourceStatus: + sourceResult.status === 'fulfilled' + ? sourceResult.value + : 'unknown', }), - 'Reconcile Solana source status', + 'Observe Solana source status', ); } @@ -619,40 +633,33 @@ export class TransactionPayController extends BaseController< } /** - * Resumes observation for all persisted, non-terminal Solana Pay intents. - * Source submission and provider notification are intentionally excluded. + * Scans persisted TransactionController records and observes non-terminal + * Solana execution status. This method never signs, broadcasts, or notifies. * * @returns Latest statuses keyed by target transaction ID. */ - async recoverSolanaPay(): Promise> { + async recoverSolanaPayStatus(): Promise> { const results: Record = {}; + const { transactions } = this.messenger.call( + 'TransactionController:getState', + ); - for (const [transactionId, intent] of Object.entries( - this.state.payIntents, - )) { - const execution = getSolanaPayExecution(intent); + for (const transaction of transactions) { + const execution = transaction.metamaskPay?.solanaExecution; - const isFollowUpTerminal = - !intent.requiresNonAtomicFollowUp || - ['confirmed', 'failed', 'user-rejected', 'not-submitted'].includes( - execution.followUpStatus ?? 'not-started', - ); - const isRecoveryComplete = - ['user-rejected', 'not-submitted'].includes(execution.sourceStatus) || - (['confirmed', 'failed'].includes(execution.sourceStatus) && - isTerminalRelayStatus(execution.relayStatus) && - (execution.relayStatus !== 'success' || isFollowUpTerminal)); + if (!execution) { + continue; + } - if ( - !intent.sourceChainId.startsWith('solana:') || - !intent.requestId || - isRecoveryComplete - ) { + const status = getSolanaPayStatus(execution); + + if (isTerminalSolanaPayOutcome(status)) { continue; } - results[transactionId] = - await this.#recoverSolanaPayIntent(transactionId); + results[transaction.id] = await this.#recoverSolanaPayExecution( + transaction.id, + ); } return results; @@ -826,7 +833,7 @@ export class TransactionPayController extends BaseController< return this.#requirePolymarket().submitDepositWalletBatch(...args); } - async #recoverSolanaPayIntent( + async #recoverSolanaPayExecution( transactionId: string, ): Promise { const startTime = Date.now(); @@ -851,23 +858,17 @@ export class TransactionPayController extends BaseController< } async #advanceNonAtomicFollowUp(transactionId: string): Promise { - let intent = this.#requireSolanaPayIntent(transactionId); - let execution = getSolanaPayExecution(intent); + let { execution, transaction } = + this.#requireSolanaExecution(transactionId); if ( - !intent.requiresNonAtomicFollowUp || + !execution.requiresNonAtomicFollowUp || execution.relayStatus !== 'success' || execution.sourceStatus !== 'confirmed' ) { return; } - const transaction = getTransaction(transactionId, this.messenger); - - if (!transaction) { - throw new Error('TransactionPayController: Target transaction missing'); - } - if (execution.followUpStatus === 'not-started') { const submitFollowUp = this.#requireSolanaCallbacks().submitNonAtomicFollowUp; @@ -878,50 +879,37 @@ export class TransactionPayController extends BaseController< ); } - this.#updatePayIntent( + this.#persistSolanaExecution( transactionId, - (current) => ({ - ...current, - execution: { - ...getSolanaPayExecution(current), - followUpStatus: 'attempting', - }, - }), - 'Start Solana pay non-atomic follow-up', + { ...execution, followUpStatus: 'attempting' }, + 'Start one sponsored Money Account destination follow-up', ); const result = await submitFollowUp({ - requestId: intent.requestId as string, - relayTransactionId: intent.targetTransactionId, + requestId: execution.requestId, + relayTransactionId: execution.targetTransactionId, transaction, }); - const followUpStatus = - result.outcome === 'submitted' ? 'submitted' : result.outcome; + const followUpStatus = getFollowUpStatusFromSubmission(result.outcome); - this.#updatePayIntent( + this.#updateSolanaExecution( transactionId, (current) => ({ ...current, - execution: { - ...getSolanaPayExecution(current), - followUpStatus: - followUpStatus === 'ambiguous' ? 'unknown' : followUpStatus, - }, + followUpStatus, followUpTransactionId: result.outcome === 'submitted' ? result.transactionId : undefined, }), - `Record Solana pay follow-up outcome: ${result.outcome}`, + `Record Money Account destination follow-up: ${result.outcome}`, ); - intent = this.#requireSolanaPayIntent(transactionId); - execution = getSolanaPayExecution(intent); + ({ execution, transaction } = + this.#requireSolanaExecution(transactionId)); } if ( - intent.followUpTransactionId && - (execution.followUpStatus === 'submitted' || - execution.followUpStatus === 'pending' || - execution.followUpStatus === 'unknown') + execution.followUpTransactionId && + ['submitted', 'pending', 'unknown'].includes(execution.followUpStatus) ) { const getFollowUpStatus = this.#requireSolanaCallbacks().getNonAtomicFollowUpStatus; @@ -935,21 +923,18 @@ export class TransactionPayController extends BaseController< const result = await observePromise( getFollowUpStatus({ transaction, - transactionId: intent.followUpTransactionId, + transactionId: execution.followUpTransactionId, }), ); - this.#updatePayIntent( + this.#updateSolanaExecution( transactionId, (current) => ({ ...current, - execution: { - ...getSolanaPayExecution(current), - followUpStatus: - result.status === 'fulfilled' ? result.value : 'unknown', - }, + followUpStatus: + result.status === 'fulfilled' ? result.value : 'unknown', }), - 'Reconcile Solana pay non-atomic follow-up', + 'Observe Money Account destination follow-up', ); } } @@ -960,7 +945,7 @@ export class TransactionPayController extends BaseController< ): void { const transaction = getTransaction(transactionId, this.messenger); - if (transaction?.status !== 'submitted') { + if (transaction?.status !== 'submitted' || !transaction.isExternalPublish) { return; } @@ -975,7 +960,7 @@ export class TransactionPayController extends BaseController< return; } - if (status.outcome.type !== 'succeeded') { + if (status.outcome !== 'succeeded') { return; } @@ -983,7 +968,7 @@ export class TransactionPayController extends BaseController< { transactionId, messenger: this.messenger, - note: 'Complete Solana pay intent', + note: 'Complete external Solana pay execution', }, (current) => { current.isIntentComplete = true; @@ -1009,69 +994,93 @@ export class TransactionPayController extends BaseController< return this.#solana; } - #requireSolanaPayIntent(transactionId: string): TransactionPayIntent { - const intent = this.state.payIntents[transactionId]; + #requireTransaction(transactionId: string): TransactionMeta { + const transaction = getTransaction(transactionId, this.messenger); - if (!intent?.sourceChainId.startsWith('solana:')) { - throw new Error('TransactionPayController: Solana Pay intent missing'); + if (!transaction) { + throw new Error(`Transaction not found: ${transactionId}`); + } + + return transaction; + } + + #requireSolanaPaySource(transaction: TransactionMeta): TransactionPaySource { + const source = transaction.metamaskPay?.source; + + if (!source) { + throw new Error('TransactionPayController: Solana Pay source missing'); + } + + const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId; + const assetChainId = parseCaipAssetType(source.sourceAssetId).chainId; + + if ( + accountChainId !== assetChainId || + !accountChainId.startsWith('solana:') + ) { + throw new Error('TransactionPayController: Invalid Solana Pay source'); + } + + return source; + } + + #requireSolanaExecution(transactionId: string): { + execution: MetamaskPaySolanaExecution; + source: TransactionPaySource; + transaction: TransactionMeta; + } { + const transaction = this.#requireTransaction(transactionId); + const source = this.#requireSolanaPaySource(transaction); + const execution = transaction.metamaskPay?.solanaExecution; + + if (!execution) { + throw new Error('TransactionPayController: Solana execution missing'); + } + + const sourceChainId = parseCaipAccountId(source.sourceAccountId).chainId; + + if (execution.sourceChainId !== sourceChainId) { + throw new Error( + 'TransactionPayController: Solana execution chain mismatch', + ); } - return intent; + return { execution, source, transaction }; } #getSolanaPayStatus(transactionId: string): SolanaPayStatus { - const intent = this.#requireSolanaPayIntent(transactionId); - const execution = getSolanaPayExecution(intent); - - return { - failureReason: intent.relayFailureReason, - outcome: deriveSolanaPayOutcome(intent), - providerNotificationStatus: execution.providerNotificationStatus, - followUpStatus: execution.followUpStatus, - followUpTransactionId: intent.followUpTransactionId, - relayStatus: execution.relayStatus, - requestId: intent.requestId as string, - sourceFailureReason: intent.sourceFailureReason, - sourceStatus: execution.sourceStatus, - submissionOutcome: execution.submissionOutcome, - sourceTransactionId: intent.sourceTransactionId, - targetTransactionId: intent.targetTransactionId, - }; + return getSolanaPayStatus( + this.#requireSolanaExecution(transactionId).execution, + ); } - #updatePayIntent( + #updateSolanaExecution( transactionId: string, - updateIntent: (intent: TransactionPayIntent) => TransactionPayIntent, + updateExecution: ( + execution: MetamaskPaySolanaExecution, + ) => MetamaskPaySolanaExecution, note: string, ): void { - const intent = this.#requireSolanaPayIntent(transactionId); - this.#persistPayIntent(transactionId, updateIntent(intent), note); + const { execution } = this.#requireSolanaExecution(transactionId); + this.#persistSolanaExecution( + transactionId, + updateExecution(execution), + note, + ); } - #persistPayIntent( + #persistSolanaExecution( transactionId: string, - intent: TransactionPayIntent, + execution: MetamaskPaySolanaExecution, note: string, ): void { - const persistedIntent = intent.sourceChainId.startsWith('solana:') - ? { ...intent, outcome: deriveSolanaPayOutcome(intent) } - : intent; - updateTransaction( - { - transactionId, - messenger: this.messenger, - note, - }, + { transactionId, messenger: this.messenger, note }, (transaction) => { transaction.metamaskPay ??= {}; - transaction.metamaskPay.intent = { ...persistedIntent }; + transaction.metamaskPay.solanaExecution = { ...execution }; }, ); - - this.update((state) => { - state.payIntents[transactionId] = { ...persistedIntent }; - }); } #removeTransactionData(transactionId: string): void { diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts index bd21a5e3f2d..b9aecb6c92e 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -1,16 +1,12 @@ import type { + MetamaskPaySolanaExecution, PublishHookResult, TransactionMeta, } from '@metamask/transaction-controller'; -import type { CaipChainId } from '@metamask/utils'; import { TransactionPayStrategy } from '../index.js'; import { getMessengerMock } from '../tests/messenger-mock.js'; -import type { - SolanaPayStatus, - TransactionPayControllerState, - TransactionPayQuote, -} from '../types.js'; +import type { TransactionPayQuote } from '../types.js'; import { getStrategyByName } from '../utils/strategy.js'; import { TransactionPayPublishHook } from './TransactionPayPublishHook.js'; @@ -27,6 +23,23 @@ const QUOTE_MOCK = { strategy: TransactionPayStrategy.Across, } as TransactionPayQuote; +function getSolanaExecution(): MetamaskPaySolanaExecution { + return { + atomicProductActionIncluded: false, + atomicProductActionRequired: false, + followUpStatus: 'not-required', + notificationStatus: 'not-ready', + phase: 'ready', + relayStatus: 'not-observed', + requestId: 'relay-request-123', + requiresNonAtomicFollowUp: false, + sourceAmountRaw: '1000000', + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + sourceStatus: 'not-observed', + sourceWalletAccountId: 'wallet-account-uuid', + }; +} + describe('TransactionPayPublishHook', () => { const isSmartTransactionMock = jest.fn(); const executeMock = jest.fn(); @@ -86,34 +99,27 @@ describe('TransactionPayPublishHook', () => { }, } as TransactionPayControllerState); + TRANSACTION_META_MOCK.metamaskPay = undefined; getTransactionControllerStateMock.mockReturnValue({ transactions: [TRANSACTION_META_MOCK], }); }); it('routes a persisted Solana intent through the controller submission boundary', async () => { - getControllerStateMock.mockReturnValue({ - payIntents: { - [TRANSACTION_META_MOCK.id]: { - version: 2, - sourceAccountId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', - sourceAmountRaw: '1000000', - sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - sourceChainId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, - sourceWalletAccountId: 'wallet-account-uuid', - }, - }, - transactionData: {}, - } as TransactionPayControllerState); + getControllerStateMock.mockReturnValue({ transactionData: {} }); + TRANSACTION_META_MOCK.metamaskPay = { + solanaExecution: getSolanaExecution(), + }; submitSolanaPayMock.mockResolvedValue({ - outcome: { type: 'submitted' }, - providerNotificationStatus: 'succeeded', + followUpStatus: 'not-required', + notificationStatus: 'success', + outcome: 'submitted', + phase: 'submitted', relayStatus: 'pending', requestId: 'relay-request-123', - sourceStatus: 'submitted', + sourceStatus: 'pending', + sourceTransactionId: 'signature', submissionOutcome: 'submitted', }); @@ -142,40 +148,21 @@ describe('TransactionPayPublishHook', () => { 'propagates externally handled %s outcome', async (outcome, sourceFailureReason, expectedError) => { const effectiveOutcome = outcome ?? 'ambiguous'; - getControllerStateMock.mockReturnValue({ - payIntents: { - [TRANSACTION_META_MOCK.id]: { - version: 2, - sourceAccountId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', - sourceAmountRaw: '1000000', - sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', - sourceChainId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, - sourceWalletAccountId: 'wallet-account-uuid', - }, - }, - transactionData: {}, - } as TransactionPayControllerState); - - let payOutcome: SolanaPayStatus['outcome'] = { - phase: 'source', - type: 'unknown', + getControllerStateMock.mockReturnValue({ transactionData: {} }); + TRANSACTION_META_MOCK.metamaskPay = { + solanaExecution: getSolanaExecution(), }; - if (effectiveOutcome === 'user-rejected') { - payOutcome = { type: 'user-rejected' }; - } else if (effectiveOutcome === 'not-submitted') { - payOutcome = { type: 'source-failed', guaranteedNotSubmitted: true }; - } submitSolanaPayMock.mockResolvedValue({ - outcome: payOutcome, - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', + followUpStatus: 'not-required', + notificationStatus: 'not-ready', + outcome: effectiveOutcome, + phase: effectiveOutcome === 'ambiguous' ? 'unknown' : effectiveOutcome, + relayStatus: 'not-observed', requestId: 'relay-request-123', sourceFailureReason, sourceStatus: - effectiveOutcome === 'ambiguous' ? 'unknown' : effectiveOutcome, + effectiveOutcome === 'ambiguous' ? 'unknown' : 'not-observed', submissionOutcome: outcome, }); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts index ce27893f48e..58e253240e0 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -65,9 +65,9 @@ export class TransactionPayPublishHook { ); const transactionData = controllerState.transactionData?.[transactionId]; - const payIntent = controllerState.payIntents?.[transactionId]; + const solanaExecution = transactionMeta.metamaskPay?.solanaExecution; - if (payIntent?.sourceChainId.startsWith('solana:')) { + if (solanaExecution) { updateTransaction( { transactionId, diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index a678d87ee14..17304826b79 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -14,9 +14,10 @@ export type { PolymarketCallbacks, QuoteErrorInfo, QuoteErrorReason, - SetPayIntentRequest, + SetPaySourceRequest, SolanaPayCallbacks, SolanaPayFollowUpRequest, + SolanaPayOutcome, SolanaPayPreflight, SolanaPayPreflightData, SolanaPayQuote, @@ -51,9 +52,9 @@ export type { TransactionPayControllerGetFiatOptionsAction, TransactionPayControllerGetSolanaPayQuoteAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerNotifyRelayOfSolanaTransactionAction, TransactionPayControllerReconcileSolanaPayAction, - TransactionPayControllerRecoverSolanaPayAction, - TransactionPayControllerRetrySolanaPayNotificationAction, + TransactionPayControllerRecoverSolanaPayStatusAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetPaySourceAction, diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts index cfdcf8c38a9..e95d5c74e9f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -1,6 +1,9 @@ import { Interface } from '@ethersproject/abi'; import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + MetamaskPaySolanaExecution, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { CaipAccountId, CaipAssetType, @@ -13,7 +16,7 @@ import { getMessengerMock } from '../../tests/messenger-mock.js'; import type { SolanaPayPreflightData, TransactionData, - TransactionPayIntent, + TransactionPaySource, } from '../../types.js'; import { buildRelaySolanaQuoteRequest, @@ -33,14 +36,10 @@ const SOLANA_USDC = const SOLANA_NATIVE = `${SOLANA_CHAIN_ID}/slip44:501` as CaipAssetType; const SOLANA_NATIVE_RELAY_ADDRESS = '11111111111111111111111111111111'; -function getIntent(sourceAssetId: CaipAssetType): TransactionPayIntent { +function getSource(sourceAssetId: CaipAssetType): TransactionPaySource { return { - version: 2, sourceAccountId: SOLANA_ACCOUNT, - sourceAmountRaw: '1000000', sourceAssetId, - sourceChainId: SOLANA_CHAIN_ID, - sourceWalletAccountId: 'wallet-account-uuid', }; } @@ -152,7 +151,8 @@ describe('Solana Relay Pay', () => { it('derives an exact-input SPL route from Core transaction state', async () => { const result = await buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), + '1000000', TRANSACTION, TRANSACTION_DATA, messenger, @@ -173,7 +173,8 @@ describe('Solana Relay Pay', () => { it('maps the CAIP native SOL asset to the Relay native sentinel', async () => { const result = await buildRelaySolanaQuoteRequest( - getIntent(SOLANA_NATIVE), + getSource(SOLANA_NATIVE), + '1000000', TRANSACTION, TRANSACTION_DATA, messenger, @@ -189,7 +190,8 @@ describe('Solana Relay Pay', () => { TransactionType.predictDepositAndOrder, ])('embeds atomic product calls for %s', async (type) => { const result = await buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), + '1000000', { ...TRANSACTION, type }, TRANSACTION_DATA, messenger, @@ -218,7 +220,8 @@ describe('Solana Relay Pay', () => { it('uses the immutable Solana intent amount for Money Account despite stale EVM source amounts', async () => { const result = await buildRelaySolanaQuoteRequest( - { ...getIntent(SOLANA_USDC), sourceAmountRaw: '765432' }, + getSource(SOLANA_USDC), + '765432', { ...TRANSACTION, type: TransactionType.predictDeposit }, { ...TRANSACTION_DATA, @@ -252,7 +255,8 @@ describe('Solana Relay Pay', () => { }); const result = await buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), + '1000000', { ...TRANSACTION, type: TransactionType.predictDeposit }, TRANSACTION_DATA, messenger, @@ -273,7 +277,8 @@ describe('Solana Relay Pay', () => { it('rejects a product route that is non-atomic outside Money Account', async () => { await expect( buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), + '1000000', { ...TRANSACTION, type: TransactionType.predictDeposit }, { ...TRANSACTION_DATA, atomic: false }, messenger, @@ -284,7 +289,8 @@ describe('Solana Relay Pay', () => { it('rejects a route without a target token', async () => { await expect( buildRelaySolanaQuoteRequest( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), + '1000000', TRANSACTION, { ...TRANSACTION_DATA, tokens: [] }, messenger, @@ -292,11 +298,11 @@ describe('Solana Relay Pay', () => { ).rejects.toThrow('Missing Solana Pay target token'); }); - it('rejects an exact-input route without a Core-derived source amount', async () => { - const intent = { ...getIntent(SOLANA_USDC), sourceAmountRaw: undefined }; + it('rejects an exact-input route without an immutable source amount', async () => { await expect( buildRelaySolanaQuoteRequest( - intent, + getSource(SOLANA_USDC), + '', TRANSACTION, { ...TRANSACTION_DATA, sourceAmounts: undefined }, messenger, @@ -307,33 +313,34 @@ describe('Solana Relay Pay', () => { it.each([ [ { - ...getIntent(SOLANA_USDC), - sourceChainId: 'solana:devnet' as CaipChainId, + sourceAccountId: 'solana:devnet:account' as CaipAccountId, + sourceAssetId: 'solana:devnet/token:mint' as CaipAssetType, }, 'Unsupported Solana source chain', ], [ { - ...getIntent(SOLANA_USDC), + ...getSource(SOLANA_USDC), sourceAccountId: 'solana:other:account' as CaipAccountId, }, - 'Solana source account does not match source chain', + 'Solana source account and asset must use the same chain', ], [ { - ...getIntent(SOLANA_USDC), + ...getSource(SOLANA_USDC), sourceAssetId: 'solana:other/token:mint' as CaipAssetType, }, - 'Solana source asset does not match source chain', + 'Solana source account and asset must use the same chain', ], [ - getIntent(`${SOLANA_CHAIN_ID}/erc20:0x1234` as CaipAssetType), + getSource(`${SOLANA_CHAIN_ID}/erc20:0x1234` as CaipAssetType), 'Unsupported Solana source asset', ], ] as const)('rejects invalid source identity %#', async (intent, error) => { await expect( buildRelaySolanaQuoteRequest( intent, + '1000000', TRANSACTION, TRANSACTION_DATA, messenger, @@ -459,7 +466,7 @@ describe('Solana Relay Pay', () => { it('retains finalized fees and rent for an affordable SPL payment', () => { expect( normalizeSolanaPayPreflight( - getIntent(SOLANA_USDC), + getSource(SOLANA_USDC), '1000000', PREFLIGHT_DATA, ), @@ -487,7 +494,7 @@ describe('Solana Relay Pay', () => { it('includes the retained reserve in native SOL affordability', () => { expect( - normalizeSolanaPayPreflight(getIntent(SOLANA_NATIVE), '95000', { + normalizeSolanaPayPreflight(getSource(SOLANA_NATIVE), '95000', { ...PREFLIGHT_DATA, nativeBalanceRaw: '100000', sourceBalanceRaw: '100000', @@ -506,7 +513,7 @@ describe('Solana Relay Pay', () => { it('rejects inconsistent native SOL balance observations', () => { expect(() => - normalizeSolanaPayPreflight(getIntent(SOLANA_NATIVE), '1', { + normalizeSolanaPayPreflight(getSource(SOLANA_NATIVE), '1', { ...PREFLIGHT_DATA, nativeBalanceRaw: '2', sourceBalanceRaw: '1', @@ -516,7 +523,7 @@ describe('Solana Relay Pay', () => { it('reports source and native shortfalls independently for SPL', () => { expect( - normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + normalizeSolanaPayPreflight(getSource(SOLANA_USDC), '1000000', { ...PREFLIGHT_DATA, nativeBalanceRaw: '10000', sourceBalanceRaw: '900000', @@ -532,7 +539,7 @@ describe('Solana Relay Pay', () => { it('rejects an unbound prepared transaction', () => { expect(() => - normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + normalizeSolanaPayPreflight(getSource(SOLANA_USDC), '1000000', { ...PREFLIGHT_DATA, preparationId: '', }), @@ -548,7 +555,7 @@ describe('Solana Relay Pay', () => { ['nativeBalanceRaw', 'NaN'], ] as const)('rejects invalid atomic input %s', (field, value) => { expect(() => - normalizeSolanaPayPreflight(getIntent(SOLANA_USDC), '1000000', { + normalizeSolanaPayPreflight(getSource(SOLANA_USDC), '1000000', { ...PREFLIGHT_DATA, [field]: value, }), @@ -557,75 +564,59 @@ describe('Solana Relay Pay', () => { }); describe('deriveSolanaPayOutcome', () => { + function getExecution( + overrides: Partial, + ): MetamaskPaySolanaExecution { + return { + atomicProductActionIncluded: false, + atomicProductActionRequired: false, + followUpStatus: 'not-required', + notificationStatus: 'not-ready', + phase: 'ready', + relayStatus: 'not-observed', + requestId: 'relay-request-123', + requiresNonAtomicFollowUp: false, + sourceAmountRaw: '1000000', + sourceChainId: SOLANA_CHAIN_ID, + sourceStatus: 'not-observed', + sourceWalletAccountId: 'wallet-account-uuid', + ...overrides, + } as MetamaskPaySolanaExecution; + } + it.each([ - [ - { - submissionOutcome: 'user-rejected', - sourceStatus: 'user-rejected', - }, - { type: 'user-rejected' }, - ], - [ - { - submissionOutcome: 'not-submitted', - sourceStatus: 'not-submitted', - }, - { guaranteedNotSubmitted: true, type: 'source-failed' }, - ], - [ - { sourceStatus: 'failed' }, - { guaranteedNotSubmitted: false, type: 'source-failed' }, - ], - [{ relayStatus: 'failure' }, { type: 'relay-failed' }], - [{ relayStatus: 'refund' }, { type: 'refunded' }], - [{ followUpStatus: 'failed' }, { type: 'follow-up-failed' }], - [ - { submissionOutcome: 'ambiguous', sourceStatus: 'unknown' }, - { phase: 'source', type: 'unknown' }, - ], - [{ relayStatus: 'unknown' }, { phase: 'relay', type: 'unknown' }], - [{ followUpStatus: 'unknown' }, { phase: 'follow-up', type: 'unknown' }], + [{ phase: 'user-rejected' }, 'user-rejected'], + [{ phase: 'not-submitted' }, 'not-submitted'], + [{ sourceStatus: 'failed' }, 'source-failed'], + [{ relayStatus: 'failure' }, 'relay-failed'], + [{ relayStatus: 'refund' }, 'refunded'], + [{ followUpStatus: 'failed' }, 'follow-up-failed'], + [{ phase: 'unknown' }, 'unknown'], + [{ relayStatus: 'unknown' }, 'unknown'], + [{ followUpStatus: 'unknown' }, 'unknown'], [ { followUpStatus: 'confirmed', relayStatus: 'success', sourceStatus: 'confirmed', }, - { type: 'succeeded' }, + 'succeeded', ], - [{ sourceStatus: 'attempting' }, { type: 'attempting' }], - [{ sourceStatus: 'submitted' }, { type: 'submitted' }], - ] as const)('derives %# from durable axes', (overrides, expected) => { - const intent: TransactionPayIntent = { - ...getIntent(SOLANA_USDC), - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', - sourceStatus: 'not-started', - ...overrides, - }, - }; - - expect(deriveSolanaPayOutcome(intent)).toMatchObject(expected); + [{ phase: 'attempting' }, 'attempting'], + [{ phase: 'submitted', sourceTransactionId: 'signature' }, 'submitted'], + ] as const)('derives %# from durable checkpoint', (overrides, expected) => { + expect(deriveSolanaPayOutcome(getExecution(overrides))).toBe(expected); }); it('does not succeed when a required atomic product action was omitted', () => { - const intent: TransactionPayIntent = { - ...getIntent(SOLANA_USDC), + const execution = getExecution({ atomicProductActionIncluded: false, atomicProductActionRequired: true, - execution: { - followUpStatus: 'not-required', - providerNotificationStatus: 'succeeded', - relayStatus: 'success', - sourceStatus: 'confirmed', - }, - }; - - expect(deriveSolanaPayOutcome(intent)).toStrictEqual({ - type: 'submitted', + relayStatus: 'success', + sourceStatus: 'confirmed', }); + + expect(deriveSolanaPayOutcome(execution)).toBe('ready'); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts index e9736c796bd..4cdebb8495d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -4,21 +4,21 @@ import { TransactionType, } from '@metamask/transaction-controller'; import type { - MetamaskPayExecution, - MetamaskPayOutcome, - MetamaskPayRelayStatus, + MetamaskPaySolanaExecution, TransactionMeta, } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; +import { parseCaipAccountId, parseCaipAssetType } from '@metamask/utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { PaymentOverride } from '../../constants.js'; import type { + SolanaPayOutcome, SolanaPayPreflight, SolanaPayPreflightData, TransactionData, TransactionPayControllerMessenger, - TransactionPayIntent, + TransactionPaySource, } from '../../types.js'; import { RELAY_SOLANA_CHAIN_ID, @@ -37,25 +37,29 @@ const SOLANA_TOKEN_ASSET_NAMESPACE = 'token:'; const HEX_BYTES_REGEX = /^(?:[\da-f]{2})*$/iu; /** - * Builds the Relay /quote/v2 request for a persisted Solana Pay intent. + * Builds the Relay /quote/v2 request from persisted Solana Pay source metadata. * - * @param intent - Persisted chain-agnostic source identity. + * @param source - Persisted chain-agnostic source identity. + * @param sourceAmountRaw - Immutable atomic source amount. * @param transaction - Parent product transaction. * @param transactionData - Core-owned Pay quote inputs. * @param messenger - Controller messenger used for destination delegation. * @returns A Relay Solana request derived entirely by Core. */ export async function buildRelaySolanaQuoteRequest( - intent: TransactionPayIntent, + source: TransactionPaySource, + sourceAmountRaw: string, transaction: TransactionMeta, transactionData: TransactionData, messenger: TransactionPayControllerMessenger, ): Promise { - if (intent.sourceChainId !== SOLANA_MAINNET_CAIP_CHAIN_ID) { - throw new Error(`Unsupported Solana source chain: ${intent.sourceChainId}`); + const sourceChainId = getSourceChainId(source); + + if (sourceChainId !== SOLANA_MAINNET_CAIP_CHAIN_ID) { + throw new Error(`Unsupported Solana source chain: ${sourceChainId}`); } - const sourceAccount = getSourceAccount(intent); + const sourceAccount = getSourceAccount(source, sourceChainId); const target = transactionData.tokens[0]; if (!target) { @@ -87,7 +91,6 @@ export async function buildRelaySolanaQuoteRequest( : undefined; const txs = atomicDestination?.txs; const tradeType = txs ? 'EXACT_OUTPUT' : 'EXACT_INPUT'; - const { sourceAmountRaw } = intent; const amount = txs ? target.amountRaw : sourceAmountRaw; if (!amount) { @@ -102,7 +105,7 @@ export async function buildRelaySolanaQuoteRequest( destinationChainId: Number(target.chainId), destinationCurrency: target.address, originChainId: RELAY_SOLANA_CHAIN_ID, - originCurrency: getOriginCurrency(intent), + originCurrency: getOriginCurrency(source, sourceChainId), recipient, refundTo: sourceAccount, tradeType, @@ -117,13 +120,13 @@ export async function buildRelaySolanaQuoteRequest( * Native SOL must retain fees and rent in addition to the source amount. SPL * sources evaluate token affordability separately from the native reserve. * - * @param intent - Persisted source identity. + * @param source - Persisted source identity. * @param sourceAmountRaw - Exact-input amount in atomic source units. * @param data - Platform RPC and prepared-transaction observations. * @returns Normalized client-consumable preflight. */ export function normalizeSolanaPayPreflight( - intent: TransactionPayIntent, + source: TransactionPaySource, sourceAmountRaw: string, data: SolanaPayPreflightData, ): SolanaPayPreflight { @@ -151,7 +154,7 @@ export function normalizeSolanaPayPreflight( const retainedReserve = totalFee .plus(rentDebit) .plus(rentExemptionRequirement); - const isNative = intent.sourceAssetId.endsWith( + const isNative = source.sourceAssetId.endsWith( `/${SOLANA_NATIVE_ASSET_REFERENCE}`, ); @@ -219,91 +222,87 @@ export function getRelaySolanaTransaction( } /** - * Returns the durable initial execution checkpoints for an executable quote. + * Creates the ready checkpoint once an executable Relay request exists. * - * @param requiresNonAtomicFollowUp - Whether target completion requires a sponsored follow-up. - * @returns Initial one-attempt status axes. + * @param request - Immutable source and route correlation. + * @param request.sourceWalletAccountId - Wallet-local account ID. + * @param request.sourceChainId - Source chain derived from CAIP metadata. + * @param request.sourceAmountRaw - Immutable atomic source amount. + * @param request.requestId - Relay request ID. + * @param request.atomicProductActionRequired - Whether an atomic action is required. + * @param request.atomicProductActionIncluded - Whether the quote includes that action. + * @param request.requiresNonAtomicFollowUp - Whether Money Account follow-up is required. + * @returns Ready durable execution checkpoint. */ -export function getInitialSolanaPayExecution( - requiresNonAtomicFollowUp = false, -): MetamaskPayExecution { +export function getInitialSolanaPayExecution(request: { + sourceWalletAccountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + sourceChainId: CaipChainId; + sourceAmountRaw: string; + requestId: string; + atomicProductActionRequired: boolean; + atomicProductActionIncluded: boolean; + requiresNonAtomicFollowUp: boolean; +}): MetamaskPaySolanaExecution { return { - followUpStatus: requiresNonAtomicFollowUp ? 'not-started' : 'not-required', - providerNotificationStatus: 'not-started', - relayStatus: 'not-started', - sourceStatus: 'not-started', + ...request, + phase: 'ready', + sourceStatus: 'not-observed', + relayStatus: 'not-observed', + notificationStatus: 'not-ready', + followUpStatus: request.requiresNonAtomicFollowUp + ? 'not-started' + : 'not-required', }; } /** - * Derives the durable business outcome from independent execution axes. + * Derives the client and lifecycle outcome from the durable checkpoint. * - * @param intent - Persisted Pay intent and checkpoints. - * @returns Client-consumable outcome used for parent lifecycle transitions. + * @param execution - Durable external Solana execution checkpoint. + * @returns Derived lifecycle outcome. */ export function deriveSolanaPayOutcome( - intent: TransactionPayIntent, -): MetamaskPayOutcome { - const execution = - intent.execution ?? - getInitialSolanaPayExecution(intent.requiresNonAtomicFollowUp); - const followUpStatus = execution.followUpStatus ?? 'not-required'; - - if (execution.submissionOutcome === 'user-rejected') { - return { type: 'user-rejected' }; + execution: MetamaskPaySolanaExecution, +): SolanaPayOutcome { + if (execution.phase === 'user-rejected') { + return 'user-rejected'; } - if (execution.submissionOutcome === 'not-submitted') { - return { - guaranteedNotSubmitted: true, - reason: intent.sourceFailureReason, - type: 'source-failed', - }; + if (execution.phase === 'not-submitted') { + return 'not-submitted'; } if (execution.sourceStatus === 'failed') { - return { - guaranteedNotSubmitted: false, - reason: intent.sourceFailureReason, - type: 'source-failed', - }; + return 'source-failed'; } if (execution.relayStatus === 'failure') { - return { reason: intent.relayFailureReason, type: 'relay-failed' }; + return 'relay-failed'; } if (execution.relayStatus === 'refund') { - return { reason: intent.relayFailureReason, type: 'refunded' }; + return 'refunded'; } - if ( - followUpStatus === 'failed' || - followUpStatus === 'not-submitted' || - followUpStatus === 'user-rejected' - ) { - return { type: 'follow-up-failed' }; + if (execution.followUpStatus === 'failed') { + return 'follow-up-failed'; } if ( + execution.phase === 'unknown' || execution.sourceStatus === 'unknown' || - execution.submissionOutcome === 'ambiguous' + execution.relayStatus === 'unknown' || + execution.followUpStatus === 'unknown' ) { - return { phase: 'source', type: 'unknown' }; - } - - if (execution.relayStatus === 'unknown') { - return { phase: 'relay', type: 'unknown' }; - } - - if (followUpStatus === 'unknown') { - return { phase: 'follow-up', type: 'unknown' }; + return 'unknown'; } const isFollowUpComplete = - followUpStatus === 'not-required' || followUpStatus === 'confirmed'; + execution.followUpStatus === 'not-required' || + execution.followUpStatus === 'confirmed'; const isAtomicProductActionComplete = - !intent.atomicProductActionRequired || intent.atomicProductActionIncluded; + !execution.atomicProductActionRequired || + execution.atomicProductActionIncluded; if ( execution.sourceStatus === 'confirmed' && @@ -311,23 +310,10 @@ export function deriveSolanaPayOutcome( isFollowUpComplete && isAtomicProductActionComplete ) { - return { type: 'succeeded' }; + return 'succeeded'; } - if (execution.sourceStatus === 'attempting') { - return { type: 'attempting' }; - } - - if ( - execution.submissionOutcome === 'submitted' || - intent.sourceTransactionId !== undefined || - ['submitted', 'pending', 'confirmed'].includes(execution.sourceStatus) || - ['pending', 'success'].includes(execution.relayStatus) - ) { - return { type: 'submitted' }; - } - - return { type: 'not-started' }; + return execution.phase; } /** @@ -336,7 +322,9 @@ export function deriveSolanaPayOutcome( * @param status - Status returned by Relay. * @returns Durable Relay observation. */ -export function mapRelayStatus(status: RelayStatus): MetamaskPayRelayStatus { +export function mapRelayStatus( + status: RelayStatus, +): MetamaskPaySolanaExecution['relayStatus'] { if (status === 'success') { return 'success'; } @@ -352,24 +340,29 @@ export function mapRelayStatus(status: RelayStatus): MetamaskPayRelayStatus { return 'pending'; } -function getSourceAccount(intent: TransactionPayIntent): string { - const prefix = `${intent.sourceChainId}:`; +function getSourceChainId(source: TransactionPaySource): CaipChainId { + const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId; + const assetChainId = parseCaipAssetType(source.sourceAssetId).chainId; - if (!intent.sourceAccountId.startsWith(prefix)) { - throw new Error('Solana source account does not match source chain'); + if (accountChainId !== assetChainId) { + throw new Error('Solana source account and asset must use the same chain'); } - return intent.sourceAccountId.slice(prefix.length); + return accountChainId; } -function getOriginCurrency(intent: TransactionPayIntent): string { - const prefix = `${intent.sourceChainId}/`; - - if (!intent.sourceAssetId.startsWith(prefix)) { - throw new Error('Solana source asset does not match source chain'); - } +function getSourceAccount( + source: TransactionPaySource, + sourceChainId: CaipChainId, +): string { + return source.sourceAccountId.slice(`${sourceChainId}:`.length); +} - const assetReference = intent.sourceAssetId.slice(prefix.length); +function getOriginCurrency( + source: TransactionPaySource, + sourceChainId: CaipChainId, +): string { + const assetReference = source.sourceAssetId.slice(`${sourceChainId}/`.length); if (assetReference === SOLANA_NATIVE_ASSET_REFERENCE) { return RELAY_SOLANA_NATIVE_CURRENCY; @@ -379,7 +372,7 @@ function getOriginCurrency(intent: TransactionPayIntent): string { return assetReference.slice(SOLANA_TOKEN_ASSET_NAMESPACE.length); } - throw new Error(`Unsupported Solana source asset: ${intent.sourceAssetId}`); + throw new Error(`Unsupported Solana source asset: ${source.sourceAssetId}`); } function isRelaySolanaTransaction( diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 5fd1ab56339..310dc000723 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -37,8 +37,8 @@ import type { SentinelApiServiceActions } from '@metamask/sentinel-api-service'; import type { AuthorizationList, TransactionControllerAddTransactionBatchAction, - TransactionControllerEstimateGasAction, TransactionControllerConfirmTransactionAction, + TransactionControllerEstimateGasAction, TransactionControllerEstimateGasBatchAction, TransactionControllerFailTransactionAction, TransactionControllerUnapprovedTransactionAddedEvent, @@ -46,9 +46,12 @@ import type { import type { BatchTransaction, BatchTransactionParams, - MetamaskPayIntent, - MetamaskPayRelayStatus, - MetamaskPaySourceStatus, + MetamaskPaySolanaExecution, + MetamaskPaySolanaFollowUpStatus, + MetamaskPaySolanaNotificationStatus, + MetamaskPaySolanaRelayStatus, + MetamaskPaySolanaSourceStatus, + MetamaskPaySource, TransactionControllerAddTransactionAction, TransactionControllerGetGasFeeTokensAction, TransactionControllerGetStateAction, @@ -293,73 +296,46 @@ export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ 'Money Keyring', ]; -/** Request to fetch an executable Solana-source Relay quote. */ +/** Request to build an executable Solana quote from the persisted Pay source. */ export type GetSolanaPayQuoteRequest = { + /** Immutable wallet-local account ID for the one source attempt. */ + sourceWalletAccountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + + /** Immutable selected source amount in atomic units. */ + sourceAmountRaw: string; + /** Target TransactionController transaction ID used to derive the route. */ transactionId: string; }; -/** Platform observations used by Core to normalize Solana affordability. */ +/** Platform observations normalized by Core into Solana affordability policy. */ export type SolanaPayPreflightData = { - /** Opaque serialized transaction prepared by the platform callback. */ preparedTransaction: string; - - /** Opaque identity binding submission to this exact preparation. */ preparationId: string; - - /** Available native SOL balance in lamports. */ nativeBalanceRaw: string; - - /** Finalized base network fee in lamports, excluding priority fee. */ networkFeeRaw: string; - - /** Finalized priority fee in lamports. */ priorityFeeRaw: string; - - /** Rent debited by the prepared transaction in lamports. */ rentDebitRaw: string; - - /** Lamports that must remain to satisfy rent exemption. */ rentExemptionRequirementRaw: string; - - /** Available balance of the selected source asset in atomic units. */ sourceBalanceRaw: string; }; -/** Core-normalized Solana quote preflight policy. */ export type SolanaPayPreflight = SolanaPayPreflightData & { - /** Affordability decision and exact atomic shortfalls. */ affordability: { isAffordable: boolean; nativeShortfallRaw: string; sourceShortfallRaw: string; }; - - /** Native balance required by the transaction and retained reserve. */ requiredNativeBalanceRaw: string; - - /** Source-asset balance required, including reserve for native SOL. */ requiredSourceBalanceRaw: string; - - /** Fees and rent retained in addition to the source amount. */ retainedReserveRaw: string; - - /** Exact-input source amount in atomic units. */ sourceAmountRaw: string; - - /** Finalized base and priority fee total in lamports. */ totalFeeRaw: string; }; -/** Client-consumable Relay quote and normalized Solana preflight. */ export type SolanaPayQuote = { - /** Raw provider quote retained for execution and provider diagnostics. */ providerQuote: RelaySolanaQuote; - - /** Core-owned normalized fee, rent, reserve, and affordability policy. */ preflight: SolanaPayPreflight; - - /** Core-derived route policy; clients must not reconstruct these fields. */ route: { atomicProductActionIncluded: boolean; recipient: Hex; @@ -368,179 +344,94 @@ export type SolanaPayQuote = { }; }; -/** Request passed to the client-owned Solana sign-and-broadcast boundary. */ -export type SolanaPaySignAndSendTransactionRequest = { - /** Wallet-local InternalAccount.id passed to the Snap request. */ - accountId: TransactionPayIntent['sourceWalletAccountId']; - - /** Canonical CAIP-10 source identity used for validation/diagnostics. */ +export type GetSolanaPayPreflightRequest = { + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; caipAccountId: CaipAccountId; - - /** Opaque serialized transaction returned by the preflight callback. */ - preparedTransaction: string; - - /** Opaque identity binding submission to the displayed preflight. */ - preparationId: string; - - /** Relay correlation ID. */ requestId: string; - - /** Canonical Solana chain scope used by the Snap client request. */ scope: CaipChainId; + sourceAssetId: CaipAssetType; + sourceAmountRaw: string; + transaction: RelaySolanaTransaction; }; -/** Request used to prepare and price a Relay Solana transaction. */ -export type GetSolanaPayPreflightRequest = { - /** Wallet-local InternalAccount.id used to prepare the Snap transaction. */ - accountId: TransactionPayIntent['sourceWalletAccountId']; - - /** Canonical CAIP-10 source identity. */ +export type SolanaPaySignAndSendTransactionRequest = { + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; caipAccountId: CaipAccountId; - - /** Relay correlation ID. */ + preparedTransaction: string; + preparationId: string; requestId: string; - - /** Canonical Solana chain scope. */ scope: CaipChainId; - - /** Canonical source asset whose balances are being evaluated. */ - sourceAssetId: CaipAssetType; - - /** Exact-input source amount in atomic units. */ - sourceAmountRaw: string; - - /** Relay instruction and LUT handoff to prepare with current RPC data. */ - transaction: RelaySolanaTransaction; }; -/** Discriminated result of one client-owned submission callback invocation. */ export type SolanaPaySubmissionResult = | { outcome: 'submitted'; transactionId: string } | { outcome: 'user-rejected' } | { outcome: 'not-submitted'; reason?: string } | { outcome: 'ambiguous'; reason?: string }; -/** Request used to observe a previously submitted Solana transaction. */ export type GetSolanaPayTransactionStatusRequest = { - /** Wallet-local InternalAccount.id. */ - accountId: TransactionPayIntent['sourceWalletAccountId']; - - /** Canonical CAIP-10 source identity. */ + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; caipAccountId: CaipAccountId; - - /** Canonical Solana chain scope. */ scope: CaipChainId; - - /** Base58 Solana transaction signature. */ transactionId: string; }; -/** Request for the required one-shot sponsored target follow-up. */ export type SolanaPayFollowUpRequest = { - /** Relay request ID that settled the source payment. */ requestId: string; - - /** Relay destination transaction ID, when available. */ relayTransactionId?: string; - - /** Target transaction whose sponsored follow-up must be submitted. */ transaction: TransactionMeta; }; -/** Request used to observe a sponsored non-atomic follow-up. */ export type GetSolanaPayFollowUpStatusRequest = { - /** Target parent transaction. */ transaction: TransactionMeta; - - /** Submitted follow-up transaction ID. */ transactionId: string; }; -/** Client-owned Solana operations required by TransactionPayController. */ export type SolanaPayCallbacks = { - /** - * Prepares the instruction/LUT handoff with current RPC data and returns raw - * fee, rent, and balance observations. Core owns normalization and policy. - */ getPreflight: ( request: GetSolanaPayPreflightRequest, ) => Promise; - - /** - * Invokes `SnapController:handleRequest` with `signAndSendTransaction` - * exactly once and returns an explicit completion outcome. - */ signAndSendTransaction: ( request: SolanaPaySignAndSendTransactionRequest, ) => Promise; - - /** Observes source-chain status without submitting or resubmitting. */ getTransactionStatus: ( request: GetSolanaPayTransactionStatusRequest, ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; - - /** Submits a required sponsored non-atomic follow-up exactly once. */ submitNonAtomicFollowUp?: ( request: SolanaPayFollowUpRequest, ) => Promise; - - /** Observes a submitted sponsored follow-up without resubmitting it. */ getNonAtomicFollowUpStatus?: ( request: GetSolanaPayFollowUpStatusRequest, ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; }; -/** Independent durable observations returned by reconciliation. */ -export type SolanaPayStatus = { - /** Core-derived durable business outcome. */ - outcome: NonNullable; - - /** Provider failure classification, when supplied by Relay. */ - failureReason?: string; +export type SolanaPayOutcome = + | 'ready' + | 'attempting' + | 'submitted' + | 'user-rejected' + | 'not-submitted' + | 'unknown' + | 'source-failed' + | 'relay-failed' + | 'refunded' + | 'follow-up-failed' + | 'succeeded'; - /** Status of the Relay transaction-index notification. */ - providerNotificationStatus: - | 'not-started' - | 'pending' - | 'succeeded' - | 'failed'; - - /** Relay request ID. */ +export type SolanaPayStatus = { + outcome: SolanaPayOutcome; + phase: MetamaskPaySolanaExecution['phase']; requestId: string; - - /** Relay settlement status. */ - relayStatus: MetamaskPayRelayStatus; - - /** Source-chain observation status. */ - sourceStatus: MetamaskPaySourceStatus; - - /** Status of a required sponsored non-atomic follow-up. */ - followUpStatus?: - | 'not-required' - | 'not-started' - | 'attempting' - | 'submitted' - | 'pending' - | 'confirmed' - | 'failed' - | 'user-rejected' - | 'not-submitted' - | 'unknown'; - - /** Follow-up transaction ID, when submitted. */ - followUpTransactionId?: string; - - /** Durable completion outcome of the single submission callback. */ - submissionOutcome?: SolanaPaySubmissionResult['outcome']; - - /** Stable source submission failure or interruption detail. */ - sourceFailureReason?: string; - - /** Base58 source transaction signature, when known. */ + sourceStatus: MetamaskPaySolanaSourceStatus; + relayStatus: MetamaskPaySolanaRelayStatus; + notificationStatus: MetamaskPaySolanaNotificationStatus; + followUpStatus: MetamaskPaySolanaFollowUpStatus; sourceTransactionId?: string; - - /** Destination transaction identifier, when Relay reports one. */ targetTransactionId?: string; + followUpTransactionId?: string; + sourceFailureReason?: string; + relayFailureReason?: string; + submissionOutcome?: SolanaPaySubmissionResult['outcome']; }; /** Options for the TransactionPayController. */ @@ -575,7 +466,7 @@ export type TransactionPayControllerOptions = { /** Callbacks for the Polymarket relayer; required only for the Polymarket deposit-wallet flow. */ polymarket?: PolymarketCallbacks; - /** Client-owned Solana compilation, Snap submission, and observation operations. */ + /** Client-owned Solana preparation, signing, observation, and follow-up operations. */ solana?: SolanaPayCallbacks; /** Initial state of the controller. */ @@ -671,7 +562,7 @@ export type TransactionData = { /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; - /** Transient executable Relay /quote/v2 response for a Solana source. */ + /** Transient executable Solana quote and bound preflight. */ solanaPayQuote?: SolanaPayQuote; /** Most relevant structured validation error from the latest quote attempt. */ diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index ea3349dd1c3..67480f402fa 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -982,17 +982,12 @@ describe('Quotes Utils', () => { it('preserves chain-agnostic source metadata when updating EVM compatibility metadata', async () => { await run(); - const intent = { - version: 2, + const source = { sourceAccountId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId, - sourceAmountRaw: '1000000', sourceAssetId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, - sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as CaipChainId, - requestId: 'relay-request-123', - sourceWalletAccountId: 'wallet-account-uuid', - } as const; + }; const transactionMetaMock = { metamaskPay: { source }, } as TransactionMeta;