@metamask/ramps-controller 26.3.0 → 27.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { BaseController } from '@metamask/base-controller';
2
- import { BrokenCircuitError } from '@metamask/controller-utils';
2
+ import { BrokenCircuitError, HttpError } from '@metamask/controller-utils';
3
3
  import { BigNumber } from 'bignumber.js';
4
- import { applyAutorampRemoteStatus, AutorampStatus, buildBrazilMusdAutorampRequest, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js';
4
+ import { applyAutorampRemoteStatus, AutorampStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js';
5
5
  import { getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js';
6
6
  import { areOrdersEqual, deleteOrderInUserStorage, syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInUserStorage, } from './order-syncing/index.js';
7
7
  import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js';
@@ -81,9 +81,9 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [
81
81
  'AuthenticationController:getSessionProfile',
82
82
  'AuthenticationController:isSignedIn',
83
83
  'KeyringController:signPersonalMessage',
84
- 'KycController:getSessionStatusForVendor',
85
84
  'KycController:getProviderFlowStatus',
86
- 'KycController:refreshSessionStatus',
85
+ 'KycController:readSessionStatus',
86
+ 'KycController:fetchSessionStatusOnce',
87
87
  'KycController:hasCompletedVendorDisclaimers',
88
88
  'KycController:hasCompletedSessionDisclaimers',
89
89
  'KycController:clearState',
@@ -92,6 +92,15 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [
92
92
  'UserStorageController:performGetStorageAllFeatureEntries',
93
93
  'UserStorageController:performBatchSetStorage',
94
94
  ];
95
+ /**
96
+ * `finalStatus` values that end a KYC session. Mirrors
97
+ * `TERMINAL_SESSION_STATUSES` in `@metamask/kyc-controller`.
98
+ */
99
+ const TERMINAL_VBA_KYC_FINAL_STATUSES = new Set([
100
+ 'approved',
101
+ 'rejected',
102
+ 'retry',
103
+ ]);
95
104
  /**
96
105
  * KYC vocabulary used on the VBA onboarding snapshot. `'none'` means no
97
106
  * session exists yet (or the vendor returned an unrecognized status).
@@ -112,13 +121,21 @@ export const VBA_PROVIDER_FLOW_STATUSES = [
112
121
  ];
113
122
  /**
114
123
  * Autoramp setup progress after KYC has been approved.
115
- * `'in_progress'` is reserved for hosts that observe an in-flight hydrate;
116
- * {@link RampsController.hydrateVbaOnboarding} itself returns `'ready'` or
117
- * `'retryable_failure'` once the coalesced run settles.
124
+ *
125
+ * `'needs_wallet_registration'` means KYC is approved, this wallet has no
126
+ * persisted registration row, and there is no usable autoramp.
127
+ * `'needs_source_currency'` means KYC is approved, the registration row is
128
+ * `active` or `disabled`, and there is no usable autoramp.
129
+ * `'in_progress'` is reserved for hosts that observe an in-flight hydrate.
130
+ * {@link RampsController.hydrateVbaOnboarding} returns `'ready'`,
131
+ * `'needs_wallet_registration'`, `'needs_source_currency'`, or
132
+ * `'retryable_failure'` once an approved read settles.
118
133
  */
119
134
  export const VBA_AUTORAMP_STATUSES = [
120
135
  'not_ready',
121
136
  'in_progress',
137
+ 'needs_wallet_registration',
138
+ 'needs_source_currency',
122
139
  'ready',
123
140
  'retryable_failure',
124
141
  ];
@@ -144,24 +161,97 @@ function toVbaKycStatus(value) {
144
161
  return 'none';
145
162
  }
146
163
  /**
147
- * Idempotency key for the standing BRL to mUSD autoramp.
164
+ * Maps a KYC session onto the snapshot vocabulary.
165
+ *
166
+ * Relay approval (`kycStatus: 'approved'`) counts even when `finalStatus` is
167
+ * still `pending`. A non-approved session whose provider flow was submitted,
168
+ * or whose status is pending, is `pending`.
169
+ *
170
+ * @param session - Persisted or freshly fetched KYC session.
171
+ * @param providerFlowStatus - Durable provider-flow outcome.
172
+ * @returns The snapshot KYC status.
173
+ */
174
+ function resolveVbaKycStatus(session, providerFlowStatus) {
175
+ if (session.finalStatus === 'approved' || session.kycStatus === 'approved') {
176
+ return 'approved';
177
+ }
178
+ const mapped = toVbaKycStatus(session.finalStatus);
179
+ if (mapped === 'rejected' || mapped === 'retry') {
180
+ return mapped;
181
+ }
182
+ if (session.finalStatus === 'pending' ||
183
+ session.kycStatus === 'pending' ||
184
+ providerFlowStatus === 'submitted') {
185
+ return 'pending';
186
+ }
187
+ return mapped;
188
+ }
189
+ /**
190
+ * Idempotency key for a standing fiat to mUSD autoramp on Monad.
148
191
  *
149
- * The customer and wallet keep retries of one create on the same key. Rejected
150
- * or cancelled route ids are appended, sorted, so a replacement create is a
151
- * new key and MoonPay does not replay the terminal route.
192
+ * The source fiat, customer, and wallet keep retries of one create on the
193
+ * same key (`brl-musd-monad` versus `usd-musd-monad`). Rejected or cancelled
194
+ * route ids are appended, sorted, so a replacement create is a new key and
195
+ * MoonPay does not replay the terminal route.
152
196
  *
197
+ * @param sourceCurrencyCode - Fiat code from the create body. Trimmed and lowercased here.
153
198
  * @param customerId - MoonPay customer id.
154
199
  * @param walletAddress - Money Account address that receives mUSD.
155
200
  * @param terminalAutorampIds - Ids of rejected or cancelled routes for this wallet.
156
201
  * @returns Idempotency-Key value.
157
202
  */
158
- function buildBrazilMusdAutorampIdempotencyKey(customerId, walletAddress, terminalAutorampIds) {
159
- const base = `brl-musd-monad:${customerId}:${walletAddress.trim().toLowerCase()}`;
203
+ function buildMusdAutorampIdempotencyKey(sourceCurrencyCode, customerId, walletAddress, terminalAutorampIds) {
204
+ const fiatPrefix = sourceCurrencyCode.trim().toLowerCase();
205
+ const base = `${fiatPrefix}-musd-monad:${customerId}:${walletAddress.trim().toLowerCase()}`;
160
206
  if (terminalAutorampIds.length === 0) {
161
207
  return base;
162
208
  }
163
209
  return `${base}:${[...terminalAutorampIds].sort().join(':')}`;
164
210
  }
211
+ /**
212
+ * Reads the first fiat `source_currencies` code from an autoramp create body.
213
+ *
214
+ * @param request - Caller-supplied create payload.
215
+ * @returns Lowercased fiat code, or `null` when the body has none.
216
+ */
217
+ function readCreateAutorampFiatCode(request) {
218
+ const sourceCurrencies = request.source_currencies;
219
+ if (!Array.isArray(sourceCurrencies)) {
220
+ return null;
221
+ }
222
+ for (const currency of sourceCurrencies) {
223
+ if (!currency || typeof currency !== 'object') {
224
+ continue;
225
+ }
226
+ const record = currency;
227
+ if (record.type !== 'Fiat' || typeof record.code !== 'string') {
228
+ continue;
229
+ }
230
+ const code = record.code.trim().toLowerCase();
231
+ if (code.length > 0) {
232
+ return code;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+ /**
238
+ * Reads the destination wallet from an autoramp create body.
239
+ *
240
+ * @param request - Caller-supplied create payload.
241
+ * @returns Trimmed recipient address, or `null` when it is missing.
242
+ */
243
+ function readCreateAutorampWalletAddress(request) {
244
+ const recipient = request.recipient_account;
245
+ if (!recipient || typeof recipient !== 'object') {
246
+ return null;
247
+ }
248
+ const { address } = recipient;
249
+ if (typeof address !== 'string') {
250
+ return null;
251
+ }
252
+ const trimmed = address.trim();
253
+ return trimmed.length > 0 ? trimmed : null;
254
+ }
165
255
  /**
166
256
  * Distinguishes an already-materialized {@link AutorampAccount} from the
167
257
  * create-fields shape accepted by {@link RampsController.addAutoramp}.
@@ -290,6 +380,18 @@ const rampsControllerMetadata = {
290
380
  includeInStateLogs: true,
291
381
  usedInUi: true,
292
382
  },
383
+ moneyAccountWalletRegistrations: {
384
+ persist: true,
385
+ includeInDebugSnapshot: true,
386
+ includeInStateLogs: true,
387
+ usedInUi: true,
388
+ },
389
+ vbaAutorampListFetchedAt: {
390
+ persist: true,
391
+ includeInDebugSnapshot: true,
392
+ includeInStateLogs: false,
393
+ usedInUi: true,
394
+ },
293
395
  providerAutoSelected: {
294
396
  persist: true,
295
397
  includeInDebugSnapshot: true,
@@ -340,6 +442,8 @@ export function getDefaultRampsControllerState() {
340
442
  },
341
443
  orders: [],
342
444
  autoramps: [],
445
+ moneyAccountWalletRegistrations: [],
446
+ vbaAutorampListFetchedAt: null,
343
447
  providerAutoSelected: false,
344
448
  };
345
449
  }
@@ -2390,17 +2494,31 @@ export class RampsController extends BaseController {
2390
2494
  *
2391
2495
  * The vendor `customer_id` is resolved via
2392
2496
  * {@link RampsController.resolveAutorampCustomerId} and injected into the
2393
- * request (any caller-supplied `customer_id` is overwritten).
2394
- *
2395
- * @param request - CreateAutoramp payload.
2396
- * @param options - Optional idempotency key forwarded to the proxy.
2497
+ * request (any caller-supplied `customer_id` is overwritten). The caller
2498
+ * supplies the fiat body. When that body names a source fiat and a
2499
+ * recipient wallet, the idempotency key is derived from that fiat
2500
+ * (`brl-musd-monad` or `usd-musd-monad`), the customer, the wallet, and
2501
+ * rejected or cancelled autoramp ids. A caller-supplied key is used only
2502
+ * when the body has no source fiat or recipient address.
2503
+ *
2504
+ * @param request - CreateAutoramp payload, including the source fiat.
2505
+ * @param options - Optional idempotency key forwarded when the body does
2506
+ * not identify a fiat route.
2397
2507
  * @param options.idempotencyKey - Value sent as `Idempotency-Key`.
2398
2508
  * @returns The created/updated local {@link AutorampAccount}.
2399
2509
  */
2400
2510
  async createAutoramp(request, options = {}) {
2401
2511
  const customerId = await this.resolveAutorampCustomerId();
2512
+ const fiatCode = readCreateAutorampFiatCode(request);
2513
+ const walletAddress = readCreateAutorampWalletAddress(request);
2514
+ const requestOptions = fiatCode && walletAddress
2515
+ ? {
2516
+ ...options,
2517
+ idempotencyKey: buildMusdAutorampIdempotencyKey(fiatCode, customerId, walletAddress, this.#terminalAutorampIdsForWallet(walletAddress)),
2518
+ }
2519
+ : options;
2402
2520
  const body = { ...request, customer_id: customerId };
2403
- const remote = await this.messenger.call('NeoBankService:createAutoramp', body, options);
2521
+ const remote = await this.messenger.call('NeoBankService:createAutoramp', body, requestOptions);
2404
2522
  return this.#applyAutorampRemoteSnapshot(remote);
2405
2523
  }
2406
2524
  /**
@@ -2498,7 +2616,7 @@ export class RampsController extends BaseController {
2498
2616
  };
2499
2617
  const existingResult = await resolveLookup();
2500
2618
  if (existingResult) {
2501
- return existingResult;
2619
+ return this.#finishMoneyAccountWalletRegistration(address, existingResult);
2502
2620
  }
2503
2621
  let idempotencyKey = createIdempotencyKey();
2504
2622
  let lastMessage;
@@ -2533,7 +2651,7 @@ export class RampsController extends BaseController {
2533
2651
  idempotencyKey,
2534
2652
  });
2535
2653
  machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' });
2536
- return result;
2654
+ return this.#finishMoneyAccountWalletRegistration(address, result);
2537
2655
  }
2538
2656
  catch (error) {
2539
2657
  if (!(error instanceof WalletRegistrationError)) {
@@ -2576,7 +2694,7 @@ export class RampsController extends BaseController {
2576
2694
  machine.status === 'checkThenRetry') {
2577
2695
  const reconciledResult = await resolveLookup();
2578
2696
  if (reconciledResult) {
2579
- return reconciledResult;
2697
+ return this.#finishMoneyAccountWalletRegistration(address, reconciledResult);
2580
2698
  }
2581
2699
  }
2582
2700
  if (machine.status !== 'signing') {
@@ -2586,23 +2704,37 @@ export class RampsController extends BaseController {
2586
2704
  }
2587
2705
  }
2588
2706
  /**
2589
- * Refreshes KYC session facts and, when Iron has approved KYC, activates the
2590
- * Money Account (wallet registration + autoramp). Hosts map the returned
2707
+ * Reads VBA onboarding facts. Hosts map the returned
2591
2708
  * {@link VbaOnboardingSnapshot} onto their own funnel; this method does not
2592
2709
  * name screens.
2593
2710
  *
2594
- * Overlapping calls share one run so polling cannot trigger duplicate wallet
2595
- * signatures or autoramp creation.
2711
+ * The read uses the persisted KYC session. `refreshKyc` performs one
2712
+ * session-status GET when a session id is already stored and `finalStatus`
2713
+ * is not terminal. `refreshAutoramps` lists autoramps when KYC is approved,
2714
+ * unless this wallet already has a usable route. It does not sign, post a
2715
+ * self-hosted address, create an autoramp, or start KYC polling.
2716
+ *
2717
+ * Overlapping calls share one run.
2596
2718
  *
2597
2719
  * @param params - VBA onboarding parameters.
2598
2720
  * @param params.walletAddress - Monad Money Account wallet address.
2721
+ * @param params.refreshKyc - When true, fetch session status once for a
2722
+ * non-terminal persisted session. A failed GET keeps that session. Defaults
2723
+ * to false.
2724
+ * @param params.refreshAutoramps - When true, replace the autoramp cursor
2725
+ * from `GET /neobank/autoramps` unless a usable route is already stored.
2726
+ * Defaults to false.
2599
2727
  * @returns Independent KYC and autoramp facts for the current customer.
2600
2728
  */
2601
- async hydrateVbaOnboarding({ walletAddress, }) {
2729
+ async hydrateVbaOnboarding({ walletAddress, refreshKyc = false, refreshAutoramps = false, }) {
2602
2730
  if (this.#vbaOnboardingHydrationPromise) {
2603
2731
  return await this.#vbaOnboardingHydrationPromise;
2604
2732
  }
2605
- const hydrationPromise = this.#hydrateVbaOnboarding(walletAddress);
2733
+ const hydrationPromise = this.#hydrateVbaOnboarding({
2734
+ walletAddress,
2735
+ refreshKyc,
2736
+ refreshAutoramps,
2737
+ });
2606
2738
  this.#vbaOnboardingHydrationPromise = hydrationPromise;
2607
2739
  try {
2608
2740
  return await hydrationPromise;
@@ -2613,46 +2745,39 @@ export class RampsController extends BaseController {
2613
2745
  }
2614
2746
  }
2615
2747
  }
2616
- async #hydrateVbaOnboarding(walletAddress) {
2617
- // Prefer the in-memory/persisted session status over the backend
2618
- // latest-status endpoint: after SumSub the backend endpoint can lag, while
2619
- // the controller state reflects the journey/SDK outcome. Fall back to a
2620
- // backend fetch only when the controller has no session in state (e.g. a
2621
- // reinstall/cleared state resuming an existing customer, or a brand-new user
2622
- // with no session at all).
2623
- let session = null;
2624
- // Whether `session` came from persisted controller state (as opposed to a
2625
- // fresh backend fetch, which is always scoped to the current user). Only a
2626
- // persisted session can belong to a previous identity, so only that path
2627
- // needs the ownership check below.
2628
- let sessionFromCache = false;
2629
- try {
2630
- session = this.messenger.call('KycController:refreshSessionStatus');
2631
- sessionFromCache = true;
2748
+ async #hydrateVbaOnboarding({ walletAddress, refreshKyc, refreshAutoramps, }) {
2749
+ let session = this.messenger.call('KycController:readSessionStatus');
2750
+ // A persisted session can outlive the identity that created it. Reusing it
2751
+ // makes session-scoped calls fail closed. Discard it before any fetch, with
2752
+ // the registration rows and autoramp cursor it left behind, since neither
2753
+ // is keyed by profile.
2754
+ if (session && !(await this.#isVbaSessionOwnedByCurrentProfile(session))) {
2755
+ this.messenger.call('KycController:clearState');
2756
+ this.update((state) => {
2757
+ state.moneyAccountWalletRegistrations = [];
2758
+ state.autoramps = [];
2759
+ state.vbaAutorampListFetchedAt = null;
2760
+ });
2761
+ return { ...EMPTY_VBA_ONBOARDING_SNAPSHOT };
2632
2762
  }
2633
- catch {
2763
+ if (refreshKyc &&
2764
+ session?.id &&
2765
+ !TERMINAL_VBA_KYC_FINAL_STATUSES.has(session.finalStatus)) {
2634
2766
  try {
2635
- session = await this.messenger.call('KycController:getSessionStatusForVendor', 'iron');
2767
+ session = await this.messenger.call('KycController:fetchSessionStatusOnce', session.id);
2636
2768
  }
2637
- catch {
2638
- // No session exists for this customer yet: the backend returns 404
2639
- // ("KYC session not found"), which surfaces as a rejection here.
2640
- session = null;
2769
+ catch (error) {
2770
+ if (error instanceof HttpError && error.httpStatus === 404) {
2771
+ this.messenger.call('KycController:clearState');
2772
+ return { ...EMPTY_VBA_ONBOARDING_SNAPSHOT };
2773
+ }
2774
+ // The persisted session is already in hand. A transient status GET
2775
+ // must not reject the read.
2641
2776
  }
2642
2777
  }
2643
2778
  if (!session) {
2644
2779
  return { ...EMPTY_VBA_ONBOARDING_SNAPSHOT };
2645
2780
  }
2646
- // A persisted session can outlive the identity that created it — e.g. a new
2647
- // wallet created over an install that still holds a previous customer's
2648
- // session. Reusing it makes the backend reject every session-scoped call
2649
- // (owner mismatch), dead-ending the user. Verify ownership up front against
2650
- // the signed-in profile and, on a mismatch, discard the stale session.
2651
- if (sessionFromCache &&
2652
- !(await this.#isVbaSessionOwnedByCurrentProfile(session))) {
2653
- this.messenger.call('KycController:clearState');
2654
- return { ...EMPTY_VBA_ONBOARDING_SNAPSHOT };
2655
- }
2656
2781
  const vendorDisclaimersComplete = await this.messenger.call('KycController:hasCompletedVendorDisclaimers');
2657
2782
  const sessionDisclaimersComplete = await this.messenger.call('KycController:hasCompletedSessionDisclaimers');
2658
2783
  const providerFlowStatus = this.messenger.call('KycController:getProviderFlowStatus');
@@ -2661,15 +2786,7 @@ export class RampsController extends BaseController {
2661
2786
  vendorDisclaimersComplete,
2662
2787
  sessionDisclaimersComplete,
2663
2788
  providerFlowStatus,
2664
- // The relay approves (`kycStatus: 'approved'`) as soon as the KYC decision
2665
- // is made, while the vendor-side `finalStatus` can lag at `'pending'` until
2666
- // the vendor record is finalized (and in sandbox may never advance). Treat
2667
- // either signal as approval so a relay-approved user is not stranded on the
2668
- // pending screen. This mirrors the UKYC backend, whose own "already
2669
- // approved" check is `finalStatus === 'approved' || kycStatus === 'approved'`.
2670
- kycStatus: session.finalStatus === 'approved' || session.kycStatus === 'approved'
2671
- ? 'approved'
2672
- : toVbaKycStatus(session.finalStatus),
2789
+ kycStatus: resolveVbaKycStatus(session, providerFlowStatus),
2673
2790
  autorampStatus: 'not_ready',
2674
2791
  };
2675
2792
  if (snapshot.kycStatus !== 'approved') {
@@ -2678,46 +2795,98 @@ export class RampsController extends BaseController {
2678
2795
  if (!walletAddress.trim()) {
2679
2796
  throw new Error('walletAddress is required after KYC acceptance.');
2680
2797
  }
2681
- // KYC is approved; the remaining work activates the Money account (register
2682
- // the wallet + ensure an autoramp). Those calls hit the neobank backend and
2683
- // can fail transiently (e.g. an address-list lookup timeout). Surface
2684
- // `retryable_failure` so the host can keep the user on a pending screen
2685
- // rather than a fatal error — the KYC decision itself already succeeded.
2686
- try {
2687
- const registration = await this.registerMoneyAccountWallet({
2688
- address: walletAddress,
2689
- });
2690
- if (registration.type === 'lookupUnavailable') {
2691
- throw registration.error;
2798
+ if (this.#hasUsableAutorampForWallet(walletAddress)) {
2799
+ return { ...snapshot, autorampStatus: 'ready' };
2800
+ }
2801
+ if (refreshAutoramps) {
2802
+ try {
2803
+ await this.#replaceAutorampCursorFromList();
2692
2804
  }
2693
- const customerId = await this.resolveAutorampCustomerId();
2694
- const remoteAutoramps = await this.messenger.call('NeoBankService:getAutoramps', { customerId });
2695
- const remoteAutorampIds = new Set(remoteAutoramps.map((autoramp) => autoramp.id));
2696
- for (const autoramp of remoteAutoramps) {
2697
- this.#applyAutorampRemoteSnapshot(autoramp);
2805
+ catch {
2806
+ return { ...snapshot, autorampStatus: 'retryable_failure' };
2698
2807
  }
2699
- this.update((state) => {
2700
- state.autoramps = state.autoramps.filter((autoramp) => remoteAutorampIds.has(autoramp.id));
2701
- });
2702
- const trimmedWalletAddress = walletAddress.trim();
2703
- const normalizedWalletAddress = trimmedWalletAddress.toLowerCase();
2704
- const autorampsForWallet = this.state.autoramps.filter((autoramp) => autoramp.walletAddress.toLowerCase() === normalizedWalletAddress);
2705
- const hasUsableAutoramp = autorampsForWallet.some((autoramp) => autoramp.status !== AutorampStatus.Rejected &&
2706
- autoramp.status !== AutorampStatus.Cancelled);
2707
- if (!hasUsableAutoramp) {
2708
- const terminalAutorampIds = autorampsForWallet
2709
- .filter((autoramp) => autoramp.status === AutorampStatus.Rejected ||
2710
- autoramp.status === AutorampStatus.Cancelled)
2711
- .map((autoramp) => autoramp.id);
2712
- await this.createAutoramp(buildBrazilMusdAutorampRequest(trimmedWalletAddress), {
2713
- idempotencyKey: buildBrazilMusdAutorampIdempotencyKey(customerId, trimmedWalletAddress, terminalAutorampIds),
2714
- });
2808
+ if (this.#hasUsableAutorampForWallet(walletAddress)) {
2809
+ return { ...snapshot, autorampStatus: 'ready' };
2715
2810
  }
2716
2811
  }
2717
- catch {
2718
- return { ...snapshot, autorampStatus: 'retryable_failure' };
2812
+ const registration = this.state.moneyAccountWalletRegistrations.find((row) => row.walletAddress.trim().toLowerCase() ===
2813
+ walletAddress.trim().toLowerCase());
2814
+ if (!registration) {
2815
+ return { ...snapshot, autorampStatus: 'needs_wallet_registration' };
2719
2816
  }
2720
- return { ...snapshot, autorampStatus: 'ready' };
2817
+ return { ...snapshot, autorampStatus: 'needs_source_currency' };
2818
+ }
2819
+ /**
2820
+ * Writes the registration row after a successful register outcome.
2821
+ * `lookupUnavailable` writes nothing.
2822
+ *
2823
+ * @param address - Money Account wallet address.
2824
+ * @param result - Registration outcome.
2825
+ * @returns The same outcome.
2826
+ */
2827
+ #finishMoneyAccountWalletRegistration(address, result) {
2828
+ if (result.type === 'lookupUnavailable') {
2829
+ return result;
2830
+ }
2831
+ const normalized = address.trim().toLowerCase();
2832
+ const status = result.type === 'registeredDisabled' ? 'disabled' : 'active';
2833
+ const row = {
2834
+ walletAddress: normalized,
2835
+ status,
2836
+ updatedAt: Date.now(),
2837
+ };
2838
+ this.update((state) => {
2839
+ const index = state.moneyAccountWalletRegistrations.findIndex((existing) => existing.walletAddress.trim().toLowerCase() === normalized);
2840
+ if (index === -1) {
2841
+ state.moneyAccountWalletRegistrations.push(row);
2842
+ }
2843
+ else {
2844
+ state.moneyAccountWalletRegistrations[index] = row;
2845
+ }
2846
+ });
2847
+ return result;
2848
+ }
2849
+ /**
2850
+ * Ids of rejected or cancelled autoramps for a wallet, used in create keys.
2851
+ *
2852
+ * @param walletAddress - Destination wallet address.
2853
+ * @returns Terminal autoramp ids for that wallet.
2854
+ */
2855
+ #terminalAutorampIdsForWallet(walletAddress) {
2856
+ const normalized = walletAddress.trim().toLowerCase();
2857
+ return this.state.autoramps
2858
+ .filter((autoramp) => autoramp.walletAddress.toLowerCase() === normalized &&
2859
+ (autoramp.status === AutorampStatus.Rejected ||
2860
+ autoramp.status === AutorampStatus.Cancelled))
2861
+ .map((autoramp) => autoramp.id);
2862
+ }
2863
+ /**
2864
+ * Whether this wallet has an autoramp that is not Rejected or Cancelled.
2865
+ *
2866
+ * @param walletAddress - Destination wallet address.
2867
+ * @returns Whether a usable route is already on the cursor.
2868
+ */
2869
+ #hasUsableAutorampForWallet(walletAddress) {
2870
+ const normalized = walletAddress.trim().toLowerCase();
2871
+ return this.state.autoramps.some((autoramp) => autoramp.walletAddress.toLowerCase() === normalized &&
2872
+ autoramp.status !== AutorampStatus.Rejected &&
2873
+ autoramp.status !== AutorampStatus.Cancelled);
2874
+ }
2875
+ /**
2876
+ * Replaces the autoramp cursor from `GET /neobank/autoramps` and records
2877
+ * `vbaAutorampListFetchedAt` only after that GET succeeds.
2878
+ */
2879
+ async #replaceAutorampCursorFromList() {
2880
+ const customerId = await this.resolveAutorampCustomerId();
2881
+ const remoteAutoramps = await this.messenger.call('NeoBankService:getAutoramps', { customerId });
2882
+ const remoteAutorampIds = new Set(remoteAutoramps.map((autoramp) => autoramp.id));
2883
+ for (const autoramp of remoteAutoramps) {
2884
+ this.#applyAutorampRemoteSnapshot(autoramp);
2885
+ }
2886
+ this.update((state) => {
2887
+ state.autoramps = state.autoramps.filter((autoramp) => remoteAutorampIds.has(autoramp.id));
2888
+ state.vbaAutorampListFetchedAt = Date.now();
2889
+ });
2721
2890
  }
2722
2891
  /**
2723
2892
  * Whether a persisted KYC session belongs to the currently signed-in profile.