-
-
Notifications
You must be signed in to change notification settings - Fork 304
feat(transaction-controller): prepare transactions for approval #10109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
pedronfigueiredo
wants to merge
3
commits into
main
Choose a base branch
from
pnf/canonical-transaction-approval-preparation-handoff-implementation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
c15f52f
feat(transaction-controller): prepare transactions for approval
pedronfigueiredo 7b588e4
docs(transaction-controller): link approval preparation changelog
pedronfigueiredo d03969e
fix(transaction-controller): satisfy approval preparation lint
pedronfigueiredo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
187 changes: 187 additions & 0 deletions
187
packages/transaction-controller/src/utils/prepare-transaction-for-approval.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
117 changes: 117 additions & 0 deletions
117
packages/transaction-controller/src/utils/prepare-transaction-for-approval.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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({ | ||
| 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, | ||
| }, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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)andisGasFeeSponsoredAvailableandisIntentproperties.So we have a single source of truth per client, fixed metadata, and a logical injection point during the actual submission flow.