From c15f52f640a9ca1b859f94f87fc88ff330de9934 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 4 Sep 2026 11:26:09 +0100 Subject: [PATCH 1/3] feat(transaction-controller): prepare transactions for approval --- packages/transaction-controller/CHANGELOG.md | 5 + packages/transaction-controller/src/index.ts | 8 + .../prepare-transaction-for-approval.test.ts | 171 ++++++++++++++++++ .../utils/prepare-transaction-for-approval.ts | 114 ++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts create mode 100644 packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 5c2913b6fe9..afef1510532 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 + - 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 b7b17038a97..9841776511c 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 00000000000..f0e283aedf7 --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts @@ -0,0 +1,171 @@ +import { cloneDeep } from 'lodash'; + +import type { TransactionMeta } from '../types.js'; +import { TransactionStatus } from '../types.js'; +import type { + TransactionApprovalSigningMode, + TransactionApprovalSponsorshipFacts, +} from './prepare-transaction-for-approval.js'; +import { prepareTransactionForApproval } from './prepare-transaction-for-approval.js'; + +type TruthTableRow = [ + available: boolean, + supported: boolean, + optedOut: boolean, + required: boolean, + externalSigningSupported: boolean, + expectedSponsorship: boolean, + expectedSigningMode: TransactionApprovalSigningMode | 'error', +]; + +const TRUTH_TABLE: TruthTableRow[] = [ + [false, false, false, false, false, false, 'local'], + [false, false, false, false, true, false, 'local'], + [false, false, false, true, false, false, 'error'], + [false, false, false, true, true, false, 'error'], + [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, false, true, false, false, 'error'], + [false, true, false, true, true, false, 'error'], + [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, false, false, 'error'], + [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 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, +) { + return prepareTransactionForApproval({ + signing: { externalSigningSupported }, + sponsorship, + transactionMeta, + }); +} + +describe('prepareTransactionForApproval', () => { + it.each(TRUTH_TABLE)( + 'normalizes available=%s supported=%s optedOut=%s required=%s externalSigningSupported=%s', + ( + available, + supported, + optedOut, + required, + externalSigningSupported, + expectedSponsorship, + expectedSigningMode, + ) => { + const prepareTransaction = () => + prepare( + TRANSACTION_META, + { available, supported, optedOut, required }, + externalSigningSupported, + ); + + if (expectedSigningMode === 'error') { + expect(prepareTransaction).toThrow( + 'Required transaction sponsorship is unavailable', + ); + return; + } + + expect(prepareTransaction()).toStrictEqual({ + decisions: { + signingMode: expectedSigningMode, + sponsorshipEnabled: expectedSponsorship, + }, + transactionMeta: { + ...TRANSACTION_META, + isExternalSign: expectedSigningMode === 'external', + isGasFeeSponsored: expectedSponsorship, + }, + }); + }, + ); + + 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 00000000000..13b9efe10c6 --- /dev/null +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts @@ -0,0 +1,114 @@ +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. + * @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, + }, + }; +} From 7b588e443b7cba56818a0aa76d0b28dc990f3b5f Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 4 Sep 2026 11:26:52 +0100 Subject: [PATCH 2/3] docs(transaction-controller): link approval preparation changelog --- packages/transaction-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index afef1510532..9ce2f6ff540 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `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 +- 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] From d03969ed7d46db21dea69f399477c330ab987fc5 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Fri, 4 Sep 2026 11:37:36 +0100 Subject: [PATCH 3/3] fix(transaction-controller): satisfy approval preparation lint --- .../prepare-transaction-for-approval.test.ts | 64 ++++++++++++------- .../utils/prepare-transaction-for-approval.ts | 3 + 2 files changed, 43 insertions(+), 24 deletions(-) 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 index f0e283aedf7..1fb0a3ccd3e 100644 --- a/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts @@ -3,41 +3,41 @@ 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 TruthTableRow = [ +type SponsorshipFactsRow = [ available: boolean, supported: boolean, optedOut: boolean, required: boolean, externalSigningSupported: boolean, +]; + +type SuccessfulTruthTableRow = [ + ...SponsorshipFactsRow, expectedSponsorship: boolean, - expectedSigningMode: TransactionApprovalSigningMode | 'error', + expectedSigningMode: TransactionApprovalSigningMode, ]; -const TRUTH_TABLE: TruthTableRow[] = [ +const SUCCESSFUL_TRUTH_TABLE: SuccessfulTruthTableRow[] = [ [false, false, false, false, false, false, 'local'], [false, false, false, false, true, false, 'local'], - [false, false, false, true, false, false, 'error'], - [false, false, false, true, true, false, 'error'], [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, false, true, false, false, 'error'], - [false, true, false, true, true, false, 'error'], [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, false, false, 'error'], [true, false, false, true, true, true, 'external'], [true, false, true, false, false, false, 'local'], [true, false, true, false, true, false, 'local'], @@ -53,6 +53,14 @@ const TRUTH_TABLE: TruthTableRow[] = [ [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', @@ -75,7 +83,7 @@ function prepare( transactionMeta: TransactionMeta, sponsorship: TransactionApprovalSponsorshipFacts, externalSigningSupported: boolean, -) { +): PrepareTransactionForApprovalResult { return prepareTransactionForApproval({ signing: { externalSigningSupported }, sponsorship, @@ -84,7 +92,7 @@ function prepare( } describe('prepareTransactionForApproval', () => { - it.each(TRUTH_TABLE)( + it.each(SUCCESSFUL_TRUTH_TABLE)( 'normalizes available=%s supported=%s optedOut=%s required=%s externalSigningSupported=%s', ( available, @@ -95,21 +103,13 @@ describe('prepareTransactionForApproval', () => { expectedSponsorship, expectedSigningMode, ) => { - const prepareTransaction = () => - prepare( - TRANSACTION_META, - { available, supported, optedOut, required }, - externalSigningSupported, - ); - - if (expectedSigningMode === 'error') { - expect(prepareTransaction).toThrow( - 'Required transaction sponsorship is unavailable', - ); - return; - } + const result = prepare( + TRANSACTION_META, + { available, supported, optedOut, required }, + externalSigningSupported, + ); - expect(prepareTransaction()).toStrictEqual({ + expect(result).toStrictEqual({ decisions: { signingMode: expectedSigningMode, sponsorshipEnabled: expectedSponsorship, @@ -123,6 +123,22 @@ describe('prepareTransactionForApproval', () => { }, ); + 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); diff --git a/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts index 13b9efe10c6..b07bd108525 100644 --- a/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts +++ b/packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts @@ -77,6 +77,9 @@ export type PrepareTransactionForApprovalResult = { * 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. */