From edd90cf0c0e93270b2caf9ac675481370c3b1acb Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Tue, 28 Jul 2026 12:21:19 -0700
Subject: [PATCH 1/6] add generic hosted payment flow
---
packages/sdk/src/common/api.test.ts | 99 ++++++--
packages/sdk/src/common/api.ts | 39 +++
packages/sdk/src/web/api/index.ts | 4 +
packages/sdk/src/web/api/navTree.test.ts | 59 +++++
packages/sdk/src/web/api/navTree.ts | 77 ++++++
.../sdk/src/web/components/DaimoModal.tsx | 81 +++---
.../sdk/src/web/components/ExchangePage.tsx | 61 -----
.../components/ExternalPaymentFlowPage.tsx | 75 ++++++
.../web/components/ExternalPaymentPage.tsx | 19 ++
.../src/web/components/SelectAmountPage.tsx | 26 +-
packages/sdk/src/web/hooks/locales/en.ts | 6 +-
packages/sdk/src/web/hooks/locales/es.ts | 4 +
packages/sdk/src/web/hooks/locales/ja.ts | 6 +-
packages/sdk/src/web/hooks/locales/ko.ts | 6 +-
packages/sdk/src/web/hooks/locales/pt.ts | 6 +-
packages/sdk/src/web/hooks/locales/zh.ts | 6 +-
packages/sdk/src/web/hooks/navEvent.ts | 11 +-
packages/sdk/src/web/hooks/types.ts | 16 +-
packages/sdk/src/web/hooks/useSessionNav.ts | 233 ++++++++++++------
19 files changed, 614 insertions(+), 220 deletions(-)
create mode 100644 packages/sdk/src/web/api/navTree.test.ts
delete mode 100644 packages/sdk/src/web/components/ExchangePage.tsx
create mode 100644 packages/sdk/src/web/components/ExternalPaymentFlowPage.tsx
diff --git a/packages/sdk/src/common/api.test.ts b/packages/sdk/src/common/api.test.ts
index 79011aff..f13f4efe 100644
--- a/packages/sdk/src/common/api.test.ts
+++ b/packages/sdk/src/common/api.test.ts
@@ -3,22 +3,89 @@ import { describe, expect, test } from "vitest";
import { zCreatePaymentMethodRequest } from "./api.js";
describe("zCreatePaymentMethodRequest", () => {
- test("accepts BinanceUSDC and BinanceUSDT exchange ids", () => {
- for (const exchangeId of ["BinanceUSDC", "BinanceUSDT"] as const) {
- expect(
- zCreatePaymentMethodRequest.parse({
- clientSecret: "secret",
- paymentMethod: {
- type: "exchange",
- exchangeId,
- amountUsd: 10,
+ test("accepts canonical USD exchange requests", () => {
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "exchange",
+ exchangeId: "Coinbase",
+ amountUsd: 10,
+ },
+ }).paymentMethod,
+ ).toEqual({
+ type: "exchange",
+ exchangeId: "Coinbase",
+ amountUsd: 10,
+ });
+ });
+
+ test("accepts backend-defined hosted methods and source currencies", () => {
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "hosted",
+ hostedPaymentMethodId: "FutureHostedMethod",
+ countryCode: "DE",
+ sourceAmount: {
+ units: "10.50",
+ currency: "EUR",
+ },
+ },
+ }).paymentMethod,
+ ).toEqual({
+ type: "hosted",
+ hostedPaymentMethodId: "FutureHostedMethod",
+ countryCode: "DE",
+ sourceAmount: {
+ units: "10.50",
+ currency: "EUR",
+ },
+ });
+ });
+
+ test("rejects malformed source amounts without encoding provider policy", () => {
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "hosted",
+ hostedPaymentMethodId: "FutureHostedMethod",
+ countryCode: "DE",
+ sourceAmount: {
+ units: "0",
+ currency: "EUR",
+ },
+ },
+ }),
+ ).toThrow();
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "hosted",
+ hostedPaymentMethodId: "FutureHostedMethod",
+ countryCode: "DE",
+ sourceAmount: {
+ units: "10.00",
+ currency: "eur",
},
- }).paymentMethod,
- ).toEqual({
- type: "exchange",
- exchangeId,
- amountUsd: 10,
- });
- }
+ },
+ }),
+ ).toThrow();
+ });
+
+ test("keeps hosted methods out of the exchange enum", () => {
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "exchange",
+ exchangeId: "Revolut",
+ amountUsd: 10,
+ },
+ }),
+ ).toThrow();
});
});
diff --git a/packages/sdk/src/common/api.ts b/packages/sdk/src/common/api.ts
index f6652fc7..34677d2b 100644
--- a/packages/sdk/src/common/api.ts
+++ b/packages/sdk/src/common/api.ts
@@ -6,6 +6,10 @@ import { zAddress, zSolanaAddress } from "./primitives.js";
import type { SessionPublicInfo } from "./session.js";
const zPlatform = z.enum(["ios", "android", "other", "desktop", "mobile"]);
+const zPositiveDecimalUnits = z
+ .string()
+ .max(100)
+ .regex(/^(?:[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)$/);
export const zExchangeId = z.enum([
"Coinbase",
@@ -41,6 +45,18 @@ export const zCreatePaymentMethodRequest = z.object({
amountUsd: z.number().positive(),
platform: zPlatform.optional(),
}),
+ z.object({
+ type: z.literal("hosted"),
+ /** Opaque method ID supplied by the backend navigation tree. */
+ hostedPaymentMethodId: z.string().min(1),
+ /** ISO-3166 alpha-2 country selected in the backend navigation tree. */
+ countryCode: z.string().regex(/^[A-Z]{2}$/),
+ sourceAmount: z.object({
+ units: zPositiveDecimalUnits,
+ currency: z.string().regex(/^[A-Z]{3}$/),
+ }),
+ platform: zPlatform.optional(),
+ }),
z.object({
type: z.literal("stripe"),
amountUsd: z.number().positive(),
@@ -121,6 +137,17 @@ export type CreatePaymentMethodResponse = {
/** Invoice expiry time (unix seconds). Present for Lightning invoices. */
expiresAt?: number;
};
+ /** Hosted-payment details, present for a generic external handoff. */
+ hosted?: {
+ /** URL where the user completes the hosted payment. */
+ url: string;
+ /** Message to display while waiting for the payment. */
+ waitingMessage: string;
+ /** Hosted link expiry time (unix seconds), when supplied by the backend. */
+ expiresAt?: number;
+ /** Provider-neutral estimate for quoted hosted payments. */
+ quote?: HostedPaymentQuote;
+ };
/** Fiat payment details, present when payment method is fiat. */
fiat?: {
/** Hosted URL where the user completes KYC and the selected fiat flow. */
@@ -143,6 +170,18 @@ export type CreatePaymentMethodResponse = {
};
};
+export type HostedPaymentQuote = {
+ sourceAmountUnits: string;
+ sourceCurrency: string;
+ estimatedDestinationUnits: string;
+ destinationCurrency: string;
+ fees: {
+ kind: "service" | "network" | "partner";
+ amountUnits: string;
+ currency: string;
+ }[];
+};
+
export type CheckSessionResponse = {
/** Current session state. */
session: SessionPublicInfo;
diff --git a/packages/sdk/src/web/api/index.ts b/packages/sdk/src/web/api/index.ts
index b8237518..9cfd39f0 100644
--- a/packages/sdk/src/web/api/index.ts
+++ b/packages/sdk/src/web/api/index.ts
@@ -10,6 +10,9 @@ export type {
NavNodeDeeplink,
NavNodeDepositAddress,
NavNodeExchange,
+ NavExternalPaymentNode,
+ NavExternalHandoff,
+ NavNodeHostedPayment,
NavNodeFiat,
NavNodeKycRequirement,
NavNodeKycRequirementDisplayItem,
@@ -17,6 +20,7 @@ export type {
NavNodeKycRequirementItem,
NavNodeKycRequirementKind,
NavNodeTronDeposit,
+ NavSourceAmount,
SessionNavInfo,
SessionWithNav,
} from "./navTree.js";
diff --git a/packages/sdk/src/web/api/navTree.test.ts b/packages/sdk/src/web/api/navTree.test.ts
new file mode 100644
index 00000000..fa7ea476
--- /dev/null
+++ b/packages/sdk/src/web/api/navTree.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ getNavExternalHandoff,
+ getNavSourceAmount,
+ type NavNodeExchange,
+ type NavNodeHostedPayment,
+} from "./navTree.js";
+
+describe("external payment nav policy", () => {
+ test("uses backend-provided amount and handoff descriptors", () => {
+ const node: NavNodeHostedPayment = {
+ type: "HostedPayment",
+ id: "HostedPayment-FutureHostedMethod",
+ title: "Future hosted method",
+ hostedPaymentMethodId: "FutureHostedMethod",
+ countryCode: "JP",
+ sourceAmount: {
+ currency: "JPY",
+ currencySymbol: "¥",
+ decimals: 0,
+ minimum: 100,
+ maximum: 10000,
+ },
+ externalHandoff: {
+ desktopBehavior: "popup",
+ popupName: "future-hosted-method",
+ },
+ };
+
+ expect(getNavSourceAmount(node)).toEqual(node.sourceAmount);
+ expect(getNavExternalHandoff(node)).toEqual(node.externalHandoff);
+ });
+
+ test("falls back to legacy USD nav fields without provider checks", () => {
+ const node: NavNodeExchange = {
+ type: "Exchange",
+ id: "Exchange-Coinbase",
+ title: "Coinbase",
+ exchangeId: "Coinbase",
+ requiredUsd: 10,
+ minimumUsd: 1,
+ maximumUsd: 100,
+ };
+
+ expect(getNavSourceAmount(node)).toEqual({
+ currency: "USD",
+ currencySymbol: "$",
+ decimals: 2,
+ required: 10,
+ minimum: 1,
+ maximum: 100,
+ });
+ expect(getNavExternalHandoff(node)).toEqual({
+ desktopBehavior: "qr",
+ placeholderDensity: "short",
+ });
+ });
+});
diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts
index 626fbb0c..ef25a6a4 100644
--- a/packages/sdk/src/web/api/navTree.ts
+++ b/packages/sdk/src/web/api/navTree.ts
@@ -89,23 +89,99 @@ export type NavNodeDeeplink = NavNodeCommon & {
pageIcon?: string;
};
+export type NavSourceAmount = {
+ /** ISO 4217 source currency selected by the backend. */
+ currency: string;
+ /** Presentation symbol selected by the backend, for example "$" or "€". */
+ currencySymbol: string;
+ /** Decimal places submitted to the backend. */
+ decimals: number;
+ required?: number;
+ minimum: number;
+ maximum: number;
+};
+
+export type NavExternalHandoff = {
+ desktopBehavior: "popup" | "qr";
+ popupName?: string;
+ placeholderDensity?: "short" | "medium" | "long";
+};
+
export type NavNodeExchange = NavNodeCommon & {
type: "Exchange";
exchangeId: ExchangeId;
icon?: string;
+ /** Backend-owned amount and currency policy. Optional for old servers. */
+ sourceAmount?: NavSourceAmount;
+ /** Backend-owned external handoff presentation. Optional for old servers. */
+ externalHandoff?: NavExternalHandoff;
+ /** @deprecated Use sourceAmount.required. */
requiredUsd?: number;
+ /** @deprecated Use sourceAmount.minimum. */
minimumUsd: number;
+ /** @deprecated Use sourceAmount.maximum. */
maximumUsd: number;
};
export type NavNodeCashApp = NavNodeCommon & {
type: "CashApp";
icon?: string;
+ /** Backend-owned amount and currency policy. Optional for old servers. */
+ sourceAmount?: NavSourceAmount;
+ /** Backend-owned external handoff presentation. Optional for old servers. */
+ externalHandoff?: NavExternalHandoff;
requiredUsd?: number;
minimumUsd: number;
maximumUsd: number;
};
+/**
+ * Generic hosted redirect. The SDK renders the interaction without knowing
+ * which provider owns the method ID.
+ */
+export type NavNodeHostedPayment = NavNodeCommon & {
+ type: "HostedPayment";
+ hostedPaymentMethodId: string;
+ /** ISO-3166 alpha-2 country to echo when initiating this hosted payment. */
+ countryCode: string;
+ icon?: string;
+ sourceAmount: NavSourceAmount;
+ externalHandoff: NavExternalHandoff;
+};
+
+export type NavExternalPaymentNode =
+ | NavNodeExchange
+ | NavNodeCashApp
+ | NavNodeHostedPayment;
+
+/** Compatibility fallback for nav trees produced by pre-sourceAmount servers. */
+export function getNavSourceAmount(
+ node: NavExternalPaymentNode,
+): NavSourceAmount {
+ if (node.type === "HostedPayment") return node.sourceAmount;
+ if (node.sourceAmount != null) return node.sourceAmount;
+ return {
+ currency: "USD",
+ currencySymbol: "$",
+ decimals: 2,
+ required: node.requiredUsd,
+ minimum: node.minimumUsd,
+ maximum: node.maximumUsd,
+ };
+}
+
+/** Compatibility fallback for nav trees produced by pre-handoff servers. */
+export function getNavExternalHandoff(
+ node: NavExternalPaymentNode,
+): NavExternalHandoff {
+ if (node.type === "HostedPayment") return node.externalHandoff;
+ if (node.externalHandoff != null) return node.externalHandoff;
+ return {
+ desktopBehavior: "qr",
+ placeholderDensity: "short",
+ };
+}
+
export type NavNodeStripe = NavNodeCommon & {
type: "Stripe";
icon?: string;
@@ -144,6 +220,7 @@ export type NavNode =
| NavNodeDeeplink
| NavNodeExchange
| NavNodeCashApp
+ | NavNodeHostedPayment
| NavNodeStripe
| NavNodeTronDeposit
| NavNodeConnectedWallet
diff --git a/packages/sdk/src/web/components/DaimoModal.tsx b/packages/sdk/src/web/components/DaimoModal.tsx
index b4c26235..1471cdd9 100644
--- a/packages/sdk/src/web/components/DaimoModal.tsx
+++ b/packages/sdk/src/web/components/DaimoModal.tsx
@@ -16,14 +16,14 @@ import type {
NavLocationOption,
RecreateSessionWithNavResponse,
} from "../api/index.js";
+import { getNavSourceAmount } from "../api/navTree.js";
import type {
+ NavExternalPaymentNode,
NavNode,
- NavNodeCashApp,
NavNodeChooseOption,
NavNodeConnectedWallet,
NavNodeDeeplink,
NavNodeDepositAddress,
- NavNodeExchange,
NavNodeStripe,
NavNodeTronDeposit,
SessionWithNav,
@@ -64,7 +64,7 @@ import { ChooseWalletPage } from "./ChooseWalletPage.js";
import { ConfirmationPage } from "./ConfirmationPage.js";
import { EmbeddedContainer, ModalContainer } from "./containers.js";
import { DeeplinkPage } from "./DeeplinkPage.js";
-import { ExchangePage } from "./ExchangePage.js";
+import { ExternalPaymentFlowPage } from "./ExternalPaymentFlowPage.js";
import { ExpiredPage } from "./ExpiredPage.js";
import { AccountPaymentInstructionsPage } from "./account/AccountBankDetailsPage.js";
import { AccountInstitutionPickerPage } from "./account/AccountBankPickerPage.js";
@@ -105,8 +105,6 @@ import { QRCode } from "./QRCode.js";
import { WaitingDepositAddressPage } from "./WaitingDepositAddressPage.js";
import { WalletAmountPage } from "./WalletAmountPage.js";
-type ExchangeLikeNode = NavNodeExchange | NavNodeCashApp;
-
export type DaimoModalLocalizationProps = {
/** Override country used to localize auto payment-method sessions. */
countryCode?: DaimoCountryCode;
@@ -818,8 +816,8 @@ function renderEntry(
return renderWaitingDeposit(entry, ctx);
case "waiting-tron":
return renderWaitingTron(entry, ctx);
- case "exchange-page":
- return renderExchangePage(entry, ctx);
+ case "external-payment":
+ return renderExternalPayment(entry, ctx);
case "stripe-onramp":
return renderStripeOnramp(entry, ctx);
case "wallet-choose-chain":
@@ -1101,8 +1099,8 @@ function renderSelectAmount(
return (