From 04dc4c257a906eb0cec1a5daf850636aa330e102 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:29:56 -0600 Subject: [PATCH 01/10] fix: correct autoramp identity merge, KYC vendor id, and unref safety --- packages/kyc-controller/src/KycController.ts | 21 ++++++++++++++-- .../ramps-controller/src/NeoBankService.ts | 5 +++- .../ramps-controller/src/RampsController.ts | 24 +++++++++++++++---- .../ramps-controller/src/autorampAccount.ts | 11 +++++++-- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 29e7f21e12..6a4d98520c 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -633,6 +633,12 @@ export class KycController extends BaseController< state.email = params.email; } state.activeVendor = vendor; + // `moonpayCustomerId` is only ever issued by the MoonPay Check / Auth + // frames. Leaving it set while the flow switches to another vendor would + // make `getCustomerIdentity` report a MoonPay id under the wrong vendor. + if (vendor !== 'moonpay') { + state.moonpayCustomerId = null; + } state.activeProduct = params?.product ?? null; }); @@ -703,6 +709,9 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.email = params.email; state.activeVendor = 'iron'; + // See `initialize`: a MoonPay-issued customer id must not survive a + // switch to Iron, or `getCustomerIdentity` reports the wrong vendor. + state.moonpayCustomerId = null; }); const generation = this.#generation; try { @@ -1537,6 +1546,12 @@ export class KycController extends BaseController< } catch (error) { // Applicant already finished KYC — treat as completed for Money toast. if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) { + // A reset() may have landed while `launch` was in flight; forcing + // `completed` (and publishing `statusChanged`) on an idle controller + // would resurrect a flow the consumer already tore down. + if (this.#generation !== generation) { + return { alreadyCompleted: true }; + } this.#applyUserStatus({ status: 'completed', sumsubSessionId: null, @@ -1670,14 +1685,16 @@ export class KycController extends BaseController< tick(); }, this.#userStatusPollIntervalMs); // Allow the process to exit while a pending-status poll is scheduled. - this.#userStatusPollTimer.unref(); + // React Native / browser timers are numbers with no `unref`, hence the + // optional call. + this.#userStatusPollTimer.unref?.(); }; this.#userStatusPollTimer = setTimeout(() => { this.#userStatusPollTimer = null; // eslint-disable-next-line @typescript-eslint/no-floating-promises tick(); }, this.#userStatusPollIntervalMs); - this.#userStatusPollTimer.unref(); + this.#userStatusPollTimer.unref?.(); } /** diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index cbc358c03f..76a9aecb4a 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -179,7 +179,10 @@ export function mapNeoBankAutorampToRemoteSnapshot( id: response.id, customerId: response.customer_id, walletAddress: - response.wallet_address ?? response.recipient_account?.address, + response.wallet_address !== undefined && + response.wallet_address.length > 0 + ? response.wallet_address + : response.recipient_account?.address, status: response.status, depositRailsSummary, }; diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e691bbf623..fe7e0fbc5e 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -316,6 +316,22 @@ function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus { ); } +/** + * Distinguishes an already-materialized {@link AutorampAccount} from the + * create-fields shape accepted by {@link RampsController.addAutoramp}. + * + * @param value - Full account or create fields. + * @returns Whether the value already carries the derived account fields. + */ +function isFullAutorampAccount( + value: AutorampAccount | { id: string; customerId: string }, +): value is AutorampAccount { + return ( + typeof (value as AutorampAccount).updatedAt === 'number' && + (value as AutorampAccount).lastSeenStatus !== undefined + ); +} + function getRampsErrorInfo(error: unknown): RampsErrorInfo { if (error instanceof BrokenCircuitError && hasStringMessage(error)) { return { @@ -2670,11 +2686,9 @@ export class RampsController extends BaseController< status?: AutorampAccount['status'] | string; }, ): AutorampAccount { - const account = - typeof (accountOrInput as AutorampAccount).updatedAt === 'number' && - (accountOrInput as AutorampAccount).lastSeenStatus !== undefined - ? accountOrInput - : createAutorampAccount(accountOrInput); + const account: AutorampAccount = isFullAutorampAccount(accountOrInput) + ? accountOrInput + : createAutorampAccount(accountOrInput); this.update((state) => { const idx = state.autoramps.findIndex( diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts index 03fe6e939a..eca7f7fa38 100644 --- a/packages/ramps-controller/src/autorampAccount.ts +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -207,8 +207,15 @@ export function applyAutorampRemoteStatus( const account: AutorampAccount = { ...local, id: remote.id, - customerId: remote.customerId ?? local.customerId, - walletAddress: remote.walletAddress ?? local.walletAddress, + // A blank remote identity field means "not supplied", not "cleared": the + // proxy omits or empties these on partial status pushes, so keep the local + // value rather than wiping it. + customerId: + remote.customerId.length > 0 ? remote.customerId : local.customerId, + walletAddress: + remote.walletAddress !== undefined && remote.walletAddress.length > 0 + ? remote.walletAddress + : local.walletAddress, status: remoteStatus, lastSeenStatus: previousStatus, updatedAt: Date.now(), From 3db6781bfdf5c8c624ad3f3545c64c1483538b45 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:34:18 -0600 Subject: [PATCH 02/10] test: cover autoramp user-storage sync integration --- .../controller-integration.test.ts | 653 ++++++++++++++++++ 1 file changed, 653 insertions(+) create mode 100644 packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts new file mode 100644 index 0000000000..e0cf44f3f1 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts @@ -0,0 +1,653 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + computeAutorampMergePlan, + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, +} from './controller-integration.js'; +import { mapAutorampToUserStorageEntry } from './format-utils.js'; +import type { + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampAccount, +} from './types.js'; + +/** + * Builds an autoramp account with sync-relevant defaults. + * + * @param overrides - Fields to override on the generated account. + * @returns A sync-aware autoramp account. + */ +function buildAccount( + overrides: Partial & { id: string }, +): SyncAutorampAccount { + return { + ...createAutorampAccount({ + customerId: 'customer-1', + walletAddress: '0xwallet', + status: AutorampStatus.Authorized, + updatedAt: 1_000, + ...overrides, + }), + ...(overrides.deletedAt === undefined + ? {} + : { deletedAt: overrides.deletedAt }), + }; +} + +/** + * Serializes an account the way User Storage would return it. + * + * @param account - Account to serialize. + * @returns JSON string of the remote entry. + */ +function toRemoteEntryJson(account: SyncAutorampAccount): string { + return JSON.stringify(mapAutorampToUserStorageEntry(account)); +} + +type Harness = { + options: AutorampSyncingOptions; + controller: jest.Mocked & { + state: { autoramps: AutorampAccount[] }; + }; + call: jest.Mock; + onAutorampSyncErroneousSituation: jest.Mock; + batchSetCalls: () => [string, string][][]; +}; + +/** + * Builds a sync test harness with a stubbed controller and messenger. + * + * @param args - Harness configuration. + * @param args.localAccounts - Accounts present in controller state. + * @param args.remoteEntries - Raw JSON entries returned by User Storage. + * @param args.pendingDeletes - Accounts queued for remote soft-delete. + * @param args.canSync - Whether the Backup & Sync gates should pass. + * @param args.trace - Optional trace callback. + * @returns The harness. + */ +function buildHarness({ + localAccounts = [], + remoteEntries = [], + pendingDeletes = [], + canSync = true, + trace, +}: { + localAccounts?: AutorampAccount[]; + remoteEntries?: (string | null)[]; + pendingDeletes?: AutorampAccount[]; + canSync?: boolean; + trace?: AutorampSyncingOptions['trace']; +} = {}): Harness { + const batchSetCalls: [string, string][][] = []; + + const call = jest.fn((action: string, ...args: unknown[]) => { + switch (action) { + case 'UserStorageController:getState': + return { isBackupAndSyncEnabled: canSync }; + case 'AuthenticationController:isSignedIn': + return canSync; + case 'UserStorageController:performGetStorageAllFeatureEntries': + return remoteEntries; + case 'UserStorageController:performBatchSetStorage': + batchSetCalls.push(args[1] as [string, string][]); + return undefined; + default: + throw new Error(`unexpected action ${action}`); + } + }); + + const state = { autoramps: [...localAccounts] }; + + const controller = { + state, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn((account: AutorampAccount) => { + const index = state.autoramps.findIndex( + (entry) => entry.id === account.id, + ); + if (index === -1) { + state.autoramps.push(account); + } else { + state.autoramps[index] = account; + } + return account; + }), + removeAutoramp: jest.fn((autorampId: string) => { + state.autoramps = state.autoramps.filter( + (entry) => entry.id !== autorampId, + ); + controller.state.autoramps = state.autoramps; + }), + getPendingRemoteAutorampDeletes: jest.fn(() => pendingDeletes), + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + } as unknown as Harness['controller']; + + const onAutorampSyncErroneousSituation = jest.fn(); + + return { + options: { + getRampsControllerInstance: () => controller, + getMessenger: () => ({ call }) as never, + ...(trace ? { trace } : {}), + }, + controller, + call, + onAutorampSyncErroneousSituation, + batchSetCalls: () => batchSetCalls, + }; +} + +describe('computeAutorampMergePlan', () => { + it('ignores remote tombstones for accounts that are absent locally', () => { + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([], [remote]); + + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('re-uploads a local account that is newer than a remote tombstone', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 9_000 }); + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToUpdateRemotely.map((account) => account.id)).toStrictEqual( + ['ar-1'], + ); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + }); + + it('treats a local account with no timestamp as older than a tombstone', () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: undefined, + } as unknown as SyncAutorampAccount; + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToDeleteLocally.map((account) => account.id)).toStrictEqual( + ['ar-1'], + ); + }); + + it('imports the remote account when it is newer than the local copy', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 2_000, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally.map((a) => a.status)).toStrictEqual([ + AutorampStatus.Approved, + ]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('plans no work when local and remote accounts match', () => { + const local = buildAccount({ id: 'ar-1' }); + const remote = buildAccount({ id: 'ar-1' }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + expect([...plan.remoteAccountsMap.keys()]).toStrictEqual(['ar-1']); + }); +}); + +describe('syncAutorampsWithUserStorage', () => { + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.setIsAutorampSyncingInProgress).not.toHaveBeenCalled(); + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('returns early when User Storage holds no entries', async () => { + const harness = buildHarness({ remoteEntries: [] }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.batchSetCalls()).toStrictEqual([]); + expect(harness.controller.setIsAutorampSyncingInProgress).toHaveBeenCalledWith( + false, + ); + }); + + it('treats a null feature-entries response as empty', async () => { + const harness = buildHarness(); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if ( + action === 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return null; + } + throw new Error(`unexpected action ${action}`); + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('imports remote-only accounts into controller state', async () => { + const remote = buildAccount({ id: 'ar-remote', updatedAt: 2_000 }); + const harness = buildHarness({ remoteEntries: [toRemoteEntryJson(remote)] }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ar-remote' }), + ); + expect( + harness.controller.setIsApplyingAutorampSyncChanges.mock.calls, + ).toStrictEqual([[true], [false]]); + }); + + it('uploads local-only accounts to User Storage', async () => { + const local = buildAccount({ id: 'ar-local', updatedAt: 3_000 }); + const other = buildAccount({ id: 'ar-other', updatedAt: 4_000 }); + const harness = buildHarness({ + localAccounts: [local, other], + remoteEntries: [toRemoteEntryJson(other)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-local']); + }); + + it('stamps an upload that has no local timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-local' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const harness = buildHarness({ localAccounts: [local] }); + harness.call.mockImplementation((action: string, ...args: unknown[]) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if ( + action === 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))]; + } + if (action === 'UserStorageController:performBatchSetStorage') { + const entries = args[1] as [string, string][]; + const uploaded = entries.find(([key]) => key === 'ar-local'); + expect(uploaded).toBeDefined(); + expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan( + 0, + ); + return undefined; + } + throw new Error(`unexpected action ${action}`); + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.call).toHaveBeenCalledWith( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + expect.any(Array), + ); + }); + + it('deletes local accounts that were tombstoned remotely', async () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const tombstone = buildAccount({ + id: 'ar-1', + updatedAt: 5_000, + deletedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(tombstone)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.removeAutoramp).toHaveBeenCalledWith('ar-1'); + }); + + it('does not re-import a remote account that is queued for local deletion', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(pending)], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('uploads tombstones for pending remote deletes and acknowledges them', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + const tombstone = entries.find(([key]) => key === 'ar-pending'); + expect(tombstone).toBeDefined(); + expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan(0); + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).toHaveBeenCalledWith([pending]); + }); + + it('ignores pending deletes that have no storage key', async () => { + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [ + { ...buildAccount({ id: 'ar-pending' }), id: '' } as AutorampAccount, + ], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).not.toHaveBeenCalled(); + }); + + it('reports an unsupported storage version and skips the entry', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: '999', + o: { id: 'ar-1' }, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Unsupported autoramp storage version', + { version: '999', expectedVersion: USER_STORAGE_VERSION }, + ); + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('reports a remote entry that is missing its payload', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Remote autoramp entry missing payload', + {}, + ); + }); + + it('reports a remote entry that cannot be parsed', async () => { + const harness = buildHarness({ remoteEntries: ['not json'] }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to parse remote autoramp entry', + expect.objectContaining({ entryLength: 'not json'.length }), + ); + }); + + it('skips a remote entry whose payload has no id', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: '', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + }, + lu: 1_000, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + expect(harness.onAutorampSyncErroneousSituation).not.toHaveBeenCalled(); + }); + + it('skips a remote write whose account has an empty storage key', async () => { + const harness = buildHarness({ + localAccounts: [ + { ...buildAccount({ id: 'ar-local' }), id: '' } as AutorampAccount, + ], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + }); + harness.controller.getPendingRemoteAutorampDeletes.mockReturnValue([]); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('reports and rethrows when the sync fails', async () => { + const harness = buildHarness(); + const failure = new Error('storage down'); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw failure; + }); + + await expect( + syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ), + ).rejects.toThrow('storage down'); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Error synchronizing autoramps', + { error: failure }, + ); + expect(harness.controller.setIsAutorampSyncingInProgress).toHaveBeenLastCalledWith( + false, + ); + }); + + it('wraps the sync and the batch save in traces when a callback is given', async () => { + const traceNames: string[] = []; + const trace = jest.fn(async (request: { name: string }, fn?: () => unknown) => { + traceNames.push(request.name); + return await (fn as () => Promise)(); + }) as unknown as AutorampSyncingOptions['trace']; + + const harness = buildHarness({ + localAccounts: [buildAccount({ id: 'ar-local' })], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + trace, + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(traceNames).toStrictEqual([ + 'Ramps Autoramp Sync Full', + 'Ramps Autoramp Sync Save Batch', + ]); + }); +}); + +describe('updateAutorampInRemoteStorage', () => { + it('writes the account with a refreshed timestamp', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account that is not syncable', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); + +describe('deleteAutorampInRemoteStorage', () => { + it('writes a tombstone for the account', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).dt).toBeGreaterThan(0); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account with no id', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the tombstone write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); From ac1275dbee628adca8fb9b4d1b29803ae1d34c3b Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:36:31 -0600 Subject: [PATCH 03/10] test: cover autoramp mutation and remote-push paths --- .../src/RampsController.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e6f3b43aaa..b6205736eb 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9305,6 +9305,181 @@ describe('RampsController', () => { ); }); }); + + /** + * Registers the User Storage / auth handlers that let the incremental + * autoramp pushes run, so tests can drive the remote-write code paths. + * + * @param rootMessenger - Root messenger of the controller under test. + * @param batchSet - Handler for `performBatchSetStorage`. + */ + function registerAutorampSyncHandlers( + rootMessenger: RootMessenger, + batchSet: jest.Mock, + ): void { + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => ({ isBackupAndSyncEnabled: true }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + } + + /** + * Lets floating remote-push promises settle. + */ + async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it('updates an existing autoramp when the id is already known', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Approved, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(updated.walletAddress).toBe('0xdef'); + expect(updated.status).toBe(AutorampStatus.Approved); + }); + }); + + it('ignores removal and notification for unknown autoramp ids', async () => { + await withController(({ controller }) => { + controller.removeAutoramp('missing'); + controller.markAutorampAsNotified('missing'); + + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('queues a remote delete when a full sync holds the semaphore', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + controller.setIsAutorampSyncingInProgress(true); + controller.removeAutoramp('ar-1'); + + const pending = controller.getPendingRemoteAutorampDeletes(); + expect(pending.map((account) => account.id)).toStrictEqual(['ar-1']); + + controller.acknowledgePendingRemoteAutorampDeletes([]); + expect(controller.getPendingRemoteAutorampDeletes()).toHaveLength(1); + + controller.acknowledgePendingRemoteAutorampDeletes(pending); + expect(controller.getPendingRemoteAutorampDeletes()).toStrictEqual([]); + + controller.setIsAutorampSyncingInProgress(false); + }); + }); + + it('suppresses remote pushes while applying sync changes locally', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.setIsApplyingAutorampSyncChanges(true); + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + controller.removeAutoramp('ar-1'); + controller.setIsApplyingAutorampSyncChanges(false); + + await flushPromises(); + + expect(batchSet).not.toHaveBeenCalled(); + }); + }); + + it('swallows remote storage failures raised by autoramp mutations', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockRejectedValue(new Error('storage down')); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + await flushPromises(); + + controller.markAutorampAsNotified('ar-1'); + await flushPromises(); + + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + await flushPromises(); + + controller.removeAutoramp('ar-1'); + await flushPromises(); + + expect(batchSet).toHaveBeenCalled(); + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('keeps local identity fields when a remote push omits or blanks them', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const afterOmitted = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + status: AutorampStatus.Approved, + }); + + expect(afterOmitted.customerId).toBe('cust-1'); + expect(afterOmitted.walletAddress).toBe('0xabc'); + + const afterBlank = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + walletAddress: '', + status: AutorampStatus.Approved, + }); + + expect(afterBlank.customerId).toBe('cust-1'); + expect(afterBlank.walletAddress).toBe('0xabc'); + }); + }); }); describe('registerMoneyAccountWallet', () => { From 9109957ad818fda5c90cefb226b6fdc526ad615d Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:39:39 -0600 Subject: [PATCH 04/10] test: restore ramps-controller coverage thresholds --- .../src/RampsController.test.ts | 13 ++++++ .../controller-integration.test.ts | 45 +++++++++++++++++++ .../controller-integration.ts | 11 ++++- .../src/autoramp-syncing/format-utils.test.ts | 29 ++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index b6205736eb..44a5388eb9 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9451,6 +9451,19 @@ describe('RampsController', () => { }); }); + it('creates an autoramp from a push that carries no wallet address', async () => { + await withController(({ controller }) => { + const created = controller.applyAutorampStatusFromPush({ + id: 'ar-new', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(created.walletAddress).toBe(''); + expect(controller.state.autoramps).toHaveLength(1); + }); + }); + it('keeps local identity fields when a remote push omits or blanks them', async () => { await withController(({ controller }) => { controller.addAutoramp({ diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts index e0cf44f3f1..8148498c77 100644 --- a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts @@ -384,6 +384,51 @@ describe('syncAutorampsWithUserStorage', () => { ).not.toHaveBeenCalled(); }); + it('re-uploads a local account whose newer remote copy was not imported', async () => { + // The account is queued for deletion, so the newer remote copy is not + // applied locally; the surviving local copy still has to reach the remote. + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + expect(JSON.parse(entries[0][1]).o.status).toBe(AutorampStatus.Authorized); + }); + + it('stamps a re-uploaded local account that has no timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).lu).toBeGreaterThan(0); + }); + it('reports an unsupported storage version and skips the entry', async () => { const harness = buildHarness({ remoteEntries: [ diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts index 28e99d31a8..e6c03c52be 100644 --- a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -153,7 +153,7 @@ async function getRemoteAutoramps( async function saveAutorampsToUserStorage( accounts: SyncAutorampAccount[], options: AutorampSyncingOptions, - config: SyncAutorampsWithUserStorageConfig = {}, + config: SyncAutorampsWithUserStorageConfig, ): Promise { const { getMessenger, trace } = options; const { onAutorampSyncErroneousSituation } = config; @@ -162,6 +162,9 @@ async function saveAutorampsToUserStorage( const storageEntries: [string, string][] = []; for (const account of accounts) { const key = createAutorampStorageKey(account); + // Defensive: every caller filters on `isSyncableAutoramp` or a non-empty + // key before reaching here, so an id-less account is unreachable today. + /* istanbul ignore next */ if (!key) { onAutorampSyncErroneousSituation?.( 'Skipping autoramp remote write with empty storage key', @@ -174,6 +177,9 @@ async function saveAutorampsToUserStorage( JSON.stringify(mapAutorampToUserStorageEntry(account)), ]); } + // Defensive: only reachable if every account was skipped above, which the + // callers' filtering already rules out. + /* istanbul ignore next */ if (storageEntries.length === 0) { return; } @@ -289,6 +295,9 @@ export async function syncAutorampsWithUserStorage( ...getLocalAccounts() .filter((account) => { const key = createAutorampStorageKey(account); + // Defensive: `pendingDeletes` already excludes anything still + // present locally, so this cannot match a local account. + /* istanbul ignore next */ if (pendingDeleteKeys.has(key)) { return false; } diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts index 540cf7e88c..a70dfa16a1 100644 --- a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -61,6 +61,35 @@ describe('autoramp-syncing/format-utils', () => { expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt'); }); + it('stamps the current time when the account has no update timestamp', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + updatedAt: 0, + }); + + expect(entry.lu).toBeGreaterThan(0); + expect(entry.o).not.toHaveProperty('notifiedForStatus'); + expect(entry).not.toHaveProperty('dt'); + }); + + it('normalizes a notified status and defaults a missing timestamp', () => { + const mapped = mapUserStorageEntryToAutoramp({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + }); + + expect(mapped.notifiedForStatus).toBe(AutorampStatus.Approved); + expect(mapped.updatedAt).toBeGreaterThan(0); + expect(mapped).not.toHaveProperty('deletedAt'); + }); + it('compares sync-relevant fields', () => { expect(areAutorampsEqual(account, { ...account })).toBe(true); expect( From 679df71d18292252c22d387e466f387b3b7ad542 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:41:23 -0600 Subject: [PATCH 05/10] test: cover KYC vendor id clearing and reset guard --- .../kyc-controller/src/KycController.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index aef1d4f36e..c34d6a88ee 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1251,6 +1251,53 @@ describe('KycController', () => { }, ); }); + + it('drops a MoonPay id when initialize switches to another vendor', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('keeps a MoonPay id when initialize stays on MoonPay', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('drops a MoonPay id when an Iron customer is created', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.createIronCustomer({ email: 'a@b.co' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); }); describe('startSumSub', () => { @@ -2441,6 +2488,33 @@ describe('KycController', () => { ); }); + it('leaves an already-reset controller idle when SumSub reports a stale session', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit' }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (error: Error) => void = () => undefined; + handlers.createUkycSession.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.startSumSub(); + controller.reset(); + rejectSession(new Error('session_not_in_valid_state')); + + expect(await pending).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBeNull(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + it('keeps phase done when Iron SumSub reports already completed', async () => { await withController( { From 46c1615d63ee8a9a3a90035e08f1eeb6c9c02039 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:41:51 -0600 Subject: [PATCH 06/10] chore: regenerate transaction-pay messenger action types --- .../src/TransactionPayController-method-action-types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 0301e9ba2f..09e0ae9eb7 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -53,8 +53,9 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. - * Successful results are retained so retries return the prior hash without - * submitting again. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. * * @param request - Completed Iron payout details. * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` From 20e4f35fcbb9673b315959b36d99d88568bc84ea Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 02:49:51 -0600 Subject: [PATCH 07/10] style: satisfy oxfmt and jest lint rules in new tests --- .../controller-integration.test.ts | 82 +++++++++---------- 1 file changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts index 8148498c77..0d8195cf10 100644 --- a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts @@ -163,9 +163,9 @@ describe('computeAutorampMergePlan', () => { const plan = computeAutorampMergePlan([local], [remote]); - expect(plan.accountsToUpdateRemotely.map((account) => account.id)).toStrictEqual( - ['ar-1'], - ); + expect( + plan.accountsToUpdateRemotely.map((account) => account.id), + ).toStrictEqual(['ar-1']); expect(plan.accountsToDeleteLocally).toStrictEqual([]); }); @@ -178,9 +178,9 @@ describe('computeAutorampMergePlan', () => { const plan = computeAutorampMergePlan([local], [remote]); - expect(plan.accountsToDeleteLocally.map((account) => account.id)).toStrictEqual( - ['ar-1'], - ); + expect( + plan.accountsToDeleteLocally.map((account) => account.id), + ).toStrictEqual(['ar-1']); }); it('imports the remote account when it is newer than the local copy', () => { @@ -193,9 +193,9 @@ describe('computeAutorampMergePlan', () => { const plan = computeAutorampMergePlan([local], [remote]); - expect(plan.accountsToAddOrUpdateLocally.map((a) => a.status)).toStrictEqual([ - AutorampStatus.Approved, - ]); + expect( + plan.accountsToAddOrUpdateLocally.map((a) => a.status), + ).toStrictEqual([AutorampStatus.Approved]); expect(plan.accountsToUpdateRemotely).toStrictEqual([]); }); @@ -218,7 +218,9 @@ describe('syncAutorampsWithUserStorage', () => { await syncAutorampsWithUserStorage({}, harness.options); - expect(harness.controller.setIsAutorampSyncingInProgress).not.toHaveBeenCalled(); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).not.toHaveBeenCalled(); expect(harness.batchSetCalls()).toStrictEqual([]); }); @@ -228,9 +230,9 @@ describe('syncAutorampsWithUserStorage', () => { await syncAutorampsWithUserStorage({}, harness.options); expect(harness.batchSetCalls()).toStrictEqual([]); - expect(harness.controller.setIsAutorampSyncingInProgress).toHaveBeenCalledWith( - false, - ); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenCalledWith(false); }); it('treats a null feature-entries response as empty', async () => { @@ -257,7 +259,9 @@ describe('syncAutorampsWithUserStorage', () => { it('imports remote-only accounts into controller state', async () => { const remote = buildAccount({ id: 'ar-remote', updatedAt: 2_000 }); - const harness = buildHarness({ remoteEntries: [toRemoteEntryJson(remote)] }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(remote)], + }); await syncAutorampsWithUserStorage({}, harness.options); @@ -288,29 +292,9 @@ describe('syncAutorampsWithUserStorage', () => { ...buildAccount({ id: 'ar-local' }), updatedAt: 0, } as unknown as AutorampAccount; - const harness = buildHarness({ localAccounts: [local] }); - harness.call.mockImplementation((action: string, ...args: unknown[]) => { - if (action === 'UserStorageController:getState') { - return { isBackupAndSyncEnabled: true }; - } - if (action === 'AuthenticationController:isSignedIn') { - return true; - } - if ( - action === 'UserStorageController:performGetStorageAllFeatureEntries' - ) { - return [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))]; - } - if (action === 'UserStorageController:performBatchSetStorage') { - const entries = args[1] as [string, string][]; - const uploaded = entries.find(([key]) => key === 'ar-local'); - expect(uploaded).toBeDefined(); - expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan( - 0, - ); - return undefined; - } - throw new Error(`unexpected action ${action}`); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))], }); await syncAutorampsWithUserStorage({}, harness.options); @@ -320,6 +304,10 @@ describe('syncAutorampsWithUserStorage', () => { USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, expect.any(Array), ); + const [entries] = harness.batchSetCalls(); + const uploaded = entries.find(([key]) => key === 'ar-local'); + expect(uploaded).toBeDefined(); + expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan(0); }); it('deletes local accounts that were tombstoned remotely', async () => { @@ -363,7 +351,9 @@ describe('syncAutorampsWithUserStorage', () => { const [entries] = harness.batchSetCalls(); const tombstone = entries.find(([key]) => key === 'ar-pending'); expect(tombstone).toBeDefined(); - expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan(0); + expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan( + 0, + ); expect( harness.controller.acknowledgePendingRemoteAutorampDeletes, ).toHaveBeenCalledWith([pending]); @@ -568,17 +558,19 @@ describe('syncAutorampsWithUserStorage', () => { 'Error synchronizing autoramps', { error: failure }, ); - expect(harness.controller.setIsAutorampSyncingInProgress).toHaveBeenLastCalledWith( - false, - ); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenLastCalledWith(false); }); it('wraps the sync and the batch save in traces when a callback is given', async () => { const traceNames: string[] = []; - const trace = jest.fn(async (request: { name: string }, fn?: () => unknown) => { - traceNames.push(request.name); - return await (fn as () => Promise)(); - }) as unknown as AutorampSyncingOptions['trace']; + const trace = jest.fn( + async (request: { name: string }, fn?: () => unknown) => { + traceNames.push(request.name); + return await (fn as () => Promise)(); + }, + ) as unknown as AutorampSyncingOptions['trace']; const harness = buildHarness({ localAccounts: [buildAccount({ id: 'ar-local' })], From 0def7b4f7a16c1442e6dabaaffcf96b6edaa1629 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 03:13:56 -0600 Subject: [PATCH 08/10] docs: add changelog entries for the neobank-demo CI fixes --- packages/kyc-controller/CHANGELOG.md | 6 ++++++ packages/ramps-controller/CHANGELOG.md | 4 ++++ packages/transaction-pay-controller/CHANGELOG.md | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 0c857a9d1a..34e465f3d5 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -25,4 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. ([#9853](https://github.com/MetaMask/core/pull/9853)) +### Fixed + +- Clear `moonpayCustomerId` when the active vendor changes, so `getCustomerIdentity()` can no longer report a MoonPay customer id under another vendor. The id is dropped when `initialize` starts a non-MoonPay flow and when `createIronCustomer` switches to Iron. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Call `unref()` on the user-status poll timer only when it exists. React Native and browser timers are numbers, so the unconditional call threw when Money status polling started outside Node. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Skip the `session_not_in_valid_state` completion write when a `reset()` superseded the SumSub flow, so a late vendor response can no longer force `userStatus` to `completed` (and publish `statusChanged`) on an idle controller. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 34d66549c0..97b6eb6e82 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. ([#9859](https://github.com/MetaMask/core/pull/9859), [#9853](https://github.com/MetaMask/core/pull/9853)) - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ([#9853](https://github.com/MetaMask/core/pull/9853)) +### Fixed + +- Keep the local `customerId` / `walletAddress` when a remote autoramp snapshot omits or blanks them. The proxy sends empty identity fields on partial status pushes, and `applyAutorampRemoteStatus` / `mapNeoBankAutorampToRemoteSnapshot` treated those as a clear, wiping valid local values during refresh-on-load and websocket pushes. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) + ## [20.0.0] ### Changed diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d796a2b02f..1841e2ff89 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval. Skipped results (vaulting disabled) are not retained, so a later enablement can retry the same payout hash. ([#9849](https://github.com/MetaMask/core/pull/9849), [#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) - Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [26.3.0] From addfa62e64719c4fa3bd02adbb2859a58f3b6cb2 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 03:23:21 -0600 Subject: [PATCH 09/10] test: cover randomUUID idempotency key path on Node 18 --- .../src/wallet-registration-service.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/ramps-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts index c3be35aede..1fc9ba8b59 100644 --- a/packages/ramps-controller/src/wallet-registration-service.test.ts +++ b/packages/ramps-controller/src/wallet-registration-service.test.ts @@ -87,6 +87,27 @@ describe('createIdempotencyKey', () => { expect(createIdempotencyKey().length).toBeGreaterThan(0); }); + // `globalThis.crypto.randomUUID` is absent under Node 18, so the preferred + // path has to be exercised against an installed stub rather than the ambient + // runtime. + it('prefers randomUUID when the runtime provides it', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: () => 'uuid-1' }, + }); + try { + expect(createIdempotencyKey()).toBe('uuid-1'); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } + } + }); + it('falls back when randomUUID is unavailable', () => { const originalDescriptor = Object.getOwnPropertyDescriptor( globalThis, From 18fb4ea39392e82384435f4438b3cfcd1b8afc99 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 03:35:19 -0600 Subject: [PATCH 10/10] test: remove crypto stub leak that broke the Node 18 fallback case --- .../ramps-controller/src/wallet-registration-service.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/ramps-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts index 1fc9ba8b59..d76327eeba 100644 --- a/packages/ramps-controller/src/wallet-registration-service.test.ts +++ b/packages/ramps-controller/src/wallet-registration-service.test.ts @@ -104,6 +104,10 @@ describe('createIdempotencyKey', () => { } finally { if (originalDescriptor) { Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } else { + // Node 18 exposes no own `crypto` descriptor, so the stub has to be + // removed rather than restored, or it leaks into later tests. + Reflect.deleteProperty(globalThis, 'crypto'); } } });