@unifold/core 0.1.74 → 0.1.76

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.mjs CHANGED
@@ -41,7 +41,7 @@ function generatePrefixedKSUID(prefix) {
41
41
  }
42
42
 
43
43
  // src/lib/client-headers.ts
44
- var SDK_VERSION = true ? "0.1.74" : "0.0.0-dev";
44
+ var SDK_VERSION = true ? "0.1.76" : "0.0.0-dev";
45
45
  var CLIENT_VERSION_HEADER = "x-unifold-client-version";
46
46
  var CLIENT_USER_AGENT_HEADER = "x-unifold-client-user-agent";
47
47
  function detectRuntime() {
@@ -539,6 +539,7 @@ async function getDefaultOnrampToken(params, publishableKey) {
539
539
  const queryParams = new URLSearchParams();
540
540
  if (params.country_code) queryParams.append("country_code", params.country_code);
541
541
  if (params.subdivision_code) queryParams.append("subdivision_code", params.subdivision_code);
542
+ if (params.service_provider) queryParams.append("service_provider", params.service_provider);
542
543
  queryParams.append("token_address", params.token_address);
543
544
  queryParams.append("chain_id", params.chain_id);
544
545
  queryParams.append("chain_type", params.chain_type);
@@ -803,6 +804,25 @@ async function createExchangeSession(request, publishableKey) {
803
804
  }
804
805
  return response.json();
805
806
  }
807
+ async function getOnrampSessionStatus(externalId, publishableKey, signal) {
808
+ const pk = publishableKey || DEFAULT_PUBLISHABLE_KEY;
809
+ validatePublishableKey(pk);
810
+ const response = await apiFetch(`${API_BASE_URL}/v1/public/onramps/sessions/status`, {
811
+ method: "POST",
812
+ headers: {
813
+ accept: "application/json",
814
+ "x-publishable-key": pk,
815
+ "Content-Type": "application/json"
816
+ },
817
+ body: JSON.stringify({ external_id: externalId }),
818
+ signal
819
+ });
820
+ if (!response.ok) {
821
+ const error = await response.json().catch(() => ({ message: response.statusText }));
822
+ throw new Error(`Failed to get session status: ${error.message || response.statusText}`);
823
+ }
824
+ return response.json();
825
+ }
806
826
  function getExchangeSessionStartUrl(request, publishableKey) {
807
827
  const params = new URLSearchParams();
808
828
  params.append("publishable_key", publishableKey);
@@ -821,24 +841,95 @@ function getExchangeSessionStartUrl(request, publishableKey) {
821
841
  if (request.source_amount) {
822
842
  params.append("source_amount", request.source_amount);
823
843
  }
844
+ if (request.country_code) {
845
+ params.append("country_code", request.country_code);
846
+ }
847
+ if (request.subdivision_code) {
848
+ params.append("subdivision_code", request.subdivision_code);
849
+ }
824
850
  params.append("external_id", request.external_id ?? generatePrefixedKSUID("exc"));
825
851
  return `${API_BASE_URL}/v1/public/onramps/exchanges/sessions/start?${params.toString()}`;
826
852
  }
827
- async function getIntegrationExchanges(publishableKey) {
853
+ async function getIntegrationExchanges(publishableKey, query) {
828
854
  const pk = publishableKey || DEFAULT_PUBLISHABLE_KEY;
829
855
  validatePublishableKey(pk);
830
- const response = await apiFetch(`${API_BASE_URL}/v1/public/integrations/oauth/exchanges`, {
831
- method: "GET",
856
+ const params = new URLSearchParams();
857
+ if (query?.country_code) params.append("country_code", query.country_code);
858
+ if (query?.subdivision_code) params.append("subdivision_code", query.subdivision_code);
859
+ const queryString = params.toString() ? `?${params.toString()}` : "";
860
+ const headers = {
861
+ accept: "application/json",
862
+ "x-publishable-key": pk
863
+ };
864
+ const response = await apiFetch(
865
+ `${API_BASE_URL}/v1/public/integrations/exchanges${queryString}`,
866
+ {
867
+ method: "GET",
868
+ headers
869
+ }
870
+ );
871
+ if (response.ok) {
872
+ return response.json();
873
+ }
874
+ if (response.status === 404) {
875
+ const legacyResponse = await apiFetch(
876
+ `${API_BASE_URL}/v1/public/integrations/oauth/exchanges${queryString}`,
877
+ {
878
+ method: "GET",
879
+ headers
880
+ }
881
+ );
882
+ if (legacyResponse.ok) {
883
+ return legacyResponse.json();
884
+ }
885
+ throw new Error(`Failed to fetch integration exchanges: ${legacyResponse.statusText}`);
886
+ }
887
+ throw new Error(`Failed to fetch integration exchanges: ${response.statusText}`);
888
+ }
889
+ async function createIntegrationExchangeSession(request, publishableKey) {
890
+ const pk = publishableKey || DEFAULT_PUBLISHABLE_KEY;
891
+ validatePublishableKey(pk);
892
+ const response = await apiFetch(`${API_BASE_URL}/v1/public/integrations/exchanges/sessions`, {
893
+ method: "POST",
832
894
  headers: {
833
895
  accept: "application/json",
834
- "x-publishable-key": pk
835
- }
896
+ "x-publishable-key": pk,
897
+ "Content-Type": "application/json"
898
+ },
899
+ body: JSON.stringify(request)
836
900
  });
837
901
  if (!response.ok) {
838
- throw new Error(`Failed to fetch integration exchanges: ${response.statusText}`);
902
+ throw new Error(`Failed to create integration exchange session: ${response.statusText}`);
839
903
  }
840
904
  return response.json();
841
905
  }
906
+ function getIntegrationExchangeSessionStartUrl(request, publishableKey) {
907
+ const params = new URLSearchParams();
908
+ params.append("publishable_key", publishableKey);
909
+ params.append("service_provider", request.service_provider);
910
+ params.append("chain_type", request.chain_type);
911
+ params.append("address", request.address);
912
+ if (request.preferred_destination_currency) {
913
+ params.append("preferred_destination_currency", request.preferred_destination_currency);
914
+ }
915
+ if (request.preferred_destination_network) {
916
+ params.append("preferred_destination_network", request.preferred_destination_network);
917
+ }
918
+ if (request.source_currency) {
919
+ params.append("source_currency", request.source_currency);
920
+ }
921
+ if (request.source_amount) {
922
+ params.append("source_amount", request.source_amount);
923
+ }
924
+ if (request.country_code) {
925
+ params.append("country_code", request.country_code);
926
+ }
927
+ if (request.subdivision_code) {
928
+ params.append("subdivision_code", request.subdivision_code);
929
+ }
930
+ params.append("external_id", request.external_id ?? generatePrefixedKSUID("exc"));
931
+ return `${API_BASE_URL}/v1/public/integrations/exchanges/sessions/start?${params.toString()}`;
932
+ }
842
933
  async function startIntegrationOAuth(publishableKey) {
843
934
  const pk = publishableKey || DEFAULT_PUBLISHABLE_KEY;
844
935
  validatePublishableKey(pk);
@@ -1732,10 +1823,17 @@ function createCoinbaseGooglePaySession(request, onrampToken, publishableKey, si
1732
1823
  }
1733
1824
 
1734
1825
  // src/lib/events.ts
1826
+ var DirectExecutionEventType = /* @__PURE__ */ ((DirectExecutionEventType2) => {
1827
+ DirectExecutionEventType2["DETECTED"] = "direct_execution.detected";
1828
+ DirectExecutionEventType2["UPDATED"] = "direct_execution.updated";
1829
+ DirectExecutionEventType2["SUCCEEDED"] = "direct_execution.succeeded";
1830
+ DirectExecutionEventType2["FAILED"] = "direct_execution.failed";
1831
+ return DirectExecutionEventType2;
1832
+ })(DirectExecutionEventType || {});
1735
1833
  var DepositEventType = /* @__PURE__ */ ((DepositEventType2) => {
1736
1834
  DepositEventType2["ONRAMP_SESSION_CREATED"] = "onramp_session.created";
1737
- DepositEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
1738
- DepositEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed";
1835
+ DepositEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */;
1836
+ DepositEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */;
1739
1837
  DepositEventType2["METHOD_SELECTED"] = "deposit.method_selected";
1740
1838
  DepositEventType2["TOKEN_SELECTED"] = "deposit.token_selected";
1741
1839
  DepositEventType2["WALLET_SELECTED"] = "deposit.wallet_selected";
@@ -1755,8 +1853,8 @@ var DepositEventType = /* @__PURE__ */ ((DepositEventType2) => {
1755
1853
  return DepositEventType2;
1756
1854
  })(DepositEventType || {});
1757
1855
  var WithdrawEventType = /* @__PURE__ */ ((WithdrawEventType2) => {
1758
- WithdrawEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
1759
- WithdrawEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed";
1856
+ WithdrawEventType2["DIRECT_EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */;
1857
+ WithdrawEventType2["DIRECT_EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */;
1760
1858
  WithdrawEventType2["TOKEN_SELECTED"] = "withdraw.token_selected";
1761
1859
  WithdrawEventType2["FLOW_STARTED"] = "withdraw.flow_started";
1762
1860
  return WithdrawEventType2;
@@ -1855,6 +1953,52 @@ function mapDirectExecution(execution) {
1855
1953
  } : void 0
1856
1954
  };
1857
1955
  }
1956
+ function mapOnrampQuote(quote) {
1957
+ return {
1958
+ serviceProvider: quote.service_provider,
1959
+ serviceProviderDisplayName: quote.service_provider_display_name,
1960
+ paymentMethodType: quote.payment_method_type,
1961
+ sourceCurrency: quote.source_currency,
1962
+ countryCode: quote.country_code,
1963
+ sourceAmount: quote.source_amount,
1964
+ sourceAmountWithoutFees: quote.source_amount_without_fees,
1965
+ totalFee: quote.total_fee,
1966
+ networkFee: quote.network_fee,
1967
+ transactionFee: quote.transaction_fee,
1968
+ partnerFee: quote.partner_fee,
1969
+ destinationAmount: quote.destination_amount,
1970
+ destinationAmountWithoutFees: quote.destination_amount_without_fees,
1971
+ destinationCurrency: quote.destination_currency,
1972
+ destinationNetwork: quote.destination_network,
1973
+ exchangeRate: quote.exchange_rate,
1974
+ customerScore: quote.customer_score,
1975
+ lowKyc: quote.low_kyc,
1976
+ iconUrl: quote.icon_url,
1977
+ iconUrls: quote.icon_urls,
1978
+ institutionName: quote.institution_name
1979
+ };
1980
+ }
1981
+ function mapDefaultOnrampToken(response) {
1982
+ const metadata = response.destination_token_metadata;
1983
+ return {
1984
+ network: response.destination_network,
1985
+ currency: response.destination_currency,
1986
+ isStablecoin: response.is_stablecoin ?? false,
1987
+ token: {
1988
+ symbol: metadata.symbol,
1989
+ name: metadata.name,
1990
+ tokenAddress: metadata.token_address,
1991
+ decimals: metadata.decimals,
1992
+ chainId: metadata.chain_id,
1993
+ chainType: metadata.chain_type,
1994
+ chainName: metadata.chain_name,
1995
+ iconUrl: metadata.icon_url,
1996
+ iconUrls: metadata.icon_urls,
1997
+ chainIconUrl: metadata.chain?.icon_url ?? ""
1998
+ },
1999
+ estimatedProcessingTimeSeconds: response.estimated_processing_time
2000
+ };
2001
+ }
1858
2002
 
1859
2003
  // src/lib/deposit-session.ts
1860
2004
  var DETECTION_POLL_INTERVAL_MS = 2500;
@@ -1862,16 +2006,16 @@ var SCAN_NUDGE_INTERVAL_MS = 5e3;
1862
2006
  var DETECTION_ARM_DELAY_MS = 5e3;
1863
2007
  var LOOKBACK_MS = 6e4;
1864
2008
  var ADDRESS_CREATE_MAX_ATTEMPTS = 4;
1865
- var DepositSessionEventType = /* @__PURE__ */ ((DepositSessionEventType2) => {
2009
+ var DepositSessionEventType = ((DepositSessionEventType2) => {
1866
2010
  DepositSessionEventType2["SESSION_STARTED"] = "deposit_session.started";
1867
2011
  DepositSessionEventType2["ADDRESSES_CREATED"] = "deposit_session.addresses_created";
1868
2012
  DepositSessionEventType2["CONFIRMATION_STARTED"] = "deposit_session.confirmation_started";
1869
2013
  DepositSessionEventType2["SESSION_STOPPED"] = "deposit_session.stopped";
1870
2014
  DepositSessionEventType2["SESSION_ERRORED"] = "deposit_session.errored";
1871
- DepositSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected";
1872
- DepositSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated";
1873
- DepositSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded";
1874
- DepositSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed";
2015
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected" /* DETECTED */] = "EXECUTION_DETECTED";
2016
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated" /* UPDATED */] = "EXECUTION_UPDATED";
2017
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */] = "EXECUTION_SUCCEEDED";
2018
+ DepositSessionEventType2[DepositSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */] = "EXECUTION_FAILED";
1875
2019
  return DepositSessionEventType2;
1876
2020
  })(DepositSessionEventType || {});
1877
2021
  var IN_PROGRESS_STATUSES = [
@@ -2094,17 +2238,17 @@ var DepositSession = class {
2094
2238
  }
2095
2239
  const offs = [
2096
2240
  this.on(
2097
- "direct_execution.succeeded" /* EXECUTION_SUCCEEDED */,
2241
+ DepositSessionEventType.EXECUTION_SUCCEEDED,
2098
2242
  (event) => settle(() => resolve(event.data.object))
2099
2243
  ),
2100
- this.on("direct_execution.failed" /* EXECUTION_FAILED */, (event) => {
2244
+ this.on(DepositSessionEventType.EXECUTION_FAILED, (event) => {
2101
2245
  if (this.anyExecutionInFlight()) return;
2102
2246
  rejectFailure(event.data.object);
2103
2247
  }),
2104
2248
  // A previously-failed wait condition can become settleable when
2105
2249
  // the last in-flight execution also fails (updated → failed is
2106
2250
  // covered above; updated → refunded transitions re-check here).
2107
- this.on("direct_execution.updated" /* EXECUTION_UPDATED */, () => {
2251
+ this.on(DepositSessionEventType.EXECUTION_UPDATED, () => {
2108
2252
  if (this.anyExecutionInFlight() || this.firstSuccess) return;
2109
2253
  const failed = this.snapshot.executions.find(
2110
2254
  (execution) => FAILURE_STATUSES.includes(execution.status)
@@ -2394,18 +2538,18 @@ var DepositSession = class {
2394
2538
  this.commit();
2395
2539
  const eventCreated = this.executionEventTimestamp(wire);
2396
2540
  if (previousStatus === null) {
2397
- this.emitExecutionEvent("direct_execution.detected" /* EXECUTION_DETECTED */, execution, eventCreated);
2541
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_DETECTED, execution, eventCreated);
2398
2542
  } else {
2399
2543
  this.emitExecutionEvent(
2400
- "direct_execution.updated" /* EXECUTION_UPDATED */,
2544
+ DepositSessionEventType.EXECUTION_UPDATED,
2401
2545
  { ...execution, previousStatus },
2402
2546
  eventCreated
2403
2547
  );
2404
2548
  }
2405
2549
  if (wire.status === "succeeded" /* SUCCEEDED */ && wasInProgressOrNew) {
2406
- this.emitExecutionEvent("direct_execution.succeeded" /* EXECUTION_SUCCEEDED */, execution, eventCreated);
2550
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_SUCCEEDED, execution, eventCreated);
2407
2551
  } else if (FAILURE_STATUSES.includes(wire.status) && (previousStatus === null || !FAILURE_STATUSES.includes(previousStatus))) {
2408
- this.emitExecutionEvent("direct_execution.failed" /* EXECUTION_FAILED */, execution, eventCreated);
2552
+ this.emitExecutionEvent(DepositSessionEventType.EXECUTION_FAILED, execution, eventCreated);
2409
2553
  }
2410
2554
  this.notify();
2411
2555
  }
@@ -2508,22 +2652,1015 @@ var DepositSession = class {
2508
2652
  }
2509
2653
  };
2510
2654
 
2511
- // src/lib/client.ts
2512
- var UnifoldClient = class {
2513
- constructor(options) {
2655
+ // src/lib/onramp-session.ts
2656
+ var QUOTE_REFRESH_INTERVAL_MS = 6e4;
2657
+ var ADDRESS_CREATE_MAX_ATTEMPTS2 = 4;
2658
+ var OnrampSessionEventType = ((OnrampSessionEventType2) => {
2659
+ OnrampSessionEventType2["SESSION_STARTED"] = "onramp_session.started";
2660
+ OnrampSessionEventType2["ADDRESSES_CREATED"] = "onramp_session.addresses_created";
2661
+ OnrampSessionEventType2["QUOTES_UPDATED"] = "onramp_session.quotes_updated";
2662
+ OnrampSessionEventType2[OnrampSessionEventType2["CHECKOUT_CREATED"] = "onramp_session.created" /* ONRAMP_SESSION_CREATED */] = "CHECKOUT_CREATED";
2663
+ OnrampSessionEventType2["SESSION_STOPPED"] = "onramp_session.stopped";
2664
+ OnrampSessionEventType2["SESSION_ERRORED"] = "onramp_session.errored";
2665
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_DETECTED"] = "direct_execution.detected" /* DETECTED */] = "EXECUTION_DETECTED";
2666
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_UPDATED"] = "direct_execution.updated" /* UPDATED */] = "EXECUTION_UPDATED";
2667
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_SUCCEEDED"] = "direct_execution.succeeded" /* SUCCEEDED */] = "EXECUTION_SUCCEEDED";
2668
+ OnrampSessionEventType2[OnrampSessionEventType2["EXECUTION_FAILED"] = "direct_execution.failed" /* FAILED */] = "EXECUTION_FAILED";
2669
+ return OnrampSessionEventType2;
2670
+ })(OnrampSessionEventType || {});
2671
+ var FAILURE_STATUSES2 = ["failed" /* FAILED */, "refunded" /* REFUNDED */];
2672
+ var IN_PROGRESS_STATUSES2 = [
2673
+ "pending" /* PENDING */,
2674
+ "waiting" /* WAITING */,
2675
+ "delayed" /* DELAYED */
2676
+ ];
2677
+ var OnrampSessionWaitError = class extends Error {
2678
+ constructor(code, message, cause) {
2679
+ super(message);
2680
+ __publicField(this, "code");
2681
+ /** Failed execution (`DEPOSIT_FAILED`) or fatal {@link OnrampSessionError} (`SESSION_ERROR`). */
2682
+ __publicField(this, "cause");
2683
+ this.name = "OnrampSessionWaitError";
2684
+ this.code = code;
2685
+ this.cause = cause;
2686
+ }
2687
+ };
2688
+ var SessionCheckError2 = class extends Error {
2689
+ constructor(code, message) {
2690
+ super(message);
2691
+ this.code = code;
2692
+ }
2693
+ };
2694
+ var delay2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2695
+ var OnrampSession = class {
2696
+ constructor(config) {
2697
+ /** Immutable id for correlation, `osess_<ksuid>`. Client-generated. */
2698
+ __publicField(this, "id");
2699
+ __publicField(this, "emitter", new TypedEmitter());
2700
+ __publicField(this, "listeners", /* @__PURE__ */ new Set());
2514
2701
  __publicField(this, "publishableKey");
2515
- const { publishableKey } = options;
2516
- if (!publishableKey || publishableKey.trim() === "") {
2517
- throw new Error("Unifold: publishableKey is required");
2702
+ __publicField(this, "externalUserId");
2703
+ __publicField(this, "destination");
2704
+ __publicField(this, "paymentMethodType");
2705
+ __publicField(this, "email");
2706
+ __publicField(this, "quoteRefreshIntervalMs");
2707
+ __publicField(this, "method");
2708
+ // Mutable quote request (updateQuoteRequest). Exactly one of
2709
+ // sourceAmount / destinationAmount is truthy at a time (amount mode).
2710
+ // Country/subdivision: host-supplied values win; when absent they are
2711
+ // IP-detected during start() (see resolveCountry).
2712
+ __publicField(this, "explicitCountryCode");
2713
+ __publicField(this, "explicitSubdivisionCode");
2714
+ __publicField(this, "detectedCountryCode", null);
2715
+ __publicField(this, "detectedSubdivisionCode", null);
2716
+ __publicField(this, "sourceAmount");
2717
+ __publicField(this, "destinationAmount");
2718
+ __publicField(this, "sourceCurrency");
2719
+ // Run state. runToken invalidates in-flight async work across stop()/restart.
2720
+ __publicField(this, "runToken", 0);
2721
+ __publicField(this, "startPromise", null);
2722
+ __publicField(this, "destroyed", false);
2723
+ /** Pending waiter rejections, invoked by destroy() so waiters never hang. */
2724
+ __publicField(this, "waiterDestroyCallbacks", /* @__PURE__ */ new Set());
2725
+ /** In-flight destination-token + quotes pipeline (see syncQuoteInputs). */
2726
+ __publicField(this, "syncPromise", null);
2727
+ /** A sync was asked for mid-pipeline; the loop owes it another pass. */
2728
+ __publicField(this, "syncRequested", false);
2729
+ /** Code of the non-fatal error already reported, so a streak emits once. */
2730
+ __publicField(this, "nonFatalErrorLatch", null);
2731
+ /** True once run() got past preparing — gates live quote-input syncing. */
2732
+ __publicField(this, "prepared", false);
2733
+ __publicField(this, "refreshTimer", null);
2734
+ /** service_provider chosen via selectQuote(); sticky across quote refreshes. */
2735
+ __publicField(this, "manualSelection", null);
2736
+ /** First execution to succeed this run — waitForSuccess's one-shot answer. */
2737
+ __publicField(this, "firstSuccess", null);
2738
+ // Settlement watcher (composed DepositSession), created at createCheckout().
2739
+ __publicField(this, "watcher", null);
2740
+ __publicField(this, "watcherOffs", []);
2741
+ // Snapshot state
2742
+ __publicField(this, "status", "idle");
2743
+ __publicField(this, "addresses", []);
2744
+ __publicField(this, "destinationToken", null);
2745
+ /** Geo `destinationToken` was resolved for; null while unresolved. */
2746
+ __publicField(this, "destinationTokenGeoKey", null);
2747
+ __publicField(this, "quotes", []);
2748
+ __publicField(this, "selectedQuote", null);
2749
+ __publicField(this, "isRefreshingQuotes", false);
2750
+ __publicField(this, "quotesUpdatedAt", null);
2751
+ __publicField(this, "checkout", null);
2752
+ __publicField(this, "executions", []);
2753
+ __publicField(this, "checkingDeposit", false);
2754
+ __publicField(this, "error", null);
2755
+ __publicField(this, "snapshot");
2756
+ if (!config.publishableKey || config.publishableKey.trim() === "") {
2757
+ throw new Error("OnrampSession: publishableKey is required");
2518
2758
  }
2519
- if (!publishableKey.startsWith("pk_test_") && !publishableKey.startsWith("pk_live_")) {
2520
- console.warn('Unifold: publishableKey should start with "pk_test_" or "pk_live_".');
2759
+ if (!config.externalUserId) {
2760
+ throw new Error("OnrampSession: externalUserId is required");
2521
2761
  }
2522
- this.publishableKey = publishableKey;
2762
+ if (config.quoteRequest.sourceAmount && config.quoteRequest.destinationAmount) {
2763
+ throw new Error(
2764
+ "OnrampSession: provide exactly one of quoteRequest.sourceAmount / destinationAmount"
2765
+ );
2766
+ }
2767
+ this.id = generatePrefixedKSUID("osess");
2768
+ this.publishableKey = config.publishableKey;
2769
+ this.externalUserId = config.externalUserId;
2770
+ this.destination = config.destination;
2771
+ this.paymentMethodType = config.paymentMethodType ?? "card";
2772
+ this.email = config.email;
2773
+ this.quoteRefreshIntervalMs = config.quoteRefreshIntervalMs ?? QUOTE_REFRESH_INTERVAL_MS;
2774
+ this.method = this.paymentMethodType === "apple_pay" ? "apple_pay" : "card";
2775
+ this.explicitCountryCode = config.quoteRequest.countryCode;
2776
+ this.explicitSubdivisionCode = config.quoteRequest.subdivisionCode;
2777
+ this.sourceAmount = config.quoteRequest.sourceAmount;
2778
+ this.destinationAmount = config.quoteRequest.destinationAmount;
2779
+ this.sourceCurrency = config.quoteRequest.sourceCurrency ?? "usd";
2780
+ this.snapshot = this.buildSnapshot();
2523
2781
  }
2524
- /** Create a headless deposit-session flow controller. */
2525
- createDepositSession(params) {
2526
- return new DepositSession({ ...params, publishableKey: this.publishableKey });
2782
+ // -- Public surface -------------------------------------------------------
2783
+ /** Synchronous snapshot; the reference is stable until state changes. */
2784
+ getSnapshot() {
2785
+ return this.snapshot;
2786
+ }
2787
+ /**
2788
+ * Subscribe to snapshot changes (external-store contract; drives
2789
+ * `useSyncExternalStore` in the React binding). Returns an unsubscribe fn.
2790
+ */
2791
+ subscribe(listener) {
2792
+ this.listeners.add(listener);
2793
+ return () => {
2794
+ this.listeners.delete(listener);
2795
+ };
2796
+ }
2797
+ on(type, handler) {
2798
+ return this.emitter.on(type, handler);
2799
+ }
2800
+ /**
2801
+ * Creates/fetches addresses, resolves the onramp destination token (both
2802
+ * with fail-fast recipient validation), and fetches the first quotes.
2803
+ * Idempotent while running; callable again after stop() or a fatal error.
2804
+ */
2805
+ start() {
2806
+ if (this.destroyed) {
2807
+ return Promise.reject(new Error("OnrampSession has been destroyed"));
2808
+ }
2809
+ if (this.startPromise) return this.startPromise;
2810
+ this.startPromise = this.run();
2811
+ return this.startPromise;
2812
+ }
2813
+ /**
2814
+ * Update the quote request (amount / currency / country) and refetch
2815
+ * quotes. Debounce keystrokes host-side — every call that changes
2816
+ * something hits the quotes API. No-op after createCheckout().
2817
+ *
2818
+ * Amount-mode switching: patching `sourceAmount` while in destination mode
2819
+ * (or `destinationAmount` while in source mode) switches modes — the other
2820
+ * amount is cleared. Patching BOTH to truthy values in one call throws
2821
+ * (exactly one drives quoting).
2822
+ */
2823
+ updateQuoteRequest(patch) {
2824
+ if (this.destroyed) return;
2825
+ let nextSourceAmount = "sourceAmount" in patch ? patch.sourceAmount : this.sourceAmount;
2826
+ let nextDestinationAmount = "destinationAmount" in patch ? patch.destinationAmount : this.destinationAmount;
2827
+ if (nextSourceAmount && nextDestinationAmount) {
2828
+ const sourcePatched = !!patch.sourceAmount;
2829
+ const destinationPatched = !!patch.destinationAmount;
2830
+ if (sourcePatched && destinationPatched) {
2831
+ throw new Error(
2832
+ "OnrampSession.updateQuoteRequest: provide exactly one of sourceAmount / destinationAmount"
2833
+ );
2834
+ }
2835
+ if (destinationPatched) nextSourceAmount = void 0;
2836
+ else nextDestinationAmount = void 0;
2837
+ }
2838
+ const next = {
2839
+ countryCode: patch.countryCode ?? this.explicitCountryCode,
2840
+ subdivisionCode: "subdivisionCode" in patch ? patch.subdivisionCode : this.explicitSubdivisionCode,
2841
+ sourceAmount: nextSourceAmount,
2842
+ destinationAmount: nextDestinationAmount,
2843
+ sourceCurrency: patch.sourceCurrency ?? this.sourceCurrency
2844
+ };
2845
+ const changed = next.countryCode !== this.explicitCountryCode || next.subdivisionCode !== this.explicitSubdivisionCode || next.sourceAmount !== this.sourceAmount || next.destinationAmount !== this.destinationAmount || next.sourceCurrency !== this.sourceCurrency;
2846
+ this.explicitCountryCode = next.countryCode;
2847
+ this.explicitSubdivisionCode = next.subdivisionCode;
2848
+ this.sourceAmount = next.sourceAmount;
2849
+ this.destinationAmount = next.destinationAmount;
2850
+ this.sourceCurrency = next.sourceCurrency;
2851
+ if (!changed || this.checkout || !this.startPromise) return;
2852
+ if (this.prepared) {
2853
+ void this.syncQuoteInputs(this.runToken);
2854
+ }
2855
+ }
2856
+ /** Refetch quotes with the current request. Resolves when the fetch settles. */
2857
+ refreshQuotes() {
2858
+ if (this.destroyed || !this.startPromise || this.checkout || !this.prepared) {
2859
+ return Promise.resolve();
2860
+ }
2861
+ return this.syncQuoteInputs(this.runToken);
2862
+ }
2863
+ /**
2864
+ * Pick a provider quote by `serviceProvider`. The selection is sticky
2865
+ * across refreshes: while the provider keeps quoting it stays selected
2866
+ * (with fresh pricing); if it drops out, selection falls back to the
2867
+ * backend's top quote and auto-selection resumes.
2868
+ *
2869
+ * Returns the selected quote, or null when no quote matches.
2870
+ */
2871
+ selectQuote(serviceProvider) {
2872
+ if (this.destroyed) return null;
2873
+ const match = this.quotes.find((quote) => quote.serviceProvider === serviceProvider);
2874
+ if (!match) return null;
2875
+ this.manualSelection = serviceProvider;
2876
+ this.selectedQuote = match;
2877
+ this.commit();
2878
+ this.notify();
2879
+ return match;
2880
+ }
2881
+ /**
2882
+ * Build the provider-hosted checkout and start watching the deposit
2883
+ * addresses for the provider's on-chain settlement.
2884
+ *
2885
+ * Uses `snapshot.selectedQuote` (backend's top quote unless the host called
2886
+ * selectQuote()). Hosts that manage their own selection UI can instead pass
2887
+ * `options.serviceProvider` for a one-shot choice without mutating the
2888
+ * sticky selection.
2889
+ *
2890
+ * Synchronous by design: the URL is assembled locally (single-use token
2891
+ * exchange happens when it is opened), so hosts can `window.open()` the
2892
+ * result inside the click handler without tripping popup blockers.
2893
+ *
2894
+ * Throws when the session is not ready (no matching quote / addresses or
2895
+ * destination token missing) — gate your button on `status === 'ready'`
2896
+ * and `selectedQuote`.
2897
+ */
2898
+ createCheckout(options = {}) {
2899
+ if (this.destroyed) throw new Error("OnrampSession has been destroyed");
2900
+ const quote = options.serviceProvider ? this.quotes.find((q) => q.serviceProvider === options.serviceProvider) : this.selectedQuote;
2901
+ if (!quote) {
2902
+ throw new Error(
2903
+ options.serviceProvider ? `OnrampSession.createCheckout: no quote from '${options.serviceProvider}'` : "OnrampSession.createCheckout: no quote selected"
2904
+ );
2905
+ }
2906
+ if (!this.destinationToken) {
2907
+ throw new Error("OnrampSession.createCheckout: destination token not resolved yet");
2908
+ }
2909
+ const wallet = this.addresses.find(
2910
+ (address) => address.chainType === this.destinationToken?.token.chainType
2911
+ );
2912
+ if (!wallet) {
2913
+ throw new Error("OnrampSession.createCheckout: no deposit address for the onramp chain type");
2914
+ }
2915
+ const externalId = options.externalId ?? generatePrefixedKSUID("orsext");
2916
+ const request = {
2917
+ service_provider: quote.serviceProvider,
2918
+ country_code: (quote.countryCode || this.effectiveCountryCode()).toUpperCase(),
2919
+ source_currency: quote.sourceCurrency,
2920
+ // Exactly one of source_amount / destination_amount — the session's
2921
+ // current amount mode (quotes were fetched in the same mode, so the
2922
+ // quoted providers all support it).
2923
+ ...this.sourceAmount ? { source_amount: this.sourceAmount } : { destination_amount: this.destinationAmount },
2924
+ destination_currency: quote.destinationCurrency,
2925
+ destination_network: quote.destinationNetwork,
2926
+ wallet_address: wallet.address,
2927
+ subdivision_code: this.effectiveSubdivisionCode(),
2928
+ external_id: externalId,
2929
+ email: options.email ?? this.email,
2930
+ payment_method_type: quote.paymentMethodType ?? this.paymentMethodType
2931
+ };
2932
+ const checkout = {
2933
+ url: getOnrampSessionStartUrl(request, this.publishableKey),
2934
+ externalId,
2935
+ serviceProvider: quote.serviceProvider,
2936
+ quote,
2937
+ sourceAmount: this.sourceAmount ?? null,
2938
+ destinationAmount: this.sourceAmount ? null : this.destinationAmount ?? null,
2939
+ sourceCurrency: quote.sourceCurrency,
2940
+ createdAt: Date.now()
2941
+ };
2942
+ this.checkout = checkout;
2943
+ this.clearRefreshTimer();
2944
+ this.setStatus("awaiting_payment");
2945
+ this.commit();
2946
+ this.emitSessionEvent(OnrampSessionEventType.CHECKOUT_CREATED, {
2947
+ externalId,
2948
+ sessionId: this.id,
2949
+ url: checkout.url,
2950
+ serviceProvider: quote.serviceProvider
2951
+ });
2952
+ this.notify();
2953
+ this.startWatcher();
2954
+ return checkout;
2955
+ }
2956
+ /**
2957
+ * Stops quote refresh and settlement watching. The session can be
2958
+ * restarted with start(), which resets quotes/checkout/executions
2959
+ * (fresh run).
2960
+ */
2961
+ stop() {
2962
+ const wasActive = this.startPromise !== null;
2963
+ this.runToken += 1;
2964
+ this.clearRefreshTimer();
2965
+ this.teardownWatcher();
2966
+ this.startPromise = null;
2967
+ this.syncPromise = null;
2968
+ this.isRefreshingQuotes = false;
2969
+ this.checkingDeposit = false;
2970
+ if (this.status !== "idle" && this.status !== "error") {
2971
+ this.setStatus("idle");
2972
+ }
2973
+ if (wasActive && !this.destroyed) {
2974
+ this.commit();
2975
+ this.emitSessionEvent("onramp_session.stopped" /* SESSION_STOPPED */, { sessionId: this.id });
2976
+ this.notify();
2977
+ }
2978
+ }
2979
+ /** stop() + release all listeners. Terminal — start() rejects afterwards. */
2980
+ destroy() {
2981
+ this.stop();
2982
+ this.destroyed = true;
2983
+ Array.from(this.waiterDestroyCallbacks).forEach((callback) => callback());
2984
+ this.waiterDestroyCallbacks.clear();
2985
+ this.emitter.removeAllListeners();
2986
+ this.listeners.clear();
2987
+ }
2988
+ // -- Promise waiters (subscription sugar over the event stream) ------------
2989
+ /**
2990
+ * Resolve when the session reaches one of the given statuses (immediately
2991
+ * if it's already there) — e.g. `waitForStatus('ready')` awaits quotes,
2992
+ * `waitForStatus('processing')` awaits detection of the settlement.
2993
+ * Statuses carry no outcomes; await those with {@link waitForSuccess}.
2994
+ *
2995
+ * Rejects with {@link OnrampSessionWaitError} on abort or destroy().
2996
+ * Does not start or stop the session — it only listens.
2997
+ */
2998
+ waitForStatus(status, options = {}) {
2999
+ const statuses = Array.isArray(status) ? status : [status];
3000
+ return new Promise((resolve, reject) => {
3001
+ this.installWaiter({
3002
+ options,
3003
+ reject,
3004
+ subscribe: (settle) => {
3005
+ const check = () => {
3006
+ if (statuses.includes(this.snapshot.status)) settle(() => resolve(this.snapshot));
3007
+ };
3008
+ check();
3009
+ return this.subscribe(check);
3010
+ }
3011
+ });
3012
+ });
3013
+ }
3014
+ /**
3015
+ * Resolve with the **first** succeeded {@link DirectExecution} observed by
3016
+ * this session — same one-shot first-completion contract as
3017
+ * {@link DepositSession.waitForSuccess}. A failure only rejects
3018
+ * (`DEPOSIT_FAILED`) when no other observed execution is still in flight;
3019
+ * fatal session errors reject with `SESSION_ERROR`. The session keeps
3020
+ * watching after success — subscribe to `direct_execution.succeeded` to
3021
+ * react to every settlement.
3022
+ */
3023
+ waitForSuccess(options = {}) {
3024
+ return new Promise((resolve, reject) => {
3025
+ this.installWaiter({
3026
+ options,
3027
+ reject,
3028
+ subscribe: (settle) => {
3029
+ const rejectFailure = (failed) => settle(
3030
+ () => reject(new OnrampSessionWaitError("DEPOSIT_FAILED", "Deposit failed", failed))
3031
+ );
3032
+ if (this.firstSuccess) {
3033
+ const first = this.firstSuccess;
3034
+ settle(() => resolve(first));
3035
+ return () => {
3036
+ };
3037
+ }
3038
+ const alreadyFailed = this.executions.find(
3039
+ (execution) => FAILURE_STATUSES2.includes(execution.status)
3040
+ );
3041
+ if (alreadyFailed && !this.anyExecutionInFlight()) {
3042
+ rejectFailure(alreadyFailed);
3043
+ return () => {
3044
+ };
3045
+ }
3046
+ if (this.error?.fatal) {
3047
+ const fatal = this.error;
3048
+ settle(() => reject(new OnrampSessionWaitError("SESSION_ERROR", fatal.message, fatal)));
3049
+ return () => {
3050
+ };
3051
+ }
3052
+ const offs = [
3053
+ this.on(
3054
+ OnrampSessionEventType.EXECUTION_SUCCEEDED,
3055
+ (event) => settle(() => resolve(event.data.object))
3056
+ ),
3057
+ this.on(OnrampSessionEventType.EXECUTION_FAILED, (event) => {
3058
+ if (this.anyExecutionInFlight()) return;
3059
+ rejectFailure(event.data.object);
3060
+ }),
3061
+ this.on(OnrampSessionEventType.EXECUTION_UPDATED, () => {
3062
+ if (this.anyExecutionInFlight() || this.firstSuccess) return;
3063
+ const failed = this.executions.find(
3064
+ (execution) => FAILURE_STATUSES2.includes(execution.status)
3065
+ );
3066
+ if (failed) rejectFailure(failed);
3067
+ }),
3068
+ this.on("onramp_session.errored" /* SESSION_ERRORED */, (event) => {
3069
+ if (!event.data.object.fatal) return;
3070
+ settle(
3071
+ () => reject(
3072
+ new OnrampSessionWaitError(
3073
+ "SESSION_ERROR",
3074
+ event.data.object.message,
3075
+ event.data.object
3076
+ )
3077
+ )
3078
+ );
3079
+ })
3080
+ ];
3081
+ return () => offs.forEach((off) => off());
3082
+ }
3083
+ });
3084
+ });
3085
+ }
3086
+ /** Shared waiter plumbing — AbortSignal / destroy() rejection with single settlement. */
3087
+ installWaiter({
3088
+ options,
3089
+ reject,
3090
+ subscribe
3091
+ }) {
3092
+ if (this.destroyed) {
3093
+ reject(new OnrampSessionWaitError("DESTROYED", "OnrampSession has been destroyed"));
3094
+ return;
3095
+ }
3096
+ if (options.signal?.aborted) {
3097
+ reject(new OnrampSessionWaitError("ABORTED", "Wait aborted", options.signal.reason));
3098
+ return;
3099
+ }
3100
+ let settled = false;
3101
+ let unsubscribe = null;
3102
+ const cleanup = () => {
3103
+ unsubscribe?.();
3104
+ options.signal?.removeEventListener("abort", onAbort);
3105
+ this.waiterDestroyCallbacks.delete(onDestroy);
3106
+ };
3107
+ const settle = (finish) => {
3108
+ if (settled) return;
3109
+ settled = true;
3110
+ cleanup();
3111
+ finish();
3112
+ };
3113
+ const onAbort = () => settle(
3114
+ () => reject(new OnrampSessionWaitError("ABORTED", "Wait aborted", options.signal?.reason))
3115
+ );
3116
+ const onDestroy = () => settle(
3117
+ () => reject(
3118
+ new OnrampSessionWaitError("DESTROYED", "OnrampSession was destroyed while waiting")
3119
+ )
3120
+ );
3121
+ this.waiterDestroyCallbacks.add(onDestroy);
3122
+ options.signal?.addEventListener("abort", onAbort, { once: true });
3123
+ unsubscribe = subscribe(settle);
3124
+ if (settled) cleanup();
3125
+ }
3126
+ // -- Run lifecycle ---------------------------------------------------------
3127
+ async run() {
3128
+ const token = ++this.runToken;
3129
+ this.teardownWatcher();
3130
+ this.prepared = false;
3131
+ this.addresses = [];
3132
+ this.destinationToken = null;
3133
+ this.destinationTokenGeoKey = null;
3134
+ this.quotes = [];
3135
+ this.selectedQuote = null;
3136
+ this.manualSelection = null;
3137
+ this.quotesUpdatedAt = null;
3138
+ this.nonFatalErrorLatch = null;
3139
+ this.checkout = null;
3140
+ this.executions = [];
3141
+ this.firstSuccess = null;
3142
+ this.checkingDeposit = false;
3143
+ this.error = null;
3144
+ this.setStatus("preparing");
3145
+ this.commit();
3146
+ this.emitSessionEvent("onramp_session.started" /* SESSION_STARTED */, { sessionId: this.id });
3147
+ this.notify();
3148
+ let wallets;
3149
+ let destinationToken;
3150
+ try {
3151
+ [wallets, destinationToken] = await Promise.all([
3152
+ this.createAddressesWithRetry(token),
3153
+ this.resolveCountry().then(() => this.resolveDestinationToken()),
3154
+ this.runStartChecks()
3155
+ ]);
3156
+ } catch (cause) {
3157
+ if (token !== this.runToken) return;
3158
+ const isCheck = cause instanceof SessionCheckError2;
3159
+ this.failFatally(
3160
+ isCheck ? cause.code : "ADDRESS_CREATION_FAILED",
3161
+ isCheck ? cause.message : "Failed to prepare onramp session",
3162
+ cause
3163
+ );
3164
+ return;
3165
+ }
3166
+ if (token !== this.runToken) return;
3167
+ this.addresses = wallets.map(mapWalletToDepositAddress);
3168
+ this.destinationToken = destinationToken.token;
3169
+ this.destinationTokenGeoKey = destinationToken.geoKey;
3170
+ this.prepared = true;
3171
+ this.setStatus("quoting");
3172
+ this.commit();
3173
+ this.emitSessionEvent("onramp_session.addresses_created" /* ADDRESSES_CREATED */, {
3174
+ sessionId: this.id,
3175
+ addresses: this.addresses
3176
+ });
3177
+ this.notify();
3178
+ if (token !== this.runToken) return;
3179
+ await this.syncQuoteInputs(token);
3180
+ if (token !== this.runToken) return;
3181
+ if (this.quoteRefreshIntervalMs > 0) {
3182
+ this.refreshTimer = setInterval(() => {
3183
+ if (token !== this.runToken || this.checkout) return;
3184
+ void this.syncQuoteInputs(token);
3185
+ }, this.quoteRefreshIntervalMs);
3186
+ }
3187
+ }
3188
+ failFatally(code, message, cause) {
3189
+ this.clearRefreshTimer();
3190
+ this.startPromise = null;
3191
+ this.error = { code, message, fatal: true, cause };
3192
+ this.setStatus("error");
3193
+ this.commit();
3194
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3195
+ sessionId: this.id,
3196
+ code,
3197
+ message,
3198
+ fatal: true
3199
+ });
3200
+ this.notify();
3201
+ }
3202
+ async createAddressesWithRetry(token) {
3203
+ let lastError;
3204
+ for (let attempt = 0; attempt < ADDRESS_CREATE_MAX_ATTEMPTS2; attempt++) {
3205
+ if (attempt > 0) {
3206
+ await delay2(Math.min(1e3 * 2 ** (attempt - 1), 1e4));
3207
+ if (token !== this.runToken) throw new Error("OnrampSession stopped");
3208
+ }
3209
+ try {
3210
+ const response = await createDepositAddress(
3211
+ {
3212
+ external_user_id: this.externalUserId,
3213
+ destination_chain_type: this.destination.chainType,
3214
+ destination_chain_id: this.destination.chainId,
3215
+ destination_token_address: this.destination.tokenAddress,
3216
+ recipient_address: this.destination.recipientAddress,
3217
+ contract_calls: this.destination.contractCalls
3218
+ },
3219
+ this.publishableKey
3220
+ );
3221
+ return response.data;
3222
+ } catch (error) {
3223
+ lastError = error;
3224
+ }
3225
+ }
3226
+ throw lastError;
3227
+ }
3228
+ /** Host-supplied geo wins; otherwise the IP-detected value; 'US' as a last resort. */
3229
+ effectiveCountryCode() {
3230
+ return this.explicitCountryCode || this.detectedCountryCode || "US";
3231
+ }
3232
+ /**
3233
+ * Explicit subdivision wins. A detected subdivision applies only while the
3234
+ * country is also detected — mixing a detected subdivision into an
3235
+ * explicitly-set country would pin quotes to a region of the wrong country.
3236
+ */
3237
+ effectiveSubdivisionCode() {
3238
+ if (this.explicitSubdivisionCode !== void 0) return this.explicitSubdivisionCode;
3239
+ if (!this.explicitCountryCode && this.detectedCountryCode) {
3240
+ return this.detectedSubdivisionCode ?? void 0;
3241
+ }
3242
+ return void 0;
3243
+ }
3244
+ /**
3245
+ * Auto-detect the payer's country from their IP when the host didn't
3246
+ * supply one — so integrators don't need to build geo plumbing to render
3247
+ * a buy screen. Never fatal: detection failure falls back to 'US' (modal
3248
+ * parity — BuyWithCard quotes with `userIpInfo?.alpha2 || 'US'`). The
3249
+ * detection result is cached for the session's lifetime (restarts reuse
3250
+ * it); an explicit countryCode — at construction or via
3251
+ * updateQuoteRequest — always overrides.
3252
+ */
3253
+ async resolveCountry() {
3254
+ if (this.explicitCountryCode || this.detectedCountryCode) return;
3255
+ try {
3256
+ const info = await getIpAddress();
3257
+ this.detectedCountryCode = info.alpha2 || "US";
3258
+ this.detectedSubdivisionCode = info.subdivision_code ?? null;
3259
+ } catch {
3260
+ this.detectedCountryCode = "US";
3261
+ this.detectedSubdivisionCode = null;
3262
+ }
3263
+ }
3264
+ /** Identity of the geo a destination-token resolution was made for. */
3265
+ geoKey() {
3266
+ return `${this.effectiveCountryCode()}|${this.effectiveSubdivisionCode() ?? ""}`;
3267
+ }
3268
+ /**
3269
+ * Resolve the provider-side network/currency the destination maps to. The
3270
+ * geo it was resolved for travels with the result: routing is
3271
+ * geo-dependent, so the caller has to know when a later country change
3272
+ * invalidates it.
3273
+ */
3274
+ async resolveDestinationToken() {
3275
+ const geoKey = this.geoKey();
3276
+ try {
3277
+ const response = await getDefaultOnrampToken(
3278
+ {
3279
+ country_code: this.effectiveCountryCode(),
3280
+ subdivision_code: this.effectiveSubdivisionCode(),
3281
+ token_address: this.destination.tokenAddress,
3282
+ chain_id: this.destination.chainId,
3283
+ chain_type: this.destination.chainType
3284
+ },
3285
+ this.publishableKey
3286
+ );
3287
+ return { token: mapDefaultOnrampToken(response), geoKey };
3288
+ } catch {
3289
+ throw new SessionCheckError2(
3290
+ "DESTINATION_TOKEN_FAILED",
3291
+ "No onramp route available for this destination token"
3292
+ );
3293
+ }
3294
+ }
3295
+ /**
3296
+ * Fail-fast recipient validation — parity with DepositSession. Fails open
3297
+ * on network errors (the backend still enforces at execution time), but a
3298
+ * definitive negative result is fatal.
3299
+ */
3300
+ async runStartChecks() {
3301
+ const recipientValid = await verifyRecipientAddress(
3302
+ {
3303
+ chain_type: this.destination.chainType,
3304
+ chain_id: this.destination.chainId,
3305
+ token_address: this.destination.tokenAddress,
3306
+ recipient_address: this.destination.recipientAddress
3307
+ },
3308
+ this.publishableKey
3309
+ ).then((result) => result.valid).catch(() => null);
3310
+ if (recipientValid === false) {
3311
+ throw new SessionCheckError2(
3312
+ "INVALID_RECIPIENT",
3313
+ "Recipient address cannot receive funds for this destination"
3314
+ );
3315
+ }
3316
+ }
3317
+ // -- Quotes ----------------------------------------------------------------
3318
+ /**
3319
+ * Bring the quote inputs and the destination token back in sync, then
3320
+ * fetch quotes. Destination-token routing is geo-dependent (the modal
3321
+ * re-runs its default-token effect whenever `userIpInfo` changes), so a
3322
+ * payer-country change has to re-resolve it first — quoting on the network
3323
+ * and currency routed for the previous geo would also carry into the
3324
+ * checkout built from those quotes.
3325
+ */
3326
+ syncQuoteInputs(token) {
3327
+ if (this.syncPromise) {
3328
+ this.syncRequested = true;
3329
+ return this.syncPromise;
3330
+ }
3331
+ this.syncPromise = Promise.resolve().then(() => this.syncQuoteInputsUntilFresh(token)).finally(() => {
3332
+ this.syncPromise = null;
3333
+ });
3334
+ return this.syncPromise;
3335
+ }
3336
+ /** Loops until the session state matches the inputs it was built from. */
3337
+ async syncQuoteInputsUntilFresh(token) {
3338
+ while (token === this.runToken && !this.checkout) {
3339
+ this.syncRequested = false;
3340
+ if (!this.destinationToken || this.destinationTokenGeoKey !== this.geoKey()) {
3341
+ if (!await this.resolveDestinationTokenForGeo(token) && !this.syncRequested) return;
3342
+ continue;
3343
+ }
3344
+ if (!await this.fetchQuotesOnce(token) && !this.syncRequested) return;
3345
+ }
3346
+ }
3347
+ /**
3348
+ * Re-resolve the destination token for the current geo. Returns false when
3349
+ * the run ended or the lookup failed.
3350
+ */
3351
+ async resolveDestinationTokenForGeo(token) {
3352
+ this.destinationToken = null;
3353
+ this.destinationTokenGeoKey = null;
3354
+ this.quotes = [];
3355
+ this.selectedQuote = null;
3356
+ this.isRefreshingQuotes = true;
3357
+ this.commit();
3358
+ this.notify();
3359
+ let resolved;
3360
+ try {
3361
+ resolved = await this.resolveDestinationToken();
3362
+ } catch (cause) {
3363
+ if (token !== this.runToken) return false;
3364
+ this.isRefreshingQuotes = false;
3365
+ this.raiseNonFatal(
3366
+ "DESTINATION_TOKEN_FAILED",
3367
+ "No onramp route available for this destination token",
3368
+ cause
3369
+ );
3370
+ return false;
3371
+ }
3372
+ if (token !== this.runToken) return false;
3373
+ this.destinationToken = resolved.token;
3374
+ this.destinationTokenGeoKey = resolved.geoKey;
3375
+ this.isRefreshingQuotes = false;
3376
+ this.commit();
3377
+ this.notify();
3378
+ return true;
3379
+ }
3380
+ /** Returns true when the request inputs moved and quotes must be refetched. */
3381
+ async fetchQuotesOnce(token) {
3382
+ const destinationToken = this.destinationToken;
3383
+ if (!destinationToken) return false;
3384
+ if (this.destinationAmount && !destinationToken.isStablecoin) {
3385
+ this.quotes = [];
3386
+ this.selectedQuote = null;
3387
+ if (this.status === "quoting") this.setStatus("ready");
3388
+ this.raiseNonFatal(
3389
+ "DESTINATION_AMOUNT_UNSUPPORTED",
3390
+ "Buying a fixed destination amount is only supported for stablecoin destinations"
3391
+ );
3392
+ return false;
3393
+ }
3394
+ const activeAmount = this.sourceAmount || this.destinationAmount || "";
3395
+ const amount = parseFloat(activeAmount);
3396
+ if (!Number.isFinite(amount) || amount <= 0) {
3397
+ this.quotes = [];
3398
+ this.selectedQuote = null;
3399
+ if (this.status === "quoting") this.setStatus("ready");
3400
+ this.commit();
3401
+ this.notify();
3402
+ return false;
3403
+ }
3404
+ const requestSourceAmount = this.sourceAmount;
3405
+ const requestDestinationAmount = this.destinationAmount;
3406
+ const requestCurrency = this.sourceCurrency;
3407
+ const requestCountry = this.effectiveCountryCode();
3408
+ const requestSubdivision = this.effectiveSubdivisionCode();
3409
+ this.isRefreshingQuotes = true;
3410
+ this.commit();
3411
+ this.notify();
3412
+ try {
3413
+ const response = await getOnrampQuotes(
3414
+ {
3415
+ country_code: requestCountry,
3416
+ // Exactly one of source_amount / destination_amount (amount mode).
3417
+ ...requestSourceAmount ? { source_amount: requestSourceAmount } : { destination_amount: requestDestinationAmount },
3418
+ source_currency: requestCurrency.toLowerCase(),
3419
+ destination_currency: destinationToken.currency,
3420
+ destination_network: destinationToken.network,
3421
+ subdivision_code: requestSubdivision
3422
+ },
3423
+ this.publishableKey
3424
+ );
3425
+ if (token !== this.runToken || this.checkout) return false;
3426
+ const stale = requestSourceAmount !== this.sourceAmount || requestDestinationAmount !== this.destinationAmount || requestCurrency !== this.sourceCurrency || requestCountry !== this.effectiveCountryCode() || requestSubdivision !== this.effectiveSubdivisionCode();
3427
+ if (!stale) {
3428
+ this.quotes = response.data.map(mapOnrampQuote);
3429
+ this.reconcileSelection();
3430
+ this.quotesUpdatedAt = Date.now();
3431
+ if (this.nonFatalErrorLatch) {
3432
+ this.nonFatalErrorLatch = null;
3433
+ if (this.error && !this.error.fatal) this.error = null;
3434
+ }
3435
+ this.commit();
3436
+ this.emitSessionEvent("onramp_session.quotes_updated" /* QUOTES_UPDATED */, {
3437
+ sessionId: this.id,
3438
+ quotes: this.quotes,
3439
+ selectedQuote: this.selectedQuote
3440
+ });
3441
+ }
3442
+ } catch (cause) {
3443
+ if (token !== this.runToken || this.checkout) return false;
3444
+ console.error("[unifold] failed to fetch onramp quotes:", cause);
3445
+ this.quotes = [];
3446
+ this.selectedQuote = null;
3447
+ this.raiseNonFatal("QUOTES_FAILED", "Failed to fetch onramp quotes", cause);
3448
+ } finally {
3449
+ if (token === this.runToken) {
3450
+ this.isRefreshingQuotes = false;
3451
+ if (this.status === "quoting") this.setStatus("ready");
3452
+ this.commit();
3453
+ this.notify();
3454
+ }
3455
+ }
3456
+ return token === this.runToken && !this.checkout && this.startPromise !== null && (requestSourceAmount !== this.sourceAmount || requestDestinationAmount !== this.destinationAmount || requestCurrency !== this.sourceCurrency || requestCountry !== this.effectiveCountryCode() || requestSubdivision !== this.effectiveSubdivisionCode());
3457
+ }
3458
+ /**
3459
+ * Record a non-fatal error and announce it once per streak — auto-refresh
3460
+ * keeps retrying, and repeating the event every 60s would be noise.
3461
+ */
3462
+ raiseNonFatal(code, message, cause) {
3463
+ const alreadyReported = this.nonFatalErrorLatch === code;
3464
+ if (!alreadyReported) {
3465
+ this.nonFatalErrorLatch = code;
3466
+ this.error = { code, message, fatal: false, cause };
3467
+ }
3468
+ this.commit();
3469
+ if (!alreadyReported) {
3470
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3471
+ sessionId: this.id,
3472
+ code,
3473
+ message,
3474
+ fatal: false
3475
+ });
3476
+ }
3477
+ this.notify();
3478
+ }
3479
+ /** Sticky manual selection: keep the host's provider while it still quotes. */
3480
+ reconcileSelection() {
3481
+ if (this.manualSelection) {
3482
+ const match = this.quotes.find((quote) => quote.serviceProvider === this.manualSelection);
3483
+ if (match) {
3484
+ this.selectedQuote = match;
3485
+ return;
3486
+ }
3487
+ this.manualSelection = null;
3488
+ }
3489
+ this.selectedQuote = this.quotes[0] ?? null;
3490
+ }
3491
+ // -- Settlement watcher (composed DepositSession) ---------------------------
3492
+ /**
3493
+ * Start (once) the composed {@link DepositSession} that watches the deposit
3494
+ * addresses for the provider's on-chain settlement. Its baseline starts at
3495
+ * checkout time — correct for card rails, where funds can only arrive after
3496
+ * the user pays at the provider. Detection polling, the backend scan nudge
3497
+ * (auto-armed), the lookback window, and `direct_execution.*` semantics are
3498
+ * all inherited rather than reimplemented.
3499
+ */
3500
+ startWatcher() {
3501
+ if (this.watcher) return;
3502
+ const watcher = new DepositSession({
3503
+ publishableKey: this.publishableKey,
3504
+ externalUserId: this.externalUserId,
3505
+ destination: this.destination,
3506
+ confirmationMode: "auto",
3507
+ method: this.method
3508
+ });
3509
+ this.watcher = watcher;
3510
+ const offEvents = watcher.on("*", (event) => {
3511
+ switch (event.type) {
3512
+ case DepositSessionEventType.EXECUTION_DETECTED:
3513
+ case DepositSessionEventType.EXECUTION_UPDATED:
3514
+ case DepositSessionEventType.EXECUTION_SUCCEEDED:
3515
+ case DepositSessionEventType.EXECUTION_FAILED:
3516
+ this.syncFromWatcher(watcher);
3517
+ if (event.type === DepositSessionEventType.EXECUTION_SUCCEEDED && !this.firstSuccess) {
3518
+ this.firstSuccess = event.data.object;
3519
+ }
3520
+ this.forwardExecutionEvent(event.type, event);
3521
+ break;
3522
+ case "deposit_session.errored" /* SESSION_ERRORED */: {
3523
+ const { code, message, fatal } = event.data.object;
3524
+ this.syncFromWatcher(watcher);
3525
+ if (fatal) {
3526
+ this.error = { code, message, fatal: true };
3527
+ this.setStatus("error");
3528
+ this.commit();
3529
+ }
3530
+ this.emitSessionEvent("onramp_session.errored" /* SESSION_ERRORED */, {
3531
+ sessionId: this.id,
3532
+ code,
3533
+ message,
3534
+ fatal
3535
+ });
3536
+ break;
3537
+ }
3538
+ default:
3539
+ break;
3540
+ }
3541
+ });
3542
+ const offSnapshot = watcher.subscribe(() => {
3543
+ this.syncFromWatcher(watcher);
3544
+ this.notify();
3545
+ });
3546
+ this.watcherOffs = [offEvents, offSnapshot];
3547
+ void watcher.start().catch(() => {
3548
+ });
3549
+ }
3550
+ syncFromWatcher(watcher) {
3551
+ if (this.destroyed || this.watcher !== watcher) return;
3552
+ const inner = watcher.getSnapshot();
3553
+ this.executions = inner.executions;
3554
+ this.checkingDeposit = inner.isCheckingDeposit;
3555
+ if (this.status !== "error") {
3556
+ if (inner.error && !inner.error.fatal) {
3557
+ this.error = {
3558
+ code: inner.error.code,
3559
+ message: inner.error.message,
3560
+ fatal: false,
3561
+ cause: inner.error.cause
3562
+ };
3563
+ } else if (!inner.error && this.error && !this.error.fatal) {
3564
+ this.error = null;
3565
+ }
3566
+ this.setStatus(inner.status === "processing" ? "processing" : "awaiting_payment");
3567
+ }
3568
+ this.commit();
3569
+ }
3570
+ forwardExecutionEvent(type, event) {
3571
+ this.emitter.emit(
3572
+ type,
3573
+ {
3574
+ id: event.id,
3575
+ type,
3576
+ created: event.created,
3577
+ method: this.method,
3578
+ data: event.data
3579
+ }
3580
+ );
3581
+ }
3582
+ teardownWatcher() {
3583
+ this.watcherOffs.forEach((off) => off());
3584
+ this.watcherOffs = [];
3585
+ this.watcher?.destroy();
3586
+ this.watcher = null;
3587
+ }
3588
+ // -- Internals --------------------------------------------------------------
3589
+ anyExecutionInFlight() {
3590
+ return this.executions.some((execution) => IN_PROGRESS_STATUSES2.includes(execution.status));
3591
+ }
3592
+ setStatus(status) {
3593
+ this.status = status;
3594
+ }
3595
+ clearRefreshTimer() {
3596
+ if (this.refreshTimer) {
3597
+ clearInterval(this.refreshTimer);
3598
+ this.refreshTimer = null;
3599
+ }
3600
+ }
3601
+ buildSnapshot() {
3602
+ return {
3603
+ status: this.status,
3604
+ countryCode: this.explicitCountryCode || this.detectedCountryCode || null,
3605
+ addresses: this.addresses,
3606
+ destinationToken: this.destinationToken,
3607
+ quotes: this.quotes,
3608
+ selectedQuote: this.selectedQuote,
3609
+ isQuoteAutoSelected: this.manualSelection === null,
3610
+ canSelectProvider: this.quotes.length > 1,
3611
+ isRefreshingQuotes: this.isRefreshingQuotes,
3612
+ quotesUpdatedAt: this.quotesUpdatedAt,
3613
+ checkout: this.checkout,
3614
+ executions: this.executions,
3615
+ latestExecution: this.executions[0] ?? null,
3616
+ isCheckingDeposit: this.checkingDeposit,
3617
+ error: this.error
3618
+ };
3619
+ }
3620
+ /** Rebuild the snapshot so getSnapshot() reflects current state. */
3621
+ commit() {
3622
+ this.snapshot = this.buildSnapshot();
3623
+ }
3624
+ notify() {
3625
+ this.listeners.forEach((listener) => {
3626
+ try {
3627
+ listener();
3628
+ } catch (error) {
3629
+ console.error("[unifold] snapshot listener threw", error);
3630
+ }
3631
+ });
3632
+ }
3633
+ emitSessionEvent(type, object) {
3634
+ this.emitter.emit(type, {
3635
+ id: generatePrefixedKSUID("sevt"),
3636
+ type,
3637
+ created: Math.floor(Date.now() / 1e3),
3638
+ method: this.method,
3639
+ data: { object }
3640
+ });
3641
+ }
3642
+ };
3643
+
3644
+ // src/lib/client.ts
3645
+ var UnifoldClient = class {
3646
+ constructor(options) {
3647
+ __publicField(this, "publishableKey");
3648
+ const { publishableKey } = options;
3649
+ if (!publishableKey || publishableKey.trim() === "") {
3650
+ throw new Error("Unifold: publishableKey is required");
3651
+ }
3652
+ if (!publishableKey.startsWith("pk_test_") && !publishableKey.startsWith("pk_live_")) {
3653
+ console.warn('Unifold: publishableKey should start with "pk_test_" or "pk_live_".');
3654
+ }
3655
+ this.publishableKey = publishableKey;
3656
+ }
3657
+ /** Create a headless deposit-session flow controller. */
3658
+ createDepositSession(params) {
3659
+ return new DepositSession({ ...params, publishableKey: this.publishableKey });
3660
+ }
3661
+ /** Create a headless fiat-onramp flow controller (buy with card by default). */
3662
+ createOnrampSession(params) {
3663
+ return new OnrampSession({ ...params, publishableKey: this.publishableKey });
2527
3664
  }
2528
3665
  /**
2529
3666
  * Create (idempotently) and return the user's deposit addresses for a
@@ -2734,11 +3871,16 @@ export {
2734
3871
  DepositSession,
2735
3872
  DepositSessionEventType,
2736
3873
  DepositSessionWaitError,
3874
+ DirectExecutionEventType,
2737
3875
  ExecutionStatus,
2738
3876
  IneligibilityReason,
2739
3877
  IntegrationProvider,
2740
3878
  IntegrationTransferError,
2741
3879
  LOOKBACK_MS,
3880
+ OnrampSession,
3881
+ OnrampSessionEventType,
3882
+ OnrampSessionWaitError,
3883
+ QUOTE_REFRESH_INTERVAL_MS,
2742
3884
  SCAN_NUDGE_INTERVAL_MS,
2743
3885
  SOLANA_USDC_ADDRESS,
2744
3886
  StripeApiResponseError,
@@ -2755,6 +3897,7 @@ export {
2755
3897
  createCoinbaseWalletPaySession,
2756
3898
  createDepositAddress,
2757
3899
  createExchangeSession,
3900
+ createIntegrationExchangeSession,
2758
3901
  createIntegrationTransfer,
2759
3902
  createOnrampSession,
2760
3903
  createOnrampVerificationSession,
@@ -2787,12 +3930,14 @@ export {
2787
3930
  getGooglePayProviders,
2788
3931
  getIconUrl,
2789
3932
  getIconUrlWithCdn,
3933
+ getIntegrationExchangeSessionStartUrl,
2790
3934
  getIntegrationExchanges,
2791
3935
  getIntegrationHoldings,
2792
3936
  getIntegrationTransferDefaultToken,
2793
3937
  getIpAddress,
2794
3938
  getOnrampQuotes,
2795
3939
  getOnrampSessionStartUrl,
3940
+ getOnrampSessionStatus,
2796
3941
  getOnrampVerificationSession,
2797
3942
  getPreferredIconUrl,
2798
3943
  getProjectConfig,
@@ -2811,7 +3956,9 @@ export {
2811
3956
  isGooglePayLimitReached,
2812
3957
  isWalletPayLimitReached,
2813
3958
  listPaymentIntentExecutions,
3959
+ mapDefaultOnrampToken,
2814
3960
  mapDirectExecution,
3961
+ mapOnrampQuote,
2815
3962
  mapWalletToDepositAddress,
2816
3963
  pollDirectExecutions,
2817
3964
  queryExecutions,