Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type {
LegacyGasFeeEstimates,
Log,
MetamaskPayMetadata,
MetamaskPaySolanaErrorCode,
MetamaskPaySolanaExecution,
MetamaskPaySolanaFollowUpStatus,
MetamaskPaySolanaNotificationStatus,
Expand Down
20 changes: 20 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -215,6 +226,7 @@ export type TransactionPayControllerPolymarketSubmitDepositWalletBatchAction = {
export type TransactionPayControllerMethodActions =
| TransactionPayControllerSetPaySourceAction
| TransactionPayControllerGetSolanaPayQuoteAction
| TransactionPayControllerGetSolanaPaySupportDiagnosticsAction
| TransactionPayControllerSubmitSolanaPayAction
| TransactionPayControllerNotifyRelayOfSolanaTransactionAction
| TransactionPayControllerReconcileSolanaPayAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -196,6 +198,7 @@ describe('TransactionPayController', () => {
});

getStrategyOrderMock.mockReturnValue([TransactionPayStrategy.Relay]);
isSolanaPayEnabledMock.mockReturnValue(true);
getRelayPollingIntervalMock.mockReturnValue(1);
getRelayPollingTimeoutMock.mockReturnValue(100);
updateQuotesMock.mockResolvedValue(true);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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',
Expand All @@ -1066,7 +1224,7 @@ describe('TransactionPayController', () => {

expect(failTransactionMock).toHaveBeenCalledWith(
TRANSACTION_ID_MOCK,
expect.any(Error),
expect.objectContaining({ code: errorCodeByOutcome[outcome] }),
);
},
);
Expand Down
Loading