@unifold/core 0.1.75 → 0.1.77

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.
package/dist/index.js CHANGED
@@ -31,11 +31,16 @@ __export(index_exports, {
31
31
  DepositSession: () => DepositSession,
32
32
  DepositSessionEventType: () => DepositSessionEventType,
33
33
  DepositSessionWaitError: () => DepositSessionWaitError,
34
+ DirectExecutionEventType: () => DirectExecutionEventType,
34
35
  ExecutionStatus: () => ExecutionStatus,
35
36
  IneligibilityReason: () => IneligibilityReason,
36
37
  IntegrationProvider: () => IntegrationProvider,
37
38
  IntegrationTransferError: () => IntegrationTransferError,
38
39
  LOOKBACK_MS: () => LOOKBACK_MS,
40
+ OnrampSession: () => OnrampSession,
41
+ OnrampSessionEventType: () => OnrampSessionEventType,
42
+ OnrampSessionWaitError: () => OnrampSessionWaitError,
43
+ QUOTE_REFRESH_INTERVAL_MS: () => QUOTE_REFRESH_INTERVAL_MS,
39
44
  SCAN_NUDGE_INTERVAL_MS: () => SCAN_NUDGE_INTERVAL_MS,
40
45
  SOLANA_USDC_ADDRESS: () => SOLANA_USDC_ADDRESS,
41
46
  StripeApiResponseError: () => StripeApiResponseError,
@@ -111,7 +116,9 @@ __export(index_exports, {
111
116
  isGooglePayLimitReached: () => isGooglePayLimitReached,
112
117
  isWalletPayLimitReached: () => isWalletPayLimitReached,
113
118
  listPaymentIntentExecutions: () => listPaymentIntentExecutions,
119
+ mapDefaultOnrampToken: () => mapDefaultOnrampToken,
114
120
  mapDirectExecution: () => mapDirectExecution,
121
+ mapOnrampQuote: () => mapOnrampQuote,
115
122
  mapWalletToDepositAddress: () => mapWalletToDepositAddress,
116
123
  pollDirectExecutions: () => pollDirectExecutions,
117
124
  queryExecutions: () => queryExecutions,
@@ -184,7 +191,7 @@ function generatePrefixedKSUID(prefix) {
184
191
  }
185
192
 
186
193
  // src/lib/client-headers.ts
187
- var SDK_VERSION = true ? "0.1.75" : "0.0.0-dev";
194
+ var SDK_VERSION = true ? "0.1.77" : "0.0.0-dev";
188
195
  var CLIENT_VERSION_HEADER = "x-unifold-client-version";
189
196
  var CLIENT_USER_AGENT_HEADER = "x-unifold-client-user-agent";
190
197
  function detectRuntime() {
@@ -1966,10 +1973,17 @@ function createCoinbaseGooglePaySession(request, onrampToken, publishableKey, si
1966
1973
  }
1967
1974
 
1968
1975
  // src/lib/events.ts
1976
+ var DirectExecutionEventType = /* @__PURE__ */ ((DirectExecutionEventType2) => {
1977
+ DirectExecutionEventType2["DETECTED"] = "direct_execution.detected";
1978
+ DirectExecutionEventType2["UPDATED"] = "direct_execution.updated";
1979
+ DirectExecutionEventType2["SUCCEEDED"] = "direct_execution.succeeded";
1980
+ DirectExecutionEventType2["FAILED"] = "direct_execution.failed";
1981
+ return DirectExecutionEventType2;
1982
+ })(DirectExecutionEventType || {});
1969
1983
  var DepositEventType = /* @__PURE__ */ ((DepositEventType2) => {
1970
1984
  DepositEventType2["ONRAMP_SESSION_CREATED"] = "onramp_session.created";
1971
- DepositEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
1972
- DepositEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed";
1985
+ DepositEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */;
1986
+ DepositEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */;
1973
1987
  DepositEventType2["METHOD_SELECTED"] = "deposit.method_selected";
1974
1988
  DepositEventType2["TOKEN_SELECTED"] = "deposit.token_selected";
1975
1989
  DepositEventType2["WALLET_SELECTED"] = "deposit.wallet_selected";
@@ -1989,8 +2003,8 @@ var DepositEventType = /* @__PURE__ */ ((DepositEventType2) => {
1989
2003
  return DepositEventType2;
1990
2004
  })(DepositEventType || {});
1991
2005
  var WithdrawEventType = /* @__PURE__ */ ((WithdrawEventType2) => {
1992
- WithdrawEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
1993
- WithdrawEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed";
2006
+ WithdrawEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */;
2007
+ WithdrawEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */;
1994
2008
  WithdrawEventType2["TOKEN_SELECTED"] = "withdraw.token_selected";
1995
2009
  WithdrawEventType2["FLOW_STARTED"] = "withdraw.flow_started";
1996
2010
  return WithdrawEventType2;
@@ -2089,6 +2103,52 @@ function mapDirectExecution(execution) {
2089
2103
  } : void 0
2090
2104
  };
2091
2105
  }
2106
+ function mapOnrampQuote(quote) {
2107
+ return {
2108
+ serviceProvider: quote.service_provider,
2109
+ serviceProviderDisplayName: quote.service_provider_display_name,
2110
+ paymentMethodType: quote.payment_method_type,
2111
+ sourceCurrency: quote.source_currency,
2112
+ countryCode: quote.country_code,
2113
+ sourceAmount: quote.source_amount,
2114
+ sourceAmountWithoutFees: quote.source_amount_without_fees,
2115
+ totalFee: quote.total_fee,
2116
+ networkFee: quote.network_fee,
2117
+ transactionFee: quote.transaction_fee,
2118
+ partnerFee: quote.partner_fee,
2119
+ destinationAmount: quote.destination_amount,
2120
+ destinationAmountWithoutFees: quote.destination_amount_without_fees,
2121
+ destinationCurrency: quote.destination_currency,
2122
+ destinationNetwork: quote.destination_network,
2123
+ exchangeRate: quote.exchange_rate,
2124
+ customerScore: quote.customer_score,
2125
+ lowKyc: quote.low_kyc,
2126
+ iconUrl: quote.icon_url,
2127
+ iconUrls: quote.icon_urls,
2128
+ institutionName: quote.institution_name
2129
+ };
2130
+ }
2131
+ function mapDefaultOnrampToken(response) {
2132
+ const metadata = response.destination_token_metadata;
2133
+ return {
2134
+ network: response.destination_network,
2135
+ currency: response.destination_currency,
2136
+ isStablecoin: response.is_stablecoin ?? false,
2137
+ token: {
2138
+ symbol: metadata.symbol,
2139
+ name: metadata.name,
2140
+ tokenAddress: metadata.token_address,
2141
+ decimals: metadata.decimals,
2142
+ chainId: metadata.chain_id,
2143
+ chainType: metadata.chain_type,
2144
+ chainName: metadata.chain_name,
2145
+ iconUrl: metadata.icon_url,
2146
+ iconUrls: metadata.icon_urls,
2147
+ chainIconUrl: metadata.chain?.icon_url ?? ""
2148
+ },
2149
+ estimatedProcessingTimeSeconds: response.estimated_processing_time
2150
+ };
2151
+ }
2092
2152
 
2093
2153
  // src/lib/deposit-session.ts
2094
2154
  var DETECTION_POLL_INTERVAL_MS = 2500;
@@ -2096,16 +2156,16 @@ var SCAN_NUDGE_INTERVAL_MS = 5e3;
2096
2156
  var DETECTION_ARM_DELAY_MS = 5e3;
2097
2157
  var LOOKBACK_MS = 6e4;
2098
2158
  var ADDRESS_CREATE_MAX_ATTEMPTS = 4;
2099
- var DepositSessionEventType = /* @__PURE__ */ ((DepositSessionEventType2) => {
2159
+ var DepositSessionEventType = ((DepositSessionEventType2) => {
2100
2160
  DepositSessionEventType2["SESSION_STARTED"] = "deposit_session.started";
2101
2161
  DepositSessionEventType2["ADDRESSES_CREATED"] = "deposit_session.addresses_created";
2102
2162
  DepositSessionEventType2["CONFIRMATION_STARTED"] = "deposit_session.confirmation_started";
2103
2163
  DepositSessionEventType2["SESSION_STOPPED"] = "deposit_session.stopped";
2104
2164
  DepositSessionEventType2["SESSION_ERRORED"] = "deposit_session.errored";
2105
- DepositSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected";
2106
- DepositSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated";
2107
- DepositSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
2108
- DepositSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed";
2165
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected" /* DETECTED */] = "EXECUTION_DETECTED";
2166
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated" /* UPDATED */] = "EXECUTION_UPDATED";
2167
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */] = "EXECUTION_SUCCEEDED";
2168
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */] = "EXECUTION_FAILED";
2109
2169
  return DepositSessionEventType2;
2110
2170
  })(DepositSessionEventType || {});
2111
2171
  var IN_PROGRESS_STATUSES = [
@@ -2328,17 +2388,17 @@ var DepositSession = class {
2328
2388
  }
2329
2389
  const offs = [
2330
2390
  this.on(
2331
- "direct_execution.succeeded" /* EXECUTION_SUCCEEDED */,
2391
+ DepositSessionEventType.EXECUTION_SUCCEEDED,
2332
2392
  (event) => settle(() => resolve(event.data.object))
2333
2393
  ),
2334
- this.on("direct_execution.failed" /* EXECUTION_FAILED */, (event) => {
2394
+ this.on(DepositSessionEventType.EXECUTION_FAILED, (event) => {
2335
2395
  if (this.anyExecutionInFlight()) return;
2336
2396
  rejectFailure(event.data.object);
2337
2397
  }),
2338
2398
  // A previously-failed wait condition can become settleable when
2339
2399
  // the last in-flight execution also fails (updated → failed is
2340
2400
  // covered above; updated → refunded transitions re-check here).
2341
- this.on("direct_execution.updated" /* EXECUTION_UPDATED */, () => {
2401
+ this.on(DepositSessionEventType.EXECUTION_UPDATED, () => {
2342
2402
  if (this.anyExecutionInFlight() || this.firstSuccess) return;
2343
2403
  const failed = this.snapshot.executions.find(
2344
2404
  (execution) => FAILURE_STATUSES.includes(execution.status)
@@ -2628,18 +2688,18 @@ var DepositSession = class {
2628
2688
  this.commit();
2629
2689
  const eventCreated = this.executionEventTimestamp(wire);
2630
2690
  if (previousStatus === null) {
2631
- this.emitExecutionEvent("direct_execution.detected" /* EXECUTION_DETECTED */, execution, eventCreated);
2691
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_DETECTED, execution, eventCreated);
2632
2692
  } else {
2633
2693
  this.emitExecutionEvent(
2634
- "direct_execution.updated" /* EXECUTION_UPDATED */,
2694
+ DepositSessionEventType.EXECUTION_UPDATED,
2635
2695
  { ...execution, previousStatus },
2636
2696
  eventCreated
2637
2697
  );
2638
2698
  }
2639
2699
  if (wire.status === "succeeded" /* SUCCEEDED */ && wasInProgressOrNew) {
2640
- this.emitExecutionEvent("direct_execution.succeeded" /* EXECUTION_SUCCEEDED */, execution, eventCreated);
2700
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_SUCCEEDED, execution, eventCreated);
2641
2701
  } else if (FAILURE_STATUSES.includes(wire.status) && (previousStatus === null || !FAILURE_STATUSES.includes(previousStatus))) {
2642
- this.emitExecutionEvent("direct_execution.failed" /* EXECUTION_FAILED */, execution, eventCreated);
2702
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_FAILED, execution, eventCreated);
2643
2703
  }
2644
2704
  this.notify();
2645
2705
  }
@@ -2742,6 +2802,995 @@ var DepositSession = class {
2742
2802
  }
2743
2803
  };
2744
2804
 
2805
+ // src/lib/onramp-session.ts
2806
+ var QUOTE_REFRESH_INTERVAL_MS = 6e4;
2807
+ var ADDRESS_CREATE_MAX_ATTEMPTS2 = 4;
2808
+ var OnrampSessionEventType = ((OnrampSessionEventType2) => {
2809
+ OnrampSessionEventType2["SESSION_STARTED"] = "onramp_session.started";
2810
+ OnrampSessionEventType2["ADDRESSES_CREATED"] = "onramp_session.addresses_created";
2811
+ OnrampSessionEventType2["QUOTES_UPDATED"] = "onramp_session.quotes_updated";
2812
+ OnrampSessionEventType2[OnrampSessionEventType2["CHECKOUT_CREATED"] = "onramp_session.created" /* ONRAMP_SESSION_CREATED */] = "CHECKOUT_CREATED";
2813
+ OnrampSessionEventType2["SESSION_STOPPED"] = "onramp_session.stopped";
2814
+ OnrampSessionEventType2["SESSION_ERRORED"] = "onramp_session.errored";
2815
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected" /* DETECTED */] = "EXECUTION_DETECTED";
2816
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated" /* UPDATED */] = "EXECUTION_UPDATED";
2817
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */] = "EXECUTION_SUCCEEDED";
2818
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */] = "EXECUTION_FAILED";
2819
+ return OnrampSessionEventType2;
2820
+ })(OnrampSessionEventType || {});
2821
+ var FAILURE_STATUSES2 = ["failed" /* FAILED */, "refunded" /* REFUNDED */];
2822
+ var IN_PROGRESS_STATUSES2 = [
2823
+ "pending" /* PENDING */,
2824
+ "waiting" /* WAITING */,
2825
+ "delayed" /* DELAYED */
2826
+ ];
2827
+ var OnrampSessionWaitError = class extends Error {
2828
+ constructor(code, message, cause) {
2829
+ super(message);
2830
+ __publicField(this, "code");
2831
+ /** Failed execution (`DEPOSIT_FAILED`) or fatal {@link OnrampSessionError} (`SESSION_ERROR`). */
2832
+ __publicField(this, "cause");
2833
+ this.name = "OnrampSessionWaitError";
2834
+ this.code = code;
2835
+ this.cause = cause;
2836
+ }
2837
+ };
2838
+ var SessionCheckError2 = class extends Error {
2839
+ constructor(code, message) {
2840
+ super(message);
2841
+ this.code = code;
2842
+ }
2843
+ };
2844
+ var delay2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2845
+ var OnrampSession = class {
2846
+ constructor(config) {
2847
+ /** Immutable id for correlation, `osess_<ksuid>`. Client-generated. */
2848
+ __publicField(this, "id");
2849
+ __publicField(this, "emitter", new TypedEmitter());
2850
+ __publicField(this, "listeners", /* @__PURE__ */ new Set());
2851
+ __publicField(this, "publishableKey");
2852
+ __publicField(this, "externalUserId");
2853
+ __publicField(this, "destination");
2854
+ __publicField(this, "paymentMethodType");
2855
+ __publicField(this, "email");
2856
+ __publicField(this, "quoteRefreshIntervalMs");
2857
+ __publicField(this, "method");
2858
+ // Mutable quote request (updateQuoteRequest). Exactly one of
2859
+ // sourceAmount / destinationAmount is truthy at a time (amount mode).
2860
+ // Country/subdivision: host-supplied values win; when absent they are
2861
+ // IP-detected during start() (see resolveCountry).
2862
+ __publicField(this, "explicitCountryCode");
2863
+ __publicField(this, "explicitSubdivisionCode");
2864
+ __publicField(this, "detectedCountryCode", null);
2865
+ __publicField(this, "detectedSubdivisionCode", null);
2866
+ __publicField(this, "sourceAmount");
2867
+ __publicField(this, "destinationAmount");
2868
+ __publicField(this, "sourceCurrency");
2869
+ // Run state. runToken invalidates in-flight async work across stop()/restart.
2870
+ __publicField(this, "runToken", 0);
2871
+ __publicField(this, "startPromise", null);
2872
+ __publicField(this, "destroyed", false);
2873
+ /** Pending waiter rejections, invoked by destroy() so waiters never hang. */
2874
+ __publicField(this, "waiterDestroyCallbacks", /* @__PURE__ */ new Set());
2875
+ /** In-flight destination-token + quotes pipeline (see syncQuoteInputs). */
2876
+ __publicField(this, "syncPromise", null);
2877
+ /** A sync was asked for mid-pipeline; the loop owes it another pass. */
2878
+ __publicField(this, "syncRequested", false);
2879
+ /** Code of the non-fatal error already reported, so a streak emits once. */
2880
+ __publicField(this, "nonFatalErrorLatch", null);
2881
+ /** True once run() got past preparing — gates live quote-input syncing. */
2882
+ __publicField(this, "prepared", false);
2883
+ __publicField(this, "refreshTimer", null);
2884
+ /** service_provider chosen via selectQuote(); sticky across quote refreshes. */
2885
+ __publicField(this, "manualSelection", null);
2886
+ /** First execution to succeed this run — waitForSuccess's one-shot answer. */
2887
+ __publicField(this, "firstSuccess", null);
2888
+ // Settlement watcher (composed DepositSession), created at createCheckout().
2889
+ __publicField(this, "watcher", null);
2890
+ __publicField(this, "watcherOffs", []);
2891
+ // Snapshot state
2892
+ __publicField(this, "status", "idle");
2893
+ __publicField(this, "addresses", []);
2894
+ __publicField(this, "destinationToken", null);
2895
+ /** Geo `destinationToken` was resolved for; null while unresolved. */
2896
+ __publicField(this, "destinationTokenGeoKey", null);
2897
+ __publicField(this, "quotes", []);
2898
+ __publicField(this, "selectedQuote", null);
2899
+ __publicField(this, "isRefreshingQuotes", false);
2900
+ __publicField(this, "quotesUpdatedAt", null);
2901
+ __publicField(this, "checkout", null);
2902
+ __publicField(this, "executions", []);
2903
+ __publicField(this, "checkingDeposit", false);
2904
+ __publicField(this, "error", null);
2905
+ __publicField(this, "snapshot");
2906
+ if (!config.publishableKey || config.publishableKey.trim() === "") {
2907
+ throw new Error("OnrampSession: publishableKey is required");
2908
+ }
2909
+ if (!config.externalUserId) {
2910
+ throw new Error("OnrampSession: externalUserId is required");
2911
+ }
2912
+ if (config.quoteRequest.sourceAmount && config.quoteRequest.destinationAmount) {
2913
+ throw new Error(
2914
+ "OnrampSession: provide exactly one of quoteRequest.sourceAmount / destinationAmount"
2915
+ );
2916
+ }
2917
+ this.id = generatePrefixedKSUID("osess");
2918
+ this.publishableKey = config.publishableKey;
2919
+ this.externalUserId = config.externalUserId;
2920
+ this.destination = config.destination;
2921
+ this.paymentMethodType = config.paymentMethodType ?? "card";
2922
+ this.email = config.email;
2923
+ this.quoteRefreshIntervalMs = config.quoteRefreshIntervalMs ?? QUOTE_REFRESH_INTERVAL_MS;
2924
+ this.method = this.paymentMethodType === "apple_pay" ? "apple_pay" : "card";
2925
+ this.explicitCountryCode = config.quoteRequest.countryCode;
2926
+ this.explicitSubdivisionCode = config.quoteRequest.subdivisionCode;
2927
+ this.sourceAmount = config.quoteRequest.sourceAmount;
2928
+ this.destinationAmount = config.quoteRequest.destinationAmount;
2929
+ this.sourceCurrency = config.quoteRequest.sourceCurrency ?? "usd";
2930
+ this.snapshot = this.buildSnapshot();
2931
+ }
2932
+ // -- Public surface -------------------------------------------------------
2933
+ /** Synchronous snapshot; the reference is stable until state changes. */
2934
+ getSnapshot() {
2935
+ return this.snapshot;
2936
+ }
2937
+ /**
2938
+ * Subscribe to snapshot changes (external-store contract; drives
2939
+ * `useSyncExternalStore` in the React binding). Returns an unsubscribe fn.
2940
+ */
2941
+ subscribe(listener) {
2942
+ this.listeners.add(listener);
2943
+ return () => {
2944
+ this.listeners.delete(listener);
2945
+ };
2946
+ }
2947
+ on(type, handler) {
2948
+ return this.emitter.on(type, handler);
2949
+ }
2950
+ /**
2951
+ * Creates/fetches addresses, resolves the onramp destination token (both
2952
+ * with fail-fast recipient validation), and fetches the first quotes.
2953
+ * Idempotent while running; callable again after stop() or a fatal error.
2954
+ */
2955
+ start() {
2956
+ if (this.destroyed) {
2957
+ return Promise.reject(new Error("OnrampSession has been destroyed"));
2958
+ }
2959
+ if (this.startPromise) return this.startPromise;
2960
+ this.startPromise = this.run();
2961
+ return this.startPromise;
2962
+ }
2963
+ /**
2964
+ * Update the quote request (amount / currency / country) and refetch
2965
+ * quotes. Debounce keystrokes host-side — every call that changes
2966
+ * something hits the quotes API. No-op after createCheckout().
2967
+ *
2968
+ * Amount-mode switching: patching `sourceAmount` while in destination mode
2969
+ * (or `destinationAmount` while in source mode) switches modes — the other
2970
+ * amount is cleared. Patching BOTH to truthy values in one call throws
2971
+ * (exactly one drives quoting).
2972
+ */
2973
+ updateQuoteRequest(patch) {
2974
+ if (this.destroyed) return;
2975
+ let nextSourceAmount = "sourceAmount" in patch ? patch.sourceAmount : this.sourceAmount;
2976
+ let nextDestinationAmount = "destinationAmount" in patch ? patch.destinationAmount : this.destinationAmount;
2977
+ if (nextSourceAmount && nextDestinationAmount) {
2978
+ const sourcePatched = !!patch.sourceAmount;
2979
+ const destinationPatched = !!patch.destinationAmount;
2980
+ if (sourcePatched && destinationPatched) {
2981
+ throw new Error(
2982
+ "OnrampSession.updateQuoteRequest: provide exactly one of sourceAmount / destinationAmount"
2983
+ );
2984
+ }
2985
+ if (destinationPatched) nextSourceAmount = void 0;
2986
+ else nextDestinationAmount = void 0;
2987
+ }
2988
+ const next = {
2989
+ countryCode: patch.countryCode ?? this.explicitCountryCode,
2990
+ subdivisionCode: "subdivisionCode" in patch ? patch.subdivisionCode : this.explicitSubdivisionCode,
2991
+ sourceAmount: nextSourceAmount,
2992
+ destinationAmount: nextDestinationAmount,
2993
+ sourceCurrency: patch.sourceCurrency ?? this.sourceCurrency
2994
+ };
2995
+ const changed = next.countryCode !== this.explicitCountryCode || next.subdivisionCode !== this.explicitSubdivisionCode || next.sourceAmount !== this.sourceAmount || next.destinationAmount !== this.destinationAmount || next.sourceCurrency !== this.sourceCurrency;
2996
+ this.explicitCountryCode = next.countryCode;
2997
+ this.explicitSubdivisionCode = next.subdivisionCode;
2998
+ this.sourceAmount = next.sourceAmount;
2999
+ this.destinationAmount = next.destinationAmount;
3000
+ this.sourceCurrency = next.sourceCurrency;
3001
+ if (!changed || this.checkout || !this.startPromise) return;
3002
+ if (this.prepared) {
3003
+ void this.syncQuoteInputs(this.runToken);
3004
+ }
3005
+ }
3006
+ /** Refetch quotes with the current request. Resolves when the fetch settles. */
3007
+ refreshQuotes() {
3008
+ if (this.destroyed || !this.startPromise || this.checkout || !this.prepared) {
3009
+ return Promise.resolve();
3010
+ }
3011
+ return this.syncQuoteInputs(this.runToken);
3012
+ }
3013
+ /**
3014
+ * Pick a provider quote by `serviceProvider`. The selection is sticky
3015
+ * across refreshes: while the provider keeps quoting it stays selected
3016
+ * (with fresh pricing); if it drops out, selection falls back to the
3017
+ * backend's top quote and auto-selection resumes.
3018
+ *
3019
+ * Returns the selected quote, or null when no quote matches.
3020
+ */
3021
+ selectQuote(serviceProvider) {
3022
+ if (this.destroyed) return null;
3023
+ const match = this.quotes.find((quote) => quote.serviceProvider === serviceProvider);
3024
+ if (!match) return null;
3025
+ this.manualSelection = serviceProvider;
3026
+ this.selectedQuote = match;
3027
+ this.commit();
3028
+ this.notify();
3029
+ return match;
3030
+ }
3031
+ /**
3032
+ * Build the provider-hosted checkout and start watching the deposit
3033
+ * addresses for the provider's on-chain settlement.
3034
+ *
3035
+ * Uses `snapshot.selectedQuote` (backend's top quote unless the host called
3036
+ * selectQuote()). Hosts that manage their own selection UI can instead pass
3037
+ * `options.serviceProvider` for a one-shot choice without mutating the
3038
+ * sticky selection.
3039
+ *
3040
+ * Synchronous by design: the URL is assembled locally (single-use token
3041
+ * exchange happens when it is opened), so hosts can `window.open()` the
3042
+ * result inside the click handler without tripping popup blockers.
3043
+ *
3044
+ * Throws when the session is not ready (no matching quote / addresses or
3045
+ * destination token missing) — gate your button on `status === 'ready'`
3046
+ * and `selectedQuote`.
3047
+ */
3048
+ createCheckout(options = {}) {
3049
+ if (this.destroyed) throw new Error("OnrampSession has been destroyed");
3050
+ const quote = options.serviceProvider ? this.quotes.find((q) => q.serviceProvider === options.serviceProvider) : this.selectedQuote;
3051
+ if (!quote) {
3052
+ throw new Error(
3053
+ options.serviceProvider ? `OnrampSession.createCheckout: no quote from '${options.serviceProvider}'` : "OnrampSession.createCheckout: no quote selected"
3054
+ );
3055
+ }
3056
+ if (!this.destinationToken) {
3057
+ throw new Error("OnrampSession.createCheckout: destination token not resolved yet");
3058
+ }
3059
+ const wallet = this.addresses.find(
3060
+ (address) => address.chainType === this.destinationToken?.token.chainType
3061
+ );
3062
+ if (!wallet) {
3063
+ throw new Error("OnrampSession.createCheckout: no deposit address for the onramp chain type");
3064
+ }
3065
+ const externalId = options.externalId ?? generatePrefixedKSUID("orsext");
3066
+ const request = {
3067
+ service_provider: quote.serviceProvider,
3068
+ country_code: (quote.countryCode || this.effectiveCountryCode()).toUpperCase(),
3069
+ source_currency: quote.sourceCurrency,
3070
+ // Exactly one of source_amount / destination_amount — the session's
3071
+ // current amount mode (quotes were fetched in the same mode, so the
3072
+ // quoted providers all support it).
3073
+ ...this.sourceAmount ? { source_amount: this.sourceAmount } : { destination_amount: this.destinationAmount },
3074
+ destination_currency: quote.destinationCurrency,
3075
+ destination_network: quote.destinationNetwork,
3076
+ wallet_address: wallet.address,
3077
+ subdivision_code: this.effectiveSubdivisionCode(),
3078
+ external_id: externalId,
3079
+ email: options.email ?? this.email,
3080
+ payment_method_type: quote.paymentMethodType ?? this.paymentMethodType
3081
+ };
3082
+ const checkout = {
3083
+ url: getOnrampSessionStartUrl(request, this.publishableKey),
3084
+ externalId,
3085
+ serviceProvider: quote.serviceProvider,
3086
+ quote,
3087
+ sourceAmount: this.sourceAmount ?? null,
3088
+ destinationAmount: this.sourceAmount ? null : this.destinationAmount ?? null,
3089
+ sourceCurrency: quote.sourceCurrency,
3090
+ createdAt: Date.now()
3091
+ };
3092
+ this.checkout = checkout;
3093
+ this.clearRefreshTimer();
3094
+ this.setStatus("awaiting_payment");
3095
+ this.commit();
3096
+ this.emitSessionEvent(OnrampSessionEventType.CHECKOUT_CREATED, {
3097
+ externalId,
3098
+ sessionId: this.id,
3099
+ url: checkout.url,
3100
+ serviceProvider: quote.serviceProvider
3101
+ });
3102
+ this.notify();
3103
+ this.startWatcher();
3104
+ return checkout;
3105
+ }
3106
+ /**
3107
+ * Stops quote refresh and settlement watching. The session can be
3108
+ * restarted with start(), which resets quotes/checkout/executions
3109
+ * (fresh run).
3110
+ */
3111
+ stop() {
3112
+ const wasActive = this.startPromise !== null;
3113
+ this.runToken += 1;
3114
+ this.clearRefreshTimer();
3115
+ this.teardownWatcher();
3116
+ this.startPromise = null;
3117
+ this.syncPromise = null;
3118
+ this.isRefreshingQuotes = false;
3119
+ this.checkingDeposit = false;
3120
+ if (this.status !== "idle" && this.status !== "error") {
3121
+ this.setStatus("idle");
3122
+ }
3123
+ if (wasActive && !this.destroyed) {
3124
+ this.commit();
3125
+ this.emitSessionEvent("onramp_session.stopped" /* SESSION_STOPPED */, { sessionId: this.id });
3126
+ this.notify();
3127
+ }
3128
+ }
3129
+ /** stop() + release all listeners. Terminal — start() rejects afterwards. */
3130
+ destroy() {
3131
+ this.stop();
3132
+ this.destroyed = true;
3133
+ Array.from(this.waiterDestroyCallbacks).forEach((callback) => callback());
3134
+ this.waiterDestroyCallbacks.clear();
3135
+ this.emitter.removeAllListeners();
3136
+ this.listeners.clear();
3137
+ }
3138
+ // -- Promise waiters (subscription sugar over the event stream) ------------
3139
+ /**
3140
+ * Resolve when the session reaches one of the given statuses (immediately
3141
+ * if it's already there) — e.g. `waitForStatus('ready')` awaits quotes,
3142
+ * `waitForStatus('processing')` awaits detection of the settlement.
3143
+ * Statuses carry no outcomes; await those with {@link waitForSuccess}.
3144
+ *
3145
+ * Rejects with {@link OnrampSessionWaitError} on abort or destroy().
3146
+ * Does not start or stop the session — it only listens.
3147
+ */
3148
+ waitForStatus(status, options = {}) {
3149
+ const statuses = Array.isArray(status) ? status : [status];
3150
+ return new Promise((resolve, reject) => {
3151
+ this.installWaiter({
3152
+ options,
3153
+ reject,
3154
+ subscribe: (settle) => {
3155
+ const check = () => {
3156
+ if (statuses.includes(this.snapshot.status)) settle(() => resolve(this.snapshot));
3157
+ };
3158
+ check();
3159
+ return this.subscribe(check);
3160
+ }
3161
+ });
3162
+ });
3163
+ }
3164
+ /**
3165
+ * Resolve with the **first** succeeded {@link DirectExecution} observed by
3166
+ * this session — same one-shot first-completion contract as
3167
+ * {@link DepositSession.waitForSuccess}. A failure only rejects
3168
+ * (`DEPOSIT_FAILED`) when no other observed execution is still in flight;
3169
+ * fatal session errors reject with `SESSION_ERROR`. The session keeps
3170
+ * watching after success — subscribe to `direct_execution.succeeded` to
3171
+ * react to every settlement.
3172
+ */
3173
+ waitForSuccess(options = {}) {
3174
+ return new Promise((resolve, reject) => {
3175
+ this.installWaiter({
3176
+ options,
3177
+ reject,
3178
+ subscribe: (settle) => {
3179
+ const rejectFailure = (failed) => settle(
3180
+ () => reject(new OnrampSessionWaitError("DEPOSIT_FAILED", "Deposit failed", failed))
3181
+ );
3182
+ if (this.firstSuccess) {
3183
+ const first = this.firstSuccess;
3184
+ settle(() => resolve(first));
3185
+ return () => {
3186
+ };
3187
+ }
3188
+ const alreadyFailed = this.executions.find(
3189
+ (execution) => FAILURE_STATUSES2.includes(execution.status)
3190
+ );
3191
+ if (alreadyFailed && !this.anyExecutionInFlight()) {
3192
+ rejectFailure(alreadyFailed);
3193
+ return () => {
3194
+ };
3195
+ }
3196
+ if (this.error?.fatal) {
3197
+ const fatal = this.error;
3198
+ settle(() => reject(new OnrampSessionWaitError("SESSION_ERROR", fatal.message, fatal)));
3199
+ return () => {
3200
+ };
3201
+ }
3202
+ const offs = [
3203
+ this.on(
3204
+ OnrampSessionEventType.EXECUTION_SUCCEEDED,
3205
+ (event) => settle(() => resolve(event.data.object))
3206
+ ),
3207
+ this.on(OnrampSessionEventType.EXECUTION_FAILED, (event) => {
3208
+ if (this.anyExecutionInFlight()) return;
3209
+ rejectFailure(event.data.object);
3210
+ }),
3211
+ this.on(OnrampSessionEventType.EXECUTION_UPDATED, () => {
3212
+ if (this.anyExecutionInFlight() || this.firstSuccess) return;
3213
+ const failed = this.executions.find(
3214
+ (execution) => FAILURE_STATUSES2.includes(execution.status)
3215
+ );
3216
+ if (failed) rejectFailure(failed);
3217
+ }),
3218
+ this.on("onramp_session.errored" /* SESSION_ERRORED */, (event) => {
3219
+ if (!event.data.object.fatal) return;
3220
+ settle(
3221
+ () => reject(
3222
+ new OnrampSessionWaitError(
3223
+ "SESSION_ERROR",
3224
+ event.data.object.message,
3225
+ event.data.object
3226
+ )
3227
+ )
3228
+ );
3229
+ })
3230
+ ];
3231
+ return () => offs.forEach((off) => off());
3232
+ }
3233
+ });
3234
+ });
3235
+ }
3236
+ /** Shared waiter plumbing — AbortSignal / destroy() rejection with single settlement. */
3237
+ installWaiter({
3238
+ options,
3239
+ reject,
3240
+ subscribe
3241
+ }) {
3242
+ if (this.destroyed) {
3243
+ reject(new OnrampSessionWaitError("DESTROYED", "OnrampSession has been destroyed"));
3244
+ return;
3245
+ }
3246
+ if (options.signal?.aborted) {
3247
+ reject(new OnrampSessionWaitError("ABORTED", "Wait aborted", options.signal.reason));
3248
+ return;
3249
+ }
3250
+ let settled = false;
3251
+ let unsubscribe = null;
3252
+ const cleanup = () => {
3253
+ unsubscribe?.();
3254
+ options.signal?.removeEventListener("abort", onAbort);
3255
+ this.waiterDestroyCallbacks.delete(onDestroy);
3256
+ };
3257
+ const settle = (finish) => {
3258
+ if (settled) return;
3259
+ settled = true;
3260
+ cleanup();
3261
+ finish();
3262
+ };
3263
+ const onAbort = () => settle(
3264
+ () => reject(new OnrampSessionWaitError("ABORTED", "Wait aborted", options.signal?.reason))
3265
+ );
3266
+ const onDestroy = () => settle(
3267
+ () => reject(
3268
+ new OnrampSessionWaitError("DESTROYED", "OnrampSession was destroyed while waiting")
3269
+ )
3270
+ );
3271
+ this.waiterDestroyCallbacks.add(onDestroy);
3272
+ options.signal?.addEventListener("abort", onAbort, { once: true });
3273
+ unsubscribe = subscribe(settle);
3274
+ if (settled) cleanup();
3275
+ }
3276
+ // -- Run lifecycle ---------------------------------------------------------
3277
+ async run() {
3278
+ const token = ++this.runToken;
3279
+ this.teardownWatcher();
3280
+ this.prepared = false;
3281
+ this.addresses = [];
3282
+ this.destinationToken = null;
3283
+ this.destinationTokenGeoKey = null;
3284
+ this.quotes = [];
3285
+ this.selectedQuote = null;
3286
+ this.manualSelection = null;
3287
+ this.quotesUpdatedAt = null;
3288
+ this.nonFatalErrorLatch = null;
3289
+ this.checkout = null;
3290
+ this.executions = [];
3291
+ this.firstSuccess = null;
3292
+ this.checkingDeposit = false;
3293
+ this.error = null;
3294
+ this.setStatus("preparing");
3295
+ this.commit();
3296
+ this.emitSessionEvent("onramp_session.started" /* SESSION_STARTED */, { sessionId: this.id });
3297
+ this.notify();
3298
+ let wallets;
3299
+ let destinationToken;
3300
+ try {
3301
+ [wallets, destinationToken] = await Promise.all([
3302
+ this.createAddressesWithRetry(token),
3303
+ this.resolveCountry().then(() => this.resolveDestinationToken()),
3304
+ this.runStartChecks()
3305
+ ]);
3306
+ } catch (cause) {
3307
+ if (token !== this.runToken) return;
3308
+ const isCheck = cause instanceof SessionCheckError2;
3309
+ this.failFatally(
3310
+ isCheck ? cause.code : "ADDRESS_CREATION_FAILED",
3311
+ isCheck ? cause.message : "Failed to prepare onramp session",
3312
+ cause
3313
+ );
3314
+ return;
3315
+ }
3316
+ if (token !== this.runToken) return;
3317
+ this.addresses = wallets.map(mapWalletToDepositAddress);
3318
+ this.destinationToken = destinationToken.token;
3319
+ this.destinationTokenGeoKey = destinationToken.geoKey;
3320
+ this.prepared = true;
3321
+ this.setStatus("quoting");
3322
+ this.commit();
3323
+ this.emitSessionEvent("onramp_session.addresses_created" /* ADDRESSES_CREATED */, {
3324
+ sessionId: this.id,
3325
+ addresses: this.addresses
3326
+ });
3327
+ this.notify();
3328
+ if (token !== this.runToken) return;
3329
+ await this.syncQuoteInputs(token);
3330
+ if (token !== this.runToken) return;
3331
+ if (this.quoteRefreshIntervalMs > 0) {
3332
+ this.refreshTimer = setInterval(() => {
3333
+ if (token !== this.runToken || this.checkout) return;
3334
+ void this.syncQuoteInputs(token);
3335
+ }, this.quoteRefreshIntervalMs);
3336
+ }
3337
+ }
3338
+ failFatally(code, message, cause) {
3339
+ this.clearRefreshTimer();
3340
+ this.startPromise = null;
3341
+ this.error = { code, message, fatal: true, cause };
3342
+ this.setStatus("error");
3343
+ this.commit();
3344
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3345
+ sessionId: this.id,
3346
+ code,
3347
+ message,
3348
+ fatal: true
3349
+ });
3350
+ this.notify();
3351
+ }
3352
+ async createAddressesWithRetry(token) {
3353
+ let lastError;
3354
+ for (let attempt = 0; attempt < ADDRESS_CREATE_MAX_ATTEMPTS2; attempt++) {
3355
+ if (attempt > 0) {
3356
+ await delay2(Math.min(1e3 * 2 ** (attempt - 1), 1e4));
3357
+ if (token !== this.runToken) throw new Error("OnrampSession stopped");
3358
+ }
3359
+ try {
3360
+ const response = await createDepositAddress(
3361
+ {
3362
+ external_user_id: this.externalUserId,
3363
+ destination_chain_type: this.destination.chainType,
3364
+ destination_chain_id: this.destination.chainId,
3365
+ destination_token_address: this.destination.tokenAddress,
3366
+ recipient_address: this.destination.recipientAddress,
3367
+ contract_calls: this.destination.contractCalls
3368
+ },
3369
+ this.publishableKey
3370
+ );
3371
+ return response.data;
3372
+ } catch (error) {
3373
+ lastError = error;
3374
+ }
3375
+ }
3376
+ throw lastError;
3377
+ }
3378
+ /** Host-supplied geo wins; otherwise the IP-detected value; 'US' as a last resort. */
3379
+ effectiveCountryCode() {
3380
+ return this.explicitCountryCode || this.detectedCountryCode || "US";
3381
+ }
3382
+ /**
3383
+ * Explicit subdivision wins. A detected subdivision applies only while the
3384
+ * country is also detected — mixing a detected subdivision into an
3385
+ * explicitly-set country would pin quotes to a region of the wrong country.
3386
+ */
3387
+ effectiveSubdivisionCode() {
3388
+ if (this.explicitSubdivisionCode !== void 0) return this.explicitSubdivisionCode;
3389
+ if (!this.explicitCountryCode && this.detectedCountryCode) {
3390
+ return this.detectedSubdivisionCode ?? void 0;
3391
+ }
3392
+ return void 0;
3393
+ }
3394
+ /**
3395
+ * Auto-detect the payer's country from their IP when the host didn't
3396
+ * supply one — so integrators don't need to build geo plumbing to render
3397
+ * a buy screen. Never fatal: detection failure falls back to 'US' (modal
3398
+ * parity — BuyWithCard quotes with `userIpInfo?.alpha2 || 'US'`). The
3399
+ * detection result is cached for the session's lifetime (restarts reuse
3400
+ * it); an explicit countryCode — at construction or via
3401
+ * updateQuoteRequest — always overrides.
3402
+ */
3403
+ async resolveCountry() {
3404
+ if (this.explicitCountryCode || this.detectedCountryCode) return;
3405
+ try {
3406
+ const info = await getIpAddress();
3407
+ this.detectedCountryCode = info.alpha2 || "US";
3408
+ this.detectedSubdivisionCode = info.subdivision_code ?? null;
3409
+ } catch {
3410
+ this.detectedCountryCode = "US";
3411
+ this.detectedSubdivisionCode = null;
3412
+ }
3413
+ }
3414
+ /** Identity of the geo a destination-token resolution was made for. */
3415
+ geoKey() {
3416
+ return `${this.effectiveCountryCode()}|${this.effectiveSubdivisionCode() ?? ""}`;
3417
+ }
3418
+ /**
3419
+ * Resolve the provider-side network/currency the destination maps to. The
3420
+ * geo it was resolved for travels with the result: routing is
3421
+ * geo-dependent, so the caller has to know when a later country change
3422
+ * invalidates it.
3423
+ */
3424
+ async resolveDestinationToken() {
3425
+ const geoKey = this.geoKey();
3426
+ try {
3427
+ const response = await getDefaultOnrampToken(
3428
+ {
3429
+ country_code: this.effectiveCountryCode(),
3430
+ subdivision_code: this.effectiveSubdivisionCode(),
3431
+ token_address: this.destination.tokenAddress,
3432
+ chain_id: this.destination.chainId,
3433
+ chain_type: this.destination.chainType
3434
+ },
3435
+ this.publishableKey
3436
+ );
3437
+ return { token: mapDefaultOnrampToken(response), geoKey };
3438
+ } catch {
3439
+ throw new SessionCheckError2(
3440
+ "DESTINATION_TOKEN_FAILED",
3441
+ "No onramp route available for this destination token"
3442
+ );
3443
+ }
3444
+ }
3445
+ /**
3446
+ * Fail-fast recipient validation — parity with DepositSession. Fails open
3447
+ * on network errors (the backend still enforces at execution time), but a
3448
+ * definitive negative result is fatal.
3449
+ */
3450
+ async runStartChecks() {
3451
+ const recipientValid = await verifyRecipientAddress(
3452
+ {
3453
+ chain_type: this.destination.chainType,
3454
+ chain_id: this.destination.chainId,
3455
+ token_address: this.destination.tokenAddress,
3456
+ recipient_address: this.destination.recipientAddress
3457
+ },
3458
+ this.publishableKey
3459
+ ).then((result) => result.valid).catch(() => null);
3460
+ if (recipientValid === false) {
3461
+ throw new SessionCheckError2(
3462
+ "INVALID_RECIPIENT",
3463
+ "Recipient address cannot receive funds for this destination"
3464
+ );
3465
+ }
3466
+ }
3467
+ // -- Quotes ----------------------------------------------------------------
3468
+ /**
3469
+ * Bring the quote inputs and the destination token back in sync, then
3470
+ * fetch quotes. Destination-token routing is geo-dependent (the modal
3471
+ * re-runs its default-token effect whenever `userIpInfo` changes), so a
3472
+ * payer-country change has to re-resolve it first — quoting on the network
3473
+ * and currency routed for the previous geo would also carry into the
3474
+ * checkout built from those quotes.
3475
+ */
3476
+ syncQuoteInputs(token) {
3477
+ if (this.syncPromise) {
3478
+ this.syncRequested = true;
3479
+ return this.syncPromise;
3480
+ }
3481
+ this.syncPromise = Promise.resolve().then(() => this.syncQuoteInputsUntilFresh(token)).finally(() => {
3482
+ this.syncPromise = null;
3483
+ });
3484
+ return this.syncPromise;
3485
+ }
3486
+ /** Loops until the session state matches the inputs it was built from. */
3487
+ async syncQuoteInputsUntilFresh(token) {
3488
+ while (token === this.runToken && !this.checkout) {
3489
+ this.syncRequested = false;
3490
+ if (!this.destinationToken || this.destinationTokenGeoKey !== this.geoKey()) {
3491
+ if (!await this.resolveDestinationTokenForGeo(token) && !this.syncRequested) return;
3492
+ continue;
3493
+ }
3494
+ if (!await this.fetchQuotesOnce(token) && !this.syncRequested) return;
3495
+ }
3496
+ }
3497
+ /**
3498
+ * Re-resolve the destination token for the current geo. Returns false when
3499
+ * the run ended or the lookup failed.
3500
+ */
3501
+ async resolveDestinationTokenForGeo(token) {
3502
+ this.destinationToken = null;
3503
+ this.destinationTokenGeoKey = null;
3504
+ this.quotes = [];
3505
+ this.selectedQuote = null;
3506
+ this.isRefreshingQuotes = true;
3507
+ this.commit();
3508
+ this.notify();
3509
+ let resolved;
3510
+ try {
3511
+ resolved = await this.resolveDestinationToken();
3512
+ } catch (cause) {
3513
+ if (token !== this.runToken) return false;
3514
+ this.isRefreshingQuotes = false;
3515
+ this.raiseNonFatal(
3516
+ "DESTINATION_TOKEN_FAILED",
3517
+ "No onramp route available for this destination token",
3518
+ cause
3519
+ );
3520
+ return false;
3521
+ }
3522
+ if (token !== this.runToken) return false;
3523
+ this.destinationToken = resolved.token;
3524
+ this.destinationTokenGeoKey = resolved.geoKey;
3525
+ this.isRefreshingQuotes = false;
3526
+ this.commit();
3527
+ this.notify();
3528
+ return true;
3529
+ }
3530
+ /** Returns true when the request inputs moved and quotes must be refetched. */
3531
+ async fetchQuotesOnce(token) {
3532
+ const destinationToken = this.destinationToken;
3533
+ if (!destinationToken) return false;
3534
+ if (this.destinationAmount && !destinationToken.isStablecoin) {
3535
+ this.quotes = [];
3536
+ this.selectedQuote = null;
3537
+ if (this.status === "quoting") this.setStatus("ready");
3538
+ this.raiseNonFatal(
3539
+ "DESTINATION_AMOUNT_UNSUPPORTED",
3540
+ "Buying a fixed destination amount is only supported for stablecoin destinations"
3541
+ );
3542
+ return false;
3543
+ }
3544
+ const activeAmount = this.sourceAmount || this.destinationAmount || "";
3545
+ const amount = parseFloat(activeAmount);
3546
+ if (!Number.isFinite(amount) || amount <= 0) {
3547
+ this.quotes = [];
3548
+ this.selectedQuote = null;
3549
+ if (this.status === "quoting") this.setStatus("ready");
3550
+ this.commit();
3551
+ this.notify();
3552
+ return false;
3553
+ }
3554
+ const requestSourceAmount = this.sourceAmount;
3555
+ const requestDestinationAmount = this.destinationAmount;
3556
+ const requestCurrency = this.sourceCurrency;
3557
+ const requestCountry = this.effectiveCountryCode();
3558
+ const requestSubdivision = this.effectiveSubdivisionCode();
3559
+ this.isRefreshingQuotes = true;
3560
+ this.commit();
3561
+ this.notify();
3562
+ try {
3563
+ const response = await getOnrampQuotes(
3564
+ {
3565
+ country_code: requestCountry,
3566
+ // Exactly one of source_amount / destination_amount (amount mode).
3567
+ ...requestSourceAmount ? { source_amount: requestSourceAmount } : { destination_amount: requestDestinationAmount },
3568
+ source_currency: requestCurrency.toLowerCase(),
3569
+ destination_currency: destinationToken.currency,
3570
+ destination_network: destinationToken.network,
3571
+ subdivision_code: requestSubdivision
3572
+ },
3573
+ this.publishableKey
3574
+ );
3575
+ if (token !== this.runToken || this.checkout) return false;
3576
+ const stale = requestSourceAmount !== this.sourceAmount || requestDestinationAmount !== this.destinationAmount || requestCurrency !== this.sourceCurrency || requestCountry !== this.effectiveCountryCode() || requestSubdivision !== this.effectiveSubdivisionCode();
3577
+ if (!stale) {
3578
+ this.quotes = response.data.map(mapOnrampQuote);
3579
+ this.reconcileSelection();
3580
+ this.quotesUpdatedAt = Date.now();
3581
+ if (this.nonFatalErrorLatch) {
3582
+ this.nonFatalErrorLatch = null;
3583
+ if (this.error && !this.error.fatal) this.error = null;
3584
+ }
3585
+ this.commit();
3586
+ this.emitSessionEvent("onramp_session.quotes_updated" /* QUOTES_UPDATED */, {
3587
+ sessionId: this.id,
3588
+ quotes: this.quotes,
3589
+ selectedQuote: this.selectedQuote
3590
+ });
3591
+ }
3592
+ } catch (cause) {
3593
+ if (token !== this.runToken || this.checkout) return false;
3594
+ console.error("[unifold] failed to fetch onramp quotes:", cause);
3595
+ this.quotes = [];
3596
+ this.selectedQuote = null;
3597
+ this.raiseNonFatal("QUOTES_FAILED", "Failed to fetch onramp quotes", cause);
3598
+ } finally {
3599
+ if (token === this.runToken) {
3600
+ this.isRefreshingQuotes = false;
3601
+ if (this.status === "quoting") this.setStatus("ready");
3602
+ this.commit();
3603
+ this.notify();
3604
+ }
3605
+ }
3606
+ return token === this.runToken && !this.checkout && this.startPromise !== null && (requestSourceAmount !== this.sourceAmount || requestDestinationAmount !== this.destinationAmount || requestCurrency !== this.sourceCurrency || requestCountry !== this.effectiveCountryCode() || requestSubdivision !== this.effectiveSubdivisionCode());
3607
+ }
3608
+ /**
3609
+ * Record a non-fatal error and announce it once per streak — auto-refresh
3610
+ * keeps retrying, and repeating the event every 60s would be noise.
3611
+ */
3612
+ raiseNonFatal(code, message, cause) {
3613
+ const alreadyReported = this.nonFatalErrorLatch === code;
3614
+ if (!alreadyReported) {
3615
+ this.nonFatalErrorLatch = code;
3616
+ this.error = { code, message, fatal: false, cause };
3617
+ }
3618
+ this.commit();
3619
+ if (!alreadyReported) {
3620
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3621
+ sessionId: this.id,
3622
+ code,
3623
+ message,
3624
+ fatal: false
3625
+ });
3626
+ }
3627
+ this.notify();
3628
+ }
3629
+ /** Sticky manual selection: keep the host's provider while it still quotes. */
3630
+ reconcileSelection() {
3631
+ if (this.manualSelection) {
3632
+ const match = this.quotes.find((quote) => quote.serviceProvider === this.manualSelection);
3633
+ if (match) {
3634
+ this.selectedQuote = match;
3635
+ return;
3636
+ }
3637
+ this.manualSelection = null;
3638
+ }
3639
+ this.selectedQuote = this.quotes[0] ?? null;
3640
+ }
3641
+ // -- Settlement watcher (composed DepositSession) ---------------------------
3642
+ /**
3643
+ * Start (once) the composed {@link DepositSession} that watches the deposit
3644
+ * addresses for the provider's on-chain settlement. Its baseline starts at
3645
+ * checkout time — correct for card rails, where funds can only arrive after
3646
+ * the user pays at the provider. Detection polling, the backend scan nudge
3647
+ * (auto-armed), the lookback window, and `direct_execution.*` semantics are
3648
+ * all inherited rather than reimplemented.
3649
+ */
3650
+ startWatcher() {
3651
+ if (this.watcher) return;
3652
+ const watcher = new DepositSession({
3653
+ publishableKey: this.publishableKey,
3654
+ externalUserId: this.externalUserId,
3655
+ destination: this.destination,
3656
+ confirmationMode: "auto",
3657
+ method: this.method
3658
+ });
3659
+ this.watcher = watcher;
3660
+ const offEvents = watcher.on("*", (event) => {
3661
+ switch (event.type) {
3662
+ case DepositSessionEventType.EXECUTION_DETECTED:
3663
+ case DepositSessionEventType.EXECUTION_UPDATED:
3664
+ case DepositSessionEventType.EXECUTION_SUCCEEDED:
3665
+ case DepositSessionEventType.EXECUTION_FAILED:
3666
+ this.syncFromWatcher(watcher);
3667
+ if (event.type === DepositSessionEventType.EXECUTION_SUCCEEDED && !this.firstSuccess) {
3668
+ this.firstSuccess = event.data.object;
3669
+ }
3670
+ this.forwardExecutionEvent(event.type, event);
3671
+ break;
3672
+ case "deposit_session.errored" /* SESSION_ERRORED */: {
3673
+ const { code, message, fatal } = event.data.object;
3674
+ this.syncFromWatcher(watcher);
3675
+ if (fatal) {
3676
+ this.error = { code, message, fatal: true };
3677
+ this.setStatus("error");
3678
+ this.commit();
3679
+ }
3680
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3681
+ sessionId: this.id,
3682
+ code,
3683
+ message,
3684
+ fatal
3685
+ });
3686
+ break;
3687
+ }
3688
+ default:
3689
+ break;
3690
+ }
3691
+ });
3692
+ const offSnapshot = watcher.subscribe(() => {
3693
+ this.syncFromWatcher(watcher);
3694
+ this.notify();
3695
+ });
3696
+ this.watcherOffs = [offEvents, offSnapshot];
3697
+ void watcher.start().catch(() => {
3698
+ });
3699
+ }
3700
+ syncFromWatcher(watcher) {
3701
+ if (this.destroyed || this.watcher !== watcher) return;
3702
+ const inner = watcher.getSnapshot();
3703
+ this.executions = inner.executions;
3704
+ this.checkingDeposit = inner.isCheckingDeposit;
3705
+ if (this.status !== "error") {
3706
+ if (inner.error && !inner.error.fatal) {
3707
+ this.error = {
3708
+ code: inner.error.code,
3709
+ message: inner.error.message,
3710
+ fatal: false,
3711
+ cause: inner.error.cause
3712
+ };
3713
+ } else if (!inner.error && this.error && !this.error.fatal) {
3714
+ this.error = null;
3715
+ }
3716
+ this.setStatus(inner.status === "processing" ? "processing" : "awaiting_payment");
3717
+ }
3718
+ this.commit();
3719
+ }
3720
+ forwardExecutionEvent(type, event) {
3721
+ this.emitter.emit(
3722
+ type,
3723
+ {
3724
+ id: event.id,
3725
+ type,
3726
+ created: event.created,
3727
+ method: this.method,
3728
+ data: event.data
3729
+ }
3730
+ );
3731
+ }
3732
+ teardownWatcher() {
3733
+ this.watcherOffs.forEach((off) => off());
3734
+ this.watcherOffs = [];
3735
+ this.watcher?.destroy();
3736
+ this.watcher = null;
3737
+ }
3738
+ // -- Internals --------------------------------------------------------------
3739
+ anyExecutionInFlight() {
3740
+ return this.executions.some((execution) => IN_PROGRESS_STATUSES2.includes(execution.status));
3741
+ }
3742
+ setStatus(status) {
3743
+ this.status = status;
3744
+ }
3745
+ clearRefreshTimer() {
3746
+ if (this.refreshTimer) {
3747
+ clearInterval(this.refreshTimer);
3748
+ this.refreshTimer = null;
3749
+ }
3750
+ }
3751
+ buildSnapshot() {
3752
+ return {
3753
+ status: this.status,
3754
+ countryCode: this.explicitCountryCode || this.detectedCountryCode || null,
3755
+ addresses: this.addresses,
3756
+ destinationToken: this.destinationToken,
3757
+ quotes: this.quotes,
3758
+ selectedQuote: this.selectedQuote,
3759
+ isQuoteAutoSelected: this.manualSelection === null,
3760
+ canSelectProvider: this.quotes.length > 1,
3761
+ isRefreshingQuotes: this.isRefreshingQuotes,
3762
+ quotesUpdatedAt: this.quotesUpdatedAt,
3763
+ checkout: this.checkout,
3764
+ executions: this.executions,
3765
+ latestExecution: this.executions[0] ?? null,
3766
+ isCheckingDeposit: this.checkingDeposit,
3767
+ error: this.error
3768
+ };
3769
+ }
3770
+ /** Rebuild the snapshot so getSnapshot() reflects current state. */
3771
+ commit() {
3772
+ this.snapshot = this.buildSnapshot();
3773
+ }
3774
+ notify() {
3775
+ this.listeners.forEach((listener) => {
3776
+ try {
3777
+ listener();
3778
+ } catch (error) {
3779
+ console.error("[unifold] snapshot listener threw", error);
3780
+ }
3781
+ });
3782
+ }
3783
+ emitSessionEvent(type, object) {
3784
+ this.emitter.emit(type, {
3785
+ id: generatePrefixedKSUID("sevt"),
3786
+ type,
3787
+ created: Math.floor(Date.now() / 1e3),
3788
+ method: this.method,
3789
+ data: { object }
3790
+ });
3791
+ }
3792
+ };
3793
+
2745
3794
  // src/lib/client.ts
2746
3795
  var UnifoldClient = class {
2747
3796
  constructor(options) {
@@ -2759,6 +3808,10 @@ var UnifoldClient = class {
2759
3808
  createDepositSession(params) {
2760
3809
  return new DepositSession({ ...params, publishableKey: this.publishableKey });
2761
3810
  }
3811
+ /** Create a headless fiat-onramp flow controller (buy with card by default). */
3812
+ createOnrampSession(params) {
3813
+ return new OnrampSession({ ...params, publishableKey: this.publishableKey });
3814
+ }
2762
3815
  /**
2763
3816
  * Create (idempotently) and return the user's deposit addresses for a
2764
3817
  * destination — `POST /v1/public/deposit_addresses`.
@@ -2969,11 +4022,16 @@ var i18n = en_default;
2969
4022
  DepositSession,
2970
4023
  DepositSessionEventType,
2971
4024
  DepositSessionWaitError,
4025
+ DirectExecutionEventType,
2972
4026
  ExecutionStatus,
2973
4027
  IneligibilityReason,
2974
4028
  IntegrationProvider,
2975
4029
  IntegrationTransferError,
2976
4030
  LOOKBACK_MS,
4031
+ OnrampSession,
4032
+ OnrampSessionEventType,
4033
+ OnrampSessionWaitError,
4034
+ QUOTE_REFRESH_INTERVAL_MS,
2977
4035
  SCAN_NUDGE_INTERVAL_MS,
2978
4036
  SOLANA_USDC_ADDRESS,
2979
4037
  StripeApiResponseError,
@@ -3049,7 +4107,9 @@ var i18n = en_default;
3049
4107
  isGooglePayLimitReached,
3050
4108
  isWalletPayLimitReached,
3051
4109
  listPaymentIntentExecutions,
4110
+ mapDefaultOnrampToken,
3052
4111
  mapDirectExecution,
4112
+ mapOnrampQuote,
3053
4113
  mapWalletToDepositAddress,
3054
4114
  pollDirectExecutions,
3055
4115
  queryExecutions,