diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f1d9847d095..0997bac84c2 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 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 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-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..c5c99a82d4f 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,68 @@ describe('TransactionController', () => { }); }); + describe('confirmTransaction', () => { + const submittedTransactionMock = { + ...TRANSACTION_META_MOCK, + isExternalPublish: true, + 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); + + expect(controller.state.transactions[0].status).toBe( + TransactionStatus.confirmed, + ); + expect(confirmedListener).toHaveBeenCalledTimes(1); + 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', () => { 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..b2154f2daa6 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,46 @@ 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.submitted || + !transactionMeta.isExternalPublish + ) { + throw new Error( + 'Only submitted externally published transactions can be confirmed', + ); + } + + 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 +3098,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 +3253,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 +3280,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 +4532,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 f0dd613ead1..c528a4f6ea3 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, @@ -73,6 +74,7 @@ export type { BeforeSignHook, DappSuggestedGasFees, DefaultGasEstimates, + ExternallyHandledPublishOutcome, FeeMarketEIP1559Values, FeeMarketGasFeeEstimateForLevel, FeeMarketGasFeeEstimates, @@ -89,6 +91,11 @@ export type { LegacyGasFeeEstimates, Log, MetamaskPayMetadata, + MetamaskPaySolanaExecution, + MetamaskPaySolanaFollowUpStatus, + MetamaskPaySolanaNotificationStatus, + MetamaskPaySolanaRelayStatus, + MetamaskPaySolanaSourceStatus, MetamaskPaySource, NestedTransactionMetadata, NestedTransactionUpdate, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 180900f3d67..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'; @@ -271,6 +277,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 +1808,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 +1977,25 @@ 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 = + | { + externallyHandled?: false; + transactionHash?: string; + } + | { + externallyHandled: true; + outcome: ExternallyHandledPublishOutcome; + error?: string; + transactionHash?: never; + }; /** * Custom logic to publish a transaction. @@ -2021,10 +2039,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; @@ -2167,6 +2182,78 @@ export type MetamaskPaySource = { sourceAssetId: CaipAssetType; }; +export type MetamaskPaySolanaSourceStatus = + | 'not-observed' + | 'pending' + | 'confirmed' + | 'failed' + | 'unknown'; + +export type MetamaskPaySolanaRelayStatus = + | 'not-observed' + | 'pending' + | 'success' + | 'failure' + | 'refund' + | 'unknown'; + +export type MetamaskPaySolanaNotificationStatus = + | 'not-ready' + | 'not-attempted' + | 'pending' + | 'success' + | 'failure'; + +export type MetamaskPaySolanaFollowUpStatus = + | 'not-required' + | 'not-started' + | 'attempting' + | 'submitted' + | 'pending' + | 'confirmed' + | 'failed' + | 'unknown'; + +type MetamaskPaySolanaExecutionBase = { + /** Wallet-local InternalAccount.id used by Snap requests. */ + sourceWalletAccountId: string; + + /** Source chain derived from the persisted CAIP source metadata. */ + sourceChainId: CaipChainId; + + /** Immutable atomic source amount for this executable request. */ + sourceAmountRaw: string; + + /** Relay request correlation available once an executable quote exists. */ + requestId: string; + + sourceStatus: MetamaskPaySolanaSourceStatus; + relayStatus: MetamaskPaySolanaRelayStatus; + notificationStatus: MetamaskPaySolanaNotificationStatus; + followUpStatus: MetamaskPaySolanaFollowUpStatus; + atomicProductActionRequired: boolean; + atomicProductActionIncluded: boolean; + requiresNonAtomicFollowUp: boolean; + targetTransactionId?: string; + 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. */ @@ -2192,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-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 a3a66e013f5..19db0a4326f 100644 --- a/packages/transaction-pay-controller/ARCHITECTURE.md +++ b/packages/transaction-pay-controller/ARCHITECTURE.md @@ -47,6 +47,12 @@ 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. +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. + +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`. + +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 The high level interaction with the `TransactionPayController` is as follows: @@ -70,4 +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. -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. +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. + +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 0870802e091..a83f71a21c1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,9 @@ 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 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 @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- 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 2495a408164..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,20 +6,77 @@ 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`; handler: TransactionPayController['setPaySource']; }; +/** + * Builds an executable Solana quote and its initial durable checkpoint. + * + * @param request - Immutable source snapshot and target transaction ID. + * @returns Prepared Solana quote. + */ +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. + * The callback must resolve with a discriminated completion outcome; only an + * 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. + */ +export type TransactionPayControllerSubmitSolanaPayAction = { + type: `TransactionPayController:submitSolanaPay`; + handler: TransactionPayController['submitSolanaPay']; +}; + +/** + * Notifies Relay indexing about an existing Solana signature only. + * + * @param transactionId - Target transaction ID. + * @returns Latest durable status. + */ +export type TransactionPayControllerNotifyRelayOfSolanaTransactionAction = { + type: `TransactionPayController:notifyRelayOfSolanaTransaction`; + handler: TransactionPayController['notifyRelayOfSolanaTransaction']; +}; + +/** + * Observes source and Relay status without signing or source resubmission. + * + * @param transactionId - Target transaction ID. + * @returns Latest durable status. + */ +export type TransactionPayControllerReconcileSolanaPayAction = { + type: `TransactionPayController:reconcileSolanaPay`; + handler: TransactionPayController['reconcileSolanaPay']; +}; + +/** + * 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 TransactionPayControllerRecoverSolanaPayStatusAction = { + type: `TransactionPayController:recoverSolanaPayStatus`; + handler: TransactionPayController['recoverSolanaPayStatus']; +}; + /** * Sets the transaction configuration. * @@ -157,6 +214,11 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { */ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetPaySourceAction + | TransactionPayControllerGetSolanaPayQuoteAction + | TransactionPayControllerSubmitSolanaPayAction + | TransactionPayControllerNotifyRelayOfSolanaTransactionAction + | TransactionPayControllerReconcileSolanaPayAction + | 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 0d1a03852ab..5d508a708d6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -1,7 +1,10 @@ /* eslint-disable no-new */ import { deriveStateFromMetadata } from '@metamask/base-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'; @@ -9,15 +12,27 @@ 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, + SolanaPayCallbacks, TransactionPayControllerOptions, + TransactionPayControllerState, 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 { @@ -30,6 +45,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,11 +55,69 @@ 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 const; +const SOLANA_ACCOUNT_ID = + `${SOLANA_CHAIN_ID}:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z` as CaipAccountId; +const SOLANA_ASSET_ID = + `${SOLANA_CHAIN_ID}/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` as CaipAssetType; const SOLANA_PAY_SOURCE_MOCK: TransactionPaySource = { - sourceAccountId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z' as CaipAccountId, - sourceAssetId: - 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' as CaipAssetType, + sourceAccountId: SOLANA_ACCOUNT_ID, + sourceAssetId: SOLANA_ASSET_ID, +}; +const SOLANA_TRANSACTION_ID = 'solana-signature-123'; +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', + 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: '/status', method: 'GET' }, + data: { + chainId: 792703809, + instructions: [ + { + programId: '11111111111111111111111111111111', + keys: [], + data: '00', + }, + ], + }, + status: 'incomplete', + }, + ], + }, + ], }; describe('TransactionPayController', () => { const updateFiatPaymentMock = jest.mocked(updateFiatPayment); @@ -51,6 +125,11 @@ describe('TransactionPayController', () => { const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); + 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); @@ -61,7 +140,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 getTransactionControllerStateMock: jest.Mock; /** * Create a TransactionPayController. @@ -74,7 +156,7 @@ describe('TransactionPayController', () => { ): TransactionPayController { return new TransactionPayController({ ...options, - getDelegationTransaction: jest.fn(), + getDelegationTransaction: options.getDelegationTransaction ?? jest.fn(), messenger, }); } @@ -84,7 +166,23 @@ describe('TransactionPayController', () => { const mocks = getMessengerMock({ skipRegister: true }); messenger = mocks.messenger; + confirmTransactionMock = mocks.confirmTransactionMock; + failTransactionMock = mocks.failTransactionMock; getKeyringControllerStateMock = mocks.getKeyringControllerStateMock; + getTransactionControllerStateMock = mocks.getTransactionControllerStateMock; + + messenger.registerActionHandler( + 'TransactionController:confirmTransaction', + confirmTransactionMock, + ); + messenger.registerActionHandler( + 'TransactionController:failTransaction', + failTransactionMock, + ); + messenger.registerActionHandler( + 'TransactionController:getState', + getTransactionControllerStateMock, + ); getKeyringControllerStateMock.mockReturnValue({ isUnlocked: true, @@ -98,7 +196,19 @@ describe('TransactionPayController', () => { }); getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + getRelayPollingIntervalMock.mockReturnValue(1); + getRelayPollingTimeoutMock.mockReturnValue(100); updateQuotesMock.mockResolvedValue(true); + fetchRelaySolanaQuoteMock.mockResolvedValue(SOLANA_QUOTE_MOCK); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'pending', + txHashes: [], + updatedAt: 1, + }); + notifyRelayTransactionMock.mockResolvedValue(); }); describe('constructor', () => { @@ -237,6 +347,854 @@ describe('TransactionPayController', () => { }); }); + describe('Solana execution', () => { + const TARGET_TOKEN = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex; + + 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; + } + + 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; + } + + function getControllerState(): Partial { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + isLoading: false, + 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 applyTransactionUpdates(transaction: TransactionMeta): void { + getTransactionMock.mockReturnValue(transaction); + getTransactionControllerStateMock.mockReturnValue({ + transactions: [transaction], + }); + updateTransactionMock.mockImplementation((_request, update) => { + update(transaction); + }); + } + + it('stores the first durable checkpoint only on transaction metadata', async () => { + const transaction = getTransactionMeta(); + const solana = getSolanaCallbacks(); + applyTransactionUpdates(transaction); + const controller = createController({ + solana, + state: getControllerState(), + }); + + const quote = await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); + + 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, + scope: SOLANA_CHAIN_ID, + }), + ); + expect(quote.route.targetAmountMinimum).toBe('900000'); + }); + + it.each([ + ['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, + }); + + await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); + + 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.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', + }, + }; + const signAndSendTransaction = jest.fn(); + applyTransactionUpdates(transaction); + const controller = createController({ + solana: getSolanaCallbacks({ signAndSendTransaction }), + }); + + 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('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({ + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + signAndSendTransaction, + }), + }); + + const statuses = await controller.recoverSolanaPayStatus(); + + expect(statuses[TRANSACTION_ID_MOCK].outcome).toBe('succeeded'); + expect(signAndSendTransaction).not.toHaveBeenCalled(); + expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); + }); + + 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() }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.relayStatus).toBe('success'); + }); + + 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: 'follow-up-123', + }); + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: ['0xtarget'], + updatedAt: 1, + }); + const controller = createController({ + solana: getSolanaCallbacks({ + getNonAtomicFollowUpStatus: jest.fn().mockResolvedValue('confirmed'), + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + submitNonAtomicFollowUp, + }), + }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(submitNonAtomicFollowUp).toHaveBeenCalledTimes(1); + expect(status.followUpStatus).toBe('confirmed'); + expect(status.outcome).toBe('succeeded'); + }); + + it.each([ + ['ambiguous', 'unknown'], + ['not-submitted', 'failed'], + ] as const)( + 'records Money Account follow-up outcome %s as %s', + async (outcome, expectedStatus) => { + 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({ + submitNonAtomicFollowUp: jest.fn().mockResolvedValue({ outcome }), + }), + }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.followUpStatus).toBe(expectedStatus); + }, + ); + + 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({ + getNonAtomicFollowUpStatus: jest + .fn() + .mockRejectedValue(new Error('Unavailable')), + }), + }); + + const status = await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(status.followUpStatus).toBe('unknown'); + }); + + 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('Source unavailable')), + }), + }); + + 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(status.followUpStatus).toBe('submitted'); + }); + + 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, + }, + }; + } else if (scenario === 'chain-mismatch') { + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ sourceChainId: 'solana:other' }), + }; + } + applyTransactionUpdates(transaction); + } + + const controller = createController({ solana: getSolanaCallbacks() }); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow(message); + }); + + it('rejects quote and submission precondition failures', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + const controller = createController({ + solana: getSolanaCallbacks(), + state: getControllerState(), + }); + + await expect( + 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('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, + }), + ).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', + }, + }, + }); + const controller = createController({ + solana: getSolanaCallbacks(), + state: getControllerState(), + }); + + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }), + ).rejects.toThrow('source amount mismatch'); + }); + + 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(), + }); + await controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('not affordable'); + }); + + 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() }); + + await expect( + controller.submitSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('Missing Solana Pay quote'); + }); + + 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.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: relayStatusByOutcome[outcome], + txHashes: [], + updatedAt: 1, + }); + const controller = createController({ + solana: getSolanaCallbacks({ + getTransactionStatus: jest.fn().mockResolvedValue(sourceStatus), + }), + }); + + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + 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() }); + + 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); + getTransactionControllerStateMock.mockReturnValue({ + transactions: [transaction], + }); + const controller = createController({ solana: getSolanaCallbacks() }); + + const statuses = await controller.recoverSolanaPayStatus(); + + expect(statuses[TRANSACTION_ID_MOCK].outcome).toBe('submitted'); + }); + + 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, + }), + }; + applyTransactionUpdates(transaction); + getRelayStatusMock.mockResolvedValue({ + destinationChainId: 42161, + inTxHashes: [SOLANA_TRANSACTION_ID], + originChainId: 792703809, + status: 'success', + txHashes: [], + updatedAt: 1, + }); + const controller = createController({ solana: getSolanaCallbacks() }); + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('follow-up callback missing'); + }); + + 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: [], + updatedAt: 1, + }); + const controller = createController({ solana: getSolanaCallbacks() }); + + await expect( + controller.reconcileSolanaPay(TRANSACTION_ID_MOCK), + ).rejects.toThrow('follow-up status callback missing'); + }); + + 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], + }); + const controller = createController({ solana: getSolanaCallbacks() }); + + expect(await controller.recoverSolanaPayStatus()).toStrictEqual({}); + }); + }); + 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..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'; @@ -10,16 +13,37 @@ import { updatePaymentToken } from './actions/update-payment-token.js'; import { CONTROLLER_NAME, isTransactionPayStrategy, + PaymentOverride, 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, + deriveSolanaPayOutcome, + getInitialSolanaPayExecution, + getRelaySolanaTransaction, + isSolanaPayProductTransaction, + mapRelayStatus, + normalizeSolanaPayPreflight, +} from './strategy/relay/solana-pay.js'; import type { GetAmountDataCallback, GetBalanceCallback, GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, + GetSolanaPayQuoteRequest, PolymarketCallbacks, SetPaySourceRequest, + SolanaPayCallbacks, + SolanaPayQuote, + SolanaPayStatus, + SolanaPaySubmissionResult, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -27,13 +51,19 @@ import type { TransactionPayFiatOptions, TransactionPayControllerOptions, TransactionPayControllerState, + TransactionPaySource, 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, @@ -44,11 +74,16 @@ const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', 'getFiatOptions', 'getPaymentOverrideData', + 'getSolanaPayQuote', 'getStrategy', 'polymarketGetDepositWalletAddress', + 'reconcileSolanaPay', + 'recoverSolanaPayStatus', + 'notifyRelayOfSolanaTransaction', 'polymarketSubmitDepositWalletBatch', 'setPaySource', 'setTransactionConfig', + 'submitSolanaPay', 'updateFiatPayment', 'updatePaymentToken', ] as const; @@ -66,6 +101,103 @@ 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 getExecutionSourceTransactionId( + execution: MetamaskPaySolanaExecution, +): string | undefined { + if (execution.phase !== 'submitted' && execution.phase !== 'unknown') { + return undefined; + } + + return execution.sourceTransactionId; +} + +function isTerminalSolanaPayOutcome(status: SolanaPayStatus): boolean { + return [ + 'succeeded', + 'user-rejected', + 'source-failed', + 'relay-failed', + 'refunded', + 'follow-up-failed', + ].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 === 'source-failed') { + return status.sourceFailureReason ?? 'Solana source transaction failed'; + } + + if (status.outcome === 'relay-failed') { + return status.relayFailureReason ?? 'Relay settlement failed'; + } + + if (status.outcome === 'refunded') { + return status.relayFailureReason ?? 'Relay settlement refunded'; + } + + if (status.outcome === 'follow-up-failed') { + return 'Solana pay non-atomic follow-up failed'; + } + + return undefined; +} + export class TransactionPayController extends BaseController< typeof CONTROLLER_NAME, TransactionPayControllerState, @@ -91,6 +223,8 @@ export class TransactionPayController extends BaseController< readonly #polymarket?: PolymarketCallbacks; + readonly #solana?: SolanaPayCallbacks; + constructor({ fiatOptions, getAmountData, @@ -101,6 +235,7 @@ export class TransactionPayController extends BaseController< getStrategies, messenger, polymarket, + solana, state, }: TransactionPayControllerOptions) { super({ @@ -118,6 +253,7 @@ export class TransactionPayController extends BaseController< this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; this.#polymarket = polymarket; + this.#solana = solana; this.messenger.registerMethodActionHandlers( this, @@ -145,14 +281,11 @@ export class TransactionPayController extends BaseController< } /** - * 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. */ setPaySource({ transactionId, source }: SetPaySourceRequest): void { const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId; @@ -175,6 +308,363 @@ export class TransactionPayController extends BaseController< ); } + /** + * Builds an executable Solana quote and its initial durable checkpoint. + * + * @param request - Immutable source snapshot and target transaction ID. + * @returns Prepared Solana quote. + */ + async getSolanaPayQuote( + request: GetSolanaPayQuoteRequest, + ): Promise { + const transaction = this.#requireTransaction(request.transactionId); + const source = this.#requireSolanaPaySource(transaction); + const transactionData = this.state.transactionData[request.transactionId]; + + 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( + source, + request.sourceAmountRaw, + transaction, + transactionData, + this.messenger, + ); + const providerQuote = await fetchRelaySolanaQuote( + this.messenger, + quoteRequest, + ); + const sourceTransaction = getRelaySolanaTransaction(providerQuote); + const sourceAmountRaw = providerQuote.details.currencyIn.amount; + + if ( + quoteRequest.tradeType === 'EXACT_INPUT' && + sourceAmountRaw !== quoteRequest.amount + ) { + throw new Error( + 'TransactionPayController: Relay Solana source amount mismatch', + ); + } + + const sourceChainId = parseCaipAccountId(source.sourceAccountId).chainId; + const preflightData = await this.#requireSolanaCallbacks().getPreflight({ + accountId: request.sourceWalletAccountId, + caipAccountId: source.sourceAccountId, + requestId: providerQuote.requestId, + scope: sourceChainId, + sourceAmountRaw, + 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( + source, + sourceAmountRaw, + preflightData, + ), + providerQuote, + route: { + atomicProductActionIncluded, + recipient: quoteRequest.recipient, + targetAmountMinimum: transactionData.tokens[0].amountRaw, + tradeType: quoteRequest.tradeType, + }, + }; + + this.#persistSolanaExecution( + request.transactionId, + execution, + 'Set executable Solana pay checkpoint', + ); + this.#updateTransactionData(request.transactionId, (data) => { + data.solanaPayQuote = quote; + }); + + return quote; + } + + /** + * Performs at most one client-owned Solana sign-and-broadcast attempt. + * + * 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`. 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 { execution, source } = this.#requireSolanaExecution(transactionId); + + if (execution.phase !== 'ready') { + return await this.reconcileSolanaPay(transactionId); + } + + const quote = this.state.transactionData[transactionId]?.solanaPayQuote; + + if (quote?.providerQuote.requestId !== execution.requestId) { + throw new Error('TransactionPayController: Missing Solana Pay quote'); + } + + if (!quote.preflight.affordability.isAffordable) { + throw new Error( + 'TransactionPayController: Solana source is not affordable', + ); + } + + this.#persistSolanaExecution( + transactionId, + { ...execution, phase: 'attempting' }, + 'Start one Solana source attempt', + ); + + 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 phase = + submission.outcome === 'ambiguous' ? 'unknown' : submission.outcome; + this.#persistSolanaExecution( + transactionId, + { + ...execution, + phase, + sourceFailureReason: + submission.outcome === 'user-rejected' + ? undefined + : submission.reason, + sourceStatus: + submission.outcome === 'ambiguous' ? 'unknown' : 'not-observed', + }, + `Record Solana source outcome: ${submission.outcome}`, + ); + + return submission.outcome === 'ambiguous' + ? await this.reconcileSolanaPay(transactionId) + : this.#getSolanaPayStatus(transactionId); + } + + this.#persistSolanaExecution( + transactionId, + { + ...execution, + notificationStatus: 'not-attempted', + phase: 'submitted', + sourceStatus: 'pending', + sourceTransactionId: submission.transactionId, + }, + 'Record submitted Solana source transaction', + ); + + await this.notifyRelayOfSolanaTransaction(transactionId); + return await this.reconcileSolanaPay(transactionId); + } + + /** + * Notifies Relay indexing about an existing Solana signature only. + * + * @param transactionId - Target transaction ID. + * @returns Latest durable status. + */ + async notifyRelayOfSolanaTransaction( + transactionId: string, + ): Promise { + const { execution } = this.#requireSolanaExecution(transactionId); + const sourceTransactionId = getExecutionSourceTransactionId(execution); + + if (!sourceTransactionId) { + throw new Error( + 'TransactionPayController: Missing Solana notification correlation', + ); + } + + this.#persistSolanaExecution( + transactionId, + { ...execution, notificationStatus: 'pending' }, + 'Start Relay indexing notification', + ); + + const notification = await observePromise( + notifyRelayTransaction({ + chainId: String(RELAY_SOLANA_CHAIN_ID), + requestId: execution.requestId, + txHash: sourceTransactionId, + }), + ); + + this.#updateSolanaExecution( + transactionId, + (current) => ({ + ...current, + notificationStatus: + notification.status === 'fulfilled' ? 'success' : 'failure', + }), + 'Record Relay indexing notification result', + ); + + return this.#getSolanaPayStatus(transactionId); + } + + /** + * Observes source and Relay status without signing or source resubmission. + * + * @param transactionId - Target transaction ID. + * @returns Latest durable status. + */ + async reconcileSolanaPay(transactionId: string): Promise { + const { execution: initialExecution, source } = + this.#requireSolanaExecution(transactionId); + const relayResult = await observePromise( + getRelayStatus(initialExecution.requestId), + ); + + this.#updateSolanaExecution( + transactionId, + (current) => { + if (relayResult.status === 'rejected') { + return { + ...current, + relayStatus: ['success', 'failure', 'refund'].includes( + current.relayStatus, + ) + ? current.relayStatus + : 'unknown', + }; + } + + const [observedSourceTransactionId] = relayResult.value.inTxHashes; + const sourceTransactionId = + getExecutionSourceTransactionId(current) ?? + observedSourceTransactionId; + const [targetTransactionId] = relayResult.value.txHashes.slice(-1); + const updated = { + ...current, + relayFailureReason: + relayResult.value.failReason ?? relayResult.value.refundFailReason, + 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; + }, + 'Observe Relay Solana status', + ); + + const { execution } = this.#requireSolanaExecution(transactionId); + const sourceTransactionId = getExecutionSourceTransactionId(execution); + + if ( + sourceTransactionId && + execution.sourceStatus !== 'confirmed' && + execution.sourceStatus !== 'failed' + ) { + const sourceResult = await observePromise( + this.#requireSolanaCallbacks().getTransactionStatus({ + accountId: execution.sourceWalletAccountId, + caipAccountId: source.sourceAccountId, + scope: execution.sourceChainId, + transactionId: sourceTransactionId, + }), + ); + + this.#updateSolanaExecution( + transactionId, + (current) => ({ + ...current, + sourceStatus: + sourceResult.status === 'fulfilled' + ? sourceResult.value + : 'unknown', + }), + 'Observe Solana source status', + ); + } + + await this.#advanceNonAtomicFollowUp(transactionId); + + const status = this.#getSolanaPayStatus(transactionId); + this.#updateSolanaParentLifecycle(transactionId, status); + return status; + } + + /** + * 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 recoverSolanaPayStatus(): Promise> { + const results: Record = {}; + const { transactions } = this.messenger.call( + 'TransactionController:getState', + ); + + for (const transaction of transactions) { + const execution = transaction.metamaskPay?.solanaExecution; + + if (!execution) { + continue; + } + + const status = getSolanaPayStatus(execution); + + if (isTerminalSolanaPayOutcome(status)) { + continue; + } + + results[transaction.id] = await this.#recoverSolanaPayExecution( + transaction.id, + ); + } + + return results; + } + /** * Sets the transaction configuration. * @@ -343,6 +833,153 @@ export class TransactionPayController extends BaseController< return this.#requirePolymarket().submitDepositWalletBatch(...args); } + async #recoverSolanaPayExecution( + 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 { execution, transaction } = + this.#requireSolanaExecution(transactionId); + + if ( + !execution.requiresNonAtomicFollowUp || + execution.relayStatus !== 'success' || + execution.sourceStatus !== 'confirmed' + ) { + return; + } + + if (execution.followUpStatus === 'not-started') { + const submitFollowUp = + this.#requireSolanaCallbacks().submitNonAtomicFollowUp; + + if (!submitFollowUp) { + throw new Error( + 'TransactionPayController: Non-atomic follow-up callback missing', + ); + } + + this.#persistSolanaExecution( + transactionId, + { ...execution, followUpStatus: 'attempting' }, + 'Start one sponsored Money Account destination follow-up', + ); + + const result = await submitFollowUp({ + requestId: execution.requestId, + relayTransactionId: execution.targetTransactionId, + transaction, + }); + const followUpStatus = getFollowUpStatusFromSubmission(result.outcome); + + this.#updateSolanaExecution( + transactionId, + (current) => ({ + ...current, + followUpStatus, + followUpTransactionId: + result.outcome === 'submitted' ? result.transactionId : undefined, + }), + `Record Money Account destination follow-up: ${result.outcome}`, + ); + + ({ execution, transaction } = + this.#requireSolanaExecution(transactionId)); + } + + if ( + execution.followUpTransactionId && + ['submitted', 'pending', 'unknown'].includes(execution.followUpStatus) + ) { + 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: execution.followUpTransactionId, + }), + ); + + this.#updateSolanaExecution( + transactionId, + (current) => ({ + ...current, + followUpStatus: + result.status === 'fulfilled' ? result.value : 'unknown', + }), + 'Observe Money Account destination follow-up', + ); + } + } + + #updateSolanaParentLifecycle( + transactionId: string, + status: SolanaPayStatus, + ): void { + const transaction = getTransaction(transactionId, this.messenger); + + if (transaction?.status !== 'submitted' || !transaction.isExternalPublish) { + return; + } + + const failure = getSolanaPayFailure(status); + + if (failure) { + this.messenger.call( + 'TransactionController:failTransaction', + transactionId, + new Error(failure), + ); + return; + } + + if (status.outcome !== 'succeeded') { + return; + } + + updateTransaction( + { + transactionId, + messenger: this.messenger, + note: 'Complete external Solana pay execution', + }, + (current) => { + current.isIntentComplete = true; + }, + ); + this.messenger.call( + 'TransactionController:confirmTransaction', + transactionId, + ); + } + #requirePolymarket(): PolymarketCallbacks { if (!this.#polymarket) { throw new Error('TransactionPayController: Polymarket callbacks missing'); @@ -350,6 +987,102 @@ export class TransactionPayController extends BaseController< return this.#polymarket; } + #requireSolanaCallbacks(): SolanaPayCallbacks { + if (!this.#solana) { + throw new Error('TransactionPayController: Solana callbacks missing'); + } + return this.#solana; + } + + #requireTransaction(transactionId: string): TransactionMeta { + const transaction = getTransaction(transactionId, this.messenger); + + 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 { execution, source, transaction }; + } + + #getSolanaPayStatus(transactionId: string): SolanaPayStatus { + return getSolanaPayStatus( + this.#requireSolanaExecution(transactionId).execution, + ); + } + + #updateSolanaExecution( + transactionId: string, + updateExecution: ( + execution: MetamaskPaySolanaExecution, + ) => MetamaskPaySolanaExecution, + note: string, + ): void { + const { execution } = this.#requireSolanaExecution(transactionId); + this.#persistSolanaExecution( + transactionId, + updateExecution(execution), + note, + ); + } + + #persistSolanaExecution( + transactionId: string, + execution: MetamaskPaySolanaExecution, + note: string, + ): void { + updateTransaction( + { transactionId, messenger: this.messenger, note }, + (transaction) => { + transaction.metamaskPay ??= {}; + transaction.metamaskPay.solanaExecution = { ...execution }; + }, + ); + } + #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..b9aecb6c92e 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -1,14 +1,12 @@ import type { + MetamaskPaySolanaExecution, PublishHookResult, TransactionMeta, } from '@metamask/transaction-controller'; import { TransactionPayStrategy } from '../index.js'; import { getMessengerMock } from '../tests/messenger-mock.js'; -import type { - TransactionPayControllerState, - TransactionPayQuote, -} from '../types.js'; +import type { TransactionPayQuote } from '../types.js'; import { getStrategyByName } from '../utils/strategy.js'; import { TransactionPayPublishHook } from './TransactionPayPublishHook.js'; @@ -25,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(); @@ -35,6 +50,7 @@ describe('TransactionPayPublishHook', () => { getControllerStateMock, getKeyringControllerStateMock, getTransactionControllerStateMock, + submitSolanaPayMock, updateTransactionMock, } = getMessengerMock(); @@ -83,11 +99,83 @@ 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({ transactionData: {} }); + TRANSACTION_META_MOCK.metamaskPay = { + solanaExecution: getSolanaExecution(), + }; + + submitSolanaPayMock.mockResolvedValue({ + followUpStatus: 'not-required', + notificationStatus: 'success', + outcome: 'submitted', + phase: 'submitted', + relayStatus: 'pending', + requestId: 'relay-request-123', + sourceStatus: 'pending', + sourceTransactionId: 'signature', + submissionOutcome: 'submitted', + }); + + const result = await runHook(); + + expect(submitSolanaPayMock).toHaveBeenCalledTimes(1); + expect(submitSolanaPayMock).toHaveBeenCalledWith(TRANSACTION_META_MOCK.id); + expect(executeMock).not.toHaveBeenCalled(); + 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({ transactionData: {} }); + TRANSACTION_META_MOCK.metamaskPay = { + solanaExecution: getSolanaExecution(), + }; + + submitSolanaPayMock.mockResolvedValue({ + 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' : 'not-observed', + 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 cf57a00494e..58e253240e0 100644 --- a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -65,6 +65,36 @@ export class TransactionPayPublishHook { ); const transactionData = controllerState.transactionData?.[transactionId]; + const solanaExecution = transactionMeta.metamaskPay?.solanaExecution; + + if (solanaExecution) { + updateTransaction( + { + transactionId, + messenger: this.#messenger, + note: 'Set submittedTime at Solana pay publish hook start', + }, + (transaction) => { + transaction.submittedTime = new Date().getTime(); + }, + ); + + const status = await this.#messenger.call( + 'TransactionPayController:submitSolanaPay', + transactionId, + ); + 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. const quotes = ( diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 552d61d843d..17304826b79 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -5,12 +5,25 @@ export type { GetBalanceCallback, GetBalanceRequest, GetBalanceResponse, + GetSolanaPayFollowUpStatusRequest, + GetSolanaPayPreflightRequest, + GetSolanaPayQuoteRequest, + GetSolanaPayTransactionStatusRequest, GetPaymentOverrideDataRequest, GetPaymentOverrideDataResponse, PolymarketCallbacks, QuoteErrorInfo, QuoteErrorReason, SetPaySourceRequest, + SolanaPayCallbacks, + SolanaPayFollowUpRequest, + SolanaPayOutcome, + SolanaPayPreflight, + SolanaPayPreflightData, + SolanaPayQuote, + SolanaPaySignAndSendTransactionRequest, + SolanaPayStatus, + SolanaPaySubmissionResult, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -37,11 +50,16 @@ export type { TransactionPayControllerGetAmountDataAction, TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetFiatOptionsAction, + TransactionPayControllerGetSolanaPayQuoteAction, TransactionPayControllerGetStrategyAction, + TransactionPayControllerNotifyRelayOfSolanaTransactionAction, + TransactionPayControllerReconcileSolanaPayAction, + TransactionPayControllerRecoverSolanaPayStatusAction, 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..6b07311ffaf 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -10,7 +10,11 @@ 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_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; +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..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 @@ -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'); @@ -28,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]; @@ -38,6 +41,7 @@ describe('relay-api', () => { getFeatureFlagsMock.mockReturnValue({ relayQuoteUrl: QUOTE_URL_MOCK, + relaySolanaQuoteUrl: SOLANA_QUOTE_URL_MOCK, relayExecuteUrl: EXECUTE_URL_MOCK, } as FeatureFlags); }); @@ -138,6 +142,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(SOLANA_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..c2a635bba61 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 { relaySolanaQuoteUrl } = getFeatureFlags(messenger); + const response = await relayFetch(relaySolanaQuoteUrl, { + 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..e95d5c74e9f --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -0,0 +1,632 @@ +import { Interface } from '@ethersproject/abi'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { + MetamaskPaySolanaExecution, + TransactionMeta, +} from '@metamask/transaction-controller'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Hex, +} from '@metamask/utils'; + +import { PaymentOverride } from '../../constants.js'; +import { getMessengerMock } from '../../tests/messenger-mock.js'; +import type { + SolanaPayPreflightData, + TransactionData, + TransactionPaySource, +} from '../../types.js'; +import { + buildRelaySolanaQuoteRequest, + deriveSolanaPayOutcome, + getRelaySolanaTransaction, + mapRelayStatus, + normalizeSolanaPayPreflight, +} 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 getSource(sourceAssetId: CaipAssetType): TransactionPaySource { + return { + sourceAccountId: SOLANA_ACCOUNT, + sourceAssetId, + }; +} + +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', () => { + 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( + getSource(SOLANA_USDC), + '1000000', + TRANSACTION, + TRANSACTION_DATA, + messenger, + ); + + expect(result).toStrictEqual({ + amount: '1000000', + destinationChainId: 42161, + destinationCurrency: TARGET_TOKEN, + originChainId: 792703809, + originCurrency: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + recipient: RECIPIENT, + refundTo: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + tradeType: 'EXACT_INPUT', + user: '7Ec4QeG8wF3RnTjHDrTuYP8hVV7WYuPFyM4hZUodkG6Z', + }); + }); + + it('maps the CAIP native SOL asset to the Relay native sentinel', async () => { + const result = await buildRelaySolanaQuoteRequest( + getSource(SOLANA_NATIVE), + '1000000', + TRANSACTION, + TRANSACTION_DATA, + messenger, + ); + + expect(result.originCurrency).toBe(SOLANA_NATIVE_RELAY_ADDRESS); + }); + + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])('embeds atomic product calls for %s', async (type) => { + const result = await buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '1000000', + { ...TRANSACTION, type }, + TRANSACTION_DATA, + messenger, + ); + + 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('uses the immutable Solana intent amount for Money Account despite stale EVM source amounts', async () => { + const result = await buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '765432', + { ...TRANSACTION, type: TransactionType.predictDeposit }, + { + ...TRANSACTION_DATA, + atomic: false, + paymentOverride: PaymentOverride.MoneyAccount, + }, + messenger, + ); + + expect(result.amount).toBe('765432'); + expect(result.tradeType).toBe('EXACT_INPUT'); + expect(result.txs).toBeUndefined(); + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + + 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', + }); + + const result = await buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '1000000', + { ...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 a product route that is non-atomic outside Money Account', async () => { + await expect( + buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '1000000', + { ...TRANSACTION, type: TransactionType.predictDeposit }, + { ...TRANSACTION_DATA, atomic: false }, + messenger, + ), + ).rejects.toThrow('Unsupported non-atomic Solana Pay product route'); + }); + + it('rejects a route without a target token', async () => { + await expect( + buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '1000000', + TRANSACTION, + { ...TRANSACTION_DATA, tokens: [] }, + messenger, + ), + ).rejects.toThrow('Missing Solana Pay target token'); + }); + + it('rejects an exact-input route without an immutable source amount', async () => { + await expect( + buildRelaySolanaQuoteRequest( + getSource(SOLANA_USDC), + '', + TRANSACTION, + { ...TRANSACTION_DATA, sourceAmounts: undefined }, + messenger, + ), + ).rejects.toThrow('Missing Solana Pay source amount'); + }); + + it.each([ + [ + { + sourceAccountId: 'solana:devnet:account' as CaipAccountId, + sourceAssetId: 'solana:devnet/token:mint' as CaipAssetType, + }, + 'Unsupported Solana source chain', + ], + [ + { + ...getSource(SOLANA_USDC), + sourceAccountId: 'solana:other:account' as CaipAccountId, + }, + 'Solana source account and asset must use the same chain', + ], + [ + { + ...getSource(SOLANA_USDC), + sourceAssetId: 'solana:other/token:mint' as CaipAssetType, + }, + 'Solana source account and asset must use the same chain', + ], + [ + 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, + ), + ).rejects.toThrow(error); + }); + }); + + 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', + ); + }); + }); + + 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( + getSource(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(getSource(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(getSource(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(getSource(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(getSource(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(getSource(SOLANA_USDC), '1000000', { + ...PREFLIGHT_DATA, + [field]: value, + }), + ).toThrow(`Invalid Solana preflight ${field}`); + }); + }); + + 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([ + [{ 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', + }, + 'succeeded', + ], + [{ 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 execution = getExecution({ + atomicProductActionIncluded: false, + atomicProductActionRequired: true, + relayStatus: 'success', + sourceStatus: 'confirmed', + }); + + expect(deriveSolanaPayOutcome(execution)).toBe('ready'); + }); + }); + + 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..4cdebb8495d --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -0,0 +1,498 @@ +import { Interface } from '@ethersproject/abi'; +import { + hasTransactionType, + TransactionType, +} from '@metamask/transaction-controller'; +import type { + MetamaskPaySolanaExecution, + TransactionMeta, +} from '@metamask/transaction-controller'; +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, + TransactionPaySource, +} 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 from persisted Solana Pay source metadata. + * + * @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( + source: TransactionPaySource, + sourceAmountRaw: string, + transaction: TransactionMeta, + transactionData: TransactionData, + messenger: TransactionPayControllerMessenger, +): Promise { + const sourceChainId = getSourceChainId(source); + + if (sourceChainId !== SOLANA_MAINNET_CAIP_CHAIN_ID) { + throw new Error(`Unsupported Solana source chain: ${sourceChainId}`); + } + + const sourceAccount = getSourceAccount(source, sourceChainId); + 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 amount = txs ? target.amountRaw : sourceAmountRaw; + + if (!amount) { + throw new Error('Missing Solana Pay source amount'); + } + + return { + amount, + ...(atomicDestination?.authorizationList && { + authorizationList: atomicDestination.authorizationList, + }), + destinationChainId: Number(target.chainId), + destinationCurrency: target.address, + originChainId: RELAY_SOLANA_CHAIN_ID, + originCurrency: getOriginCurrency(source, sourceChainId), + recipient, + refundTo: sourceAccount, + tradeType, + ...(txs && { txs }), + user: sourceAccount, + }; +} + +/** + * 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 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( + source: TransactionPaySource, + 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 = source.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. + * + * 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; +} + +/** + * Creates the ready checkpoint once an executable Relay request exists. + * + * @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(request: { + sourceWalletAccountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + sourceChainId: CaipChainId; + sourceAmountRaw: string; + requestId: string; + atomicProductActionRequired: boolean; + atomicProductActionIncluded: boolean; + requiresNonAtomicFollowUp: boolean; +}): MetamaskPaySolanaExecution { + return { + ...request, + phase: 'ready', + sourceStatus: 'not-observed', + relayStatus: 'not-observed', + notificationStatus: 'not-ready', + followUpStatus: request.requiresNonAtomicFollowUp + ? 'not-started' + : 'not-required', + }; +} + +/** + * Derives the client and lifecycle outcome from the durable checkpoint. + * + * @param execution - Durable external Solana execution checkpoint. + * @returns Derived lifecycle outcome. + */ +export function deriveSolanaPayOutcome( + execution: MetamaskPaySolanaExecution, +): SolanaPayOutcome { + if (execution.phase === 'user-rejected') { + return 'user-rejected'; + } + + if (execution.phase === 'not-submitted') { + return 'not-submitted'; + } + + if (execution.sourceStatus === 'failed') { + return 'source-failed'; + } + + if (execution.relayStatus === 'failure') { + return 'relay-failed'; + } + + if (execution.relayStatus === 'refund') { + return 'refunded'; + } + + if (execution.followUpStatus === 'failed') { + return 'follow-up-failed'; + } + + if ( + execution.phase === 'unknown' || + execution.sourceStatus === 'unknown' || + execution.relayStatus === 'unknown' || + execution.followUpStatus === 'unknown' + ) { + return 'unknown'; + } + + const isFollowUpComplete = + execution.followUpStatus === 'not-required' || + execution.followUpStatus === 'confirmed'; + const isAtomicProductActionComplete = + !execution.atomicProductActionRequired || + execution.atomicProductActionIncluded; + + if ( + execution.sourceStatus === 'confirmed' && + execution.relayStatus === 'success' && + isFollowUpComplete && + isAtomicProductActionComplete + ) { + return 'succeeded'; + } + + return execution.phase; +} + +/** + * 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, +): MetamaskPaySolanaExecution['relayStatus'] { + if (status === 'success') { + return 'success'; + } + + if (status === 'failure') { + return 'failure'; + } + + if (status === 'refund' || status === 'refunded') { + return 'refund'; + } + + return 'pending'; +} + +function getSourceChainId(source: TransactionPaySource): CaipChainId { + const accountChainId = parseCaipAccountId(source.sourceAccountId).chainId; + const assetChainId = parseCaipAssetType(source.sourceAssetId).chainId; + + if (accountChainId !== assetChainId) { + throw new Error('Solana source account and asset must use the same chain'); + } + + return accountChainId; +} + +function getSourceAccount( + source: TransactionPaySource, + sourceChainId: CaipChainId, +): string { + return source.sourceAccountId.slice(`${sourceChainId}:`.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; + } + + if (assetReference.startsWith(SOLANA_TOKEN_ASSET_NAMESPACE)) { + return assetReference.slice(SOLANA_TOKEN_ASSET_NAMESPACE.length); + } + + throw new Error(`Unsupported Solana source asset: ${source.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' + ); +} + +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); + + 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; +} + +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..b9d4b5ab600 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/types.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -33,6 +33,49 @@ 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' | 'EXACT_OUTPUT'; + txs?: { + to: Hex; + data: Hex; + value: Hex; + }[]; + 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 +200,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 +283,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..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'; @@ -31,6 +33,7 @@ import type { TransactionPayControllerGetPaymentOverrideDataAction, TransactionPayControllerGetStrategyAction, TransactionPayControllerPolymarketGetDepositWalletAddressAction, + TransactionPayControllerSubmitSolanaPayAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, } from '../TransactionPayController-method-action-types.js'; import type { TransactionPayControllerGetStateAction } from '../types.js'; @@ -129,6 +132,10 @@ export function getMessengerMock({ TransactionPayControllerPolymarketGetDepositWalletAddressAction['handler'] > = jest.fn(); + const submitSolanaPayMock: jest.MockedFn< + TransactionPayControllerSubmitSolanaPayAction['handler'] + > = jest.fn(); + const polymarketSubmitDepositWalletBatchMock: jest.MockedFn< TransactionPayControllerPolymarketSubmitDepositWalletBatchAction['handler'] > = jest.fn(); @@ -137,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(); @@ -269,6 +284,11 @@ export function getMessengerMock({ polymarketGetDepositWalletAddressMock, ); + messenger.registerActionHandler( + 'TransactionPayController:submitSolanaPay', + submitSolanaPayMock, + ); + messenger.registerActionHandler( 'TransactionPayController:polymarketSubmitDepositWalletBatch', polymarketSubmitDepositWalletBatchMock, @@ -279,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, @@ -311,7 +341,9 @@ export function getMessengerMock({ addTransactionMock, getAssetsControllerStateMock, addTransactionBatchMock, + confirmTransactionMock, estimateGasMock, + failTransactionMock, estimateGasBatchMock, findNetworkClientIdByChainIdMock, getAccountTrackerControllerStateMock, @@ -336,6 +368,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..310dc000723 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -37,13 +37,20 @@ import type { SentinelApiServiceActions } from '@metamask/sentinel-api-service'; import type { AuthorizationList, TransactionControllerAddTransactionBatchAction, + TransactionControllerConfirmTransactionAction, TransactionControllerEstimateGasAction, TransactionControllerEstimateGasBatchAction, + TransactionControllerFailTransactionAction, TransactionControllerUnapprovedTransactionAddedEvent, } from '@metamask/transaction-controller'; import type { BatchTransaction, BatchTransactionParams, + MetamaskPaySolanaExecution, + MetamaskPaySolanaFollowUpStatus, + MetamaskPaySolanaNotificationStatus, + MetamaskPaySolanaRelayStatus, + MetamaskPaySolanaSourceStatus, MetamaskPaySource, TransactionControllerAddTransactionAction, TransactionControllerGetGasFeeTokensAction, @@ -52,7 +59,13 @@ import type { TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; -import type { Hex, Json } from '@metamask/utils'; +import type { + CaipAccountId, + CaipAssetType, + CaipChainId, + Hex, + Json, +} from '@metamask/utils'; import type { Draft } from 'immer'; import type { @@ -60,6 +73,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 = @@ -81,8 +98,10 @@ export type AllowedActions = | SentinelApiServiceActions | TransactionControllerAddTransactionAction | TransactionControllerAddTransactionBatchAction + | TransactionControllerConfirmTransactionAction | TransactionControllerEstimateGasAction | TransactionControllerEstimateGasBatchAction + | TransactionControllerFailTransactionAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction | TransactionControllerUpdateTransactionAction; @@ -277,6 +296,144 @@ export const KEYRING_TYPES_SUPPORTING_7702: `${KeyringTypes}`[] = [ 'Money Keyring', ]; +/** 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 normalized by Core into Solana affordability policy. */ +export type SolanaPayPreflightData = { + preparedTransaction: string; + preparationId: string; + nativeBalanceRaw: string; + networkFeeRaw: string; + priorityFeeRaw: string; + rentDebitRaw: string; + rentExemptionRequirementRaw: string; + sourceBalanceRaw: string; +}; + +export type SolanaPayPreflight = SolanaPayPreflightData & { + affordability: { + isAffordable: boolean; + nativeShortfallRaw: string; + sourceShortfallRaw: string; + }; + requiredNativeBalanceRaw: string; + requiredSourceBalanceRaw: string; + retainedReserveRaw: string; + sourceAmountRaw: string; + totalFeeRaw: string; +}; + +export type SolanaPayQuote = { + providerQuote: RelaySolanaQuote; + preflight: SolanaPayPreflight; + route: { + atomicProductActionIncluded: boolean; + recipient: Hex; + targetAmountMinimum: string; + tradeType: 'EXACT_INPUT' | 'EXACT_OUTPUT'; + }; +}; + +export type GetSolanaPayPreflightRequest = { + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + caipAccountId: CaipAccountId; + requestId: string; + scope: CaipChainId; + sourceAssetId: CaipAssetType; + sourceAmountRaw: string; + transaction: RelaySolanaTransaction; +}; + +export type SolanaPaySignAndSendTransactionRequest = { + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + caipAccountId: CaipAccountId; + preparedTransaction: string; + preparationId: string; + requestId: string; + scope: CaipChainId; +}; + +export type SolanaPaySubmissionResult = + | { outcome: 'submitted'; transactionId: string } + | { outcome: 'user-rejected' } + | { outcome: 'not-submitted'; reason?: string } + | { outcome: 'ambiguous'; reason?: string }; + +export type GetSolanaPayTransactionStatusRequest = { + accountId: MetamaskPaySolanaExecution['sourceWalletAccountId']; + caipAccountId: CaipAccountId; + scope: CaipChainId; + transactionId: string; +}; + +export type SolanaPayFollowUpRequest = { + requestId: string; + relayTransactionId?: string; + transaction: TransactionMeta; +}; + +export type GetSolanaPayFollowUpStatusRequest = { + transaction: TransactionMeta; + transactionId: string; +}; + +export type SolanaPayCallbacks = { + getPreflight: ( + request: GetSolanaPayPreflightRequest, + ) => Promise; + signAndSendTransaction: ( + request: SolanaPaySignAndSendTransactionRequest, + ) => Promise; + getTransactionStatus: ( + request: GetSolanaPayTransactionStatusRequest, + ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; + submitNonAtomicFollowUp?: ( + request: SolanaPayFollowUpRequest, + ) => Promise; + getNonAtomicFollowUpStatus?: ( + request: GetSolanaPayFollowUpStatusRequest, + ) => Promise<'pending' | 'confirmed' | 'failed' | 'unknown'>; +}; + +export type SolanaPayOutcome = + | 'ready' + | 'attempting' + | 'submitted' + | 'user-rejected' + | 'not-submitted' + | 'unknown' + | 'source-failed' + | 'relay-failed' + | 'refunded' + | 'follow-up-failed' + | 'succeeded'; + +export type SolanaPayStatus = { + outcome: SolanaPayOutcome; + phase: MetamaskPaySolanaExecution['phase']; + requestId: string; + sourceStatus: MetamaskPaySolanaSourceStatus; + relayStatus: MetamaskPaySolanaRelayStatus; + notificationStatus: MetamaskPaySolanaNotificationStatus; + followUpStatus: MetamaskPaySolanaFollowUpStatus; + sourceTransactionId?: string; + targetTransactionId?: string; + followUpTransactionId?: string; + sourceFailureReason?: string; + relayFailureReason?: string; + submissionOutcome?: SolanaPaySubmissionResult['outcome']; +}; + /** Options for the TransactionPayController. */ export type TransactionPayControllerOptions = { /** Optional callback to re-encode nested transaction calldata for a given amount. */ @@ -309,6 +466,9 @@ export type TransactionPayControllerOptions = { /** Callbacks for the Polymarket relayer; required only for the Polymarket deposit-wallet flow. */ polymarket?: PolymarketCallbacks; + /** Client-owned Solana preparation, signing, observation, and follow-up operations. */ + solana?: SolanaPayCallbacks; + /** Initial state of the controller. */ state?: Partial; }; @@ -402,6 +562,9 @@ export type TransactionData = { /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; + /** Transient executable Solana quote and bound preflight. */ + solanaPayQuote?: SolanaPayQuote; + /** Most relevant structured validation error from the latest quote attempt. */ quoteError?: QuoteErrorInfo; 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, };