diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 5c2913b6fe..9ce2f6ff54 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `prepareTransactionForApproval` and its request, result, sponsorship, signing, and signing-mode types to normalize transaction sponsorship and external-signing metadata from account and publication-path capabilities without mutating the input ([#10109](https://github.com/MetaMask/core/pull/10109)) + - The helper rejects required sponsored flows when sponsorship is not opted out and no valid sponsored publication path is available + ## [69.8.0] ### Added diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index b7b17038a9..9841776511 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -144,6 +144,14 @@ export { hasTransactionType } from './utils/transaction-type.js'; export { getEffectiveRecipient, getSendRecipients } from './utils/recipient.js'; export { CHAIN_IDS } from './constants.js'; export { HARDFORK } from './utils/prepare.js'; +export { prepareTransactionForApproval } from './utils/prepare-transaction-for-approval.js'; +export type { + PrepareTransactionForApprovalRequest, + PrepareTransactionForApprovalResult, + TransactionApprovalSigningFacts, + TransactionApprovalSigningMode, + TransactionApprovalSponsorshipFacts, +} from './utils/prepare-transaction-for-approval.js'; export { getAccountAddressRelationship } from './api/accounts-api.js'; export type { GetAccountAddressRelationshipRequest, diff --git a/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts new file mode 100644 index 0000000000..1fb0a3ccd3 --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts @@ -0,0 +1,187 @@ +import { cloneDeep } from 'lodash'; + +import type { TransactionMeta } from '../types.js'; +import { TransactionStatus } from '../types.js'; +import type { + PrepareTransactionForApprovalResult, + TransactionApprovalSigningMode, + TransactionApprovalSponsorshipFacts, +} from './prepare-transaction-for-approval.js'; +import { prepareTransactionForApproval } from './prepare-transaction-for-approval.js'; + +type SponsorshipFactsRow = [ + available: boolean, + supported: boolean, + optedOut: boolean, + required: boolean, + externalSigningSupported: boolean, +]; + +type SuccessfulTruthTableRow = [ + ...SponsorshipFactsRow, + expectedSponsorship: boolean, + expectedSigningMode: TransactionApprovalSigningMode, +]; + +const SUCCESSFUL_TRUTH_TABLE: SuccessfulTruthTableRow[] = [ + [false, false, false, false, false, false, 'local'], + [false, false, false, false, true, false, 'local'], + [false, false, true, false, false, false, 'local'], + [false, false, true, false, true, false, 'local'], + [false, false, true, true, false, false, 'local'], + [false, false, true, true, true, false, 'local'], + [false, true, false, false, false, false, 'local'], + [false, true, false, false, true, false, 'local'], + [false, true, true, false, false, false, 'local'], + [false, true, true, false, true, false, 'local'], + [false, true, true, true, false, false, 'local'], + [false, true, true, true, true, false, 'local'], + [true, false, false, false, false, false, 'local'], + [true, false, false, false, true, false, 'local'], + [true, false, false, true, true, true, 'external'], + [true, false, true, false, false, false, 'local'], + [true, false, true, false, true, false, 'local'], + [true, false, true, true, false, false, 'local'], + [true, false, true, true, true, false, 'local'], + [true, true, false, false, false, true, 'local'], + [true, true, false, false, true, true, 'external'], + [true, true, false, true, false, true, 'local'], + [true, true, false, true, true, true, 'external'], + [true, true, true, false, false, false, 'local'], + [true, true, true, false, true, false, 'local'], + [true, true, true, true, false, false, 'local'], + [true, true, true, true, true, false, 'local'], +]; + +const ERROR_TRUTH_TABLE: SponsorshipFactsRow[] = [ + [false, false, false, true, false], + [false, false, false, true, true], + [false, true, false, true, false], + [false, true, false, true, true], + [true, false, false, true, false], +]; + +const TRANSACTION_META: TransactionMeta = { + chainId: '0x1', + id: 'transaction-id', + isExternalSign: true, + isGasFeeSponsored: true, + networkClientId: 'mainnet', + origin: 'https://example.test', + selectedGasFeeToken: '0x1234', + status: TransactionStatus.unapproved, + time: 123, + txParams: { + data: '0x5678', + from: '0xfrom', + nonce: '0x1', + to: '0xto', + }, +}; + +function prepare( + transactionMeta: TransactionMeta, + sponsorship: TransactionApprovalSponsorshipFacts, + externalSigningSupported: boolean, +): PrepareTransactionForApprovalResult { + return prepareTransactionForApproval({ + signing: { externalSigningSupported }, + sponsorship, + transactionMeta, + }); +} + +describe('prepareTransactionForApproval', () => { + it.each(SUCCESSFUL_TRUTH_TABLE)( + 'normalizes available=%s supported=%s optedOut=%s required=%s externalSigningSupported=%s', + ( + available, + supported, + optedOut, + required, + externalSigningSupported, + expectedSponsorship, + expectedSigningMode, + ) => { + const result = prepare( + TRANSACTION_META, + { available, supported, optedOut, required }, + externalSigningSupported, + ); + + expect(result).toStrictEqual({ + decisions: { + signingMode: expectedSigningMode, + sponsorshipEnabled: expectedSponsorship, + }, + transactionMeta: { + ...TRANSACTION_META, + isExternalSign: expectedSigningMode === 'external', + isGasFeeSponsored: expectedSponsorship, + }, + }); + }, + ); + + it.each(ERROR_TRUTH_TABLE)( + 'rejects unavailable required sponsorship with available=%s supported=%s optedOut=%s required=%s externalSigningSupported=%s', + (available, supported, optedOut, required, externalSigningSupported) => { + const prepareTransaction = (): PrepareTransactionForApprovalResult => + prepare( + TRANSACTION_META, + { available, supported, optedOut, required }, + externalSigningSupported, + ); + + expect(prepareTransaction).toThrow( + 'Required transaction sponsorship is unavailable', + ); + }, + ); + + it('preserves unrelated metadata without mutating the input', () => { + const input = cloneDeep(TRANSACTION_META); + const inputBeforePreparation = cloneDeep(input); + + const result = prepare( + input, + { + available: true, + supported: false, + optedOut: false, + required: false, + }, + false, + ); + + expect(input).toStrictEqual(inputBeforePreparation); + expect(result.transactionMeta).not.toBe(input); + expect(result.transactionMeta).toMatchObject({ + origin: input.origin, + selectedGasFeeToken: input.selectedGasFeeToken, + txParams: input.txParams, + }); + expect(result.transactionMeta.isGasFeeSponsored).toBe(false); + expect(result.transactionMeta.isExternalSign).toBe(false); + }); + + it('is idempotent', () => { + const request = { + signing: { externalSigningSupported: true }, + sponsorship: { + available: true, + supported: true, + optedOut: false, + required: false, + }, + transactionMeta: TRANSACTION_META, + }; + const firstResult = prepareTransactionForApproval(request); + const secondResult = prepareTransactionForApproval({ + ...request, + transactionMeta: firstResult.transactionMeta, + }); + + expect(secondResult).toStrictEqual(firstResult); + }); +}); diff --git a/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts new file mode 100644 index 0000000000..b07bd10852 --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts @@ -0,0 +1,117 @@ +import type { TransactionMeta } from '../types.js'; + +/** Facts that determine whether sponsorship is retained at approval time. */ +export type TransactionApprovalSponsorshipFacts = { + /** Whether simulation or the caller reports sponsorship as available. */ + available: boolean; + + /** Whether the actual account and selected publication path support optional sponsorship. */ + supported: boolean; + + /** Whether the user explicitly disabled optional sponsorship. */ + optedOut: boolean; + + /** + * Whether the product transaction requires a sponsored publication path and + * therefore cannot safely fall back to unsponsored publication. + */ + required: boolean; +}; + +/** Facts that determine how the approved transaction is signed. */ +export type TransactionApprovalSigningFacts = { + /** Whether the selected sponsored publication path signs outside local keyring signing. */ + externalSigningSupported: boolean; +}; + +/** Input for preparing transaction metadata immediately before approval. */ +export type PrepareTransactionForApprovalRequest = { + /** Transaction metadata to prepare. This object is not mutated. */ + transactionMeta: TransactionMeta; + + /** Sponsorship facts determined by simulation and the client integration. */ + sponsorship: TransactionApprovalSponsorshipFacts; + + /** Signing facts for the selected publication path. */ + signing: TransactionApprovalSigningFacts; +}; + +/** The signing mode selected for the prepared transaction. */ +export type TransactionApprovalSigningMode = 'local' | 'external'; + +/** Output from preparing transaction metadata for approval. */ +export type PrepareTransactionForApprovalResult = { + /** A copy of the transaction metadata containing the normalized fields. */ + transactionMeta: TransactionMeta; + + /** Explicit decisions made from the supplied facts. */ + decisions: { + /** Whether sponsorship is retained. */ + sponsorshipEnabled: boolean; + + /** Whether Core should sign locally or defer signing to the publication path. */ + signingMode: TransactionApprovalSigningMode; + }; +}; + +/** + * Normalize the execution-sensitive sponsorship and signing metadata used when + * approving a transaction. + * + * `isExternalSign` describes the selected publication path, rather than the + * account alone: when true, TransactionController skips local keyring signing. + * A supported sponsored path may still use local signing (for example, a Smart + * Transaction path), so sponsorship does not imply external signing. + * + * Sponsorship availability is supplied by the caller, typically from the most + * recent simulation. Product integrations also supply `required` explicitly; + * it is not inferred from transaction type. A required flow can override the + * optional-support result only when its external publication path is available. + * If a required path is unavailable, preparation throws rather than risk + * publishing a semantically invalid parent transaction. Explicit user opt-out + * is always authoritative and deliberately selects unsponsored local signing. + * + * Gas-fee-token selection is intentionally outside this helper. + * `checkGasFeeTokenBeforePublish` remains authoritative for final native-balance + * validation, refreshing token quotes, clearing or retaining the selected fee + * token, removing the nonce, and selecting external signing at publication. + * + * @param request - Transaction metadata and normalized preparation facts. + * @param request.signing - Signing facts for the selected publication path. + * @param request.sponsorship - Sponsorship facts determined by the caller. + * @param request.transactionMeta - Transaction metadata to prepare. + * @returns Prepared transaction metadata and the resulting decisions. + * @throws If sponsorship is required but no sponsored publication path is available. + */ +export function prepareTransactionForApproval({ + signing, + sponsorship, + transactionMeta, +}: PrepareTransactionForApprovalRequest): PrepareTransactionForApprovalResult { + const sponsorshipEnabled = + sponsorship.available && + !sponsorship.optedOut && + (sponsorship.supported || + (sponsorship.required && signing.externalSigningSupported)); + + if (sponsorship.required && !sponsorship.optedOut && !sponsorshipEnabled) { + throw new Error('Required transaction sponsorship is unavailable'); + } + + const signingMode: TransactionApprovalSigningMode = + sponsorshipEnabled && signing.externalSigningSupported + ? 'external' + : 'local'; + + return { + decisions: { + signingMode, + sponsorshipEnabled, + }, + transactionMeta: { + ...transactionMeta, + isExternalSign: signingMode === 'external', + isGasFeeSponsored: sponsorshipEnabled, + }, + }; +}