@metamask/ramps-controller 22.0.0 → 24.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,6 +1,7 @@
1
1
  import { BaseController } from '@metamask/base-controller';
2
2
  import { BrokenCircuitError } from '@metamask/controller-utils';
3
- import { applyAutorampRemoteStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js';
3
+ import { BigNumber } from 'bignumber.js';
4
+ import { applyAutorampRemoteStatus, AutorampStatus, createAutorampAccount, markAutorampNotified, } from './autorampAccount.js';
4
5
  import { getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js';
5
6
  import { areOrdersEqual, deleteOrderInUserStorage, syncOrdersWithUserStorage as syncOrdersWithUserStorageInternal, updateOrderInUserStorage, } from './order-syncing/index.js';
6
7
  import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js';
@@ -61,6 +62,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [
61
62
  'TransakService:cancelAllActiveOrders',
62
63
  'TransakService:getActiveOrders',
63
64
  'NeoBankService:getAutoramp',
65
+ 'NeoBankService:getAutoramps',
64
66
  'NeoBankService:createAutoramp',
65
67
  'NeoBankService:getCustomerByExternalId',
66
68
  'NeoBankService:getWalletRegistrationStatus',
@@ -79,11 +81,28 @@ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [
79
81
  'AuthenticationController:getSessionProfile',
80
82
  'AuthenticationController:isSignedIn',
81
83
  'KeyringController:signPersonalMessage',
84
+ 'KycController:getSessionStatusForVendor',
85
+ 'KycController:refreshSessionStatus',
86
+ 'KycController:hasCompletedVendorDisclaimers',
87
+ 'KycController:hasCompletedSessionDisclaimers',
82
88
  'RemoteFeatureFlagController:getState',
83
89
  'UserStorageController:getState',
84
90
  'UserStorageController:performGetStorageAllFeatureEntries',
85
91
  'UserStorageController:performBatchSetStorage',
86
92
  ];
93
+ /**
94
+ * The Mobile route for the current VBA onboarding step.
95
+ */
96
+ export var VbaOnboardingStage;
97
+ (function (VbaOnboardingStage) {
98
+ VbaOnboardingStage["EmailOtpRequired"] = "EmailOtpRequired";
99
+ VbaOnboardingStage["VendorTermsRequired"] = "VendorTermsRequired";
100
+ VbaOnboardingStage["ProviderTermsRequired"] = "ProviderTermsRequired";
101
+ VbaOnboardingStage["KycRequired"] = "KycRequired";
102
+ VbaOnboardingStage["KycPending"] = "KycPending";
103
+ VbaOnboardingStage["KycRejected"] = "KycRejected";
104
+ VbaOnboardingStage["Completed"] = "Completed";
105
+ })(VbaOnboardingStage || (VbaOnboardingStage = {}));
87
106
  /**
88
107
  * Distinguishes an already-materialized {@link AutorampAccount} from the
89
108
  * create-fields shape accepted by {@link RampsController.addAutoramp}.
@@ -218,6 +237,12 @@ const rampsControllerMetadata = {
218
237
  includeInStateLogs: true,
219
238
  usedInUi: true,
220
239
  },
240
+ vbaOnboardingStage: {
241
+ persist: true,
242
+ includeInDebugSnapshot: true,
243
+ includeInStateLogs: true,
244
+ usedInUi: true,
245
+ },
221
246
  };
222
247
  /**
223
248
  * Creates a default resource state object.
@@ -263,6 +288,7 @@ export function getDefaultRampsControllerState() {
263
288
  orders: [],
264
289
  autoramps: [],
265
290
  providerAutoSelected: false,
291
+ vbaOnboardingStage: null,
266
292
  };
267
293
  }
268
294
  const DEPENDENT_RESOURCE_KEYS = [
@@ -409,10 +435,12 @@ const MESSENGER_EXPOSED_METHODS = [
409
435
  'getPaymentMethodsForContext',
410
436
  'setSelectedPaymentMethod',
411
437
  'getQuotes',
438
+ 'getQuoteWithFees',
412
439
  'addOrder',
413
440
  'removeOrder',
414
441
  'addAutoramp',
415
442
  'createAutoramp',
443
+ 'hydrateVbaOnboarding',
416
444
  'removeAutoramp',
417
445
  'registerMoneyAccountWallet',
418
446
  'markAutorampAsNotified',
@@ -474,6 +502,27 @@ function contextStillMatches(state, context) {
474
502
  context.assetId &&
475
503
  (state.providers.selected?.id.trim() ?? '') === context.providerId);
476
504
  }
505
+ /**
506
+ * Provider codes that identify Transak's native (non-aggregator) integration,
507
+ * in the bare form produced by {@link normalizeHeadlessProviderId}.
508
+ */
509
+ const NATIVE_TRANSAK_PROVIDER_CODES = [
510
+ 'transak-native',
511
+ 'transak-native-staging',
512
+ ];
513
+ /**
514
+ * Coerces a quote fee value to a non-negative BigNumber, treating a missing or
515
+ * invalid value as zero.
516
+ *
517
+ * @param value - Raw fee value from a quote.
518
+ * @returns The fee as a non-negative BigNumber.
519
+ */
520
+ function getSafeRampsFee(value) {
521
+ const fee = new BigNumber(value ?? 0);
522
+ return fee.isFinite() && fee.isGreaterThanOrEqualTo(0)
523
+ ? fee
524
+ : new BigNumber(0);
525
+ }
477
526
  export class RampsController extends BaseController {
478
527
  /**
479
528
  * Default TTL for cached requests.
@@ -504,6 +553,7 @@ export class RampsController extends BaseController {
504
553
  #orderPollingTimer = null;
505
554
  #isPolling = false;
506
555
  #initPromise = null;
556
+ #vbaOnboardingHydrationPromise = null;
507
557
  /**
508
558
  * Semaphore that prevents sync feedback loops while applying remote order changes.
509
559
  */
@@ -1666,6 +1716,120 @@ export class RampsController extends BaseController {
1666
1716
  ],
1667
1717
  };
1668
1718
  }
1719
+ /**
1720
+ * Fetches the best on-ramp quote for a request and, when the resolved
1721
+ * provider is Transak Native, reconciles its fees to match what Transak
1722
+ * Native actually charges.
1723
+ *
1724
+ * The aggregator `/quotes` estimate of Transak's fee does not match the
1725
+ * native integration. When the resolved provider is Transak Native this
1726
+ * fetches the native buy quote (an unauthenticated, API-key-only lookup, so
1727
+ * it is safe at estimate time) and rewrites the returned quote's fee fields
1728
+ * to its `totalFee`, keeping the aggregator's `networkFee` on the network
1729
+ * line and placing the remainder in the provider fee so the breakdown
1730
+ * survives and `providerFee + networkFee` still equals the native total. A
1731
+ * non-native provider, a failed lookup, or an unusable native fee returns the
1732
+ * aggregator quote unchanged.
1733
+ *
1734
+ * Consumers (e.g. `TransactionPayController`) call this instead of owning the
1735
+ * provider check, asset-id parsing, and second native quote themselves.
1736
+ *
1737
+ * @param options - Quote options; see {@link getQuotes}, plus the fee mode.
1738
+ * @param options.amount - Fiat amount for the quote.
1739
+ * @param options.assetId - CAIP-19 asset id being bought.
1740
+ * @param options.fiat - Optional fiat currency; defaults like {@link getQuotes}.
1741
+ * @param options.paymentMethods - Optional payment method ids.
1742
+ * @param options.walletAddress - Wallet address receiving the on-ramped asset.
1743
+ * @param options.isFeeExcludedFromFiat - Whether Transak adds its fee on top
1744
+ * of the fiat amount (`true`, fee-on-top) or carves it out (`false`). Must
1745
+ * mirror the eventual checkout mode so the estimate equals the charge.
1746
+ * Defaults to `true`.
1747
+ * @param options.providers - See {@link getQuotes}.
1748
+ * @param options.autoSelectProvider - See {@link getQuotes}.
1749
+ * @param options.restrictToKnownOrNativeProviders - See {@link getQuotes}.
1750
+ * @param options.preferredProviderIds - See {@link getQuotes}.
1751
+ * @param options.region - See {@link getQuotes}.
1752
+ * @param options.redirectUrl - See {@link getQuotes}.
1753
+ * @param options.action - See {@link getQuotes}.
1754
+ * @param options.forceRefresh - See {@link getQuotes}.
1755
+ * @param options.ttl - See {@link getQuotes}.
1756
+ * @returns The best quote with native-reconciled fees, or `undefined` when
1757
+ * no quote is available.
1758
+ */
1759
+ async getQuoteWithFees(options) {
1760
+ const { isFeeExcludedFromFiat = true, ...quoteOptions } = options;
1761
+ const response = await this.getQuotes(quoteOptions);
1762
+ const quote = response.success?.[0];
1763
+ if (!quote) {
1764
+ return undefined;
1765
+ }
1766
+ return this.#reconcileNativeTransakFee(quote, {
1767
+ amount: options.amount,
1768
+ assetId: options.assetId,
1769
+ fiat: options.fiat,
1770
+ // Use the resolved quote's own payment method, not the request list: the
1771
+ // aggregator may price a method other than `paymentMethods[0]` (or the
1772
+ // caller may omit the list), and the native lookup must match the quote
1773
+ // being reconciled.
1774
+ paymentMethod: quote.quote.paymentMethod,
1775
+ isFeeExcludedFromFiat,
1776
+ });
1777
+ }
1778
+ /**
1779
+ * Rewrites a quote's fees to Transak Native's own total when the resolved
1780
+ * provider is Transak Native, so an estimate matches the native charge.
1781
+ * Returns the quote unchanged for a non-native provider, a failed native
1782
+ * lookup, or an unusable native fee.
1783
+ *
1784
+ * @param quote - The resolved aggregator quote.
1785
+ * @param context - Native lookup inputs.
1786
+ * @param context.amount - Fiat amount for the native quote.
1787
+ * @param context.assetId - CAIP-19 asset id being bought.
1788
+ * @param context.fiat - Fiat currency for the native quote.
1789
+ * @param context.paymentMethod - Payment method id for the native quote.
1790
+ * @param context.isFeeExcludedFromFiat - Fee mode for the native quote.
1791
+ * @returns The quote with reconciled fees, or the original quote.
1792
+ */
1793
+ async #reconcileNativeTransakFee(quote, { amount, assetId, fiat, paymentMethod, isFeeExcludedFromFiat, }) {
1794
+ // `normalizeHeadlessProviderId` strips the `/providers/` prefix and
1795
+ // lowercases, so `/providers/transak-native` and `transak-native` both match
1796
+ // the native codes below (and the aggregator `transak` does not).
1797
+ const providerCode = normalizeHeadlessProviderId(quote.provider);
1798
+ if (!NATIVE_TRANSAK_PROVIDER_CODES.includes(providerCode)) {
1799
+ return quote;
1800
+ }
1801
+ const fiatCurrency = fiat ?? this.state.userRegion?.country?.currency;
1802
+ if (!fiatCurrency || !paymentMethod) {
1803
+ return quote;
1804
+ }
1805
+ try {
1806
+ const network = assetId.split('/')[0];
1807
+ const nativeQuote = await this.messenger.call('TransakService:getBuyQuote', fiatCurrency, assetId, network, paymentMethod, String(amount), isFeeExcludedFromFiat);
1808
+ const nativeTotalFee = new BigNumber(nativeQuote.totalFee ?? NaN);
1809
+ if (!nativeTotalFee.isFinite() || nativeTotalFee.isLessThan(0)) {
1810
+ return quote;
1811
+ }
1812
+ // Transak Native returns a single total fee, so keep the aggregator's
1813
+ // network fee on the network line (clamped to the native total) and put
1814
+ // the remainder in the provider fee. The breakdown survives and
1815
+ // `providerFee + networkFee` still equals the native total.
1816
+ const aggregatorNetworkFee = getSafeRampsFee(quote.quote.networkFee);
1817
+ const networkFee = BigNumber.min(aggregatorNetworkFee, nativeTotalFee);
1818
+ const providerFee = nativeTotalFee.minus(networkFee);
1819
+ return {
1820
+ ...quote,
1821
+ quote: {
1822
+ ...quote.quote,
1823
+ providerFee: providerFee.toString(10),
1824
+ networkFee: networkFee.toString(10),
1825
+ totalFees: nativeTotalFee.toString(10),
1826
+ },
1827
+ };
1828
+ }
1829
+ catch {
1830
+ return quote;
1831
+ }
1832
+ }
1669
1833
  /**
1670
1834
  * Selects the best quote from a widened multi-provider response.
1671
1835
  *
@@ -2335,6 +2499,132 @@ export class RampsController extends BaseController {
2335
2499
  }
2336
2500
  }
2337
2501
  }
2502
+ /**
2503
+ * Hydrates the Mobile-routable VBA onboarding stage from KYC state and
2504
+ * completes wallet and autoramp setup after KYC acceptance.
2505
+ *
2506
+ * Overlapping calls share one run so polling cannot trigger duplicate wallet
2507
+ * signatures or autoramp creation.
2508
+ *
2509
+ * @param params - VBA onboarding parameters.
2510
+ * @param params.walletAddress - Monad Money Account wallet address.
2511
+ * @returns The hydrated onboarding stage.
2512
+ */
2513
+ async hydrateVbaOnboarding({ walletAddress, }) {
2514
+ if (this.#vbaOnboardingHydrationPromise) {
2515
+ return await this.#vbaOnboardingHydrationPromise;
2516
+ }
2517
+ const hydrationPromise = this.#hydrateVbaOnboarding(walletAddress);
2518
+ this.#vbaOnboardingHydrationPromise = hydrationPromise;
2519
+ try {
2520
+ return await hydrationPromise;
2521
+ }
2522
+ finally {
2523
+ if (this.#vbaOnboardingHydrationPromise === hydrationPromise) {
2524
+ this.#vbaOnboardingHydrationPromise = null;
2525
+ }
2526
+ }
2527
+ }
2528
+ async #hydrateVbaOnboarding(walletAddress) {
2529
+ // Fetch the customer's latest session from the vendor account so each stage
2530
+ // reflects backend truth (e.g. re-verification required after a new
2531
+ // document) rather than only device-local state. A `null` session means no
2532
+ // customer/session exists yet, so onboarding starts at the email step.
2533
+ // Prefer the in-memory/persisted session status over the backend
2534
+ // latest-status endpoint: after SumSub the backend endpoint lags (it still
2535
+ // reports kycStatus 'new' right after an 'approved' applicant result), while
2536
+ // the controller state reflects the journey/SDK outcome. Fall back to a
2537
+ // backend fetch only when the controller has no session in state (e.g. a
2538
+ // reinstall/cleared state resuming an existing customer, or a brand-new user
2539
+ // with no session at all).
2540
+ let session = null;
2541
+ try {
2542
+ session = this.messenger.call('KycController:refreshSessionStatus');
2543
+ }
2544
+ catch {
2545
+ try {
2546
+ session = await this.messenger.call('KycController:getSessionStatusForVendor', 'iron');
2547
+ }
2548
+ catch {
2549
+ // No session exists for this customer yet: the backend returns 404
2550
+ // ("KYC session not found"), which surfaces as a rejection here. Treat
2551
+ // it as "start onboarding at the email step" rather than an error.
2552
+ session = null;
2553
+ }
2554
+ }
2555
+ if (!session) {
2556
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.EmailOtpRequired);
2557
+ }
2558
+ if (!(await this.messenger.call('KycController:hasCompletedVendorDisclaimers'))) {
2559
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.VendorTermsRequired);
2560
+ }
2561
+ if (!(await this.messenger.call('KycController:hasCompletedSessionDisclaimers'))) {
2562
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.ProviderTermsRequired);
2563
+ }
2564
+ // Status fields draw from the KYC vocabulary (new | pending | approved |
2565
+ // rejected | retry). `finalStatus` is the vendor's final decision, which
2566
+ // stays `pending` until Iron finalizes. `kycStatus` is the SumSub applicant
2567
+ // outcome (from the journey/SDK result): `new` before the applicant runs
2568
+ // SumSub, moving to `approved`/`pending` once they submit while the vendor
2569
+ // finalizes. So gate the SumSub screen on `kycStatus`, and only complete
2570
+ // onboarding once `finalStatus` is the terminal `approved`.
2571
+ const { finalStatus, kycStatus } = session;
2572
+ if (finalStatus === 'rejected' || kycStatus === 'rejected') {
2573
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRejected);
2574
+ }
2575
+ if (finalStatus !== 'approved') {
2576
+ if (kycStatus === 'new' || kycStatus === 'retry') {
2577
+ // Applicant still has to run (or re-run) SumSub document verification.
2578
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.KycRequired);
2579
+ }
2580
+ // Submitted; vendor is finalizing → "verification in progress".
2581
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending);
2582
+ }
2583
+ if (!walletAddress.trim()) {
2584
+ throw new Error('walletAddress is required after KYC acceptance.');
2585
+ }
2586
+ // KYC is approved; the remaining work activates the Money account (register
2587
+ // the wallet + ensure an autoramp). Those calls hit the neobank backend and
2588
+ // can fail transiently (e.g. an address-list lookup timeout). If they do,
2589
+ // keep the user on the "verification in progress" screen so a refresh
2590
+ // retries the activation, rather than dropping them onto the recoverable-
2591
+ // error screen — the KYC decision itself already succeeded.
2592
+ try {
2593
+ const registration = await this.registerMoneyAccountWallet({
2594
+ address: walletAddress,
2595
+ });
2596
+ if (registration.type === 'lookupUnavailable') {
2597
+ throw registration.error;
2598
+ }
2599
+ const remoteAutoramps = await this.messenger.call('NeoBankService:getAutoramps');
2600
+ const remoteAutorampIds = new Set(remoteAutoramps.map((autoramp) => autoramp.id));
2601
+ for (const autoramp of remoteAutoramps) {
2602
+ this.#applyAutorampRemoteSnapshot(autoramp);
2603
+ }
2604
+ this.update((state) => {
2605
+ state.autoramps = state.autoramps.filter((autoramp) => remoteAutorampIds.has(autoramp.id));
2606
+ });
2607
+ const normalizedWalletAddress = walletAddress.toLowerCase();
2608
+ const hasUsableAutoramp = this.state.autoramps.some((autoramp) => autoramp.walletAddress.toLowerCase() === normalizedWalletAddress &&
2609
+ autoramp.status !== AutorampStatus.Rejected &&
2610
+ autoramp.status !== AutorampStatus.Cancelled);
2611
+ if (!hasUsableAutoramp) {
2612
+ await this.createAutoramp({});
2613
+ }
2614
+ }
2615
+ catch {
2616
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.KycPending);
2617
+ }
2618
+ return this.#setVbaOnboardingStage(VbaOnboardingStage.Completed);
2619
+ }
2620
+ #setVbaOnboardingStage(stage) {
2621
+ if (this.state.vbaOnboardingStage !== stage) {
2622
+ this.update((state) => {
2623
+ state.vbaOnboardingStage = stage;
2624
+ });
2625
+ }
2626
+ return stage;
2627
+ }
2338
2628
  /**
2339
2629
  * Removes a local autoramp last-seen cursor by id.
2340
2630
  *
@@ -2825,16 +3115,18 @@ export class RampsController extends BaseController {
2825
3115
  * @param network - The blockchain network identifier.
2826
3116
  * @param paymentMethod - The payment method identifier.
2827
3117
  * @param fiatAmount - The fiat amount as a string.
3118
+ * @param isFeeExcludedFromFiat - Whether fees are added to the fiat amount.
3119
+ * Defaults to true to preserve Unified Buy's native Transak behavior.
2828
3120
  * @returns The buy quote with pricing and fee details.
2829
3121
  */
2830
- async transakGetBuyQuote(fiatCurrency, cryptoCurrency, network, paymentMethod, fiatAmount) {
3122
+ async transakGetBuyQuote(fiatCurrency, cryptoCurrency, network, paymentMethod, fiatAmount, isFeeExcludedFromFiat = true) {
2831
3123
  this.update((state) => {
2832
3124
  state.nativeProviders.transak.buyQuote.isLoading = true;
2833
3125
  state.nativeProviders.transak.buyQuote.error = null;
2834
3126
  delete state.nativeProviders.transak.buyQuote.errorKey;
2835
3127
  });
2836
3128
  try {
2837
- const quote = await this.messenger.call('TransakService:getBuyQuote', fiatCurrency, cryptoCurrency, network, paymentMethod, fiatAmount);
3129
+ const quote = await this.messenger.call('TransakService:getBuyQuote', fiatCurrency, cryptoCurrency, network, paymentMethod, fiatAmount, isFeeExcludedFromFiat);
2838
3130
  this.update((state) => {
2839
3131
  state.nativeProviders.transak.buyQuote.data = quote;
2840
3132
  state.nativeProviders.transak.buyQuote.isLoading = false;