Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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({

@matthewwalsh0 matthewwalsh0 Sep 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, these state properties have grown massively in complexity and currently being awkwardly changed during lifecycle to control the flow.

So better to experiment with a callback approach such as isSponsored(tx), shouldSign(tx) and isGasFeeSponsoredAvailable and isIntent properties.

So we have a single source of truth per client, fixed metadata, and a logical injection point during the actual submission flow.

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,
},
};
}
Loading