diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 0997bac84c2..09508026913 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) +- Add stable categorical error codes to transaction-owned Solana Pay execution checkpoints for privacy-safe support diagnostics ([#10274](https://github.com/MetaMask/core/pull/10274)) ### Changed diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index c528a4f6ea3..1860c265a76 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -91,6 +91,7 @@ export type { LegacyGasFeeEstimates, Log, MetamaskPayMetadata, + MetamaskPaySolanaErrorCode, MetamaskPaySolanaExecution, MetamaskPaySolanaFollowUpStatus, MetamaskPaySolanaNotificationStatus, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 5f72c3d4b47..55dace9b3f6 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -2173,6 +2173,23 @@ export type AssetsFiatValues = { sending?: string; }; +/** Stable non-sensitive Solana Pay failure classification. */ +export type MetamaskPaySolanaErrorCode = + | 'user_rejected' + | 'quote_expired' + | 'preflight_failed' + | 'construction_failed' + | 'submission_unknown' + | 'source_status_unknown' + | 'source_transaction_failed' + | 'settlement_failed' + | 'settlement_refunded' + | 'settlement_status_unknown' + | 'follow_up_failed' + | 'follow_up_status_unknown' + | 'sponsorship_unavailable' + | 'provider_notification_failed'; + /** Chain-agnostic source metadata for a MetaMask Pay transaction. */ export type MetamaskPaySource = { /** Canonical CAIP-10 identity of the source account. */ @@ -2215,6 +2232,9 @@ export type MetamaskPaySolanaFollowUpStatus = | 'unknown'; type MetamaskPaySolanaExecutionBase = { + /** Stable non-sensitive failure classification for support and metrics. */ + errorCode?: MetamaskPaySolanaErrorCode; + /** Wallet-local InternalAccount.id used by Snap requests. */ sourceWalletAccountId: string; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a83f71a21c1..3cdb029af74 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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)) +- Add fail-closed Solana Pay admission, privacy-safe lifecycle events, and stable support diagnostics for source, settlement, notification, follow-up, and recovery outcomes ([#10274](https://github.com/MetaMask/core/pull/10274)) ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 9575cf47de2..140a699878b 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -28,6 +28,17 @@ export type TransactionPayControllerGetSolanaPayQuoteAction = { handler: TransactionPayController['getSolanaPayQuote']; }; +/** + * Returns a privacy-safe support projection for a durable Solana execution. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns Stable categorical diagnostics without raw transaction details. + */ +export type TransactionPayControllerGetSolanaPaySupportDiagnosticsAction = { + type: `TransactionPayController:getSolanaPaySupportDiagnostics`; + handler: TransactionPayController['getSolanaPaySupportDiagnostics']; +}; + /** * Performs at most one client-owned Solana sign-and-broadcast attempt. * @@ -215,6 +226,7 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = { export type TransactionPayControllerMethodActions = | TransactionPayControllerSetPaySourceAction | TransactionPayControllerGetSolanaPayQuoteAction + | TransactionPayControllerGetSolanaPaySupportDiagnosticsAction | TransactionPayControllerSubmitSolanaPayAction | TransactionPayControllerNotifyRelayOfSolanaTransactionAction | TransactionPayControllerReconcileSolanaPayAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 5d508a708d6..df3f6d70cd6 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -32,6 +32,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, getStrategyOrder, + isSolanaPayEnabled, } from './utils/feature-flags.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; @@ -139,6 +140,7 @@ describe('TransactionPayController', () => { ); const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); + const isSolanaPayEnabledMock = jest.mocked(isSolanaPayEnabled); let messenger: TransactionPayControllerMessenger; let confirmTransactionMock: jest.Mock; let failTransactionMock: jest.Mock; @@ -196,6 +198,7 @@ describe('TransactionPayController', () => { }); getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]); + isSolanaPayEnabledMock.mockReturnValue(true); getRelayPollingIntervalMock.mockReturnValue(1); getRelayPollingTimeoutMock.mockReturnValue(100); updateQuotesMock.mockResolvedValue(true); @@ -433,6 +436,95 @@ describe('TransactionPayController', () => { }); } + it('fails closed when admitting a new Solana execution while rollout is disabled', async () => { + const transaction = getTransactionMeta(); + applyTransactionUpdates(transaction); + isSolanaPayEnabledMock.mockReturnValue(false); + const controller = createController({ + solana: getSolanaCallbacks(), + state: getControllerState(), + }); + + await expect( + controller.getSolanaPayQuote({ + sourceAmountRaw: '1000000', + sourceWalletAccountId: 'wallet-account-uuid', + transactionId: TRANSACTION_ID_MOCK, + }), + ).rejects.toThrow('Solana Pay is disabled'); + expect(fetchRelaySolanaQuoteMock).not.toHaveBeenCalled(); + expect(transaction.metamaskPay?.solanaExecution).toBeUndefined(); + }); + + it('continues an admitted Solana execution after rollout is disabled', async () => { + const transaction = getTransactionMeta(); + const signAndSendTransaction = jest.fn().mockResolvedValue({ + 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, + }); + isSolanaPayEnabledMock.mockReturnValue(false); + + await controller.submitSolanaPay(TRANSACTION_ID_MOCK); + + expect(signAndSendTransaction).toHaveBeenCalledTimes(1); + expect(transaction.metamaskPay?.solanaExecution?.phase).toBe('submitted'); + }); + + it('exposes privacy-safe support diagnostics for the transaction-owned execution', () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: { + ...SOLANA_PAY_SOURCE_MOCK, + sourceAccountId: + `${SOLANA_CHAIN_ID}:private-account` as CaipAccountId, + }, + solanaExecution: getExecution({ + phase: 'submitted', + relayFailureReason: 'raw provider reason', + relayStatus: 'failure', + sourceFailureReason: 'raw source reason', + sourceStatus: 'confirmed', + sourceTransactionId: 'private-signature', + targetTransactionId: 'private-target-hash', + }), + }; + applyTransactionUpdates(transaction); + createController(); + + const diagnostic = messenger.call( + 'TransactionPayController:getSolanaPaySupportDiagnostics', + TRANSACTION_ID_MOCK, + ); + + expect(diagnostic).toStrictEqual({ + errorCode: 'settlement_failed', + followUpStatus: 'not-required', + followUpTransactionIdPresent: false, + notificationStatus: 'not-ready', + outcome: 'relay-failed', + phase: 'submitted', + provider: 'relay', + relayStatus: 'failure', + requestIdPresent: true, + sourceAssetClass: 'token', + sourceStatus: 'confirmed', + sourceTransactionIdPresent: true, + targetTransactionIdPresent: true, + }); + expect(JSON.stringify(diagnostic)).not.toContain('private'); + expect(JSON.stringify(diagnostic)).not.toContain('raw'); + }); + it('stores the first durable checkpoint only on transaction metadata', async () => { const transaction = getTransactionMeta(); const solana = getSolanaCallbacks(); @@ -620,6 +712,57 @@ describe('TransactionPayController', () => { expect(status.notificationStatus).toBe('success'); }); + it('publishes and deduplicates privacy-safe Solana lifecycle transitions', async () => { + const transaction = getTransactionMeta(); + transaction.metamaskPay = { + source: SOLANA_PAY_SOURCE_MOCK, + solanaExecution: getExecution({ + notificationStatus: 'success', + phase: 'submitted', + relayStatus: 'pending', + sourceStatus: 'pending', + sourceTransactionId: SOLANA_TRANSACTION_ID, + }), + }; + applyTransactionUpdates(transaction); + const listener = jest.fn(); + messenger.subscribe( + 'TransactionPayController:solanaPayLifecycle', + listener, + ); + const controller = createController({ + solana: getSolanaCallbacks({ + getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), + }), + }); + + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + await controller.reconcileSolanaPay(TRANSACTION_ID_MOCK); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + followUpStatus: 'not-required', + followUpTransactionIdPresent: false, + isRecovery: false, + notificationStatus: 'success', + outcome: 'submitted', + phase: 'submitted', + provider: 'relay', + relayStatus: 'pending', + requestIdPresent: true, + sourceAssetClass: 'token', + sourceStatus: 'confirmed', + sourceTransactionIdPresent: true, + targetTransactionIdPresent: false, + }); + expect(JSON.stringify(listener.mock.calls)).not.toContain( + SOLANA_TRANSACTION_ID, + ); + expect(JSON.stringify(listener.mock.calls)).not.toContain( + 'relay-request-123', + ); + }); + it('recovers by scanning transaction metadata without signing', async () => { const transaction = getTransactionMeta(); transaction.status = 'submitted'; @@ -651,16 +794,25 @@ describe('TransactionPayController', () => { txHashes: ['0xtarget'], updatedAt: 1, }); + const lifecycleListener = jest.fn(); + messenger.subscribe( + 'TransactionPayController:solanaPayLifecycle', + lifecycleListener, + ); const controller = createController({ solana: getSolanaCallbacks({ getTransactionStatus: jest.fn().mockResolvedValue('confirmed'), signAndSendTransaction, }), }); + isSolanaPayEnabledMock.mockReturnValue(false); const statuses = await controller.recoverSolanaPayStatus(); expect(statuses[TRANSACTION_ID_MOCK].outcome).toBe('succeeded'); + expect(lifecycleListener).toHaveBeenLastCalledWith( + expect.objectContaining({ isRecovery: true, outcome: 'succeeded' }), + ); expect(signAndSendTransaction).not.toHaveBeenCalled(); expect(confirmTransactionMock).toHaveBeenCalledWith(TRANSACTION_ID_MOCK); }); @@ -1042,6 +1194,12 @@ describe('TransactionPayController', () => { }), }; applyTransactionUpdates(transaction); + const errorCodeByOutcome = { + 'follow-up-failed': 'follow_up_failed', + refunded: 'settlement_refunded', + 'relay-failed': 'settlement_failed', + 'source-failed': 'source_transaction_failed', + } as const; const relayStatusByOutcome = { 'follow-up-failed': 'success', refunded: 'refund', @@ -1066,7 +1224,7 @@ describe('TransactionPayController', () => { expect(failTransactionMock).toHaveBeenCalledWith( TRANSACTION_ID_MOCK, - expect.any(Error), + expect.objectContaining({ code: errorCodeByOutcome[outcome] }), ); }, ); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 6e7939d0615..e8263ab93fd 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -17,6 +17,12 @@ import { TransactionPayStrategy, } from './constants.js'; import { QuoteRefresher } from './helpers/QuoteRefresher.js'; +import { + getSolanaPaySupportDiagnostics, + isSolanaPayLifecycleTransition, + SolanaPayError, + withSolanaPayErrorCode, +} from './solana-pay-diagnostics.js'; import { RELAY_SOLANA_CHAIN_ID } from './strategy/relay/constants.js'; import { fetchRelaySolanaQuote, @@ -41,9 +47,12 @@ import type { PolymarketCallbacks, SetPaySourceRequest, SolanaPayCallbacks, + SolanaPayErrorCode, + SolanaPayLifecyclePayload, SolanaPayQuote, SolanaPayStatus, SolanaPaySubmissionResult, + SolanaPaySupportDiagnostics, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -59,6 +68,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, getStrategyOrder, + isSolanaPayEnabled, } from './utils/feature-flags.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; @@ -75,6 +85,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getFiatOptions', 'getPaymentOverrideData', 'getSolanaPayQuote', + 'getSolanaPaySupportDiagnostics', 'getStrategy', 'polymarketGetDepositWalletAddress', 'reconcileSolanaPay', @@ -152,6 +163,7 @@ function getSolanaPayStatus( } return { + errorCode: execution.errorCode, followUpStatus: execution.followUpStatus, followUpTransactionId: execution.followUpTransactionId, notificationStatus: execution.notificationStatus, @@ -168,6 +180,20 @@ function getSolanaPayStatus( }; } +function getSolanaPaySubmissionErrorCode( + submission: Exclude, +): SolanaPayErrorCode { + if (submission.outcome === 'user-rejected') { + return 'user_rejected'; + } + + if (submission.outcome === 'ambiguous') { + return 'submission_unknown'; + } + + return submission.errorCode ?? 'preflight_failed'; +} + function getFollowUpStatusFromSubmission( outcome: SolanaPaySubmissionResult['outcome'], ): MetamaskPaySolanaExecution['followUpStatus'] { @@ -178,21 +204,32 @@ function getFollowUpStatusFromSubmission( return outcome === 'ambiguous' ? 'unknown' : 'failed'; } -function getSolanaPayFailure(status: SolanaPayStatus): string | undefined { +function getSolanaPayFailure( + status: SolanaPayStatus, +): SolanaPayError | undefined { if (status.outcome === 'source-failed') { - return status.sourceFailureReason ?? 'Solana source transaction failed'; + return new SolanaPayError( + status.errorCode ?? 'source_transaction_failed', + 'Solana source transaction failed', + ); } if (status.outcome === 'relay-failed') { - return status.relayFailureReason ?? 'Relay settlement failed'; + return new SolanaPayError('settlement_failed', 'Relay settlement failed'); } if (status.outcome === 'refunded') { - return status.relayFailureReason ?? 'Relay settlement refunded'; + return new SolanaPayError( + 'settlement_refunded', + 'Relay settlement refunded', + ); } if (status.outcome === 'follow-up-failed') { - return 'Solana pay non-atomic follow-up failed'; + return new SolanaPayError( + 'follow_up_failed', + 'Solana pay non-atomic follow-up failed', + ); } return undefined; @@ -331,6 +368,10 @@ export class TransactionPayController extends BaseController< ); } + if (!isSolanaPayEnabled(this.messenger)) { + throw new Error('TransactionPayController: Solana Pay is disabled'); + } + const quoteRequest = await buildRelaySolanaQuoteRequest( source, request.sourceAmountRaw, @@ -406,6 +447,19 @@ export class TransactionPayController extends BaseController< return quote; } + /** + * Returns a privacy-safe support projection for a durable Solana execution. + * + * @param transactionId - Target TransactionController transaction ID. + * @returns Stable categorical diagnostics without raw transaction details. + */ + getSolanaPaySupportDiagnostics( + transactionId: string, + ): SolanaPaySupportDiagnostics { + const { execution, source } = this.#requireSolanaExecution(transactionId); + return getSolanaPaySupportDiagnostics(source, execution); + } + /** * Performs at most one client-owned Solana sign-and-broadcast attempt. * @@ -459,6 +513,7 @@ export class TransactionPayController extends BaseController< transactionId, { ...execution, + errorCode: getSolanaPaySubmissionErrorCode(submission), phase, sourceFailureReason: submission.outcome === 'user-rejected' @@ -543,6 +598,13 @@ export class TransactionPayController extends BaseController< * @returns Latest durable status. */ async reconcileSolanaPay(transactionId: string): Promise { + return await this.#reconcileSolanaPay(transactionId, false); + } + + async #reconcileSolanaPay( + transactionId: string, + isRecovery: boolean, + ): Promise { const { execution: initialExecution, source } = this.#requireSolanaExecution(transactionId); const relayResult = await observePromise( @@ -593,6 +655,7 @@ export class TransactionPayController extends BaseController< : updated; }, 'Observe Relay Solana status', + isRecovery, ); const { execution } = this.#requireSolanaExecution(transactionId); @@ -622,10 +685,11 @@ export class TransactionPayController extends BaseController< : 'unknown', }), 'Observe Solana source status', + isRecovery, ); } - await this.#advanceNonAtomicFollowUp(transactionId); + await this.#advanceNonAtomicFollowUp(transactionId, isRecovery); const status = this.#getSolanaPayStatus(transactionId); this.#updateSolanaParentLifecycle(transactionId, status); @@ -839,7 +903,7 @@ export class TransactionPayController extends BaseController< const startTime = Date.now(); while (true) { - const status = await this.reconcileSolanaPay(transactionId); + const status = await this.#reconcileSolanaPay(transactionId, true); if (isTerminalSolanaPayOutcome(status)) { return status; @@ -857,7 +921,10 @@ export class TransactionPayController extends BaseController< } } - async #advanceNonAtomicFollowUp(transactionId: string): Promise { + async #advanceNonAtomicFollowUp( + transactionId: string, + isRecovery: boolean, + ): Promise { let { execution, transaction } = this.#requireSolanaExecution(transactionId); @@ -883,6 +950,7 @@ export class TransactionPayController extends BaseController< transactionId, { ...execution, followUpStatus: 'attempting' }, 'Start one sponsored Money Account destination follow-up', + isRecovery, ); const result = await submitFollowUp({ @@ -901,6 +969,7 @@ export class TransactionPayController extends BaseController< result.outcome === 'submitted' ? result.transactionId : undefined, }), `Record Money Account destination follow-up: ${result.outcome}`, + isRecovery, ); ({ execution, transaction } = @@ -935,6 +1004,7 @@ export class TransactionPayController extends BaseController< result.status === 'fulfilled' ? result.value : 'unknown', }), 'Observe Money Account destination follow-up', + isRecovery, ); } } @@ -955,7 +1025,7 @@ export class TransactionPayController extends BaseController< this.messenger.call( 'TransactionController:failTransaction', transactionId, - new Error(failure), + failure, ); return; } @@ -1049,9 +1119,11 @@ export class TransactionPayController extends BaseController< } #getSolanaPayStatus(transactionId: string): SolanaPayStatus { - return getSolanaPayStatus( - this.#requireSolanaExecution(transactionId).execution, - ); + const { execution, source } = this.#requireSolanaExecution(transactionId); + const status = getSolanaPayStatus(execution); + const diagnostics = getSolanaPaySupportDiagnostics(source, execution); + + return { ...status, errorCode: diagnostics.errorCode }; } #updateSolanaExecution( @@ -1060,12 +1132,14 @@ export class TransactionPayController extends BaseController< execution: MetamaskPaySolanaExecution, ) => MetamaskPaySolanaExecution, note: string, + isRecovery = false, ): void { const { execution } = this.#requireSolanaExecution(transactionId); this.#persistSolanaExecution( transactionId, updateExecution(execution), note, + isRecovery, ); } @@ -1073,14 +1147,38 @@ export class TransactionPayController extends BaseController< transactionId: string, execution: MetamaskPaySolanaExecution, note: string, + isRecovery = false, ): void { + const transaction = this.#requireTransaction(transactionId); + const source = this.#requireSolanaPaySource(transaction); + const previousExecution = transaction.metamaskPay?.solanaExecution; + const previousDiagnostics = previousExecution + ? getSolanaPaySupportDiagnostics(source, previousExecution) + : undefined; + const persistedExecution = withSolanaPayErrorCode(execution); + const nextDiagnostics = getSolanaPaySupportDiagnostics( + source, + persistedExecution, + ); + updateTransaction( { transactionId, messenger: this.messenger, note }, - (transaction) => { - transaction.metamaskPay ??= {}; - transaction.metamaskPay.solanaExecution = { ...execution }; + (current) => { + current.metamaskPay ??= {}; + current.metamaskPay.solanaExecution = { ...persistedExecution }; }, ); + + if (isSolanaPayLifecycleTransition(previousDiagnostics, nextDiagnostics)) { + const payload: SolanaPayLifecyclePayload = { + ...nextDiagnostics, + isRecovery, + }; + this.messenger.publish( + 'TransactionPayController:solanaPayLifecycle', + payload, + ); + } } #removeTransactionData(transactionId: string): void { diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 17304826b79..0bc6024a201 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -16,7 +16,9 @@ export type { QuoteErrorReason, SetPaySourceRequest, SolanaPayCallbacks, + SolanaPayErrorCode, SolanaPayFollowUpRequest, + SolanaPayLifecyclePayload, SolanaPayOutcome, SolanaPayPreflight, SolanaPayPreflightData, @@ -24,6 +26,7 @@ export type { SolanaPaySignAndSendTransactionRequest, SolanaPayStatus, SolanaPaySubmissionResult, + SolanaPaySupportDiagnostics, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -35,6 +38,7 @@ export type { TransactionPayControllerGetStateAction, TransactionPayControllerMessenger, TransactionPayControllerOptions, + TransactionPayControllerSolanaPayLifecycleEvent, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, TransactionPaymentToken, @@ -51,6 +55,7 @@ export type { TransactionPayControllerGetDelegationTransactionAction, TransactionPayControllerGetFiatOptionsAction, TransactionPayControllerGetSolanaPayQuoteAction, + TransactionPayControllerGetSolanaPaySupportDiagnosticsAction, TransactionPayControllerGetStrategyAction, TransactionPayControllerNotifyRelayOfSolanaTransactionAction, TransactionPayControllerReconcileSolanaPayAction, @@ -65,4 +70,8 @@ export type { } from './TransactionPayController-method-action-types.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; +export { + getSolanaPaySupportDiagnostics, + SolanaPayError, +} from './solana-pay-diagnostics.js'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook.js'; diff --git a/packages/transaction-pay-controller/src/solana-pay-diagnostics.test.ts b/packages/transaction-pay-controller/src/solana-pay-diagnostics.test.ts new file mode 100644 index 00000000000..f55b333f62b --- /dev/null +++ b/packages/transaction-pay-controller/src/solana-pay-diagnostics.test.ts @@ -0,0 +1,177 @@ +import type { MetamaskPaySolanaExecution } from '@metamask/transaction-controller'; +import type { CaipAccountId, CaipAssetType } from '@metamask/utils'; + +import { + getSolanaPaySupportDiagnostics, + isSolanaPayLifecycleTransition, + withSolanaPayErrorCode, +} from './solana-pay-diagnostics.js'; +import type { TransactionPaySource } from './types.js'; + +const SOURCE: TransactionPaySource = { + sourceAccountId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:account' as CaipAccountId, + sourceAssetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:asset' as CaipAssetType, +}; + +function getExecution( + overrides: Partial = {}, +): MetamaskPaySolanaExecution { + return { + atomicProductActionIncluded: false, + atomicProductActionRequired: false, + followUpStatus: 'not-required', + notificationStatus: 'not-ready', + phase: 'ready', + relayStatus: 'not-observed', + requestId: 'relay-request-id', + requiresNonAtomicFollowUp: false, + sourceAmountRaw: '1000000', + sourceChainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + sourceStatus: 'not-observed', + sourceWalletAccountId: 'wallet-account-id', + ...overrides, + } as MetamaskPaySolanaExecution; +} + +describe('getSolanaPaySupportDiagnostics', () => { + it.each([ + [{ phase: 'user-rejected' }, 'user_rejected'], + [{ phase: 'not-submitted' }, 'preflight_failed'], + [{ phase: 'unknown' }, 'submission_unknown'], + [{ sourceStatus: 'unknown' }, 'source_status_unknown'], + [{ sourceStatus: 'failed' }, 'source_transaction_failed'], + [{ relayStatus: 'failure' }, 'settlement_failed'], + [{ relayStatus: 'refund' }, 'settlement_refunded'], + [{ relayStatus: 'unknown' }, 'settlement_status_unknown'], + [{ followUpStatus: 'failed' }, 'follow_up_failed'], + [{ followUpStatus: 'unknown' }, 'follow_up_status_unknown'], + [ + { + atomicProductActionIncluded: false, + atomicProductActionRequired: true, + }, + 'construction_failed', + ], + [{ notificationStatus: 'failure' }, 'provider_notification_failed'], + ] as const)('maps execution override %# to %s', (override, errorCode) => { + expect( + getSolanaPaySupportDiagnostics(SOURCE, getExecution(override)).errorCode, + ).toBe(errorCode); + }); + + it('preserves a client-mapped pre-submission code', () => { + const execution = withSolanaPayErrorCode( + getExecution({ + errorCode: 'construction_failed', + phase: 'not-submitted', + }), + ); + + expect(execution.errorCode).toBe('construction_failed'); + }); + + it('returns only categorical values and identifier-presence flags', () => { + const diagnostic = getSolanaPaySupportDiagnostics( + { + ...SOURCE, + sourceAccountId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:private-account' as CaipAccountId, + }, + getExecution({ + phase: 'submitted', + relayFailureReason: 'raw provider reason', + sourceFailureReason: 'raw source reason', + sourceStatus: 'pending', + sourceTransactionId: 'private-signature', + targetTransactionId: 'private-target-hash', + }), + ); + + expect(diagnostic).toStrictEqual({ + followUpStatus: 'not-required', + followUpTransactionIdPresent: false, + notificationStatus: 'not-ready', + outcome: 'submitted', + phase: 'submitted', + provider: 'relay', + relayStatus: 'not-observed', + requestIdPresent: true, + sourceAssetClass: 'token', + sourceStatus: 'pending', + sourceTransactionIdPresent: true, + targetTransactionIdPresent: true, + }); + expect(JSON.stringify(diagnostic)).not.toContain('private'); + expect(JSON.stringify(diagnostic)).not.toContain('raw'); + }); + + it('classifies native SOL without exposing its asset identifier', () => { + const diagnostic = getSolanaPaySupportDiagnostics( + { + ...SOURCE, + sourceAssetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, + }, + getExecution(), + ); + + expect(diagnostic.sourceAssetClass).toBe('native'); + expect(JSON.stringify(diagnostic)).not.toContain('slip44:501'); + }); +}); + +describe('isSolanaPayLifecycleTransition', () => { + it('deduplicates an identical categorical projection', () => { + const diagnostic = getSolanaPaySupportDiagnostics(SOURCE, getExecution()); + + expect(isSolanaPayLifecycleTransition(diagnostic, diagnostic)).toBe(false); + }); + + it.each([ + ['source', { sourceStatus: 'pending' }], + ['Relay', { relayStatus: 'pending' }], + ['notification', { notificationStatus: 'pending' }], + ['follow-up', { followUpStatus: 'not-started' }], + ['phase and outcome', { phase: 'attempting' }], + ['error', { notificationStatus: 'failure' }], + [ + 'source identifier presence', + { phase: 'submitted', sourceTransactionId: 'signature' }, + ], + ['target identifier presence', { targetTransactionId: 'target-hash' }], + [ + 'follow-up identifier presence', + { followUpTransactionId: 'follow-up-hash' }, + ], + ] as const)('detects a %s transition', (_name, overrides) => { + const previous = getSolanaPaySupportDiagnostics(SOURCE, getExecution()); + const next = getSolanaPaySupportDiagnostics( + SOURCE, + getExecution(overrides), + ); + + expect(isSolanaPayLifecycleTransition(previous, next)).toBe(true); + }); + + it('detects a source asset class transition', () => { + const previous = getSolanaPaySupportDiagnostics(SOURCE, getExecution()); + const next = getSolanaPaySupportDiagnostics( + { + ...SOURCE, + sourceAssetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' as CaipAssetType, + }, + getExecution(), + ); + + expect(isSolanaPayLifecycleTransition(previous, next)).toBe(true); + }); + + it('treats the initial durable execution as a transition', () => { + const diagnostic = getSolanaPaySupportDiagnostics(SOURCE, getExecution()); + + expect(isSolanaPayLifecycleTransition(undefined, diagnostic)).toBe(true); + }); +}); diff --git a/packages/transaction-pay-controller/src/solana-pay-diagnostics.ts b/packages/transaction-pay-controller/src/solana-pay-diagnostics.ts new file mode 100644 index 00000000000..4571d94b2d8 --- /dev/null +++ b/packages/transaction-pay-controller/src/solana-pay-diagnostics.ts @@ -0,0 +1,161 @@ +import type { MetamaskPaySolanaExecution } from '@metamask/transaction-controller'; + +import { deriveSolanaPayOutcome } from './strategy/relay/solana-pay.js'; +import type { + SolanaPayErrorCode, + SolanaPaySupportDiagnostics, + TransactionPaySource, +} from './types.js'; + +/** Error carrying a stable, non-sensitive Solana Pay support code. */ +export class SolanaPayError extends Error { + readonly code: SolanaPayErrorCode; + + constructor(code: SolanaPayErrorCode, message: string) { + super(message); + this.code = code; + } +} + +/** + * Creates a privacy-safe support projection from a durable execution. + * + * Raw accounts, assets, amounts, request IDs, transaction IDs, provider + * payloads, and free-form error text are deliberately excluded. + * + * @param source - Persisted chain-agnostic source metadata. + * @param execution - Durable external Solana execution checkpoint. + * @returns Stable support and analytics diagnostics. + */ +export function getSolanaPaySupportDiagnostics( + source: TransactionPaySource, + execution: MetamaskPaySolanaExecution, +): SolanaPaySupportDiagnostics { + const errorCode = getSolanaPayErrorCode(execution); + + return { + ...(errorCode && { errorCode }), + followUpStatus: execution.followUpStatus, + followUpTransactionIdPresent: execution.followUpTransactionId !== undefined, + notificationStatus: execution.notificationStatus, + outcome: deriveSolanaPayOutcome(execution), + phase: execution.phase, + provider: 'relay', + relayStatus: execution.relayStatus, + requestIdPresent: true, + sourceAssetClass: source.sourceAssetId.endsWith('/slip44:501') + ? 'native' + : 'token', + sourceStatus: execution.sourceStatus, + sourceTransactionIdPresent: + (execution.phase === 'submitted' || execution.phase === 'unknown') && + execution.sourceTransactionId !== undefined, + targetTransactionIdPresent: execution.targetTransactionId !== undefined, + }; +} + +/** + * Determines whether any emitted lifecycle property changed. + * + * @param previous - Previous privacy-safe projection. + * @param next - Next privacy-safe projection. + * @returns Whether the lifecycle event should be emitted. + */ +export function isSolanaPayLifecycleTransition( + previous: SolanaPaySupportDiagnostics | undefined, + next: SolanaPaySupportDiagnostics, +): boolean { + return ( + previous === undefined || + previous.errorCode !== next.errorCode || + previous.followUpStatus !== next.followUpStatus || + previous.followUpTransactionIdPresent !== + next.followUpTransactionIdPresent || + previous.notificationStatus !== next.notificationStatus || + previous.outcome !== next.outcome || + previous.phase !== next.phase || + previous.relayStatus !== next.relayStatus || + previous.sourceAssetClass !== next.sourceAssetClass || + previous.sourceStatus !== next.sourceStatus || + previous.sourceTransactionIdPresent !== next.sourceTransactionIdPresent || + previous.targetTransactionIdPresent !== next.targetTransactionIdPresent + ); +} + +/** + * Returns an execution with only its current stable error code retained. + * + * @param execution - Durable external Solana execution checkpoint. + * @returns Execution with stale codes removed and the current code applied. + */ +export function withSolanaPayErrorCode( + execution: MetamaskPaySolanaExecution, +): MetamaskPaySolanaExecution { + const result = { ...execution }; + const errorCode = getSolanaPayErrorCode(execution); + + if (errorCode) { + result.errorCode = errorCode; + } else { + delete result.errorCode; + } + + return result; +} + +function getSolanaPayErrorCode( + execution: MetamaskPaySolanaExecution, +): SolanaPayErrorCode | undefined { + if (execution.phase === 'user-rejected') { + return 'user_rejected'; + } + + if (execution.phase === 'not-submitted') { + return execution.errorCode ?? 'preflight_failed'; + } + + if (execution.sourceStatus === 'failed') { + return 'source_transaction_failed'; + } + + if (execution.relayStatus === 'failure') { + return 'settlement_failed'; + } + + if (execution.relayStatus === 'refund') { + return 'settlement_refunded'; + } + + if (execution.followUpStatus === 'failed') { + return 'follow_up_failed'; + } + + if (execution.phase === 'unknown') { + return 'submission_unknown'; + } + + if (execution.sourceStatus === 'unknown') { + return 'source_status_unknown'; + } + + if (execution.relayStatus === 'unknown') { + return 'settlement_status_unknown'; + } + + if (execution.followUpStatus === 'unknown') { + return 'follow_up_status_unknown'; + } + + if ( + execution.atomicProductActionRequired && + !execution.atomicProductActionIncluded + ) { + return 'construction_failed'; + } + + if (execution.notificationStatus === 'failure') { + return 'provider_notification_failed'; + } + + return undefined; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts index e95d5c74e9f..b5716c4da8f 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.test.ts @@ -626,7 +626,15 @@ describe('Solana Relay Pay', () => { ['refund', 'refund'], ['refunded', 'refund'], ['waiting', 'pending'], + ['depositing', 'pending'], + ['pending', 'pending'], + ['submitted', 'pending'], + ['delayed', 'pending'], ] as const)('maps Relay status %s to %s', (relayStatus, expected) => { expect(mapRelayStatus(relayStatus)).toBe(expected); }); + + it('fails closed for an unrecognized Relay status', () => { + expect(mapRelayStatus('new-provider-status' as 'waiting')).toBe('unknown'); + }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts index 4cdebb8495d..77ebf11a44b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/solana-pay.ts @@ -337,7 +337,15 @@ export function mapRelayStatus( return 'refund'; } - return 'pending'; + if ( + ['waiting', 'depositing', 'pending', 'submitted', 'delayed'].includes( + status, + ) + ) { + return 'pending'; + } + + return 'unknown'; } function getSourceChainId(source: TransactionPaySource): CaipChainId { diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 310dc000723..2a2c9c014e7 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -46,6 +46,7 @@ import type { import type { BatchTransaction, BatchTransactionParams, + MetamaskPaySolanaErrorCode, MetamaskPaySolanaExecution, MetamaskPaySolanaFollowUpStatus, MetamaskPaySolanaNotificationStatus, @@ -277,8 +278,39 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetStateAction | TransactionPayControllerMethodActions; +export type SolanaPayErrorCode = MetamaskPaySolanaErrorCode; + +/** Privacy-safe projection of a durable Solana execution checkpoint. */ +export type SolanaPaySupportDiagnostics = { + errorCode?: SolanaPayErrorCode; + followUpStatus: MetamaskPaySolanaFollowUpStatus; + followUpTransactionIdPresent: boolean; + notificationStatus: MetamaskPaySolanaNotificationStatus; + outcome: SolanaPayOutcome; + phase: MetamaskPaySolanaExecution['phase']; + provider: 'relay'; + relayStatus: MetamaskPaySolanaRelayStatus; + requestIdPresent: boolean; + sourceAssetClass: 'native' | 'token'; + sourceStatus: MetamaskPaySolanaSourceStatus; + sourceTransactionIdPresent: boolean; + targetTransactionIdPresent: boolean; +}; + +/** Privacy-safe lifecycle payload for client-owned analytics. */ +export type SolanaPayLifecyclePayload = SolanaPaySupportDiagnostics & { + isRecovery: boolean; +}; + +/** Emitted for categorical external Solana lifecycle transitions. */ +export type TransactionPayControllerSolanaPayLifecycleEvent = { + type: `${typeof CONTROLLER_NAME}:solanaPayLifecycle`; + payload: [SolanaPayLifecyclePayload]; +}; + export type TransactionPayControllerEvents = - TransactionPayControllerStateChangeEvent; + | TransactionPayControllerStateChangeEvent + | TransactionPayControllerSolanaPayLifecycleEvent; export type TransactionPayControllerMessenger = Messenger< typeof CONTROLLER_NAME, @@ -366,7 +398,14 @@ export type SolanaPaySignAndSendTransactionRequest = { export type SolanaPaySubmissionResult = | { outcome: 'submitted'; transactionId: string } | { outcome: 'user-rejected' } - | { outcome: 'not-submitted'; reason?: string } + | { + outcome: 'not-submitted'; + errorCode?: Extract< + SolanaPayErrorCode, + 'quote_expired' | 'preflight_failed' | 'construction_failed' + >; + reason?: string; + } | { outcome: 'ambiguous'; reason?: string }; export type GetSolanaPayTransactionStatusRequest = { @@ -419,6 +458,7 @@ export type SolanaPayOutcome = | 'succeeded'; export type SolanaPayStatus = { + errorCode?: SolanaPayErrorCode; outcome: SolanaPayOutcome; phase: MetamaskPaySolanaExecution['phase']; requestId: string; diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index a79fd6e8107..e46df6a7022 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -39,6 +39,7 @@ import { getEIP7702UpgradeContractAddress, isRelayExecuteEnabled, isRelayValidationEnabled, + isSolanaPayEnabled, getFeatureFlags, getGasBuffer, getHyperliquidActivationFeeConfig, @@ -563,6 +564,46 @@ describe('Feature Flags Utils', () => { }); }); + describe('isSolanaPayEnabled', () => { + it('defaults to disabled', () => { + expect(isSolanaPayEnabled(messenger)).toBe(false); + }); + + it('returns true only when the Relay Solana capability is enabled', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { + solana: { enabled: true }, + }, + }, + }, + }, + }); + + expect(isSolanaPayEnabled(messenger)).toBe(true); + }); + + it('rejects truthy malformed values', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay: { + payStrategies: { + relay: { + solana: { enabled: 'true' }, + }, + }, + }, + }, + }); + + expect(isSolanaPayEnabled(messenger)).toBe(false); + }); + }); + describe('isRelayExecuteEnabled', () => { it('returns false when no feature flags are set', () => { expect(isRelayExecuteEnabled(messenger)).toBe(false); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 88b0f23a13d..ea1b2a58fbe 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -191,6 +191,9 @@ export type PayStrategiesConfigRaw = { originGasOverhead?: string; pollingInterval?: number; pollingTimeout?: number; + solana?: { + enabled?: unknown; + }; }; }; @@ -635,6 +638,26 @@ export function getPayStrategiesConfig( }; } +/** + * Whether new Solana-source Pay intents may be admitted. + * + * This capability is fail-closed. It must not stop observation or recovery + * for an intent that was admitted while the capability was on. + * + * @param messenger - Controller messenger. + * @returns True only when the Relay Solana capability is explicitly enabled. + */ +export function isSolanaPayEnabled( + messenger: TransactionPayControllerMessenger, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const featureFlags = state.remoteFeatureFlags?.confirmations_pay as + | FeatureFlagsRaw + | undefined; + + return featureFlags?.payStrategies?.relay?.solana?.enabled === true; +} + /** * Whether the Relay /execute gasless flow is enabled. *