From 168736d15351a3a0917af1ee10ce138e2bb94b3d Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 28 Sep 2026 15:40:26 +0200 Subject: [PATCH 01/18] feat(stellar-wallet-snap): unify refresher key resolution for open and save flows --- .../src/ui/confirmation/api.ts | 6 ++ .../src/ui/confirmation/controller.tsx | 78 ++++++++------ .../src/ui/confirmation/utils.test.ts | 94 ++++++++++++++++ .../src/ui/confirmation/utils.ts | 60 +++++++++++ .../views/MemoEdit/events.test.tsx | 101 ++++++++++++++++-- .../ui/confirmation/views/MemoEdit/events.tsx | 34 +++--- 6 files changed, 320 insertions(+), 53 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 41e9a687d..af2205be8 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -164,4 +164,10 @@ export type ConfirmationBaseProps = Partial & { * of stacking parallel refresh chains (e.g. MemoEdit Save vs open cron). */ backgroundEventId?: string; + /** + * Preference- and flow-enabled refresher keys resolved at dialog open + * (ungated by terminal Error status). MemoEdit Save reuses this snapshot so + * restart matches open-path policy without re-fetching live preferences. + */ + enabledRefresherKeys?: string[]; }; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index de23c3dad..6b63afe70 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -7,7 +7,6 @@ import type { ConfirmSendJsonRpcRequest, } from '../../handlers/clientRequest/api'; import { - ConfirmationContextRefresherKey, RefreshConfirmationContextHandler, } from '../../handlers/cronjob/refreshConfirmationContext'; import type { @@ -30,8 +29,8 @@ import { formatFeeData, formatOrigin, getPreferencesWithFallback, -} from './utils'; -import { renderConfirmationView } from './views/render'; + resolveRefresherKeys, +} from './utils';import { renderConfirmationView } from './views/render'; import type { ConfirmationViewProps } from './views/render'; type ConfirmationRenderOptions = { @@ -271,41 +270,50 @@ export class ConfirmationUXController { // Read the Error overrides from `renderContext` (not merged `context`): // `defaultContext` only ever sets Fetched/Fetching, so TS narrows those // fields and rejects a comparison against Error on the merged object. - const refresherKeys: ConfirmationContextRefresherKey[] = []; - if (enablePricing) { - refresherKeys.push(ConfirmationContextRefresherKey.Prices); - } - if ( - enableSecurityScan && - renderContext.scanFetchStatus !== FetchStatus.Error - ) { - refresherKeys.push(ConfirmationContextRefresherKey.Scan); - } - if ( - enableLocalSimulation && - renderContext.transactionsFetchStatus !== FetchStatus.Error - ) { - refresherKeys.push(ConfirmationContextRefresherKey.Transaction); - } + // Ungated keys are persisted so MemoEdit Save can restart the full + // preference-enabled set (prefs fixed for the dialog lifetime). + const enabledRefresherKeys = resolveRefresherKeys({ + enablePricing, + enableSecurityScan, + enableLocalSimulation, + }); + const refresherKeys = resolveRefresherKeys({ + enablePricing, + enableSecurityScan, + enableLocalSimulation, + statusGates: { + scanFetchStatus: renderContext.scanFetchStatus, + transactionsFetchStatus: renderContext.transactionsFetchStatus, + }, + }); - if (refresherKeys.length > 0) { - const backgroundEventId = - await RefreshConfirmationContextHandler.scheduleBackgroundEvent( - { - scope, - interfaceId: id, - interfaceKey, - refresherKeys, - }, - Duration.OneSecond, - ); - // Persist the pending event id so MemoEdit (or a later replace) can cancel - // it before scheduling another chain — bitcoin send-flow pattern. - const contextWithEventId = { ...context, backgroundEventId }; + if (enabledRefresherKeys.length > 0) { + let contextWithRefreshMeta = { + ...context, + enabledRefresherKeys, + }; + if (refresherKeys.length > 0) { + const backgroundEventId = + await RefreshConfirmationContextHandler.scheduleBackgroundEvent( + { + scope, + interfaceId: id, + interfaceKey, + refresherKeys, + }, + Duration.OneSecond, + ); + // Persist the pending event id so MemoEdit (or a later replace) can cancel + // it before scheduling another chain — bitcoin send-flow pattern. + contextWithRefreshMeta = { + ...contextWithRefreshMeta, + backgroundEventId, + }; + } await updateInterfaceIfExists( id, - renderConfirmationView(interfaceKey, contextWithEventId), - contextWithEventId, + renderConfirmationView(interfaceKey, contextWithRefreshMeta), + contextWithRefreshMeta, ); } diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index dea21382e..f41d92de2 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -1,3 +1,4 @@ +import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { FieldType } from '../../services/transaction'; import type { ReadableOperationField } from '../../services/transaction'; import { TransactionScanValidationType } from '../../services/transaction-scan'; @@ -16,6 +17,7 @@ import { isRemoteTransactionScanLoading, requiresMaliciousAcknowledgement, resolveConfirmationBanner, + resolveRefresherKeys, shouldDisableConfirmation, } from './utils'; @@ -347,4 +349,96 @@ describe('confirmation utils', () => { ).toStrictEqual([{ key: 'salt', value: 'aa', type: FieldType.copyable }]); }); }); + + describe('resolveRefresherKeys', () => { + it('returns all keys when pricing, scan, and local simulation are enabled', () => { + expect( + resolveRefresherKeys({ + enablePricing: true, + enableSecurityScan: true, + enableLocalSimulation: true, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, + ]); + }); + + it('omits prices when pricing is disabled', () => { + expect( + resolveRefresherKeys({ + enablePricing: false, + enableSecurityScan: true, + enableLocalSimulation: true, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, + ]); + }); + + it('omits scan when security scanning is disabled', () => { + expect( + resolveRefresherKeys({ + enablePricing: true, + enableSecurityScan: false, + enableLocalSimulation: true, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Transaction, + ]); + }); + + it('omits transaction when local simulation is disabled', () => { + expect( + resolveRefresherKeys({ + enablePricing: true, + enableSecurityScan: true, + enableLocalSimulation: false, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ]); + }); + + it('skips scan and transaction when status gates are Error (open path)', () => { + expect( + resolveRefresherKeys({ + enablePricing: true, + enableSecurityScan: true, + enableLocalSimulation: true, + statusGates: { + scanFetchStatus: FetchStatus.Error, + transactionsFetchStatus: FetchStatus.Error, + }, + }), + ).toStrictEqual([ConfirmationContextRefresherKey.Prices]); + }); + + it('re-arms scan and transaction when status gates are omitted (Save restart)', () => { + expect( + resolveRefresherKeys({ + enablePricing: false, + enableSecurityScan: true, + enableLocalSimulation: true, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, + ]); + }); + + it('returns an empty list when nothing is enabled', () => { + expect( + resolveRefresherKeys({ + enablePricing: false, + enableSecurityScan: false, + enableLocalSimulation: false, + }), + ).toStrictEqual([]); + }); + }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 9bf00302c..cafdaf8ba 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -5,6 +5,7 @@ import { BigNumber } from 'bignumber.js'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; +import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; import type { TransactionScanResult } from '../../services/transaction-scan'; @@ -118,6 +119,65 @@ export async function getPreferencesWithFallback(): Promise ({ name, }); +const allRefresherKeys = [ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, +]; + const scheduleArgs = { scope: 'stellar:pubnet', interfaceId: 'interface-id', interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - refresherKeys: [ - ConfirmationContextRefresherKey.Transaction, - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Prices, - ], + refresherKeys: allRefresherKeys, }; describe('MemoEdit event handlers', () => { @@ -95,13 +97,14 @@ describe('MemoEdit event handlers', () => { }); describe('Save', () => { - it('always restarts Transaction+Scan+Prices using the latest event id', async () => { + it('restarts open-time enabled refresher keys using the latest event id', async () => { jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, scope: 'stellar:pubnet', transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Fetched, + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'latest-event', }); @@ -138,6 +141,57 @@ describe('MemoEdit event handlers', () => { ); }); + it('schedules only preference-enabled keys from the open-time snapshot', async () => { + const transactionOnly = [ConfirmationContextRefresherKey.Transaction]; + jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + scope: 'stellar:pubnet', + transaction: 'xdr', + accountId: 'account-id', + transactionsFetchStatus: FetchStatus.Error, + enabledRefresherKeys: transactionOnly, + backgroundEventId: 'live-event', + }); + + await handlers[MemoEditFormNames.Form]?.({ + id: 'interface-id', + event: formSubmitEvent('memo-only-tx'), + context: { + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + scope: 'stellar:pubnet', + transaction: 'xdr', + accountId: 'account-id', + transactionsFetchStatus: FetchStatus.Error, + backgroundEventId: 'live-event', + }, + }); + + expect(scheduleSpy).toHaveBeenCalledWith( + { + ...scheduleArgs, + refresherKeys: transactionOnly, + }, + expect.anything(), + { replaceEventId: 'live-event' }, + ); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.objectContaining({ + memo: 'memo-only-tx', + transactionsFetchStatus: FetchStatus.Fetched, + backgroundEventId: 'new-event-id', + }), + ); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.not.objectContaining({ + scanFetchStatus: FetchStatus.Fetching, + }), + ); + }); + it('replaces the live event id during an in-flight tick so cron ownership aborts', async () => { // Click/open still see the in-flight tick id; Save must cancel that id and // persist a new one so the orphan tick observes ownership loss. @@ -149,6 +203,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memoScreen: true, + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'in-flight-tick-event', }); @@ -192,6 +247,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memo: 'exchange-ref', + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'live-event', }); @@ -248,6 +304,39 @@ describe('MemoEdit event handlers', () => { ); }); + it('persists the memo without rescheduling when no refresher keys were enabled at open', async () => { + jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + scope: 'stellar:pubnet', + transaction: 'xdr', + accountId: 'account-id', + transactionsFetchStatus: FetchStatus.Error, + enabledRefresherKeys: [], + }); + + await handlers[MemoEditFormNames.Form]?.({ + id: 'interface-id', + event: formSubmitEvent('no-refreshers'), + context: { + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + scope: 'stellar:pubnet', + transaction: 'xdr', + accountId: 'account-id', + transactionsFetchStatus: FetchStatus.Error, + }, + }); + + expect(scheduleSpy).not.toHaveBeenCalled(); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.objectContaining({ + memo: 'no-refreshers', + memoScreen: false, + }), + ); + }); + it('keeps the memo and shows validation error without rescheduling', async () => { await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index 4745cb317..650d9ecd6 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -51,7 +51,7 @@ async function reRender( * localSimulation refresh — still allow saving the memo onto context. * * @param context - Confirmation interface context. - * @returns True when Save should cancel-and-replace Transaction+Scan+Prices. + * @returns True when Save should cancel-and-replace the refresh pipeline. */ function canRestartRefresh(context: Record): boolean { return ( @@ -95,9 +95,9 @@ function memoFromSubmitEvent(event: UserInputEvent): string { } /** - * Saves the memo onto confirmation context and always restarts validation + - * scan when a live refresh pipeline is present, so clearing a required memo - * cannot leave Confirm enabled on a stale success. + * Saves the memo onto confirmation context and restarts the open-time + * preference-enabled refreshers when a live refresh pipeline is present, so + * clearing a required memo cannot leave Confirm enabled on a stale success. * * @param options - The user input handler context. */ @@ -142,7 +142,17 @@ async function onSaveSubmit( }; const { scope } = baseContext; - if (canRestartRefresh(baseContext) && typeof scope === 'string') { + // Prefs are fixed for the dialog lifetime: reuse the open-path snapshot from + // `resolveRefresherKeys` (ungated) instead of hardcoding or re-fetching prefs. + const refresherKeys = Array.isArray(baseContext.enabledRefresherKeys) + ? (baseContext.enabledRefresherKeys as ConfirmationContextRefresherKey[]) + : []; + + if ( + canRestartRefresh(baseContext) && + typeof scope === 'string' && + refresherKeys.length > 0 + ) { let previousEventId: string | undefined; if (typeof baseContext.backgroundEventId === 'string') { previousEventId = baseContext.backgroundEventId; @@ -155,8 +165,12 @@ async function onSaveSubmit( // Clear any prior banner; the restarted transaction refresher will set a // new error (e.g. RequiresMemo again) or leave it cleared on success. errorMessage: null, - transactionsFetchStatus: FetchStatus.Fetched, - scanFetchStatus: FetchStatus.Fetching, + ...(refresherKeys.includes(ConfirmationContextRefresherKey.Transaction) + ? { transactionsFetchStatus: FetchStatus.Fetched } + : {}), + ...(refresherKeys.includes(ConfirmationContextRefresherKey.Scan) + ? { scanFetchStatus: FetchStatus.Fetching } + : {}), }; const backgroundEventId = @@ -165,11 +179,7 @@ async function onSaveSubmit( scope: scope as ConfirmSendJsonRpcRequest['params']['scope'], interfaceId: id, interfaceKey, - refresherKeys: [ - ConfirmationContextRefresherKey.Transaction, - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Prices, - ], + refresherKeys, }, Duration.OneSecond, { replaceEventId: previousEventId }, From 0879f5a91a9b2e73bc2bfe4fd0e971e70da8ef81 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 28 Sep 2026 19:30:22 +0200 Subject: [PATCH 02/18] chore: lint --- packages/stellar-wallet-snap/CHANGELOG.md | 1 + .../stellar-wallet-snap/src/ui/confirmation/controller.tsx | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 8536d29c0..274070e4d 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Refactor confirmation refresher keys to use policy-gated snapshot for consistent open-path and Save behavior ([#378](https://github.com/MetaMask/internal-snaps/pull/378)) - Reduce `snap_getBip32Entropy` calls during `bip44:discover` from two to one by fetching the coin-type node once and reusing it for both the on-chain activity check and account derivation in `AccountService.batchCreate`; also parallelize the accounts state read and entropy fetch in `AccountService.batchCreate` for non-discover paths ([#308](https://github.com/MetaMask/internal-snaps/pull/308)) - Bump `@stellar/stellar-sdk` from `^15.0.1` to `^17.0.1` ([#302](https://github.com/MetaMask/internal-snaps/pull/302)) - Show distinct confirmation titles for each revoke-sponsorship operation type (account, claimable balance, data, liquidity pool, offer, signer, trustline) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 6b63afe70..7aa62611d 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -6,9 +6,7 @@ import type { ChangeTrustOptJsonRpcRequest, ConfirmSendJsonRpcRequest, } from '../../handlers/clientRequest/api'; -import { - RefreshConfirmationContextHandler, -} from '../../handlers/cronjob/refreshConfirmationContext'; +import { RefreshConfirmationContextHandler } from '../../handlers/cronjob/refreshConfirmationContext'; import type { SecurityScanRequest, TransactionScanResult, @@ -30,7 +28,8 @@ import { formatOrigin, getPreferencesWithFallback, resolveRefresherKeys, -} from './utils';import { renderConfirmationView } from './views/render'; +} from './utils'; +import { renderConfirmationView } from './views/render'; import type { ConfirmationViewProps } from './views/render'; type ConfirmationRenderOptions = { From 5b975ed4ee3e42955ef603ecd1ccd00b3308bff7 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Mon, 28 Sep 2026 22:03:45 +0200 Subject: [PATCH 03/18] chore: lint --- .../src/ui/confirmation/controller.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 7aa62611d..3aedc9ea2 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -272,17 +272,17 @@ export class ConfirmationUXController { // Ungated keys are persisted so MemoEdit Save can restart the full // preference-enabled set (prefs fixed for the dialog lifetime). const enabledRefresherKeys = resolveRefresherKeys({ - enablePricing, + enablePricing: enablePricing ?? false, enableSecurityScan, - enableLocalSimulation, + enableLocalSimulation: enableLocalSimulation ?? false, }); const refresherKeys = resolveRefresherKeys({ - enablePricing, + enablePricing: enablePricing ?? false, enableSecurityScan, - enableLocalSimulation, + enableLocalSimulation: enableLocalSimulation ?? false, statusGates: { - scanFetchStatus: renderContext.scanFetchStatus, - transactionsFetchStatus: renderContext.transactionsFetchStatus, + scanFetchStatus: (renderContext.scanFetchStatus as FetchStatus) ?? FetchStatus.Fetched, + transactionsFetchStatus: (renderContext.transactionsFetchStatus as FetchStatus) ?? FetchStatus.Fetched, }, }); From 8676edc0405c20d6e6be67247748c58dee130b10 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 29 Sep 2026 09:05:42 +0200 Subject: [PATCH 04/18] chore: lint --- .../stellar-wallet-snap/src/ui/confirmation/controller.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 3aedc9ea2..134587200 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -281,8 +281,11 @@ export class ConfirmationUXController { enableSecurityScan, enableLocalSimulation: enableLocalSimulation ?? false, statusGates: { - scanFetchStatus: (renderContext.scanFetchStatus as FetchStatus) ?? FetchStatus.Fetched, - transactionsFetchStatus: (renderContext.transactionsFetchStatus as FetchStatus) ?? FetchStatus.Fetched, + scanFetchStatus: + (renderContext.scanFetchStatus as FetchStatus) ?? FetchStatus.Fetched, + transactionsFetchStatus: + (renderContext.transactionsFetchStatus as FetchStatus) ?? + FetchStatus.Fetched, }, }); From cb2076e5eefe36804e3085b886eadf497cb5bb6a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 29 Sep 2026 09:10:50 +0200 Subject: [PATCH 05/18] chore: lint --- packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index 84a315edb..fd9472d02 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -1,5 +1,5 @@ -import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { KnownCaip2ChainId } from '../../api'; +import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { FieldType } from '../../services/transaction'; import type { ReadableOperationField } from '../../services/transaction'; import { TransactionScanValidationType } from '../../services/transaction-scan'; From 0ec97a48b9b053f9a07dfe11619a0a0265b35ff0 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 29 Sep 2026 10:48:35 +0200 Subject: [PATCH 06/18] refactor: simplify refresher key handling and unify restart logic --- .../src/ui/confirmation/api.ts | 10 ++-- .../src/ui/confirmation/controller.tsx | 58 +++++-------------- .../src/ui/confirmation/utils.test.ts | 27 --------- .../src/ui/confirmation/utils.ts | 34 ++--------- .../views/MemoEdit/events.test.tsx | 10 ++-- .../ui/confirmation/views/MemoEdit/events.tsx | 6 +- 6 files changed, 33 insertions(+), 112 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index af2205be8..358fb5e7c 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -160,14 +160,12 @@ export type ConfirmationBaseProps = Partial & { memoError?: LocalizedMessage | null; /** * Pending `snap_scheduleBackgroundEvent` id for confirmation context refresh. - * Stored so callers can cancel-and-replace (bitcoin send-flow pattern) instead - * of stacking parallel refresh chains (e.g. MemoEdit Save vs open cron). + * Used to cancel-and-replace when restarting the refresh chain. */ backgroundEventId?: string; /** - * Preference- and flow-enabled refresher keys resolved at dialog open - * (ungated by terminal Error status). MemoEdit Save reuses this snapshot so - * restart matches open-path policy without re-fetching live preferences. + * Preference- and flow-enabled refresher keys resolved at dialog open. + * MemoEdit Save reuses this snapshot for restart. */ - enabledRefresherKeys?: string[]; + refresherKeys?: string[]; }; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 134587200..bd3eaeb3f 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -262,56 +262,30 @@ export class ConfirmationUXController { return dialogPromise; } - // 5. Schedule background context refresh for enabled refreshers only. - // Skip Scan / Transaction when renderContext already marked them Error - // (e.g. RequiresMemo on open): a pending open cron would race - // MemoEdit's restart and double-hit Blockaid after the user saves a memo. - // Read the Error overrides from `renderContext` (not merged `context`): - // `defaultContext` only ever sets Fetched/Fetching, so TS narrows those - // fields and rejects a comparison against Error on the merged object. - // Ungated keys are persisted so MemoEdit Save can restart the full - // preference-enabled set (prefs fixed for the dialog lifetime). - const enabledRefresherKeys = resolveRefresherKeys({ - enablePricing: enablePricing ?? false, - enableSecurityScan, - enableLocalSimulation: enableLocalSimulation ?? false, - }); + // 5. Persist preference-enabled refresher keys and schedule the open cron. + // Refreshers already no-op when their slice is Error (e.g. RequiresMemo). const refresherKeys = resolveRefresherKeys({ enablePricing: enablePricing ?? false, enableSecurityScan, enableLocalSimulation: enableLocalSimulation ?? false, - statusGates: { - scanFetchStatus: - (renderContext.scanFetchStatus as FetchStatus) ?? FetchStatus.Fetched, - transactionsFetchStatus: - (renderContext.transactionsFetchStatus as FetchStatus) ?? - FetchStatus.Fetched, - }, }); - if (enabledRefresherKeys.length > 0) { - let contextWithRefreshMeta = { + if (refresherKeys.length > 0) { + const backgroundEventId = + await RefreshConfirmationContextHandler.scheduleBackgroundEvent( + { + scope, + interfaceId: id, + interfaceKey, + refresherKeys, + }, + Duration.OneSecond, + ); + const contextWithRefreshMeta = { ...context, - enabledRefresherKeys, + refresherKeys, + backgroundEventId, }; - if (refresherKeys.length > 0) { - const backgroundEventId = - await RefreshConfirmationContextHandler.scheduleBackgroundEvent( - { - scope, - interfaceId: id, - interfaceKey, - refresherKeys, - }, - Duration.OneSecond, - ); - // Persist the pending event id so MemoEdit (or a later replace) can cancel - // it before scheduling another chain — bitcoin send-flow pattern. - contextWithRefreshMeta = { - ...contextWithRefreshMeta, - backgroundEventId, - }; - } await updateInterfaceIfExists( id, renderConfirmationView(interfaceKey, contextWithRefreshMeta), diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index fd9472d02..7663d2ee0 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -428,33 +428,6 @@ describe('confirmation utils', () => { ]); }); - it('skips scan and transaction when status gates are Error (open path)', () => { - expect( - resolveRefresherKeys({ - enablePricing: true, - enableSecurityScan: true, - enableLocalSimulation: true, - statusGates: { - scanFetchStatus: FetchStatus.Error, - transactionsFetchStatus: FetchStatus.Error, - }, - }), - ).toStrictEqual([ConfirmationContextRefresherKey.Prices]); - }); - - it('re-arms scan and transaction when status gates are omitted (Save restart)', () => { - expect( - resolveRefresherKeys({ - enablePricing: false, - enableSecurityScan: true, - enableLocalSimulation: true, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Transaction, - ]); - }); - it('returns an empty list when nothing is enabled', () => { expect( resolveRefresherKeys({ diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 2b48e5967..922ae4591 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -122,57 +122,35 @@ export async function getPreferencesWithFallback(): Promise { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Fetched, - enabledRefresherKeys: allRefresherKeys, + refresherKeys: allRefresherKeys, backgroundEventId: 'latest-event', }); @@ -149,7 +149,7 @@ describe('MemoEdit event handlers', () => { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Error, - enabledRefresherKeys: transactionOnly, + refresherKeys: transactionOnly, backgroundEventId: 'live-event', }); @@ -203,7 +203,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memoScreen: true, - enabledRefresherKeys: allRefresherKeys, + refresherKeys: allRefresherKeys, backgroundEventId: 'in-flight-tick-event', }); @@ -247,7 +247,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memo: 'exchange-ref', - enabledRefresherKeys: allRefresherKeys, + refresherKeys: allRefresherKeys, backgroundEventId: 'live-event', }); @@ -311,7 +311,7 @@ describe('MemoEdit event handlers', () => { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Error, - enabledRefresherKeys: [], + refresherKeys: [], }); await handlers[MemoEditFormNames.Form]?.({ diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index 650d9ecd6..b2b29e60c 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -142,10 +142,8 @@ async function onSaveSubmit( }; const { scope } = baseContext; - // Prefs are fixed for the dialog lifetime: reuse the open-path snapshot from - // `resolveRefresherKeys` (ungated) instead of hardcoding or re-fetching prefs. - const refresherKeys = Array.isArray(baseContext.enabledRefresherKeys) - ? (baseContext.enabledRefresherKeys as ConfirmationContextRefresherKey[]) + const refresherKeys = Array.isArray(baseContext.refresherKeys) + ? (baseContext.refresherKeys as ConfirmationContextRefresherKey[]) : []; if ( From b93948bb57c020cd52920e8a68cccba75493fbf3 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 29 Sep 2026 10:59:05 +0200 Subject: [PATCH 07/18] chore: revert changelog --- packages/stellar-wallet-snap/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 1ce61e8f9..d3042728c 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Refactor confirmation refresher keys to use policy-gated snapshot for consistent open-path and Save behavior ([#378](https://github.com/MetaMask/internal-snaps/pull/378)) - Reduce `snap_getBip32Entropy` calls during `bip44:discover` from two to one by fetching the coin-type node once and reusing it for both the on-chain activity check and account derivation in `AccountService.batchCreate`; also parallelize the accounts state read and entropy fetch in `AccountService.batchCreate` for non-discover paths ([#308](https://github.com/MetaMask/internal-snaps/pull/308)) - Bump `@stellar/stellar-sdk` from `^15.0.1` to `^17.0.1` ([#302](https://github.com/MetaMask/internal-snaps/pull/302)) - Show distinct confirmation titles for each revoke-sponsorship operation type (account, claimable balance, data, liquidity pool, offer, signer, trustline) From c25b404848300f779f8968e0c32b629e60132d15 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 6 Oct 2026 22:22:31 +0200 Subject: [PATCH 08/18] refactor(stellar-wallet-snap): distinguish enabled vs scheduled refresher keys for ungated MemoEdit Save --- .../src/ui/confirmation/api.ts | 5 +- .../src/ui/confirmation/controller.test.tsx | 171 +++++++++++++++++- .../src/ui/confirmation/controller.tsx | 35 ++-- .../src/ui/confirmation/utils.test.ts | 33 ++++ .../src/ui/confirmation/utils.ts | 52 +++++- .../views/MemoEdit/events.test.tsx | 10 +- .../ui/confirmation/views/MemoEdit/events.tsx | 14 +- 7 files changed, 282 insertions(+), 38 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index 358fb5e7c..b9e980c0d 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -165,7 +165,8 @@ export type ConfirmationBaseProps = Partial & { backgroundEventId?: string; /** * Preference- and flow-enabled refresher keys resolved at dialog open. - * MemoEdit Save reuses this snapshot for restart. + * Ungated: MemoEdit Save reuses this snapshot to restart, including slices + * that were Error on open (e.g. RequiresMemo). */ - refresherKeys?: string[]; + enabledRefresherKeys?: string[]; }; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx index 0cad26857..b50551a1b 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx @@ -1,11 +1,90 @@ import { KnownCaip2ChainId } from '../../api'; -import { ConfirmationInterfaceKey } from './api'; +import { ClientRequestMethod } from '../../handlers/clientRequest/api'; +import type { ConfirmSendJsonRpcRequest } from '../../handlers/clientRequest/api'; +import { + ConfirmationContextRefresherKey, + RefreshConfirmationContextHandler, +} from '../../handlers/cronjob/refreshConfirmationContext'; +import { + createInterface, + getPreferences, + showDialog, + updateInterfaceIfExists, +} from '../../utils'; +import { ConfirmationInterfaceKey, FetchStatus } from './api'; import { ConfirmationUXController } from './controller'; +import { renderConfirmationView } from './views/render'; + +jest.mock('../../utils', () => ({ + ...jest.requireActual('../../utils'), + createInterface: jest.fn(), + showDialog: jest.fn(), + updateInterfaceIfExists: jest.fn(), + getPreferences: jest.fn(), +})); + +jest.mock('./views/render', () => ({ + renderConfirmationView: jest.fn(() => 'RENDERED'), +})); + +const sendRequest = { + jsonrpc: '2.0', + id: '1', + method: ClientRequestMethod.ConfirmSend, + params: { + scope: KnownCaip2ChainId.Mainnet, + fromAccountId: 'account-id', + toAddress: 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP', + amount: '1', + assetId: 'stellar:pubnet/native', + }, +} as unknown as ConfirmSendJsonRpcRequest; + +const preferences = { + locale: 'en', + currency: 'usd', + hideBalances: false, + useSecurityAlerts: true, + simulateOnChainActions: true, + useTokenDetection: true, + batchCheckBalances: true, + displayNftMedia: true, + useNftDetection: true, + useExternalPricingData: true, + showTestnets: true, +}; + +const allEnabledKeys = [ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, +]; describe('ConfirmationUXController', () => { - it('throws when transaction scanning is enabled without a security scan request', async () => { - const controller = new ConfirmationUXController(); + const controller = new ConfirmationUXController(); + let scheduleSpy: jest.SpiedFunction< + typeof RefreshConfirmationContextHandler.scheduleBackgroundEvent + >; + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(createInterface).mockResolvedValue('interface-id'); + jest.mocked(showDialog).mockResolvedValue(true); + jest.mocked(updateInterfaceIfExists).mockResolvedValue(true); + jest.mocked(getPreferences).mockResolvedValue(preferences); + jest.mocked(renderConfirmationView).mockReturnValue( + 'RENDERED' as unknown as ReturnType, + ); + scheduleSpy = jest + .spyOn(RefreshConfirmationContextHandler, 'scheduleBackgroundEvent') + .mockResolvedValue('open-event-id'); + }); + + afterEach(() => { + scheduleSpy.mockRestore(); + }); + + it('throws when transaction scanning is enabled without a security scan request', async () => { await expect( controller.renderConfirmationDialog({ scope: KnownCaip2ChainId.Mainnet, @@ -18,4 +97,90 @@ describe('ConfirmationUXController', () => { 'Cannot scan a transaction confirmation without a security scan request.', ); }); + + it('persists enabled keys on the first context write and schedules them', async () => { + await controller.renderConfirmationDialog({ + scope: KnownCaip2ChainId.Mainnet, + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + fee: '100', + renderContext: {}, + renderOptions: { + loadPrice: true, + securityScanning: true, + localSimulation: true, + }, + securityScanRequest: { + accountAddress: 'GABC', + transaction: 'xdr', + }, + transactionValidationRequest: { + accountId: 'account-id', + transaction: 'xdr', + request: sendRequest, + }, + }); + + expect(createInterface).toHaveBeenCalledWith('RENDERED', {}); + expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( + 1, + 'interface-id', + 'RENDERED', + expect.objectContaining({ enabledRefresherKeys: allEnabledKeys }), + ); + expect(scheduleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + interfaceId: 'interface-id', + refresherKeys: allEnabledKeys, + }), + expect.anything(), + ); + expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( + 2, + 'interface-id', + 'RENDERED', + expect.objectContaining({ + enabledRefresherKeys: allEnabledKeys, + backgroundEventId: 'open-event-id', + }), + ); + }); + + it('omits Error slices from the open cron while persisting them as enabled', async () => { + await controller.renderConfirmationDialog({ + scope: KnownCaip2ChainId.Mainnet, + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + fee: '100', + renderContext: { + scanFetchStatus: FetchStatus.Error, + transactionsFetchStatus: FetchStatus.Error, + }, + renderOptions: { + loadPrice: true, + securityScanning: true, + localSimulation: true, + }, + securityScanRequest: { + accountAddress: 'GABC', + transaction: 'xdr', + }, + transactionValidationRequest: { + accountId: 'account-id', + transaction: 'xdr', + request: sendRequest, + }, + }); + + expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( + 1, + 'interface-id', + 'RENDERED', + expect.objectContaining({ enabledRefresherKeys: allEnabledKeys }), + ); + expect(scheduleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + refresherKeys: [ConfirmationContextRefresherKey.Prices], + }), + expect.anything(), + ); + }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index bd3eaeb3f..91e5a36f4 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -28,6 +28,7 @@ import { formatOrigin, getPreferencesWithFallback, resolveRefresherKeys, + scheduledRefresherKeys, } from './utils'; import { renderConfirmationView } from './views/render'; import type { ConfirmationViewProps } from './views/render'; @@ -237,10 +238,23 @@ export class ConfirmationUXController { tokenPrices, }; + // Ungated enable set for MemoEdit Save. Open cron uses Error-gated + // scheduledKeys so RequiresMemo does not start Scan/Tx until Save. + const enabledRefresherKeys = resolveRefresherKeys({ + enablePricing: enablePricing ?? false, + enableSecurityScan, + enableLocalSimulation: enableLocalSimulation ?? false, + }); + const scheduledKeys = scheduledRefresherKeys( + enabledRefresherKeys, + renderContext, + ); + // 1. Initial context with loading state const context = { ...defaultContext, ...renderContext, + ...(enabledRefresherKeys.length > 0 ? { enabledRefresherKeys } : {}), }; // 2. Initial render with loading skeleton (always show loading if pricing enabled) @@ -262,34 +276,27 @@ export class ConfirmationUXController { return dialogPromise; } - // 5. Persist preference-enabled refresher keys and schedule the open cron. - // Refreshers already no-op when their slice is Error (e.g. RequiresMemo). - const refresherKeys = resolveRefresherKeys({ - enablePricing: enablePricing ?? false, - enableSecurityScan, - enableLocalSimulation: enableLocalSimulation ?? false, - }); - - if (refresherKeys.length > 0) { + // 5. Schedule the open cron for this-tick keys only. + if (scheduledKeys.length > 0) { const backgroundEventId = await RefreshConfirmationContextHandler.scheduleBackgroundEvent( { scope, interfaceId: id, interfaceKey, - refresherKeys, + refresherKeys: scheduledKeys, }, Duration.OneSecond, ); - const contextWithRefreshMeta = { + const contextWithEventId = { ...context, - refresherKeys, + enabledRefresherKeys, backgroundEventId, }; await updateInterfaceIfExists( id, - renderConfirmationView(interfaceKey, contextWithRefreshMeta), - contextWithRefreshMeta, + renderConfirmationView(interfaceKey, contextWithEventId), + contextWithEventId, ); } diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index 7663d2ee0..15db578c0 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -20,6 +20,7 @@ import { requiresMaliciousAcknowledgement, resolveConfirmationBanner, resolveRefresherKeys, + scheduledRefresherKeys, shouldDisableConfirmation, } from './utils'; @@ -438,4 +439,36 @@ describe('confirmation utils', () => { ).toStrictEqual([]); }); }); + + describe('scheduledRefresherKeys', () => { + const enabled = [ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Scan, + ConfirmationContextRefresherKey.Transaction, + ]; + + it('returns enabled keys when renderContext has no Error statuses', () => { + expect(scheduledRefresherKeys(enabled, {})).toStrictEqual(enabled); + }); + + it('omits scan and transaction when renderContext marked them Error', () => { + expect( + scheduledRefresherKeys(enabled, { + scanFetchStatus: FetchStatus.Error, + transactionsFetchStatus: FetchStatus.Error, + }), + ).toStrictEqual([ConfirmationContextRefresherKey.Prices]); + }); + + it('omits only scan when scanFetchStatus is Error', () => { + expect( + scheduledRefresherKeys(enabled, { + scanFetchStatus: FetchStatus.Error, + }), + ).toStrictEqual([ + ConfirmationContextRefresherKey.Prices, + ConfirmationContextRefresherKey.Transaction, + ]); + }); + }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index 922ae4591..3afe628c4 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -131,29 +131,65 @@ export type ResolveRefresherKeysParams = { }; /** - * Derives which confirmation context refreshers to schedule. + * Derives the preference- and flow-enabled refresher keys at dialog open. * - * Shared by dialog open ({@link ConfirmationUXController}) and MemoEdit Save. + * Persist this snapshot on confirmation context. MemoEdit Save reads it to + * restart; it does not call this helper again. * * @param params - Preference-gated enable flags. - * @returns Refresher keys to pass to `scheduleBackgroundEvent`. + * @returns Ungated enabled refresher keys. */ export function resolveRefresherKeys( params: ResolveRefresherKeysParams, ): ConfirmationContextRefresherKey[] { const { enablePricing, enableSecurityScan, enableLocalSimulation } = params; - const refresherKeys: ConfirmationContextRefresherKey[] = []; + const enabledRefresherKeys: ConfirmationContextRefresherKey[] = []; if (enablePricing) { - refresherKeys.push(ConfirmationContextRefresherKey.Prices); + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Prices); } if (enableSecurityScan) { - refresherKeys.push(ConfirmationContextRefresherKey.Scan); + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Scan); } if (enableLocalSimulation) { - refresherKeys.push(ConfirmationContextRefresherKey.Transaction); + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Transaction); } - return refresherKeys; + return enabledRefresherKeys; +} + +/** + * Keys allowed to run on the open-path cron tick. + * + * Scan / Transaction are omitted when `renderContext` already marked them + * Error (e.g. RequiresMemo). Read those statuses from `renderContext`, not + * the merged dialog context: `defaultContext` only ever sets Fetched/Fetching. + * + * @param enabled - Ungated keys from {@link resolveRefresherKeys}. + * @param renderContext - Caller-supplied context that may include Error overrides. + * @returns Keys to pass to `scheduleBackgroundEvent` on dialog open. + */ +export function scheduledRefresherKeys( + enabled: ConfirmationContextRefresherKey[], + renderContext: { + scanFetchStatus?: FetchStatus; + transactionsFetchStatus?: FetchStatus; + }, +): ConfirmationContextRefresherKey[] { + return enabled.filter((key) => { + if ( + key === ConfirmationContextRefresherKey.Scan && + renderContext.scanFetchStatus === FetchStatus.Error + ) { + return false; + } + if ( + key === ConfirmationContextRefresherKey.Transaction && + renderContext.transactionsFetchStatus === FetchStatus.Error + ) { + return false; + } + return true; + }); } /** diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx index 9fc9e2ae9..ca57816e0 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx @@ -104,7 +104,7 @@ describe('MemoEdit event handlers', () => { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Fetched, - refresherKeys: allRefresherKeys, + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'latest-event', }); @@ -149,7 +149,7 @@ describe('MemoEdit event handlers', () => { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Error, - refresherKeys: transactionOnly, + enabledRefresherKeys: transactionOnly, backgroundEventId: 'live-event', }); @@ -203,7 +203,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memoScreen: true, - refresherKeys: allRefresherKeys, + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'in-flight-tick-event', }); @@ -247,7 +247,7 @@ describe('MemoEdit event handlers', () => { transactionsFetchStatus: FetchStatus.Fetched, scanFetchStatus: FetchStatus.Fetched, memo: 'exchange-ref', - refresherKeys: allRefresherKeys, + enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'live-event', }); @@ -311,7 +311,7 @@ describe('MemoEdit event handlers', () => { transaction: 'xdr', accountId: 'account-id', transactionsFetchStatus: FetchStatus.Error, - refresherKeys: [], + enabledRefresherKeys: [], }); await handlers[MemoEditFormNames.Form]?.({ diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index b2b29e60c..5fe5a885a 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -142,14 +142,14 @@ async function onSaveSubmit( }; const { scope } = baseContext; - const refresherKeys = Array.isArray(baseContext.refresherKeys) - ? (baseContext.refresherKeys as ConfirmationContextRefresherKey[]) + const enabledRefresherKeys = Array.isArray(baseContext.enabledRefresherKeys) + ? (baseContext.enabledRefresherKeys as ConfirmationContextRefresherKey[]) : []; if ( canRestartRefresh(baseContext) && typeof scope === 'string' && - refresherKeys.length > 0 + enabledRefresherKeys.length > 0 ) { let previousEventId: string | undefined; if (typeof baseContext.backgroundEventId === 'string') { @@ -163,10 +163,12 @@ async function onSaveSubmit( // Clear any prior banner; the restarted transaction refresher will set a // new error (e.g. RequiresMemo again) or leave it cleared on success. errorMessage: null, - ...(refresherKeys.includes(ConfirmationContextRefresherKey.Transaction) + ...(enabledRefresherKeys.includes( + ConfirmationContextRefresherKey.Transaction, + ) ? { transactionsFetchStatus: FetchStatus.Fetched } : {}), - ...(refresherKeys.includes(ConfirmationContextRefresherKey.Scan) + ...(enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Scan) ? { scanFetchStatus: FetchStatus.Fetching } : {}), }; @@ -177,7 +179,7 @@ async function onSaveSubmit( scope: scope as ConfirmSendJsonRpcRequest['params']['scope'], interfaceId: id, interfaceKey, - refresherKeys, + refresherKeys: enabledRefresherKeys, }, Duration.OneSecond, { replaceEventId: previousEventId }, From 47882da8058ce20a5fed7f05c96513ef6735954b Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Tue, 6 Oct 2026 22:34:43 +0200 Subject: [PATCH 09/18] refactor(stellar-wallet-snap): remove unused refresher utilities and simplify context refresh logic --- .../src/ui/confirmation/api.ts | 6 +- .../src/ui/confirmation/controller.test.tsx | 171 +----------------- .../src/ui/confirmation/controller.tsx | 62 +++++-- .../src/ui/confirmation/utils.test.ts | 100 ---------- .../src/ui/confirmation/utils.ts | 74 -------- .../views/MemoEdit/events.test.tsx | 33 ---- .../ui/confirmation/views/MemoEdit/events.tsx | 23 +-- 7 files changed, 60 insertions(+), 409 deletions(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index ec4ec4a10..cc69546ae 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -162,13 +162,13 @@ export type ConfirmationBaseProps = Partial & { supportsMemo?: boolean; /** * Pending `snap_scheduleBackgroundEvent` id for confirmation context refresh. - * Used to cancel-and-replace when restarting the refresh chain. + * Stored so callers can cancel-and-replace (bitcoin send-flow pattern) instead + * of stacking parallel refresh chains (e.g. MemoEdit Save vs open cron). */ backgroundEventId?: string; /** * Preference- and flow-enabled refresher keys resolved at dialog open. - * Ungated: MemoEdit Save reuses this snapshot to restart, including slices - * that were Error on open (e.g. RequiresMemo). + * MemoEdit Save reuses this snapshot for restart. */ enabledRefresherKeys?: string[]; }; diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx index b50551a1b..0cad26857 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.test.tsx @@ -1,90 +1,11 @@ import { KnownCaip2ChainId } from '../../api'; -import { ClientRequestMethod } from '../../handlers/clientRequest/api'; -import type { ConfirmSendJsonRpcRequest } from '../../handlers/clientRequest/api'; -import { - ConfirmationContextRefresherKey, - RefreshConfirmationContextHandler, -} from '../../handlers/cronjob/refreshConfirmationContext'; -import { - createInterface, - getPreferences, - showDialog, - updateInterfaceIfExists, -} from '../../utils'; -import { ConfirmationInterfaceKey, FetchStatus } from './api'; +import { ConfirmationInterfaceKey } from './api'; import { ConfirmationUXController } from './controller'; -import { renderConfirmationView } from './views/render'; - -jest.mock('../../utils', () => ({ - ...jest.requireActual('../../utils'), - createInterface: jest.fn(), - showDialog: jest.fn(), - updateInterfaceIfExists: jest.fn(), - getPreferences: jest.fn(), -})); - -jest.mock('./views/render', () => ({ - renderConfirmationView: jest.fn(() => 'RENDERED'), -})); - -const sendRequest = { - jsonrpc: '2.0', - id: '1', - method: ClientRequestMethod.ConfirmSend, - params: { - scope: KnownCaip2ChainId.Mainnet, - fromAccountId: 'account-id', - toAddress: 'GDTF7ERUQVTX23ZD6NY5XRYC5IQAKWFVTQ6IXSMEZWGVNDDGPYCVHRZP', - amount: '1', - assetId: 'stellar:pubnet/native', - }, -} as unknown as ConfirmSendJsonRpcRequest; - -const preferences = { - locale: 'en', - currency: 'usd', - hideBalances: false, - useSecurityAlerts: true, - simulateOnChainActions: true, - useTokenDetection: true, - batchCheckBalances: true, - displayNftMedia: true, - useNftDetection: true, - useExternalPricingData: true, - showTestnets: true, -}; - -const allEnabledKeys = [ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Transaction, -]; describe('ConfirmationUXController', () => { - const controller = new ConfirmationUXController(); - let scheduleSpy: jest.SpiedFunction< - typeof RefreshConfirmationContextHandler.scheduleBackgroundEvent - >; - - beforeEach(() => { - jest.clearAllMocks(); - jest.mocked(createInterface).mockResolvedValue('interface-id'); - jest.mocked(showDialog).mockResolvedValue(true); - jest.mocked(updateInterfaceIfExists).mockResolvedValue(true); - jest.mocked(getPreferences).mockResolvedValue(preferences); - jest.mocked(renderConfirmationView).mockReturnValue( - 'RENDERED' as unknown as ReturnType, - ); - scheduleSpy = jest - .spyOn(RefreshConfirmationContextHandler, 'scheduleBackgroundEvent') - .mockResolvedValue('open-event-id'); - }); - - afterEach(() => { - scheduleSpy.mockRestore(); - }); - it('throws when transaction scanning is enabled without a security scan request', async () => { + const controller = new ConfirmationUXController(); + await expect( controller.renderConfirmationDialog({ scope: KnownCaip2ChainId.Mainnet, @@ -97,90 +18,4 @@ describe('ConfirmationUXController', () => { 'Cannot scan a transaction confirmation without a security scan request.', ); }); - - it('persists enabled keys on the first context write and schedules them', async () => { - await controller.renderConfirmationDialog({ - scope: KnownCaip2ChainId.Mainnet, - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - fee: '100', - renderContext: {}, - renderOptions: { - loadPrice: true, - securityScanning: true, - localSimulation: true, - }, - securityScanRequest: { - accountAddress: 'GABC', - transaction: 'xdr', - }, - transactionValidationRequest: { - accountId: 'account-id', - transaction: 'xdr', - request: sendRequest, - }, - }); - - expect(createInterface).toHaveBeenCalledWith('RENDERED', {}); - expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( - 1, - 'interface-id', - 'RENDERED', - expect.objectContaining({ enabledRefresherKeys: allEnabledKeys }), - ); - expect(scheduleSpy).toHaveBeenCalledWith( - expect.objectContaining({ - interfaceId: 'interface-id', - refresherKeys: allEnabledKeys, - }), - expect.anything(), - ); - expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( - 2, - 'interface-id', - 'RENDERED', - expect.objectContaining({ - enabledRefresherKeys: allEnabledKeys, - backgroundEventId: 'open-event-id', - }), - ); - }); - - it('omits Error slices from the open cron while persisting them as enabled', async () => { - await controller.renderConfirmationDialog({ - scope: KnownCaip2ChainId.Mainnet, - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - fee: '100', - renderContext: { - scanFetchStatus: FetchStatus.Error, - transactionsFetchStatus: FetchStatus.Error, - }, - renderOptions: { - loadPrice: true, - securityScanning: true, - localSimulation: true, - }, - securityScanRequest: { - accountAddress: 'GABC', - transaction: 'xdr', - }, - transactionValidationRequest: { - accountId: 'account-id', - transaction: 'xdr', - request: sendRequest, - }, - }); - - expect(updateInterfaceIfExists).toHaveBeenNthCalledWith( - 1, - 'interface-id', - 'RENDERED', - expect.objectContaining({ enabledRefresherKeys: allEnabledKeys }), - ); - expect(scheduleSpy).toHaveBeenCalledWith( - expect.objectContaining({ - refresherKeys: [ConfirmationContextRefresherKey.Prices], - }), - expect.anything(), - ); - }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx index 91e5a36f4..f0b39af7b 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/controller.tsx @@ -6,7 +6,10 @@ import type { ChangeTrustOptJsonRpcRequest, ConfirmSendJsonRpcRequest, } from '../../handlers/clientRequest/api'; -import { RefreshConfirmationContextHandler } from '../../handlers/cronjob/refreshConfirmationContext'; +import { + ConfirmationContextRefresherKey, + RefreshConfirmationContextHandler, +} from '../../handlers/cronjob/refreshConfirmationContext'; import type { SecurityScanRequest, TransactionScanResult, @@ -27,8 +30,6 @@ import { formatFeeData, formatOrigin, getPreferencesWithFallback, - resolveRefresherKeys, - scheduledRefresherKeys, } from './utils'; import { renderConfirmationView } from './views/render'; import type { ConfirmationViewProps } from './views/render'; @@ -238,17 +239,17 @@ export class ConfirmationUXController { tokenPrices, }; - // Ungated enable set for MemoEdit Save. Open cron uses Error-gated - // scheduledKeys so RequiresMemo does not start Scan/Tx until Save. - const enabledRefresherKeys = resolveRefresherKeys({ - enablePricing: enablePricing ?? false, - enableSecurityScan, - enableLocalSimulation: enableLocalSimulation ?? false, - }); - const scheduledKeys = scheduledRefresherKeys( - enabledRefresherKeys, - renderContext, - ); + // Preference/flow-enabled keys for MemoEdit Save (ungated). + const enabledRefresherKeys: ConfirmationContextRefresherKey[] = []; + if (enablePricing) { + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Prices); + } + if (enableSecurityScan) { + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Scan); + } + if (enableLocalSimulation) { + enabledRefresherKeys.push(ConfirmationContextRefresherKey.Transaction); + } // 1. Initial context with loading state const context = { @@ -276,7 +277,30 @@ export class ConfirmationUXController { return dialogPromise; } - // 5. Schedule the open cron for this-tick keys only. + // 5. Schedule background context refresh for this-tick keys only. + // Skip Scan / Transaction when renderContext already marked them Error + // (e.g. RequiresMemo on open): a pending open cron would race + // MemoEdit's restart and double-hit Blockaid after the user saves a memo. + // Read the Error overrides from `renderContext` (not merged `context`): + // `defaultContext` only ever sets Fetched/Fetching, so TS narrows those + // fields and rejects a comparison against Error on the merged object. + const scheduledKeys: ConfirmationContextRefresherKey[] = []; + if (enablePricing) { + scheduledKeys.push(ConfirmationContextRefresherKey.Prices); + } + if ( + enableSecurityScan && + renderContext.scanFetchStatus !== FetchStatus.Error + ) { + scheduledKeys.push(ConfirmationContextRefresherKey.Scan); + } + if ( + enableLocalSimulation && + renderContext.transactionsFetchStatus !== FetchStatus.Error + ) { + scheduledKeys.push(ConfirmationContextRefresherKey.Transaction); + } + if (scheduledKeys.length > 0) { const backgroundEventId = await RefreshConfirmationContextHandler.scheduleBackgroundEvent( @@ -288,11 +312,9 @@ export class ConfirmationUXController { }, Duration.OneSecond, ); - const contextWithEventId = { - ...context, - enabledRefresherKeys, - backgroundEventId, - }; + // Persist the pending event id so MemoEdit (or a later replace) can cancel + // it before scheduling another chain — bitcoin send-flow pattern. + const contextWithEventId = { ...context, backgroundEventId }; await updateInterfaceIfExists( id, renderConfirmationView(interfaceKey, contextWithEventId), diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts index 15db578c0..c93cf8c40 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.test.ts @@ -1,5 +1,4 @@ import { KnownCaip2ChainId } from '../../api'; -import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { FieldType } from '../../services/transaction'; import type { ReadableOperationField } from '../../services/transaction'; import { TransactionScanValidationType } from '../../services/transaction-scan'; @@ -19,8 +18,6 @@ import { isRemoteTransactionScanLoading, requiresMaliciousAcknowledgement, resolveConfirmationBanner, - resolveRefresherKeys, - scheduledRefresherKeys, shouldDisableConfirmation, } from './utils'; @@ -374,101 +371,4 @@ describe('confirmation utils', () => { ).toStrictEqual([{ key: 'salt', value: 'aa', type: FieldType.copyable }]); }); }); - - describe('resolveRefresherKeys', () => { - it('returns all keys when pricing, scan, and local simulation are enabled', () => { - expect( - resolveRefresherKeys({ - enablePricing: true, - enableSecurityScan: true, - enableLocalSimulation: true, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Transaction, - ]); - }); - - it('omits prices when pricing is disabled', () => { - expect( - resolveRefresherKeys({ - enablePricing: false, - enableSecurityScan: true, - enableLocalSimulation: true, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Transaction, - ]); - }); - - it('omits scan when security scanning is disabled', () => { - expect( - resolveRefresherKeys({ - enablePricing: true, - enableSecurityScan: false, - enableLocalSimulation: true, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Transaction, - ]); - }); - - it('omits transaction when local simulation is disabled', () => { - expect( - resolveRefresherKeys({ - enablePricing: true, - enableSecurityScan: true, - enableLocalSimulation: false, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Scan, - ]); - }); - - it('returns an empty list when nothing is enabled', () => { - expect( - resolveRefresherKeys({ - enablePricing: false, - enableSecurityScan: false, - enableLocalSimulation: false, - }), - ).toStrictEqual([]); - }); - }); - - describe('scheduledRefresherKeys', () => { - const enabled = [ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Scan, - ConfirmationContextRefresherKey.Transaction, - ]; - - it('returns enabled keys when renderContext has no Error statuses', () => { - expect(scheduledRefresherKeys(enabled, {})).toStrictEqual(enabled); - }); - - it('omits scan and transaction when renderContext marked them Error', () => { - expect( - scheduledRefresherKeys(enabled, { - scanFetchStatus: FetchStatus.Error, - transactionsFetchStatus: FetchStatus.Error, - }), - ).toStrictEqual([ConfirmationContextRefresherKey.Prices]); - }); - - it('omits only scan when scanFetchStatus is Error', () => { - expect( - scheduledRefresherKeys(enabled, { - scanFetchStatus: FetchStatus.Error, - }), - ).toStrictEqual([ - ConfirmationContextRefresherKey.Prices, - ConfirmationContextRefresherKey.Transaction, - ]); - }); - }); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts index e69542c2c..fd676983e 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/utils.ts @@ -5,7 +5,6 @@ import { BigNumber } from 'bignumber.js'; import type { KnownCaip19AssetIdOrSlip44Id } from '../../api'; import { KnownCaip2ChainId } from '../../api'; import { AppConfig } from '../../config'; -import { ConfirmationContextRefresherKey } from '../../handlers/cronjob/refreshConfirmationContext'; import { getNativeAssetMetadata } from '../../services/asset-metadata/utils'; import { TransactionScanValidationType } from '../../services/transaction-scan'; import type { TransactionScanResult } from '../../services/transaction-scan'; @@ -119,79 +118,6 @@ export async function getPreferencesWithFallback(): Promise { - if ( - key === ConfirmationContextRefresherKey.Scan && - renderContext.scanFetchStatus === FetchStatus.Error - ) { - return false; - } - if ( - key === ConfirmationContextRefresherKey.Transaction && - renderContext.transactionsFetchStatus === FetchStatus.Error - ) { - return false; - } - return true; - }); -} - /** * Gets the account explorer url for a given account address. * diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx index ca57816e0..39561e6cd 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx @@ -304,39 +304,6 @@ describe('MemoEdit event handlers', () => { ); }); - it('persists the memo without rescheduling when no refresher keys were enabled at open', async () => { - jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Error, - enabledRefresherKeys: [], - }); - - await handlers[MemoEditFormNames.Form]?.({ - id: 'interface-id', - event: formSubmitEvent('no-refreshers'), - context: { - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Error, - }, - }); - - expect(scheduleSpy).not.toHaveBeenCalled(); - expect(updateInterfaceIfExists).toHaveBeenCalledWith( - 'interface-id', - 'RENDERED', - expect.objectContaining({ - memo: 'no-refreshers', - memoScreen: false, - }), - ); - }); - it('keeps the memo and shows validation error without rescheduling', async () => { await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index 5fe5a885a..a973b20b6 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -158,20 +158,21 @@ async function onSaveSubmit( previousEventId = context.backgroundEventId; } - const refreshedContext = { + // Clear any prior banner; the restarted transaction refresher will set a + // new error (e.g. RequiresMemo again) or leave it cleared on success. + // Only reset slices that were preference-enabled at open. + const refreshedContext: Record = { ...nextContext, - // Clear any prior banner; the restarted transaction refresher will set a - // new error (e.g. RequiresMemo again) or leave it cleared on success. errorMessage: null, - ...(enabledRefresherKeys.includes( - ConfirmationContextRefresherKey.Transaction, - ) - ? { transactionsFetchStatus: FetchStatus.Fetched } - : {}), - ...(enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Scan) - ? { scanFetchStatus: FetchStatus.Fetching } - : {}), }; + if ( + enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Transaction) + ) { + refreshedContext.transactionsFetchStatus = FetchStatus.Fetched; + } + if (enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Scan)) { + refreshedContext.scanFetchStatus = FetchStatus.Fetching; + } const backgroundEventId = await RefreshConfirmationContextHandler.scheduleBackgroundEvent( From 3c7a1f4be99bd8a95a62966ebba98ca6f8ef6c8a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 09:50:58 +0200 Subject: [PATCH 10/18] feat(stellar-wallet-snap): keep memo draft pending save and handle save failures --- packages/stellar-wallet-snap/CHANGELOG.md | 4 + packages/stellar-wallet-snap/locales/en.json | 3 + packages/stellar-wallet-snap/locales/es.json | 3 + .../src/ui/confirmation/api.ts | 2 + .../views/MemoEdit/events.test.tsx | 145 +++++++++++------- .../ui/confirmation/views/MemoEdit/events.tsx | 131 +++++++--------- .../src/ui/confirmation/views/render.tsx | 2 +- 7 files changed, 162 insertions(+), 128 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index deeedbfb0..21d7096a1 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Emit `transaction_type` on the transaction lifecycle events. ([#406](https://github.com/MetaMask/internal-snaps/pull/406)) +### Fixed + +- Keep in-progress memo edits in `memoDraft` so Back does not commit unsaved memo text on send confirmation. + ## [1.1.1] ### Fixed diff --git a/packages/stellar-wallet-snap/locales/en.json b/packages/stellar-wallet-snap/locales/en.json index 23e13054a..b499a2491 100644 --- a/packages/stellar-wallet-snap/locales/en.json +++ b/packages/stellar-wallet-snap/locales/en.json @@ -127,6 +127,9 @@ "confirmation.memo.error.tooLong": { "message": "Memo must be 28 bytes or fewer" }, + "confirmation.memo.error.saveFailed": { + "message": "Could not save memo. Please try again." + }, "confirmation.asset": { "message": "Asset" }, diff --git a/packages/stellar-wallet-snap/locales/es.json b/packages/stellar-wallet-snap/locales/es.json index a0f6519c9..7c9e29c25 100644 --- a/packages/stellar-wallet-snap/locales/es.json +++ b/packages/stellar-wallet-snap/locales/es.json @@ -127,6 +127,9 @@ "confirmation.memo.error.tooLong": { "message": "Memo must be 28 bytes or fewer" }, + "confirmation.memo.error.saveFailed": { + "message": "No se pudo guardar el memo. Inténtalo de nuevo." + }, "confirmation.asset": { "message": "Asset" }, diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts index cc69546ae..696146b1e 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/api.ts +++ b/packages/stellar-wallet-snap/src/ui/confirmation/api.ts @@ -156,6 +156,8 @@ export type ConfirmationBaseProps = Partial & { memoScreen?: boolean; // Saved memo for the pending send (UI-owned; not on confirmSend RPC params). memo?: string; + // In-progress memo edit text (not committed until Save succeeds). + memoDraft?: string | null; // Locale key for memo validation errors on the edit screen. memoError?: LocalizedMessage | null; // When false, hide Add/Update memo controls. diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx index 39561e6cd..a8a42f934 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx @@ -48,6 +48,16 @@ const scheduleArgs = { refresherKeys: allRefresherKeys, }; +const liveSendContext = { + interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, + scope: 'stellar:pubnet', + transaction: 'xdr', + accountId: 'account-id', + transactionsFetchStatus: FetchStatus.Fetched, + enabledRefresherKeys: allRefresherKeys, + backgroundEventId: 'live-event', +}; + describe('MemoEdit event handlers', () => { const handlers = createEventHandlers(); let scheduleSpy: jest.SpiedFunction< @@ -80,6 +90,7 @@ describe('MemoEdit event handlers', () => { interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, errorMessage: 'confirmation.txnError.requiresMemo', backgroundEventId: 'live-event', + memoDraft: 'stale-draft', }, }); @@ -90,6 +101,7 @@ describe('MemoEdit event handlers', () => { expect.objectContaining({ memoScreen: true, memoError: null, + memoDraft: null, backgroundEventId: 'live-event', }), ); @@ -99,12 +111,7 @@ describe('MemoEdit event handlers', () => { describe('Save', () => { it('restarts open-time enabled refresher keys using the latest event id', async () => { jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Fetched, - enabledRefresherKeys: allRefresherKeys, + ...liveSendContext, backgroundEventId: 'latest-event', }); @@ -112,10 +119,7 @@ describe('MemoEdit event handlers', () => { id: 'interface-id', event: formSubmitEvent('exchange-ref'), context: { - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', + ...liveSendContext, transactionsFetchStatus: FetchStatus.Error, errorMessage: 'confirmation.txnError.requiresMemo', backgroundEventId: 'stale-click-event', @@ -132,6 +136,7 @@ describe('MemoEdit event handlers', () => { 'RENDERED', expect.objectContaining({ memo: 'exchange-ref', + memoDraft: null, memoScreen: false, errorMessage: null, transactionsFetchStatus: FetchStatus.Fetched, @@ -144,25 +149,17 @@ describe('MemoEdit event handlers', () => { it('schedules only preference-enabled keys from the open-time snapshot', async () => { const transactionOnly = [ConfirmationContextRefresherKey.Transaction]; jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', + ...liveSendContext, transactionsFetchStatus: FetchStatus.Error, enabledRefresherKeys: transactionOnly, - backgroundEventId: 'live-event', }); await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', event: formSubmitEvent('memo-only-tx'), context: { - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', + ...liveSendContext, transactionsFetchStatus: FetchStatus.Error, - backgroundEventId: 'live-event', }, }); @@ -196,14 +193,9 @@ describe('MemoEdit event handlers', () => { // Click/open still see the in-flight tick id; Save must cancel that id and // persist a new one so the orphan tick observes ownership loss. jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Fetched, + ...liveSendContext, scanFetchStatus: FetchStatus.Fetched, memoScreen: true, - enabledRefresherKeys: allRefresherKeys, backgroundEventId: 'in-flight-tick-event', }); @@ -211,11 +203,7 @@ describe('MemoEdit event handlers', () => { id: 'interface-id', event: formSubmitEvent('during-flight'), context: { - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Fetched, + ...liveSendContext, scanFetchStatus: FetchStatus.Fetched, memoScreen: true, backgroundEventId: 'in-flight-tick-event', @@ -240,29 +228,18 @@ describe('MemoEdit event handlers', () => { it('restarts when clearing a memo so Confirm cannot stay enabled on stale success', async () => { jest.mocked(getInterfaceContextIfExists).mockResolvedValue({ - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Fetched, + ...liveSendContext, scanFetchStatus: FetchStatus.Fetched, memo: 'exchange-ref', - enabledRefresherKeys: allRefresherKeys, - backgroundEventId: 'live-event', }); await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', event: formSubmitEvent(''), context: { - interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - scope: 'stellar:pubnet', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Fetched, + ...liveSendContext, scanFetchStatus: FetchStatus.Fetched, memo: 'exchange-ref', - backgroundEventId: 'live-event', }, }); @@ -283,7 +260,31 @@ describe('MemoEdit event handlers', () => { ); }); - it('persists the memo without rescheduling when there is no live refresh pipeline', async () => { + it('keeps a draft and shows validation error without committing memo or rescheduling', async () => { + await handlers[MemoEditFormNames.Form]?.({ + id: 'interface-id', + event: formSubmitEvent('é'.repeat(15)), + context: { + ...liveSendContext, + memo: 'committed-memo', + errorMessage: 'confirmation.txnError.requiresMemo', + transactionsFetchStatus: FetchStatus.Error, + }, + }); + + expect(scheduleSpy).not.toHaveBeenCalled(); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.objectContaining({ + memo: 'committed-memo', + memoDraft: 'é'.repeat(15), + memoError: 'confirmation.memo.error.tooLong', + }), + ); + }); + + it('shows a save error when context cannot restart refresh', async () => { await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', event: formSubmitEvent('optional-ref'), @@ -298,32 +299,66 @@ describe('MemoEdit event handlers', () => { 'interface-id', 'RENDERED', expect.objectContaining({ + memoScreen: true, + memoDraft: 'optional-ref', + memoError: 'confirmation.memo.error.saveFailed', + }), + ); + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.not.objectContaining({ memo: 'optional-ref', - memoScreen: false, }), ); }); - it('keeps the memo and shows validation error without rescheduling', async () => { + it('shows a save error when scheduling fails', async () => { + jest + .mocked(getInterfaceContextIfExists) + .mockResolvedValue(liveSendContext); + scheduleSpy.mockRejectedValueOnce(new Error('schedule failed')); + await handlers[MemoEditFormNames.Form]?.({ id: 'interface-id', - event: formSubmitEvent('é'.repeat(15)), + event: formSubmitEvent('exchange-ref'), + context: liveSendContext, + }); + + expect(updateInterfaceIfExists).toHaveBeenCalledWith( + 'interface-id', + 'RENDERED', + expect.objectContaining({ + memoScreen: true, + memoDraft: 'exchange-ref', + memoError: 'confirmation.memo.error.saveFailed', + }), + ); + }); + }); + + describe('Back', () => { + it('closes the editor without committing the draft memo', async () => { + await handlers[MemoEditFormNames.Back]?.({ + id: 'interface-id', + event: buttonEvent(MemoEditFormNames.Back), context: { interfaceKey: ConfirmationInterfaceKey.ConfirmSendTransaction, - errorMessage: 'confirmation.txnError.requiresMemo', - transaction: 'xdr', - accountId: 'account-id', - transactionsFetchStatus: FetchStatus.Error, + memo: 'committed-memo', + memoDraft: 'unsaved-draft', + memoError: 'confirmation.memo.error.tooLong', + memoScreen: true, }, }); - expect(scheduleSpy).not.toHaveBeenCalled(); expect(updateInterfaceIfExists).toHaveBeenCalledWith( 'interface-id', 'RENDERED', expect.objectContaining({ - memo: 'é'.repeat(15), - memoError: 'confirmation.memo.error.tooLong', + memo: 'committed-memo', + memoDraft: null, + memoError: null, + memoScreen: false, }), ); }); diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index a973b20b6..600abf347 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -1,12 +1,21 @@ -import { FormSubmitEventStruct } from '@metamask/snaps-sdk'; import type { UserInputEvent } from '@metamask/snaps-sdk'; +import { FormSubmitEventStruct } from '@metamask/snaps-sdk'; +import { + array, + assert, + nonempty, + optional, + string, + type, +} from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; -import type { ConfirmSendJsonRpcRequest } from '../../../../handlers/clientRequest/api'; +import { KnownCaip2ChainIdStruct } from '../../../../api'; import { getMemoValidationErrorKey } from '../../../../handlers/clientRequest/utils'; import type { ConfirmationDataContext } from '../../../../handlers/cronjob/refreshConfirmationContext'; import { ConfirmationContextRefresherKey, + ConfirmationContextRefresherKeyStruct, RefreshConfirmationContextHandler, } from '../../../../handlers/cronjob/refreshConfirmationContext'; import type { @@ -19,10 +28,20 @@ import { updateInterfaceIfExists, } from '../../../../utils'; import type { ConfirmationInterfaceKey } from '../../api'; -import { FetchStatus } from '../../api'; +import { ConfirmationInterfaceKeyStruct, FetchStatus } from '../../api'; import { renderConfirmationView } from '../render'; import { MemoEditFormNames } from './constants'; +/** + * Context fields required to save a memo and restart confirmation refresh. + */ +const MemoSaveContextStruct = type({ + scope: KnownCaip2ChainIdStruct, + interfaceKey: ConfirmationInterfaceKeyStruct, + backgroundEventId: optional(string()), + enabledRefresherKeys: nonempty(array(ConfirmationContextRefresherKeyStruct)), +}); + /** * Re-renders the interface with a patched context. * @@ -44,23 +63,6 @@ async function reRender( ); } -/** - * Whether this confirmation has a live refresh pipeline to restart. - * - * Hard pre-submit error dialogs have `request` but no transaction / - * localSimulation refresh — still allow saving the memo onto context. - * - * @param context - Confirmation interface context. - * @returns True when Save should cancel-and-replace the refresh pipeline. - */ -function canRestartRefresh(context: Record): boolean { - return ( - typeof context.transaction === 'string' && - typeof context.accountId === 'string' && - context.transactionsFetchStatus !== undefined - ); -} - /** * Opens the memo edit screen from send confirmation. * @@ -76,6 +78,7 @@ async function onOpenClick( await reRender(id, context, { memoScreen: true, memoError: null, + memoDraft: null, }); } @@ -96,8 +99,8 @@ function memoFromSubmitEvent(event: UserInputEvent): string { /** * Saves the memo onto confirmation context and restarts the open-time - * preference-enabled refreshers when a live refresh pipeline is present, so - * clearing a required memo cannot leave Confirm enabled on a stale success. + * preference-enabled refreshers so clearing a required memo cannot leave + * Confirm enabled on a stale success. * * @param options - The user input handler context. */ @@ -112,97 +115,80 @@ async function onSaveSubmit( const memo = memoFromSubmitEvent(event); const memoValidationError = getMemoValidationErrorKey(memo); if (memoValidationError) { - // Keep the submitted text in `memo` so the input is not reset when we - // re-render with the validation error. + // Keep the submitted text in `memoDraft` so the input is not reset, without + // committing it as the saved memo (Back must not persist drafts). await reRender(id, context, { - memo, + memoDraft: memo, memoError: memoValidationError, }); return; } - const { interfaceKey } = context as { - interfaceKey?: ConfirmationInterfaceKey; - }; - if (!interfaceKey) { - return; - } - // Prefer the latest interface snapshot — click-time `context` can lag a // prices/scan write between Open and Save. const interfaceContext = await getInterfaceContextIfExists(id); const baseContext = interfaceContext ?? context; - const nextContext = { - ...baseContext, - memo, - memoScreen: false, - memoError: null, - }; + try { + assert(baseContext, MemoSaveContextStruct); - const { scope } = baseContext; - const enabledRefresherKeys = Array.isArray(baseContext.enabledRefresherKeys) - ? (baseContext.enabledRefresherKeys as ConfirmationContextRefresherKey[]) - : []; - - if ( - canRestartRefresh(baseContext) && - typeof scope === 'string' && - enabledRefresherKeys.length > 0 - ) { - let previousEventId: string | undefined; - if (typeof baseContext.backgroundEventId === 'string') { - previousEventId = baseContext.backgroundEventId; - } else if (typeof context.backgroundEventId === 'string') { - previousEventId = context.backgroundEventId; - } + const { scope, interfaceKey, backgroundEventId, enabledRefresherKeys } = + baseContext; - // Clear any prior banner; the restarted transaction refresher will set a - // new error (e.g. RequiresMemo again) or leave it cleared on success. // Only reset slices that were preference-enabled at open. - const refreshedContext: Record = { - ...nextContext, + const nextContext: Record = { + ...baseContext, + memo, + memoDraft: null, + memoScreen: false, + memoError: null, + // Clear any prior banner; the restarted transaction refresher will set a + // new error (e.g. RequiresMemo again) or leave it cleared on success. errorMessage: null, }; if ( enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Transaction) ) { - refreshedContext.transactionsFetchStatus = FetchStatus.Fetched; + nextContext.transactionsFetchStatus = FetchStatus.Fetched; } if (enabledRefresherKeys.includes(ConfirmationContextRefresherKey.Scan)) { - refreshedContext.scanFetchStatus = FetchStatus.Fetching; + nextContext.scanFetchStatus = FetchStatus.Fetching; } - const backgroundEventId = + const nextBackgroundEventId = await RefreshConfirmationContextHandler.scheduleBackgroundEvent( { - scope: scope as ConfirmSendJsonRpcRequest['params']['scope'], + scope, interfaceId: id, interfaceKey, refresherKeys: enabledRefresherKeys, }, Duration.OneSecond, - { replaceEventId: previousEventId }, + { replaceEventId: backgroundEventId }, ); - const contextWithEventId = { ...refreshedContext, backgroundEventId }; + + const contextWithEventId = { + ...nextContext, + backgroundEventId: nextBackgroundEventId, + }; await updateInterfaceIfExists( id, renderConfirmationView(interfaceKey, contextWithEventId), contextWithEventId, ); - return; + } catch { + await reRender(id, context, { + memoScreen: true, + memoDraft: memo, + memoError: 'confirmation.memo.error.saveFailed', + }); } - - await updateInterfaceIfExists( - id, - renderConfirmationView(interfaceKey, nextContext), - nextContext, - ); } /** - * Returns from the memo edit screen to the confirmation view. + * Returns from the memo edit screen to the confirmation view without + * committing any in-progress draft. * * @param options - The user input handler context. */ @@ -216,6 +202,7 @@ async function onBackClick( await reRender(id, context, { memoScreen: false, memoError: null, + memoDraft: null, }); } diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx index 62f6d4280..c9646fe56 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/render.tsx @@ -50,7 +50,7 @@ export function renderConfirmationView( return ( ); From 1020e5d9a4dae5a12b906156776f2d0b931190f3 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 10:06:44 +0200 Subject: [PATCH 11/18] chore: changelog --- packages/stellar-wallet-snap/CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 21d7096a1..04ca3e792 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Emit `transaction_type` on the transaction lifecycle events. ([#406](https://github.com/MetaMask/internal-snaps/pull/406)) - ### Fixed -- Keep in-progress memo edits in `memoDraft` so Back does not commit unsaved memo text on send confirmation. +- Keep in-progress memo edits in `memoDraft` so Back does not commit unsaved memo text on send confirmation. ([#406](https://github.com/MetaMask/internal-snaps/pull/406)) ## [1.1.1] From f215b559caa6f5fe31117df8ff07cde535e60914 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 10:09:33 +0200 Subject: [PATCH 12/18] chore: lint --- packages/stellar-wallet-snap/locales/es.json | 4 ++-- packages/stellar-wallet-snap/snap.manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stellar-wallet-snap/locales/es.json b/packages/stellar-wallet-snap/locales/es.json index 7c9e29c25..3ca0597ea 100644 --- a/packages/stellar-wallet-snap/locales/es.json +++ b/packages/stellar-wallet-snap/locales/es.json @@ -128,7 +128,7 @@ "message": "Memo must be 28 bytes or fewer" }, "confirmation.memo.error.saveFailed": { - "message": "No se pudo guardar el memo. Inténtalo de nuevo." + "message": "Could not save memo. Please try again." }, "confirmation.asset": { "message": "Asset" @@ -398,7 +398,7 @@ "message": "Function" }, "confirmation.transaction.param.deployer": { - "message": "Desplegador" + "message": "Deployer" }, "confirmation.transaction.param.salt": { "message": "Salt" diff --git a/packages/stellar-wallet-snap/snap.manifest.json b/packages/stellar-wallet-snap/snap.manifest.json index 6a776c3e0..6e12b8a3d 100644 --- a/packages/stellar-wallet-snap/snap.manifest.json +++ b/packages/stellar-wallet-snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/internal-snaps.git" }, "source": { - "shasum": "eg+qBBJJLJlCDY5AN5nxbfALiNMagJXLjmLji6aBXGg=", + "shasum": "xiICxYOtACh+8NGSlN5uaSRrOo/Lx5KkXYwMsZKbzyw=", "location": { "npm": { "filePath": "dist/bundle.js", From 3898233687be384adc7d74ca0a29926816354ede Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 10:18:29 +0200 Subject: [PATCH 13/18] chore: lint --- packages/stellar-wallet-snap/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 75e56148b..dff6481f5 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,17 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) + - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. + ### Fixed - Keep in-progress memo edits in `memoDraft` so Back does not commit unsaved memo text on send confirmation. ([#406](https://github.com/MetaMask/internal-snaps/pull/406)) - Read asset metadata from AssetsController when feature flag `enableAssetMigration` and local flag `enableAssetMigration` is on ([#493](https://github.com/MetaMask/internal-snaps/pull/493)) - `enableAssetMigration` set to off. -### Changed - -- **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. - ## [1.3.0] ### Added From 6b312b0d80165e29a00c68adda0bf8e62fb638c4 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 10:35:29 +0200 Subject: [PATCH 14/18] chore: lint --- packages/stellar-wallet-snap/messages.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/stellar-wallet-snap/messages.json b/packages/stellar-wallet-snap/messages.json index f9b276d09..46edb4658 100644 --- a/packages/stellar-wallet-snap/messages.json +++ b/packages/stellar-wallet-snap/messages.json @@ -125,6 +125,9 @@ "confirmation.memo.error.tooLong": { "message": "Memo must be 28 bytes or fewer" }, + "confirmation.memo.error.saveFailed": { + "message": "Could not save memo. Please try again." + }, "confirmation.asset": { "message": "Asset" }, From 2c771538bf1580137459a89dd13d99eefa16aa8e Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 11:14:09 +0200 Subject: [PATCH 15/18] chore: lint test --- .../views/MemoEdit/events.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx index a8a42f934..9844bf08d 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.test.tsx @@ -106,6 +106,16 @@ describe('MemoEdit event handlers', () => { }), ); }); + + it('no-ops when context is missing', async () => { + await handlers[MemoEditFormNames.Open]?.({ + id: 'interface-id', + event: buttonEvent(MemoEditFormNames.Open), + context: null, + }); + + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); }); describe('Save', () => { @@ -335,6 +345,17 @@ describe('MemoEdit event handlers', () => { }), ); }); + + it('no-ops when context is missing', async () => { + await handlers[MemoEditFormNames.Form]?.({ + id: 'interface-id', + event: formSubmitEvent('exchange-ref'), + context: null, + }); + + expect(scheduleSpy).not.toHaveBeenCalled(); + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); }); describe('Back', () => { @@ -362,5 +383,15 @@ describe('MemoEdit event handlers', () => { }), ); }); + + it('no-ops when context is missing', async () => { + await handlers[MemoEditFormNames.Back]?.({ + id: 'interface-id', + event: buttonEvent(MemoEditFormNames.Back), + context: null, + }); + + expect(updateInterfaceIfExists).not.toHaveBeenCalled(); + }); }); }); From a357d20acd7affa9f3e7d4592892f13e2dd6de5a Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 13:07:34 +0200 Subject: [PATCH 16/18] chore: changelog --- packages/stellar-wallet-snap/CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index dff6481f5..887f8c738 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -7,16 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Read asset metadata from AssetsController when feature flag `enableAssetMigration` and local flag `enableAssetMigration` is on ([#493](https://github.com/MetaMask/internal-snaps/pull/493)) + - `enableAssetMigration` set to off. + ### Changed -- **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) +- **BREAKING:** Return classic keyring balance metadata `l +imit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. ### Fixed - Keep in-progress memo edits in `memoDraft` so Back does not commit unsaved memo text on send confirmation. ([#406](https://github.com/MetaMask/internal-snaps/pull/406)) -- Read asset metadata from AssetsController when feature flag `enableAssetMigration` and local flag `enableAssetMigration` is on ([#493](https://github.com/MetaMask/internal-snaps/pull/493)) - - `enableAssetMigration` set to off. ## [1.3.0] From e9fba5308d75a36e87aa06c3cf0007253d71fdf7 Mon Sep 17 00:00:00 2001 From: Florin Dzeladini Date: Thu, 22 Oct 2026 13:39:28 +0200 Subject: [PATCH 17/18] chore: changelog --- packages/stellar-wallet-snap/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stellar-wallet-snap/CHANGELOG.md b/packages/stellar-wallet-snap/CHANGELOG.md index 887f8c738..78da1743c 100644 --- a/packages/stellar-wallet-snap/CHANGELOG.md +++ b/packages/stellar-wallet-snap/CHANGELOG.md @@ -14,8 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** Return classic keyring balance metadata `l -imit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) +- **BREAKING:** Return classic keyring balance metadata `limit` in integer stroop string ([#495](https://github.com/MetaMask/internal-snaps/pull/495)) - Keyring method `getAccountBalances` and the Keyring event `AccountBalancesUpdated` now return this `limit` in stroops. ### Fixed From 04aeacf9fde0dcb46a7004fc84c47d3a89fb5331 Mon Sep 17 00:00:00 2001 From: khanti42 Date: Thu, 22 Oct 2026 14:17:26 +0200 Subject: [PATCH 18/18] fix: reRender call to use baseContext Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/ui/confirmation/views/MemoEdit/events.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx index 600abf347..d5712c230 100644 --- a/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx +++ b/packages/stellar-wallet-snap/src/ui/confirmation/views/MemoEdit/events.tsx @@ -178,7 +178,7 @@ async function onSaveSubmit( contextWithEventId, ); } catch { - await reRender(id, context, { + await reRender(id, baseContext, { memoScreen: true, memoDraft: memo, memoError: 'confirmation.memo.error.saveFailed',