From 11fb1b21205f50e0b0289ab3f4e9be31822f412d Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:13:03 -0700
Subject: [PATCH 1/6] add canonical payment method actions
---
packages/sdk/README.md | 38 ++
packages/sdk/src/common/api.test.ts | 129 ++++++-
packages/sdk/src/common/api.ts | 93 +++--
packages/sdk/src/common/index.ts | 1 +
packages/sdk/src/common/paymentMethod.test.ts | 26 ++
packages/sdk/src/common/paymentMethod.ts | 84 +++++
packages/sdk/src/common/session.ts | 37 +-
packages/sdk/src/web/api/index.ts | 5 +
packages/sdk/src/web/api/navTree.test.ts | 95 +++++
packages/sdk/src/web/api/navTree.ts | 118 +++++-
.../sdk/src/web/components/DaimoModal.tsx | 120 ++----
.../sdk/src/web/components/ExchangePage.tsx | 61 ----
...lPaymentPage.tsx => HostedPaymentPage.tsx} | 31 +-
.../src/web/components/PaymentActionPage.tsx | 73 ++++
.../src/web/components/SelectAmountPage.tsx | 30 +-
.../src/web/components/StripeOnrampPage.tsx | 55 ---
.../web/components/account/FiatPopupPage.tsx | 4 +-
packages/sdk/src/web/components/shared.tsx | 15 +-
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 | 18 +-
packages/sdk/src/web/hooks/types.ts | 21 +-
packages/sdk/src/web/hooks/useSessionNav.ts | 342 ++++++++----------
27 files changed, 933 insertions(+), 497 deletions(-)
create mode 100644 packages/sdk/src/common/paymentMethod.test.ts
create mode 100644 packages/sdk/src/common/paymentMethod.ts
create mode 100644 packages/sdk/src/web/api/navTree.test.ts
delete mode 100644 packages/sdk/src/web/components/ExchangePage.tsx
rename packages/sdk/src/web/components/{ExternalPaymentPage.tsx => HostedPaymentPage.tsx} (78%)
create mode 100644 packages/sdk/src/web/components/PaymentActionPage.tsx
delete mode 100644 packages/sdk/src/web/components/StripeOnrampPage.tsx
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index 0342b328..13a0fde7 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -15,6 +15,44 @@ See [docs.daimo.com](https://docs.daimo.com) for more.
- `@daimo/sdk/web` — React modal (``) and hooks for the built-in deposit UI
- `@daimo/sdk/native` — React Native deposit UI (``) over `react-native-webview`
+### Payment methods
+
+Payment method identity, product category, and client execution are separate
+concepts:
+
+- `PaymentMethodId` is the stable identity selected by a customer, such as
+ `CashApp`, `MtPelerin`, or `Stripe`.
+- `PaymentMethodCategory` is derived product metadata such as `paymentApp`,
+ `onramp`, or `exchange`.
+- `PaymentAction` tells a client what to do next, such as open a URL or render
+ an embedded widget.
+
+Custom UIs create action-based methods through the same request shape:
+
+```ts
+const result = await client.sessions.paymentMethods.create(sessionId, {
+ clientSecret,
+ paymentMethod: {
+ id: "CashApp",
+ sourceAmount: { currency: "USD", units: "25.00" },
+ platform: "ios",
+ },
+});
+
+switch (result.action?.type) {
+ case "openUrl":
+ window.open(result.action.url, "_blank");
+ break;
+ case "embeddedWidget":
+ // Mount result.action.sdk with its scoped client credentials.
+ break;
+}
+```
+
+The built-in modal performs this call and renders the action automatically.
+Provider-shaped exchange and Stripe request/response fields remain accepted as
+deprecated compatibility adapters.
+
### Styles
Import `@daimo/sdk/web/theme.css` for the built-in web UI. The distributed stylesheet namespaces internal classes with `daimo-` so it can coexist with a host app's Tailwind build.
diff --git a/packages/sdk/src/common/api.test.ts b/packages/sdk/src/common/api.test.ts
index 79011aff..cf738334 100644
--- a/packages/sdk/src/common/api.test.ts
+++ b/packages/sdk/src/common/api.test.ts
@@ -1,24 +1,121 @@
import { describe, expect, test } from "vitest";
+import type { PaymentMethodRequest } from "./api.js";
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 requests discriminated by method ID", () => {
+ const paymentMethod: PaymentMethodRequest = {
+ id: "CashApp",
+ sourceAmount: {
+ units: "10.50",
+ currency: "USD",
+ },
+ platform: "ios",
+ };
+
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod,
+ }).paymentMethod,
+ ).toEqual(paymentMethod);
+ });
+
+ test("allows source currency independently of product category", () => {
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ id: "MtPelerin",
+ countryCode: "CH",
+ sourceAmount: {
+ units: "10.50",
+ currency: "EUR",
+ },
+ },
+ }).paymentMethod,
+ ).toEqual({
+ id: "MtPelerin",
+ countryCode: "CH",
+ sourceAmount: {
+ units: "10.50",
+ currency: "EUR",
+ },
+ });
+ });
+
+ test("accepts Stripe through the same request shape", () => {
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ id: "Stripe",
+ sourceAmount: {
+ units: "25.00",
+ currency: "USD",
},
- }).paymentMethod,
- ).toEqual({
- type: "exchange",
- exchangeId,
- amountUsd: 10,
- });
- }
+ },
+ }).paymentMethod,
+ ).toMatchObject({ id: "Stripe" });
+ });
+
+ test("rejects malformed source amounts", () => {
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ id: "CashApp",
+ sourceAmount: {
+ units: "0",
+ currency: "USD",
+ },
+ },
+ }),
+ ).toThrow();
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ id: "CashApp",
+ sourceAmount: {
+ units: "10.00",
+ currency: "usd",
+ },
+ },
+ }),
+ ).toThrow();
+ });
+
+ test("rejects unknown method IDs", () => {
+ expect(() =>
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ id: "FutureMethod",
+ sourceAmount: {
+ units: "10.00",
+ currency: "EUR",
+ },
+ },
+ }),
+ ).toThrow();
+ });
+
+ test("continues to accept legacy provider-shaped requests", () => {
+ expect(
+ zCreatePaymentMethodRequest.parse({
+ clientSecret: "secret",
+ paymentMethod: {
+ type: "exchange",
+ exchangeId: "CashApp",
+ amountUsd: 10,
+ },
+ }).paymentMethod,
+ ).toEqual({
+ type: "exchange",
+ exchangeId: "CashApp",
+ amountUsd: 10,
+ });
});
});
diff --git a/packages/sdk/src/common/api.ts b/packages/sdk/src/common/api.ts
index f6652fc7..05ae7c7b 100644
--- a/packages/sdk/src/common/api.ts
+++ b/packages/sdk/src/common/api.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { zAccountRail } from "./account.js";
+import { type PaymentAction, zMoney } from "./paymentMethod.js";
import type { TronAddress, UUID } from "./primitives.js";
import { zAddress, zSolanaAddress } from "./primitives.js";
import type { SessionPublicInfo } from "./session.js";
@@ -23,32 +24,64 @@ export type ExchangeId = z.infer;
export const zSessionId = z.string().describe("Session ID");
+export const zActionPaymentMethodId = z.enum([
+ "CashApp",
+ "Coinbase",
+ "Binance",
+ "BinanceUSDC",
+ "BinanceUSDT",
+ "Lemon",
+ "BitgetExchange",
+ "BybitExchange",
+ "MtPelerin",
+ "Stripe",
+]);
+
+export type ActionPaymentMethodId = z.infer;
+
+export const zActionPaymentMethodRequest = z.object({
+ id: zActionPaymentMethodId,
+ sourceAmount: zMoney,
+ countryCode: z
+ .string()
+ .regex(/^[A-Z]{2}$/)
+ .optional(),
+ platform: zPlatform.optional(),
+});
+
+export type PaymentMethodRequest = z.infer;
+
+const zLegacyPaymentMethodRequest = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("evm") }),
+ z.object({ type: z.literal("tron"), amountUsd: z.number().positive() }),
+ z.object({
+ type: z.literal("solana"),
+ walletAddress: z.string().min(1),
+ inputTokenMint: z.string().min(1),
+ amountUsd: z.number().positive(),
+ }),
+ z.object({
+ type: z.literal("exchange"),
+ exchangeId: zExchangeId,
+ amountUsd: z.number().positive(),
+ platform: zPlatform.optional(),
+ }),
+ z.object({
+ type: z.literal("stripe"),
+ amountUsd: z.number().positive(),
+ }),
+ z.object({
+ type: z.literal("fiat"),
+ fiatMethod: zAccountRail.optional(),
+ }),
+]);
+
export const zCreatePaymentMethodRequest = z.object({
clientSecret: z.string(),
locale: z.string().optional(),
- paymentMethod: z.discriminatedUnion("type", [
- z.object({ type: z.literal("evm") }),
- z.object({ type: z.literal("tron"), amountUsd: z.number().positive() }),
- z.object({
- type: z.literal("solana"),
- walletAddress: z.string().min(1),
- inputTokenMint: z.string().min(1),
- amountUsd: z.number().positive(),
- }),
- z.object({
- type: z.literal("exchange"),
- exchangeId: zExchangeId,
- amountUsd: z.number().positive(),
- platform: zPlatform.optional(),
- }),
- z.object({
- type: z.literal("stripe"),
- amountUsd: z.number().positive(),
- }),
- z.object({
- type: z.literal("fiat"),
- fiatMethod: zAccountRail.optional(),
- }),
+ paymentMethod: z.union([
+ zActionPaymentMethodRequest,
+ zLegacyPaymentMethodRequest,
]),
});
@@ -91,6 +124,8 @@ export type RetrieveSessionResponse = {
export type CreatePaymentMethodResponse = {
/** Updated session state after payment method creation. */
session: SessionPublicInfo;
+ /** Canonical next step for the client. */
+ action?: PaymentAction;
/** Tron-specific payment details, present when payment method is Tron. */
tron?: {
/** Tron address to send funds to. */
@@ -112,7 +147,7 @@ export type CreatePaymentMethodResponse = {
/** Base64-encoded Solana transaction for the user to sign. */
serializedTx: string;
};
- /** Exchange-specific payment details, present when payment method is Exchange. */
+ /** @deprecated Use action. */
exchange?: {
/** Deeplink URL for the exchange. */
url: string;
@@ -128,7 +163,7 @@ export type CreatePaymentMethodResponse = {
/** Selected fiat method for this hosted flow, when pinned to one method. */
fiatMethod?: z.infer;
};
- /** Stripe Onramp details, present when payment method is Stripe. */
+ /** @deprecated Use action. */
stripe?: {
/**
* Stripe OnrampSession client secret, scoped to this onramp session.
@@ -143,6 +178,14 @@ export type CreatePaymentMethodResponse = {
};
};
+export type {
+ Money,
+ PaymentAction,
+ PaymentMethodCategory,
+ PaymentMethodId,
+ PaymentQuote,
+} from "./paymentMethod.js";
+
export type CheckSessionResponse = {
/** Current session state. */
session: SessionPublicInfo;
diff --git a/packages/sdk/src/common/index.ts b/packages/sdk/src/common/index.ts
index d0243972..56f15eb0 100644
--- a/packages/sdk/src/common/index.ts
+++ b/packages/sdk/src/common/index.ts
@@ -2,6 +2,7 @@ export * from "./account.js";
export * from "./api.js";
export * from "./chain.js";
export * from "./errors.js";
+export * from "./paymentMethod.js";
export * from "./primitives.js";
export * from "./session.js";
export * from "./theme.js";
diff --git a/packages/sdk/src/common/paymentMethod.test.ts b/packages/sdk/src/common/paymentMethod.test.ts
new file mode 100644
index 00000000..0e6b33e1
--- /dev/null
+++ b/packages/sdk/src/common/paymentMethod.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "vitest";
+
+import { zPaymentAction, zPaymentMethodCategory } from "./paymentMethod.js";
+
+describe("payment method contract", () => {
+ test("keeps product category separate from execution action", () => {
+ expect(zPaymentMethodCategory.parse("onramp")).toBe("onramp");
+ expect(
+ zPaymentAction.parse({
+ type: "openUrl",
+ url: "https://example.com/pay",
+ presentation: "popup",
+ waitingMessage: "finish your payment",
+ }),
+ ).toMatchObject({ type: "openUrl" });
+ expect(
+ zPaymentAction.parse({
+ type: "embeddedWidget",
+ sdk: "stripeOnramp",
+ clientSecret: "secret",
+ publishableKey: "key",
+ fallbackUrl: "https://example.com/pay",
+ }),
+ ).toMatchObject({ type: "embeddedWidget" });
+ });
+});
diff --git a/packages/sdk/src/common/paymentMethod.ts b/packages/sdk/src/common/paymentMethod.ts
new file mode 100644
index 00000000..4709c809
--- /dev/null
+++ b/packages/sdk/src/common/paymentMethod.ts
@@ -0,0 +1,84 @@
+import { z } from "zod";
+
+export const zPaymentMethodId = z.enum([
+ "ConnectedWallet",
+ "Interac",
+ "ACH",
+ "SEPA",
+ "ApplePay",
+ "CashApp",
+ "Coinbase",
+ "Binance",
+ "BinanceUSDC",
+ "BinanceUSDT",
+ "Lemon",
+ "BitgetExchange",
+ "BybitExchange",
+ "MtPelerin",
+ "Tron",
+ "ARS",
+ "BreB",
+ "JPYC",
+ "Stripe",
+]);
+
+export type PaymentMethodId = z.infer;
+
+/** Product grouping only. Execution is described by PaymentAction. */
+export const zPaymentMethodCategory = z.enum([
+ "wallet",
+ "bank",
+ "card",
+ "paymentApp",
+ "onramp",
+ "exchange",
+]);
+
+export type PaymentMethodCategory = z.infer;
+
+export const zMoney = z.object({
+ currency: z.string().regex(/^[A-Z]{3,10}$/),
+ units: z
+ .string()
+ .max(100)
+ .regex(/^(?:[1-9]\d*(?:\.\d+)?|0\.\d*[1-9]\d*)$/),
+});
+
+export type Money = z.infer;
+
+export const zPaymentQuote = z.object({
+ /** Money paid by the user. */
+ sourceAmount: zMoney,
+ /** Estimated money delivered before the source payment is observed. */
+ estimatedDestinationAmount: zMoney,
+ fees: z.array(
+ z.object({
+ kind: z.enum(["service", "network", "partner"]),
+ amount: zMoney,
+ }),
+ ),
+});
+
+export type PaymentQuote = z.infer;
+
+/** What a client must do after creating a payment method. */
+export const zPaymentAction = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("openUrl"),
+ url: z.string().url(),
+ presentation: z.enum(["popup", "qr"]),
+ popupName: z.string().optional(),
+ waitingMessage: z.string(),
+ expiresAt: z.number().optional(),
+ quote: zPaymentQuote.optional(),
+ }),
+ z.object({
+ type: z.literal("embeddedWidget"),
+ sdk: z.literal("stripeOnramp"),
+ clientSecret: z.string(),
+ publishableKey: z.string(),
+ fallbackUrl: z.string().url(),
+ }),
+]);
+
+export type PaymentAction = z.infer;
diff --git a/packages/sdk/src/common/session.ts b/packages/sdk/src/common/session.ts
index de98f148..aac1409d 100644
--- a/packages/sdk/src/common/session.ts
+++ b/packages/sdk/src/common/session.ts
@@ -2,6 +2,7 @@ import type { Address, Hex } from "viem";
import { z } from "zod";
import type { AccountRail } from "./account.js";
+import type { Money, PaymentMethodId } from "./paymentMethod.js";
import type { DaimoSessionTheme } from "./theme.js";
import type {
SolanaAddress,
@@ -98,21 +99,33 @@ export type PaymentMethod =
| PaymentMethodStripe
| PaymentMethodFiat;
-export type PaymentMethodStripe = {
- type: "stripe";
+type PaymentMethodCommon = {
+ /**
+ * Stable user-visible payment method identity.
+ *
+ * Optional while sessions created by older servers remain readable.
+ */
+ id?: PaymentMethodId;
+ /** Money the user authorized when the method has a fiat source amount. */
+ sourceAmount?: Money;
/** When this payment method was created (unix seconds). */
createdAt: number;
};
-export type PaymentMethodFiat = {
+export type PaymentMethodStripe = PaymentMethodCommon & {
+ /** @deprecated Inspect id for identity. This describes the source adapter. */
+ type: "stripe";
+};
+
+export type PaymentMethodFiat = PaymentMethodCommon & {
+ /** @deprecated Inspect id for identity. This describes the source adapter. */
type: "fiat";
/** Selected fiat method, when known. */
fiatMethod?: AccountRail;
- /** When this payment method was created (unix seconds). */
- createdAt: number;
};
-export type PaymentMethodEvm = {
+export type PaymentMethodEvm = PaymentMethodCommon & {
+ /** @deprecated Inspect id for identity. This describes the funds source. */
type: "evm";
/** Address that receives user's funds, checksum encoded. */
receiverAddress: Address;
@@ -133,11 +146,10 @@ export type PaymentMethodEvm = {
/** Source transaction hash, set once tx is confirmed. */
txHash?: Hex;
};
- /** When this payment method was created (unix seconds). */
- createdAt: number;
};
-export type PaymentMethodTron = {
+export type PaymentMethodTron = PaymentMethodCommon & {
+ /** @deprecated Inspect id for identity. This describes the funds source. */
type: "tron";
/** Address that receives user's funds. */
receiverAddress: TronAddress;
@@ -156,11 +168,10 @@ export type PaymentMethodTron = {
/** Source transaction hash, set once tx is confirmed. */
txHash?: TronTxHash;
};
- /** When this payment method was created (unix seconds). */
- createdAt: number;
};
-export type PaymentMethodSolana = {
+export type PaymentMethodSolana = PaymentMethodCommon & {
+ /** @deprecated Inspect id for identity. This describes the funds source. */
type: "solana";
/** Populated once user initiates a transaction. */
source?: {
@@ -177,8 +188,6 @@ export type PaymentMethodSolana = {
/** Source transaction hash, set once tx is confirmed. */
txHash?: SolanaTxHash;
};
- /** When this payment method was created (unix seconds). */
- createdAt: number;
};
export type UserMetadata = Record | null;
diff --git a/packages/sdk/src/web/api/index.ts b/packages/sdk/src/web/api/index.ts
index b8237518..7318d2d5 100644
--- a/packages/sdk/src/web/api/index.ts
+++ b/packages/sdk/src/web/api/index.ts
@@ -10,13 +10,18 @@ export type {
NavNodeDeeplink,
NavNodeDepositAddress,
NavNodeExchange,
+ NavActionPaymentMethodNode,
+ NavExternalHandoff,
NavNodeFiat,
NavNodeKycRequirement,
NavNodeKycRequirementDisplayItem,
NavNodeKycRequirementIcon,
NavNodeKycRequirementItem,
NavNodeKycRequirementKind,
+ NavNodePaymentMethod,
+ NavNodeStripe,
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..639757eb
--- /dev/null
+++ b/packages/sdk/src/web/api/navTree.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ formatNavSourceAmountUnits,
+ getNavExternalHandoff,
+ getNavSourceAmount,
+ type NavNodeExchange,
+ type NavNodePaymentMethod,
+} from "./navTree.js";
+
+describe("payment method nav policy", () => {
+ test("uses the canonical method descriptor", () => {
+ const node: NavNodePaymentMethod = {
+ type: "PaymentMethod",
+ id: "PaymentMethod-MtPelerin",
+ title: "Mt Pelerin",
+ methodId: "MtPelerin",
+ category: "onramp",
+ countryCode: "CH",
+ sourceAmount: {
+ currency: "EUR",
+ currencySymbol: "€",
+ decimals: 2,
+ minimum: 10,
+ maximum: 1000,
+ },
+ };
+
+ expect(getNavSourceAmount(node)).toEqual(node.sourceAmount);
+ expect(formatNavSourceAmountUnits(125, node.sourceAmount)).toBe("125.00");
+ });
+
+ test("rejects invalid backend amount precision", () => {
+ expect(() =>
+ formatNavSourceAmountUnits(10, {
+ currency: "EUR",
+ currencySymbol: "€",
+ decimals: 21,
+ minimum: 1,
+ maximum: 100,
+ }),
+ ).toThrow("invalid payment method amount");
+ });
+
+ test("falls back to legacy USD nav fields", () => {
+ 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: "popup",
+ legacyQrPlaceholderDensity: "short",
+ });
+ });
+
+ test.each([
+ ["Coinbase", "popup", "short"],
+ ["MtPelerin", "popup", "short"],
+ ["Binance", "qr", "medium"],
+ ["BinanceUSDC", "qr", "medium"],
+ ["BinanceUSDT", "qr", "medium"],
+ ["Lemon", "qr", "short"],
+ ] as const)(
+ "preserves legacy %s handoff behavior",
+ (exchangeId, desktopBehavior, legacyQrPlaceholderDensity) => {
+ const node: NavNodeExchange = {
+ type: "Exchange",
+ id: `Exchange-${exchangeId}`,
+ title: exchangeId,
+ exchangeId,
+ minimumUsd: 1,
+ maximumUsd: 100,
+ };
+
+ expect(getNavExternalHandoff(node)).toEqual({
+ desktopBehavior,
+ legacyQrPlaceholderDensity,
+ });
+ },
+ );
+});
diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts
index 626fbb0c..36078917 100644
--- a/packages/sdk/src/web/api/navTree.ts
+++ b/packages/sdk/src/web/api/navTree.ts
@@ -3,7 +3,8 @@ import type {
AccountRail,
DepositPaymentInteraction,
} from "../../common/account.js";
-import type { ExchangeId } from "../../common/api.js";
+import type { ActionPaymentMethodId, ExchangeId } from "../../common/api.js";
+import type { PaymentMethodCategory } from "../../common/paymentMethod.js";
import type { SessionPublicInfo } from "../../common/session.js";
/** Session plus server-defined modal navigation data. */
@@ -89,31 +90,145 @@ 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;
+};
+
+/**
+ * A payment method whose next step is returned as a PaymentAction.
+ *
+ * The method ID describes what the customer selected. The response action
+ * separately describes how the SDK should execute it.
+ */
+export type NavNodePaymentMethod = NavNodeCommon & {
+ type: "PaymentMethod";
+ methodId: ActionPaymentMethodId;
+ category: PaymentMethodCategory;
+ countryCode?: string;
+ icon?: string;
+ sourceAmount: NavSourceAmount;
+};
+
+/** @deprecated Old servers emit provider-shaped navigation nodes. */
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;
};
+/** @deprecated Old servers emit provider-shaped navigation nodes. */
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;
};
+/** @deprecated Old servers emit provider-shaped navigation nodes. */
export type NavNodeStripe = NavNodeCommon & {
type: "Stripe";
icon?: string;
+ sourceAmount?: NavSourceAmount;
requiredUsd?: number;
minimumUsd: number;
maximumUsd: number;
};
+export type NavActionPaymentMethodNode =
+ | NavNodePaymentMethod
+ | NavNodeExchange
+ | NavNodeCashApp
+ | NavNodeStripe;
+
+/** Compatibility fallback for nav trees produced by pre-sourceAmount servers. */
+export function getNavSourceAmount(
+ node: NavActionPaymentMethodNode,
+): NavSourceAmount {
+ if (node.type === "PaymentMethod") 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: NavNodeExchange | NavNodeCashApp,
+): NavExternalHandoff & {
+ legacyQrPlaceholderDensity?: "short" | "medium" | "long";
+} {
+ if (node.externalHandoff != null) return node.externalHandoff;
+ return getLegacyNavExternalHandoff(node);
+}
+
+/** Preserve pre-handoff SDK behavior while old backend nav trees remain valid. */
+function getLegacyNavExternalHandoff(
+ node: NavNodeExchange | NavNodeCashApp,
+): NavExternalHandoff & {
+ legacyQrPlaceholderDensity: "short" | "medium";
+} {
+ const exchangeId = node.type === "CashApp" ? "CashApp" : node.exchangeId;
+ return {
+ desktopBehavior:
+ exchangeId === "Coinbase" || exchangeId === "MtPelerin" ? "popup" : "qr",
+ legacyQrPlaceholderDensity:
+ exchangeId === "Binance" ||
+ exchangeId === "BinanceUSDC" ||
+ exchangeId === "BinanceUSDT"
+ ? "medium"
+ : "short",
+ };
+}
+
+/** Format an amount exactly as required by the backend-owned source policy. */
+export function formatNavSourceAmountUnits(
+ amount: number,
+ sourceAmount: NavSourceAmount,
+): string {
+ if (
+ !Number.isFinite(amount) ||
+ !Number.isInteger(sourceAmount.decimals) ||
+ sourceAmount.decimals < 0 ||
+ sourceAmount.decimals > 20
+ ) {
+ throw new Error("invalid payment method amount");
+ }
+ return amount.toFixed(sourceAmount.decimals);
+}
+
export type NavNodeTronDeposit = NavNodeCommon & {
type: "TronDeposit";
icon?: string;
@@ -142,6 +257,7 @@ export type NavNode =
| NavNodeChooseOption
| NavNodeDepositAddress
| NavNodeDeeplink
+ | NavNodePaymentMethod
| NavNodeExchange
| NavNodeCashApp
| NavNodeStripe
diff --git a/packages/sdk/src/web/components/DaimoModal.tsx b/packages/sdk/src/web/components/DaimoModal.tsx
index c0816322..aea4430b 100644
--- a/packages/sdk/src/web/components/DaimoModal.tsx
+++ b/packages/sdk/src/web/components/DaimoModal.tsx
@@ -19,15 +19,14 @@ import type {
NavLocationOption,
RecreateSessionWithNavResponse,
} from "../api/index.js";
+import { getNavSourceAmount } from "../api/navTree.js";
import type {
+ NavActionPaymentMethodNode,
NavNode,
- NavNodeCashApp,
NavNodeChooseOption,
NavNodeConnectedWallet,
NavNodeDeeplink,
NavNodeDepositAddress,
- NavNodeExchange,
- NavNodeStripe,
NavNodeTronDeposit,
SessionWithNav,
} from "../api/navTree.js";
@@ -67,7 +66,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 { PaymentActionPage } from "./PaymentActionPage.js";
import { ExpiredPage } from "./ExpiredPage.js";
import { AccountPaymentInstructionsPage } from "./account/AccountBankDetailsPage.js";
import { AccountInstitutionPickerPage } from "./account/AccountBankPickerPage.js";
@@ -93,7 +92,6 @@ import {
} from "./account/accountNav.js";
import { SelectAmountPage } from "./SelectAmountPage.js";
import { SelectTokenPage } from "./SelectTokenPage.js";
-import { StripeOnrampPage } from "./StripeOnrampPage.js";
import {
CenteredContent,
ContactSupportButton,
@@ -108,8 +106,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;
@@ -821,10 +817,8 @@ function renderEntry(
return renderWaitingDeposit(entry, ctx);
case "waiting-tron":
return renderWaitingTron(entry, ctx);
- case "exchange-page":
- return renderExchangePage(entry, ctx);
- case "stripe-onramp":
- return renderStripeOnramp(entry, ctx);
+ case "payment-method":
+ return renderPaymentMethod(entry, ctx);
case "wallet-choose-chain":
return (
);
}
- if (entry.flowType === "exchange") {
- const exchangeNode = node as NavNodeExchange;
- return (
-
- );
- }
- if (entry.flowType === "cashapp") {
- const cashAppNode = node as NavNodeCashApp;
- return (
-
- );
- }
- if (entry.flowType === "stripe") {
- const stripeNode = node as NavNodeStripe;
+ if (entry.flowType === "payment-method") {
+ if (
+ node.type !== "PaymentMethod" &&
+ node.type !== "Exchange" &&
+ node.type !== "CashApp" &&
+ node.type !== "Stripe"
+ ) {
+ return null;
+ }
+ const sourceAmount = getNavSourceAmount(node);
return (
);
return (
-
- );
-}
-
-function renderStripeOnramp(
- entry: NavEntry & { type: "stripe-onramp" },
- ctx: RenderContext,
-): React.ReactNode {
- const node = findNode(
- entry.nodeId,
- ctx.session.navTree,
- ) as NavNodeStripe | null;
- if (!node) return null;
-
- return (
-
);
diff --git a/packages/sdk/src/web/components/ExchangePage.tsx b/packages/sdk/src/web/components/ExchangePage.tsx
deleted file mode 100644
index 34487fb4..00000000
--- a/packages/sdk/src/web/components/ExchangePage.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import type { NavNodeCashApp, NavNodeExchange } from "../api/navTree.js";
-
-import { t } from "../hooks/locale.js";
-import { isDesktop, type DaimoPlatform } from "../platform.js";
-import { ExternalPaymentPage } from "./ExternalPaymentPage.js";
-
-type ExchangePageProps = {
- node: NavNodeExchange | NavNodeCashApp;
- platform: DaimoPlatform;
- exchangeUrl?: string;
- waitingMessage?: string;
- expiresAt?: number;
- isLoading?: boolean;
- onBack: () => void;
- onRetry?: () => void;
- baseUrl: string;
-};
-
-export function ExchangePage({
- node,
- platform,
- exchangeUrl,
- waitingMessage,
- isLoading,
- onBack,
- baseUrl,
-}: ExchangePageProps) {
- const exchangeId = node.type === "CashApp" ? "CashApp" : node.exchangeId;
- const desktopBehavior =
- exchangeId === "Coinbase" || exchangeId === "MtPelerin" ? "popup" : "qr";
- const usesDesktopQR = isDesktop(platform) && desktopBehavior === "qr";
-
- const isBinanceExchange =
- exchangeId === "Binance" ||
- exchangeId === "BinanceUSDC" ||
- exchangeId === "BinanceUSDT";
- const placeholderDensity = isBinanceExchange
- ? ("medium" as const)
- : ("short" as const);
-
- return (
-
- );
-}
diff --git a/packages/sdk/src/web/components/ExternalPaymentPage.tsx b/packages/sdk/src/web/components/HostedPaymentPage.tsx
similarity index 78%
rename from packages/sdk/src/web/components/ExternalPaymentPage.tsx
rename to packages/sdk/src/web/components/HostedPaymentPage.tsx
index 4f2ae847..dc38db8c 100644
--- a/packages/sdk/src/web/components/ExternalPaymentPage.tsx
+++ b/packages/sdk/src/web/components/HostedPaymentPage.tsx
@@ -15,7 +15,13 @@ import {
type QRDensity = "short" | "medium" | "long";
type DesktopBehavior = "popup" | "qr";
-type ExternalPaymentPageProps = {
+type HostedPaymentDetail = {
+ id: string;
+ label: string;
+ value: string;
+};
+
+type HostedPaymentPageProps = {
title: string;
platform: DaimoPlatform;
url?: string;
@@ -29,6 +35,7 @@ type ExternalPaymentPageProps = {
popupName?: string;
popupFeatures?: string;
placeholderDensity?: QRDensity;
+ details?: HostedPaymentDetail[];
/** Called with the opened window (null when blocked by the browser). */
onOpened?: (popup: Window | null) => void;
};
@@ -36,7 +43,7 @@ type ExternalPaymentPageProps = {
const DEFAULT_POPUP_FEATURES = "width=500,height=700";
/** Shared handoff page for providers that complete payment outside the modal. */
-export function ExternalPaymentPage({
+export function HostedPaymentPage({
title,
platform,
url,
@@ -50,8 +57,9 @@ export function ExternalPaymentPage({
popupName,
popupFeatures = DEFAULT_POPUP_FEATURES,
placeholderDensity,
+ details,
onOpened,
-}: ExternalPaymentPageProps) {
+}: HostedPaymentPageProps) {
const desktop = isDesktop(platform);
const showQR = desktop && desktopBehavior === "qr";
@@ -111,6 +119,23 @@ export function ExternalPaymentPage({
{message}
)}
+ {details != null && details.length > 0 && (
+
+ {details.map((detail) => (
+
+
-
+ {detail.label}
+
+ -
+ {detail.value}
+
+
+ ))}
+
+ )}
{!showQR && (
void;
+ baseUrl: string;
+};
+
+export function PaymentActionPage({
+ node,
+ platform,
+ action,
+ isLoading,
+ onBack,
+ baseUrl,
+}: PaymentActionPageProps) {
+ const openUrl = action?.type === "openUrl" ? action : undefined;
+ const embeddedWidget = action?.type === "embeddedWidget" ? action : undefined;
+ const presentation = openUrl?.presentation ?? "popup";
+ const usesDesktopQR = isDesktop(platform) && presentation === "qr";
+ const url = openUrl?.url ?? embeddedWidget?.fallbackUrl;
+
+ return (
+ ({
+ id: `fee-${fee.kind}-${index}`,
+ label:
+ fee.kind === "service"
+ ? t.serviceFee
+ : fee.kind === "network"
+ ? t.networkFee
+ : t.partnerFee,
+ value: `${fee.amount.units} ${fee.amount.currency}`,
+ })),
+ ]
+ }
+ />
+ );
+}
diff --git a/packages/sdk/src/web/components/SelectAmountPage.tsx b/packages/sdk/src/web/components/SelectAmountPage.tsx
index 54d6ac3b..4278c6b4 100644
--- a/packages/sdk/src/web/components/SelectAmountPage.tsx
+++ b/packages/sdk/src/web/components/SelectAmountPage.tsx
@@ -14,15 +14,19 @@ import {
type SelectAmountPageProps = {
node: NavNodeDepositAddress | { icon?: string; title: string };
- minimumUsd: number;
- maximumUsd: number;
+ minimum: number;
+ maximum: number;
+ /** Maximum source-currency decimal places. Defaults to 2. */
+ decimals?: number;
+ /** Fiat currency symbol shown before the amount. Defaults to USD. */
+ currencySymbol?: string;
/** Token suffix for display (e.g., "USDC", "USDT") */
tokenSuffix?: string;
/** Chain ID for token badge display */
chainId?: number;
/** Optional back handler. If undefined, back button is hidden. */
onBack?: () => void;
- onContinue: (amountUsd: number) => void;
+ onContinue: (amount: number) => void;
isLoading?: boolean;
error?: string | null;
baseUrl: string;
@@ -30,8 +34,10 @@ type SelectAmountPageProps = {
export function SelectAmountPage({
node,
- minimumUsd,
- maximumUsd,
+ minimum,
+ maximum,
+ decimals,
+ currencySymbol,
tokenSuffix,
chainId,
onBack,
@@ -40,11 +46,7 @@ export function SelectAmountPage({
error,
baseUrl,
}: SelectAmountPageProps) {
- const {
- amount: amountUsd,
- isValid,
- handleChange,
- } = useAmountInput(minimumUsd, maximumUsd);
+ const { amount, isValid, handleChange } = useAmountInput(minimum, maximum);
// Create pseudo-token for display if tokenSuffix is USDC or USDT and chainId is provided
const selectedToken =
@@ -95,8 +97,10 @@ export function SelectAmountPage({
{/* Amount input */}
@@ -110,7 +114,7 @@ export function SelectAmountPage({
)}
isValid && !isLoading && onContinue(amountUsd)}
+ onClick={() => isValid && !isLoading && onContinue(amount)}
disabled={!isValid || isLoading}
className="daimo-max-w-none"
>
diff --git a/packages/sdk/src/web/components/StripeOnrampPage.tsx b/packages/sdk/src/web/components/StripeOnrampPage.tsx
deleted file mode 100644
index 2ee0dcd7..00000000
--- a/packages/sdk/src/web/components/StripeOnrampPage.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { NavNodeStripe } from "../api/navTree.js";
-import { formatFixedAmount } from "../formatAmount.js";
-import { t } from "../hooks/locale.js";
-import type { DaimoPlatform } from "../platform.js";
-import { ErrorPage } from "./ErrorPage.js";
-import { ExternalPaymentPage } from "./ExternalPaymentPage.js";
-
-type StripeOnrampPageProps = {
- node: NavNodeStripe;
- platform: DaimoPlatform;
- amountUsd: number;
- redirectUrl?: string;
- isLoading?: boolean;
- error?: string;
- onBack: () => void;
- onRetry?: () => void;
- baseUrl: string;
-};
-
-export function StripeOnrampPage({
- node,
- platform,
- amountUsd,
- redirectUrl,
- isLoading,
- error,
- onBack,
- onRetry,
- baseUrl,
-}: StripeOnrampPageProps) {
- if (error) {
- return (
- window.location.reload())}
- />
- );
- }
-
- return (
-
- );
-}
diff --git a/packages/sdk/src/web/components/account/FiatPopupPage.tsx b/packages/sdk/src/web/components/account/FiatPopupPage.tsx
index 05e4413c..8b57a4ed 100644
--- a/packages/sdk/src/web/components/account/FiatPopupPage.tsx
+++ b/packages/sdk/src/web/components/account/FiatPopupPage.tsx
@@ -5,7 +5,7 @@ import { useDaimoClient } from "../../hooks/DaimoClientContext.js";
import { t } from "../../hooks/locale.js";
import { createNavLogger } from "../../hooks/navEvent.js";
import type { DaimoPlatform } from "../../platform.js";
-import { ExternalPaymentPage } from "../ExternalPaymentPage.js";
+import { HostedPaymentPage } from "../HostedPaymentPage.js";
import {
buildFiatPopupUrl,
FIAT_POPUP_FEATURES,
@@ -65,7 +65,7 @@ export function FiatPopupPage({
};
return (
- ) => {
const value = parseDisplayAmount(e.target.value);
- if (!isValidAmountInput(value, 2)) return;
+ if (!isValidAmountInput(value, decimals)) return;
setInputValue(value);
const newAmount = parseFloat(value) || 0;
@@ -164,18 +167,20 @@ export function AmountInput({
};
const displayValue = formatAmountInput(inputValue);
- const placeholder = formatAmountInput("0.00");
+ const placeholder = formatAmountInput(
+ decimals === 0 ? "0" : `0.${"0".repeat(decimals)}`,
+ );
const inputWidth =
displayValue.length === 0
? "3.55ch"
: `${Math.min(displayValue.length - (displayValue.match(/\./g) || []).length * 0.55, 12)}ch`;
const label = showMinWarning
- ? `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum)}`
+ ? `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum, decimals)}`
: showMaxWarning
- ? `${t.maximum} ${currencySymbol}${formatFixedAmount(maximum)}`
+ ? `${t.maximum} ${currencySymbol}${formatFixedAmount(maximum, decimals)}`
: (defaultLabel ??
- `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum)}`);
+ `${t.minimum} ${currencySymbol}${formatFixedAmount(minimum, decimals)}`);
const labelClass =
showMinWarning || showMaxWarning
diff --git a/packages/sdk/src/web/hooks/locales/en.ts b/packages/sdk/src/web/hooks/locales/en.ts
index 4e8f781e..a2c58d6c 100644
--- a/packages/sdk/src/web/hooks/locales/en.ts
+++ b/packages/sdk/src/web/hooks/locales/en.ts
@@ -23,11 +23,15 @@ export const en = {
mobileWallets: "Mobile Wallets",
scanWithPhone: "Scan with your phone to open wallet",
- // ExchangePage
+ // PaymentActionPage
continueTo: "Continue to",
toCompleteYourDeposit: "to complete your deposit",
open: "Open",
refreshInvoice: "Refresh",
+ estimatedOutput: "Estimated output",
+ serviceFee: "Service fee",
+ networkFee: "Network fee",
+ partnerFee: "Partner fee",
// SelectAmountPage
selectAmount: "Select Amount",
diff --git a/packages/sdk/src/web/hooks/locales/es.ts b/packages/sdk/src/web/hooks/locales/es.ts
index 2d937b99..b29cd6e5 100644
--- a/packages/sdk/src/web/hooks/locales/es.ts
+++ b/packages/sdk/src/web/hooks/locales/es.ts
@@ -23,6 +23,10 @@ export const es: typeof en = {
toCompleteYourDeposit: "para completar tu depósito",
open: "Abrir",
refreshInvoice: "Actualizar",
+ estimatedOutput: "Importe estimado",
+ serviceFee: "Comisión de servicio",
+ networkFee: "Comisión de red",
+ partnerFee: "Comisión del socio",
selectAmount: "Seleccionar monto",
loading: "Cargando",
continue: "Continuar",
diff --git a/packages/sdk/src/web/hooks/locales/ja.ts b/packages/sdk/src/web/hooks/locales/ja.ts
index d3f675e6..a4e8c6b2 100644
--- a/packages/sdk/src/web/hooks/locales/ja.ts
+++ b/packages/sdk/src/web/hooks/locales/ja.ts
@@ -25,11 +25,15 @@ export const ja: typeof en = {
mobileWallets: "モバイルウォレット",
scanWithPhone: "スマートフォンでスキャンしてウォレットを開く",
- // ExchangePage
+ // PaymentActionPage
continueTo: "",
toCompleteYourDeposit: "で入金を完了してください",
open: "開く",
refreshInvoice: "更新",
+ estimatedOutput: "受取額(見込み)",
+ serviceFee: "サービス手数料",
+ networkFee: "ネットワーク手数料",
+ partnerFee: "パートナー手数料",
// SelectAmountPage
selectAmount: "金額を選択",
diff --git a/packages/sdk/src/web/hooks/locales/ko.ts b/packages/sdk/src/web/hooks/locales/ko.ts
index d6b075a5..fa516936 100644
--- a/packages/sdk/src/web/hooks/locales/ko.ts
+++ b/packages/sdk/src/web/hooks/locales/ko.ts
@@ -25,11 +25,15 @@ export const ko: typeof en = {
mobileWallets: "모바일 지갑",
scanWithPhone: "휴대폰으로 스캔하여 지갑 열기",
- // ExchangePage
+ // PaymentActionPage
continueTo: "",
toCompleteYourDeposit: "에서 입금을 완료하세요",
open: "열기",
refreshInvoice: "새로고침",
+ estimatedOutput: "예상 수령액",
+ serviceFee: "서비스 수수료",
+ networkFee: "네트워크 수수료",
+ partnerFee: "파트너 수수료",
// SelectAmountPage
selectAmount: "금액 선택",
diff --git a/packages/sdk/src/web/hooks/locales/pt.ts b/packages/sdk/src/web/hooks/locales/pt.ts
index 8eb81e44..1c2314b6 100644
--- a/packages/sdk/src/web/hooks/locales/pt.ts
+++ b/packages/sdk/src/web/hooks/locales/pt.ts
@@ -25,11 +25,15 @@ export const pt: typeof en = {
mobileWallets: "Carteiras móveis",
scanWithPhone: "Escaneie com seu telefone para abrir a carteira",
- // ExchangePage
+ // PaymentActionPage
continueTo: "Continuar para",
toCompleteYourDeposit: "para concluir seu depósito",
open: "Abrir",
refreshInvoice: "Atualizar",
+ estimatedOutput: "Valor estimado",
+ serviceFee: "Taxa de serviço",
+ networkFee: "Taxa de rede",
+ partnerFee: "Taxa do parceiro",
// SelectAmountPage
selectAmount: "Selecionar valor",
diff --git a/packages/sdk/src/web/hooks/locales/zh.ts b/packages/sdk/src/web/hooks/locales/zh.ts
index 68341f5f..70c8e4d5 100644
--- a/packages/sdk/src/web/hooks/locales/zh.ts
+++ b/packages/sdk/src/web/hooks/locales/zh.ts
@@ -25,11 +25,15 @@ export const zh: typeof en = {
mobileWallets: "移动钱包",
scanWithPhone: "用手机扫码打开钱包",
- // ExchangePage
+ // PaymentActionPage
continueTo: "前往",
toCompleteYourDeposit: "以完成充值",
open: "打开",
refreshInvoice: "刷新",
+ estimatedOutput: "预计到账金额",
+ serviceFee: "服务费",
+ networkFee: "网络费",
+ partnerFee: "合作伙伴费用",
// SelectAmountPage
selectAmount: "选择金额",
diff --git a/packages/sdk/src/web/hooks/navEvent.ts b/packages/sdk/src/web/hooks/navEvent.ts
index 20178ff3..094aebf1 100644
--- a/packages/sdk/src/web/hooks/navEvent.ts
+++ b/packages/sdk/src/web/hooks/navEvent.ts
@@ -17,13 +17,18 @@ export type NavEventAction =
| { action: "nav_select"; targetNodeId: string; targetNodeType: NavNodeType }
| { action: "nav_back" }
| { action: "nav_deeplink"; url: string }
- | { action: "flow_amount_continue"; amountUsd: number }
+ | ({
+ action: "flow_amount_continue";
+ } & (
+ | { amountUsd: number }
+ | { sourceAmountUnits: string; sourceCurrency: string }
+ ))
| { action: "flow_refresh" }
| {
- action: "flow_exchange_url";
- exchangeId: string;
+ action: "flow_payment_action";
+ paymentMethodId: string;
+ actionType?: "openUrl" | "embeddedWidget";
success: boolean;
- url?: string;
error?: string;
}
| {
@@ -32,11 +37,6 @@ export type NavEventAction =
address?: string;
error?: string;
}
- | {
- action: "flow_stripe_onramp";
- success: boolean;
- error?: string;
- }
| { action: "popup_open"; reopen: boolean }
| { action: "popup_blocked" }
| { action: "qr_toggle"; visible: boolean }
diff --git a/packages/sdk/src/web/hooks/types.ts b/packages/sdk/src/web/hooks/types.ts
index 39b9af9b..13fc43c8 100644
--- a/packages/sdk/src/web/hooks/types.ts
+++ b/packages/sdk/src/web/hooks/types.ts
@@ -4,6 +4,7 @@ import type {
AccountRail,
DepositPaymentInteraction,
} from "../../common/account.js";
+import type { PaymentAction } from "../../common/api.js";
import type { NavNode, SessionWithNav } from "../api/navTree.js";
import type { WalletPaymentOption } from "../api/walletTypes.js";
@@ -26,7 +27,7 @@ export type NavEntry =
| {
type: "select-amount";
nodeId: string;
- flowType: "deposit" | "tron" | "exchange" | "cashapp" | "stripe";
+ flowType: "deposit" | "tron" | "payment-method";
autoNav?: boolean;
}
| {
@@ -49,22 +50,10 @@ export type NavEntry =
autoNav?: boolean;
}
| {
- type: "exchange-page";
+ type: "payment-method";
nodeId: string;
- amountUsd: number;
- exchangeUrl?: string;
- waitingMessage?: string;
- expiresAt?: number;
- error?: string;
- autoNav?: boolean;
- }
- | {
- type: "stripe-onramp";
- nodeId: string;
- amountUsd: number;
- onrampSessionClientSecret?: string;
- publishableKey?: string;
- redirectUrl?: string;
+ sourceAmount: number;
+ action?: PaymentAction;
error?: string;
autoNav?: boolean;
}
diff --git a/packages/sdk/src/web/hooks/useSessionNav.ts b/packages/sdk/src/web/hooks/useSessionNav.ts
index 8a5cb5c1..e2d2ce3d 100644
--- a/packages/sdk/src/web/hooks/useSessionNav.ts
+++ b/packages/sdk/src/web/hooks/useSessionNav.ts
@@ -11,19 +11,27 @@ import type {
AccountDepositStatus,
AccountRail,
} from "../../common/account.js";
-import type { ExchangeId } from "../../common/api.js";
+import type {
+ ActionPaymentMethodId,
+ CreatePaymentMethodResponse,
+ CreatePaymentMethodRequest,
+ PaymentAction,
+} from "../../common/api.js";
import type {
AccountAuthConfig,
DaimoCountryCode,
RecreateSessionWithNavResponse,
} from "../api/index.js";
+import {
+ formatNavSourceAmountUnits,
+ getNavExternalHandoff,
+ getNavSourceAmount,
+} from "../api/navTree.js";
import type {
+ NavActionPaymentMethodNode,
NavNode,
- NavNodeCashApp,
NavNodeChooseOption,
- NavNodeExchange,
NavNodeFiat,
- NavNodeStripe,
NavNodeTronDeposit,
SessionWithNav,
} from "../api/navTree.js";
@@ -55,7 +63,6 @@ import type { InjectedWallet } from "./useInjectedWallets.js";
import { isUserRejection, type WalletFlowResult } from "./useWalletFlow.js";
type NodeContext = { nodeId: string | null; nodeType: NavNodeType | null };
-type ExchangeNode = NavNodeExchange | NavNodeCashApp;
type SessionNavResult = {
stack: NavEntry[];
@@ -66,7 +73,7 @@ type SessionNavResult = {
handleNavigate: (nodeId: string, options?: { autoNav?: boolean }) => void;
handleBack: () => void;
handleReset: () => void;
- handleAmountContinue: (amountUsd: number) => void;
+ handleAmountContinue: (amount: number) => void;
handleRetry: () => void;
handleRefresh: () => Promise;
handleAccountSessionRecreate: (depositAmount: string) => Promise;
@@ -87,26 +94,85 @@ type SessionNavResult = {
handleAccountLogout: () => void;
};
-function isExchangeNode(node: NavNode | null): node is ExchangeNode {
- return node?.type === "Exchange" || node?.type === "CashApp";
-}
-
-function isStripeNode(node: NavNode | null): node is NavNodeStripe {
- return node?.type === "Stripe";
+function isActionPaymentMethodNode(
+ node: NavNode | null,
+): node is NavActionPaymentMethodNode {
+ return (
+ node?.type === "PaymentMethod" ||
+ node?.type === "Exchange" ||
+ node?.type === "CashApp" ||
+ node?.type === "Stripe"
+ );
}
function isTrustTronNode(node: NavNode | null): node is NavNodeTronDeposit {
return node?.type === "TronDeposit" && node.id === "Trust-Tron";
}
-function getExchangeSelection(node: ExchangeNode): {
- exchangeId: ExchangeId;
- nodeType: "Exchange" | "CashApp";
-} {
- if (node.type === "CashApp") {
- return { exchangeId: "CashApp", nodeType: "CashApp" };
+function getPaymentMethodId(
+ node: NavActionPaymentMethodNode,
+): ActionPaymentMethodId {
+ if (node.type === "PaymentMethod") return node.methodId;
+ if (node.type === "CashApp") return "CashApp";
+ if (node.type === "Stripe") return "Stripe";
+ return node.exchangeId;
+}
+
+function getPaymentMethodRequest(
+ node: NavActionPaymentMethodNode,
+ amount: number,
+ platform: DaimoPlatform,
+): CreatePaymentMethodRequest["paymentMethod"] {
+ if (node.type === "PaymentMethod") {
+ const sourceAmount = getNavSourceAmount(node);
+ return {
+ id: node.methodId,
+ sourceAmount: {
+ units: formatNavSourceAmountUnits(amount, sourceAmount),
+ currency: sourceAmount.currency,
+ },
+ countryCode: node.countryCode,
+ platform,
+ };
+ }
+ if (node.type === "Stripe") return { type: "stripe", amountUsd: amount };
+ return {
+ type: "exchange",
+ exchangeId: node.type === "CashApp" ? "CashApp" : node.exchangeId,
+ amountUsd: amount,
+ platform,
+ };
+}
+
+function getPaymentAction(
+ node: NavActionPaymentMethodNode,
+ result: CreatePaymentMethodResponse,
+): PaymentAction {
+ if (result.action != null) return result.action;
+ if (node.type === "Stripe" && result.stripe != null) {
+ return {
+ type: "embeddedWidget",
+ sdk: "stripeOnramp",
+ clientSecret: result.stripe.onrampSessionClientSecret,
+ publishableKey: result.stripe.publishableKey,
+ fallbackUrl: result.stripe.redirectUrl,
+ };
+ }
+ if (
+ (node.type === "Exchange" || node.type === "CashApp") &&
+ result.exchange != null
+ ) {
+ const handoff = getNavExternalHandoff(node);
+ return {
+ type: "openUrl",
+ url: result.exchange.url,
+ presentation: handoff.desktopBehavior,
+ popupName: handoff.popupName,
+ waitingMessage: result.exchange.waitingMessage,
+ expiresAt: result.exchange.expiresAt,
+ };
}
- return { exchangeId: node.exchangeId, nodeType: "Exchange" };
+ throw new Error("payment action not returned");
}
function replacePendingAccountEntry(
@@ -266,134 +332,70 @@ export function useSessionNav(
],
);
- const fetchExchangeUrl = useCallback(
+ const fetchPaymentMethodAction = useCallback(
async (
nodeId: string,
- exchangeId: ExchangeId,
- amountUsd: number,
- nodeType: "Exchange" | "CashApp",
+ node: NavActionPaymentMethodNode,
+ amount: number,
) => {
+ const paymentMethodId = getPaymentMethodId(node);
try {
const result = await client.sessions.paymentMethods.create(
session.sessionId,
{
clientSecret: session.clientSecret,
- paymentMethod: {
- type: "exchange",
- exchangeId,
- amountUsd,
- platform: effectivePlatform,
- },
- },
- );
- if (!result.exchange) return;
- logNavEvent(session.sessionId, session.clientSecret, {
- nodeId,
- nodeType,
- action: "flow_exchange_url",
- exchangeId,
- success: true,
- url: result.exchange.url,
- });
- setStack((prev) => {
- const top = prev[prev.length - 1];
- if (top?.type !== "exchange-page" || top.nodeId !== nodeId)
- return prev;
- return [
- ...prev.slice(0, -1),
- {
- ...top,
- exchangeUrl: result.exchange!.url,
- waitingMessage: result.exchange!.waitingMessage,
- expiresAt: result.exchange!.expiresAt,
- error: undefined,
- },
- ];
- });
- } catch (error) {
- console.error("failed to get exchange url:", error);
- const errorMsg =
- error instanceof Error ? error.message : "failed to get exchange url";
- logNavEvent(session.sessionId, session.clientSecret, {
- nodeId,
- nodeType,
- action: "flow_exchange_url",
- exchangeId,
- success: false,
- error: errorMsg,
- });
- setStack((prev) => {
- const top = prev[prev.length - 1];
- if (top?.type !== "exchange-page" || top.nodeId !== nodeId)
- return prev;
- return [...prev.slice(0, -1), { ...top, error: errorMsg }];
- });
- }
- },
- [session.sessionId, session.clientSecret, effectivePlatform, client],
- );
-
- const fetchStripeOnramp = useCallback(
- async (nodeId: string, amountUsd: number) => {
- try {
- const result = await client.sessions.paymentMethods.create(
- session.sessionId,
- {
- clientSecret: session.clientSecret,
- paymentMethod: { type: "stripe", amountUsd },
+ paymentMethod: getPaymentMethodRequest(
+ node,
+ amount,
+ effectivePlatform,
+ ),
},
);
- if (!result.stripe) {
- throw new Error("stripe onramp session not returned");
- }
- if (!result.stripe.redirectUrl) {
- throw new Error("stripe onramp url not returned");
- }
-
+ const action = getPaymentAction(node, result);
logNavEvent(session.sessionId, session.clientSecret, {
nodeId,
- nodeType: "Stripe",
- action: "flow_stripe_onramp",
+ nodeType: node.type,
+ action: "flow_payment_action",
+ paymentMethodId,
+ actionType: action.type,
success: true,
});
setStack((prev) => {
const top = prev[prev.length - 1];
- if (top?.type !== "stripe-onramp" || top.nodeId !== nodeId)
+ if (top?.type !== "payment-method" || top.nodeId !== nodeId)
return prev;
return [
...prev.slice(0, -1),
{
...top,
- onrampSessionClientSecret:
- result.stripe!.onrampSessionClientSecret,
- publishableKey: result.stripe!.publishableKey,
- redirectUrl: result.stripe!.redirectUrl,
+ action,
error: undefined,
},
];
});
} catch (error) {
- console.error("failed to create stripe onramp:", error);
+ console.error("failed to create payment action:", error);
const errorMsg =
error instanceof Error
? error.message
- : "failed to create stripe onramp";
+ : "failed to create payment action";
logNavEvent(session.sessionId, session.clientSecret, {
nodeId,
- nodeType: "Stripe",
- action: "flow_stripe_onramp",
+ nodeType: node.type,
+ action: "flow_payment_action",
+ paymentMethodId,
success: false,
error: errorMsg,
});
setStack((prev) => {
const top = prev[prev.length - 1];
- if (top?.type !== "stripe-onramp" || top.nodeId !== nodeId)
+ if (top?.type !== "payment-method" || top.nodeId !== nodeId)
return prev;
return [...prev.slice(0, -1), { ...top, error: errorMsg }];
});
}
},
- [session.sessionId, session.clientSecret, client],
+ [session.sessionId, session.clientSecret, effectivePlatform, client],
);
// ─── Account deposit handler ────────────────────────────────────────────────
@@ -728,15 +730,19 @@ export function useSessionNav(
return;
}
- if (isExchangeNode(targetNode)) {
- const { exchangeId, nodeType } = getExchangeSelection(targetNode);
- const requiredUsd = targetNode.requiredUsd ?? 0;
- if (requiredUsd > 0) {
+ if (isActionPaymentMethodNode(targetNode)) {
+ const required = getNavSourceAmount(targetNode).required ?? 0;
+ if (required > 0) {
setStack((prev) => [
...prev,
- { type: "exchange-page", nodeId, amountUsd: requiredUsd, autoNav },
+ {
+ type: "payment-method",
+ nodeId,
+ sourceAmount: required,
+ autoNav,
+ },
]);
- fetchExchangeUrl(nodeId, exchangeId, requiredUsd, nodeType);
+ fetchPaymentMethodAction(nodeId, targetNode, required);
return;
}
setStack((prev) => [
@@ -744,30 +750,13 @@ export function useSessionNav(
{
type: "select-amount",
nodeId,
- flowType: targetNode.type === "CashApp" ? "cashapp" : "exchange",
+ flowType: "payment-method",
autoNav,
},
]);
return;
}
- if (isStripeNode(targetNode)) {
- const requiredUsd = targetNode.requiredUsd ?? 0;
- if (requiredUsd > 0) {
- setStack((prev) => [
- ...prev,
- { type: "stripe-onramp", nodeId, amountUsd: requiredUsd, autoNav },
- ]);
- fetchStripeOnramp(nodeId, requiredUsd);
- return;
- }
- setStack((prev) => [
- ...prev,
- { type: "select-amount", nodeId, flowType: "stripe", autoNav },
- ]);
- return;
- }
-
if (targetNode.type === "Fiat") {
const popupRequired =
enableFiatPopup &&
@@ -782,8 +771,7 @@ export function useSessionNav(
session.sessionId,
getNodeCtx,
fetchTronAddress,
- fetchExchangeUrl,
- fetchStripeOnramp,
+ fetchPaymentMethodAction,
handleAccountNavigate,
enableFiatPopup,
],
@@ -829,9 +817,23 @@ export function useSessionNav(
// ─── Flow handlers ──────────────────────────────────────────────────────
const handleAmountContinue = useCallback(
- (amountUsd: number) => {
+ (amount: number) => {
if (!topEntry || topEntry.type !== "select-amount") return;
const { nodeId, flowType } = topEntry;
+ const selectedNode = findNode(nodeId, session.navTree);
+ const selectedSourceAmount = isActionPaymentMethodNode(selectedNode)
+ ? getNavSourceAmount(selectedNode)
+ : null;
+ const amountContext =
+ flowType === "payment-method" && selectedSourceAmount != null
+ ? {
+ sourceAmountUnits: formatNavSourceAmountUnits(
+ amount,
+ selectedSourceAmount,
+ ),
+ sourceCurrency: selectedSourceAmount.currency,
+ }
+ : { amountUsd: amount };
logNavEvent(session.sessionId, session.clientSecret, {
nodeId,
@@ -840,43 +842,29 @@ export function useSessionNav(
? "DepositAddress"
: flowType === "tron"
? "TronDeposit"
- : flowType === "cashapp"
- ? "CashApp"
- : flowType === "stripe"
- ? "Stripe"
- : "Exchange",
+ : (selectedNode?.type ?? null),
action: "flow_amount_continue",
- amountUsd,
+ ...amountContext,
});
if (flowType === "deposit") {
setStack((prev) => [
...prev,
- { type: "waiting-deposit", nodeId, amountUsd },
+ { type: "waiting-deposit", nodeId, amountUsd: amount },
]);
} else if (flowType === "tron") {
setStack((prev) => [
...prev,
- { type: "waiting-tron", nodeId, amountUsd },
- ]);
- fetchTronAddress(nodeId, amountUsd);
- } else if (flowType === "exchange" || flowType === "cashapp") {
- const node = findNode(nodeId, session.navTree);
- if (!isExchangeNode(node)) return;
- const { exchangeId, nodeType } = getExchangeSelection(node);
- setStack((prev) => [
- ...prev,
- { type: "exchange-page", nodeId, amountUsd },
+ { type: "waiting-tron", nodeId, amountUsd: amount },
]);
- fetchExchangeUrl(nodeId, exchangeId, amountUsd, nodeType);
- } else if (flowType === "stripe") {
- const node = findNode(nodeId, session.navTree);
- if (!isStripeNode(node)) return;
+ fetchTronAddress(nodeId, amount);
+ } else if (flowType === "payment-method") {
+ if (!isActionPaymentMethodNode(selectedNode)) return;
setStack((prev) => [
...prev,
- { type: "stripe-onramp", nodeId, amountUsd },
+ { type: "payment-method", nodeId, sourceAmount: amount },
]);
- fetchStripeOnramp(nodeId, amountUsd);
+ fetchPaymentMethodAction(nodeId, selectedNode, amount);
}
},
[
@@ -884,8 +872,7 @@ export function useSessionNav(
session.sessionId,
session.navTree,
fetchTronAddress,
- fetchExchangeUrl,
- fetchStripeOnramp,
+ fetchPaymentMethodAction,
],
);
@@ -956,55 +943,28 @@ export function useSessionNav(
return;
}
- if (topEntry.type === "exchange-page") {
+ if (topEntry.type === "payment-method") {
const node = findNode(topEntry.nodeId, session.navTree);
- if (!isExchangeNode(node)) return;
- const { exchangeId, nodeType } = getExchangeSelection(node);
- setStack((prev) => {
- const top = prev[prev.length - 1];
- if (top?.type !== "exchange-page") return prev;
- return [
- ...prev.slice(0, -1),
- {
- ...top,
- exchangeUrl: undefined,
- expiresAt: undefined,
- error: undefined,
- },
- ];
- });
- fetchExchangeUrl(
- topEntry.nodeId,
- exchangeId,
- topEntry.amountUsd,
- nodeType,
- );
- return;
- }
-
- if (topEntry.type === "stripe-onramp") {
+ if (!isActionPaymentMethodNode(node)) return;
setStack((prev) => {
const top = prev[prev.length - 1];
- if (top?.type !== "stripe-onramp") return prev;
+ if (top?.type !== "payment-method") return prev;
return [
...prev.slice(0, -1),
{
...top,
- onrampSessionClientSecret: undefined,
- publishableKey: undefined,
- redirectUrl: undefined,
+ action: undefined,
error: undefined,
},
];
});
- fetchStripeOnramp(topEntry.nodeId, topEntry.amountUsd);
+ fetchPaymentMethodAction(topEntry.nodeId, node, topEntry.sourceAmount);
}
}, [
topEntry,
session.navTree,
fetchTronAddress,
- fetchExchangeUrl,
- fetchStripeOnramp,
+ fetchPaymentMethodAction,
doWalletSend,
]);
From 48ad4a489e08c7bc5ed8455ec9c265ff011d17bc Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:17:45 -0700
Subject: [PATCH 2/6] bridge legacy nav nodes to canonical methods
---
packages/sdk/src/web/api/navTree.test.ts | 23 +++++++++++++++++
packages/sdk/src/web/api/navTree.ts | 28 +++++++++++++++++++++
packages/sdk/src/web/hooks/useSessionNav.ts | 5 ++--
3 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/packages/sdk/src/web/api/navTree.test.ts b/packages/sdk/src/web/api/navTree.test.ts
index 639757eb..b5259a76 100644
--- a/packages/sdk/src/web/api/navTree.test.ts
+++ b/packages/sdk/src/web/api/navTree.test.ts
@@ -4,6 +4,7 @@ import {
formatNavSourceAmountUnits,
getNavExternalHandoff,
getNavSourceAmount,
+ hasCanonicalPaymentMethod,
type NavNodeExchange,
type NavNodePaymentMethod,
} from "./navTree.js";
@@ -67,6 +68,28 @@ describe("payment method nav policy", () => {
});
});
+ test("recognizes canonical metadata on a compatibility node", () => {
+ const node: NavNodeExchange = {
+ type: "Exchange",
+ id: "Exchange-MtPelerin",
+ title: "Mt Pelerin",
+ exchangeId: "MtPelerin",
+ methodId: "MtPelerin",
+ category: "onramp",
+ sourceAmount: {
+ currency: "EUR",
+ currencySymbol: "€",
+ decimals: 2,
+ minimum: 10,
+ maximum: 1000,
+ },
+ minimumUsd: 10,
+ maximumUsd: 1000,
+ };
+
+ expect(hasCanonicalPaymentMethod(node)).toBe(true);
+ });
+
test.each([
["Coinbase", "popup", "short"],
["MtPelerin", "popup", "short"],
diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts
index 36078917..dc0f3095 100644
--- a/packages/sdk/src/web/api/navTree.ts
+++ b/packages/sdk/src/web/api/navTree.ts
@@ -126,6 +126,12 @@ export type NavNodePaymentMethod = NavNodeCommon & {
export type NavNodeExchange = NavNodeCommon & {
type: "Exchange";
exchangeId: ExchangeId;
+ /** Canonical identity emitted by upgraded servers. */
+ methodId?: ActionPaymentMethodId;
+ /** Canonical product metadata emitted by upgraded servers. */
+ category?: PaymentMethodCategory;
+ /** ISO-3166 alpha-2 country to echo when initiating this payment. */
+ countryCode?: string;
icon?: string;
/** Backend-owned amount and currency policy. Optional for old servers. */
sourceAmount?: NavSourceAmount;
@@ -142,6 +148,12 @@ export type NavNodeExchange = NavNodeCommon & {
/** @deprecated Old servers emit provider-shaped navigation nodes. */
export type NavNodeCashApp = NavNodeCommon & {
type: "CashApp";
+ /** Canonical identity emitted by upgraded servers. */
+ methodId?: "CashApp";
+ /** Canonical product metadata emitted by upgraded servers. */
+ category?: "paymentApp";
+ /** ISO-3166 alpha-2 country to echo when initiating this payment. */
+ countryCode?: string;
icon?: string;
/** Backend-owned amount and currency policy. Optional for old servers. */
sourceAmount?: NavSourceAmount;
@@ -155,6 +167,12 @@ export type NavNodeCashApp = NavNodeCommon & {
/** @deprecated Old servers emit provider-shaped navigation nodes. */
export type NavNodeStripe = NavNodeCommon & {
type: "Stripe";
+ /** Canonical identity emitted by upgraded servers. */
+ methodId?: "Stripe";
+ /** Canonical product metadata emitted by upgraded servers. */
+ category?: "onramp";
+ /** ISO-3166 alpha-2 country to echo when initiating this payment. */
+ countryCode?: string;
icon?: string;
sourceAmount?: NavSourceAmount;
requiredUsd?: number;
@@ -168,6 +186,16 @@ export type NavActionPaymentMethodNode =
| NavNodeCashApp
| NavNodeStripe;
+/** True when a compatibility node carries the canonical method contract. */
+export function hasCanonicalPaymentMethod(
+ node: NavActionPaymentMethodNode,
+): node is NavActionPaymentMethodNode & {
+ methodId: ActionPaymentMethodId;
+ category: PaymentMethodCategory;
+} {
+ return node.type === "PaymentMethod" || node.methodId != null;
+}
+
/** Compatibility fallback for nav trees produced by pre-sourceAmount servers. */
export function getNavSourceAmount(
node: NavActionPaymentMethodNode,
diff --git a/packages/sdk/src/web/hooks/useSessionNav.ts b/packages/sdk/src/web/hooks/useSessionNav.ts
index e2d2ce3d..943ec661 100644
--- a/packages/sdk/src/web/hooks/useSessionNav.ts
+++ b/packages/sdk/src/web/hooks/useSessionNav.ts
@@ -26,6 +26,7 @@ import {
formatNavSourceAmountUnits,
getNavExternalHandoff,
getNavSourceAmount,
+ hasCanonicalPaymentMethod,
} from "../api/navTree.js";
import type {
NavActionPaymentMethodNode,
@@ -112,7 +113,7 @@ function isTrustTronNode(node: NavNode | null): node is NavNodeTronDeposit {
function getPaymentMethodId(
node: NavActionPaymentMethodNode,
): ActionPaymentMethodId {
- if (node.type === "PaymentMethod") return node.methodId;
+ if (hasCanonicalPaymentMethod(node)) return node.methodId;
if (node.type === "CashApp") return "CashApp";
if (node.type === "Stripe") return "Stripe";
return node.exchangeId;
@@ -123,7 +124,7 @@ function getPaymentMethodRequest(
amount: number,
platform: DaimoPlatform,
): CreatePaymentMethodRequest["paymentMethod"] {
- if (node.type === "PaymentMethod") {
+ if (hasCanonicalPaymentMethod(node)) {
const sourceAmount = getNavSourceAmount(node);
return {
id: node.methodId,
From 9ae6ff8965b98d8ec204597306b5d0b5d75edd81 Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Thu, 30 Jul 2026 09:54:47 -0700
Subject: [PATCH 3/6] preserve hosted payment flow behavior
---
packages/sdk/src/web/api/navTree.ts | 104 +++++++++---------
.../sdk/src/web/components/DaimoModal.tsx | 1 +
.../src/web/components/PaymentActionPage.tsx | 25 ++++-
packages/sdk/src/web/hooks/locales/en.ts | 2 +
packages/sdk/src/web/hooks/locales/es.ts | 2 +
packages/sdk/src/web/hooks/locales/ja.ts | 2 +
packages/sdk/src/web/hooks/locales/ko.ts | 2 +
packages/sdk/src/web/hooks/locales/pt.ts | 2 +
packages/sdk/src/web/hooks/locales/zh.ts | 2 +
packages/sdk/src/web/hooks/useSessionNav.ts | 12 +-
10 files changed, 97 insertions(+), 57 deletions(-)
diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts
index dc0f3095..e1393b1f 100644
--- a/packages/sdk/src/web/api/navTree.ts
+++ b/packages/sdk/src/web/api/navTree.ts
@@ -186,6 +186,42 @@ export type NavActionPaymentMethodNode =
| NavNodeCashApp
| NavNodeStripe;
+export type NavNodeTronDeposit = NavNodeCommon & {
+ type: "TronDeposit";
+ icon?: string;
+ requiredUsd?: number;
+ minimumUsd: number;
+ maximumUsd: number;
+};
+
+export type NavNodeConnectedWallet = NavNodeCommon & {
+ type: "ConnectedWallet";
+ icon?: string;
+ /** When true, proactively call eth_requestAccounts. Default false (passive). */
+ autoconnect?: boolean;
+};
+
+export type NavNodeFiat = NavNodeCommon & {
+ type: "Fiat";
+ fiatMethod: AccountRail;
+ /** Semantic entry flow. Optional only while old servers remain supported. */
+ paymentInteraction?: DepositPaymentInteraction;
+ icon?: string;
+ kycRequirement?: NavNodeKycRequirement;
+};
+
+export type NavNode =
+ | NavNodeChooseOption
+ | NavNodeDepositAddress
+ | NavNodeDeeplink
+ | NavNodePaymentMethod
+ | NavNodeExchange
+ | NavNodeCashApp
+ | NavNodeStripe
+ | NavNodeTronDeposit
+ | NavNodeConnectedWallet
+ | NavNodeFiat;
+
/** True when a compatibility node carries the canonical method contract. */
export function hasCanonicalPaymentMethod(
node: NavActionPaymentMethodNode,
@@ -222,6 +258,22 @@ export function getNavExternalHandoff(
return getLegacyNavExternalHandoff(node);
}
+/** Format an amount exactly as required by the backend-owned source policy. */
+export function formatNavSourceAmountUnits(
+ amount: number,
+ sourceAmount: NavSourceAmount,
+): string {
+ if (
+ !Number.isFinite(amount) ||
+ !Number.isInteger(sourceAmount.decimals) ||
+ sourceAmount.decimals < 0 ||
+ sourceAmount.decimals > 20
+ ) {
+ throw new Error("invalid payment method amount");
+ }
+ return amount.toFixed(sourceAmount.decimals);
+}
+
/** Preserve pre-handoff SDK behavior while old backend nav trees remain valid. */
function getLegacyNavExternalHandoff(
node: NavNodeExchange | NavNodeCashApp,
@@ -240,55 +292,3 @@ function getLegacyNavExternalHandoff(
: "short",
};
}
-
-/** Format an amount exactly as required by the backend-owned source policy. */
-export function formatNavSourceAmountUnits(
- amount: number,
- sourceAmount: NavSourceAmount,
-): string {
- if (
- !Number.isFinite(amount) ||
- !Number.isInteger(sourceAmount.decimals) ||
- sourceAmount.decimals < 0 ||
- sourceAmount.decimals > 20
- ) {
- throw new Error("invalid payment method amount");
- }
- return amount.toFixed(sourceAmount.decimals);
-}
-
-export type NavNodeTronDeposit = NavNodeCommon & {
- type: "TronDeposit";
- icon?: string;
- requiredUsd?: number;
- minimumUsd: number;
- maximumUsd: number;
-};
-
-export type NavNodeConnectedWallet = NavNodeCommon & {
- type: "ConnectedWallet";
- icon?: string;
- /** When true, proactively call eth_requestAccounts. Default false (passive). */
- autoconnect?: boolean;
-};
-
-export type NavNodeFiat = NavNodeCommon & {
- type: "Fiat";
- fiatMethod: AccountRail;
- /** Semantic entry flow. Optional only while old servers remain supported. */
- paymentInteraction?: DepositPaymentInteraction;
- icon?: string;
- kycRequirement?: NavNodeKycRequirement;
-};
-
-export type NavNode =
- | NavNodeChooseOption
- | NavNodeDepositAddress
- | NavNodeDeeplink
- | NavNodePaymentMethod
- | NavNodeExchange
- | NavNodeCashApp
- | NavNodeStripe
- | NavNodeTronDeposit
- | NavNodeConnectedWallet
- | NavNodeFiat;
diff --git a/packages/sdk/src/web/components/DaimoModal.tsx b/packages/sdk/src/web/components/DaimoModal.tsx
index aea4430b..0e0f51c1 100644
--- a/packages/sdk/src/web/components/DaimoModal.tsx
+++ b/packages/sdk/src/web/components/DaimoModal.tsx
@@ -1364,6 +1364,7 @@ function renderPaymentMethod(
void;
@@ -17,6 +23,7 @@ type PaymentActionPageProps = {
export function PaymentActionPage({
node,
platform,
+ sourceAmount,
action,
isLoading,
onBack,
@@ -27,6 +34,13 @@ export function PaymentActionPage({
const presentation = openUrl?.presentation ?? "popup";
const usesDesktopQR = isDesktop(platform) && presentation === "qr";
const url = openUrl?.url ?? embeddedWidget?.fallbackUrl;
+ const sourcePolicy = getNavSourceAmount(node);
+ const sourceUnits = formatNavSourceAmountUnits(sourceAmount, sourcePolicy);
+ const formattedSourceAmount = `${sourcePolicy.currencySymbol}${sourceUnits} ${sourcePolicy.currency}`;
+ const placeholderDensity =
+ node.type === "Exchange" || node.type === "CashApp"
+ ? getNavExternalHandoff(node).legacyQrPlaceholderDensity
+ : undefined;
return (
+ `Deposit exactly ${amount} with ${provider}, then return to this page.`,
open: "Open",
refreshInvoice: "Refresh",
estimatedOutput: "Estimated output",
diff --git a/packages/sdk/src/web/hooks/locales/es.ts b/packages/sdk/src/web/hooks/locales/es.ts
index b29cd6e5..39d6a9d3 100644
--- a/packages/sdk/src/web/hooks/locales/es.ts
+++ b/packages/sdk/src/web/hooks/locales/es.ts
@@ -21,6 +21,8 @@ export const es: typeof en = {
scanWithPhone: "Escanea con tu teléfono para abrir la billetera",
continueTo: "Continuar a",
toCompleteYourDeposit: "para completar tu depósito",
+ depositExactlyWith: (amount: string, provider: string) =>
+ `Deposita exactamente ${amount} con ${provider} y luego vuelve a esta página.`,
open: "Abrir",
refreshInvoice: "Actualizar",
estimatedOutput: "Importe estimado",
diff --git a/packages/sdk/src/web/hooks/locales/ja.ts b/packages/sdk/src/web/hooks/locales/ja.ts
index a4e8c6b2..6577c347 100644
--- a/packages/sdk/src/web/hooks/locales/ja.ts
+++ b/packages/sdk/src/web/hooks/locales/ja.ts
@@ -28,6 +28,8 @@ export const ja: typeof en = {
// PaymentActionPage
continueTo: "",
toCompleteYourDeposit: "で入金を完了してください",
+ depositExactlyWith: (amount: string, provider: string) =>
+ `${provider}で正確に${amount}を入金してから、このページに戻ってください。`,
open: "開く",
refreshInvoice: "更新",
estimatedOutput: "受取額(見込み)",
diff --git a/packages/sdk/src/web/hooks/locales/ko.ts b/packages/sdk/src/web/hooks/locales/ko.ts
index fa516936..6246f020 100644
--- a/packages/sdk/src/web/hooks/locales/ko.ts
+++ b/packages/sdk/src/web/hooks/locales/ko.ts
@@ -28,6 +28,8 @@ export const ko: typeof en = {
// PaymentActionPage
continueTo: "",
toCompleteYourDeposit: "에서 입금을 완료하세요",
+ depositExactlyWith: (amount: string, provider: string) =>
+ `${provider}에서 정확히 ${amount}를 입금한 후 이 페이지로 돌아오세요.`,
open: "열기",
refreshInvoice: "새로고침",
estimatedOutput: "예상 수령액",
diff --git a/packages/sdk/src/web/hooks/locales/pt.ts b/packages/sdk/src/web/hooks/locales/pt.ts
index 1c2314b6..b6bb5f9d 100644
--- a/packages/sdk/src/web/hooks/locales/pt.ts
+++ b/packages/sdk/src/web/hooks/locales/pt.ts
@@ -28,6 +28,8 @@ export const pt: typeof en = {
// PaymentActionPage
continueTo: "Continuar para",
toCompleteYourDeposit: "para concluir seu depósito",
+ depositExactlyWith: (amount: string, provider: string) =>
+ `Deposite exatamente ${amount} com ${provider} e depois volte a esta página.`,
open: "Abrir",
refreshInvoice: "Atualizar",
estimatedOutput: "Valor estimado",
diff --git a/packages/sdk/src/web/hooks/locales/zh.ts b/packages/sdk/src/web/hooks/locales/zh.ts
index 70c8e4d5..4a828422 100644
--- a/packages/sdk/src/web/hooks/locales/zh.ts
+++ b/packages/sdk/src/web/hooks/locales/zh.ts
@@ -28,6 +28,8 @@ export const zh: typeof en = {
// PaymentActionPage
continueTo: "前往",
toCompleteYourDeposit: "以完成充值",
+ depositExactlyWith: (amount: string, provider: string) =>
+ `请使用${provider}准确存入${amount},然后返回此页面。`,
open: "打开",
refreshInvoice: "刷新",
estimatedOutput: "预计到账金额",
diff --git a/packages/sdk/src/web/hooks/useSessionNav.ts b/packages/sdk/src/web/hooks/useSessionNav.ts
index 943ec661..174a4bd9 100644
--- a/packages/sdk/src/web/hooks/useSessionNav.ts
+++ b/packages/sdk/src/web/hooks/useSessionNav.ts
@@ -149,8 +149,18 @@ function getPaymentAction(
node: NavActionPaymentMethodNode,
result: CreatePaymentMethodResponse,
): PaymentAction {
- if (result.action != null) return result.action;
+ if (result.action != null) {
+ const url =
+ result.action.type === "openUrl"
+ ? result.action.url
+ : result.action.fallbackUrl;
+ if (!url) throw new Error("payment action url not returned");
+ return result.action;
+ }
if (node.type === "Stripe" && result.stripe != null) {
+ if (!result.stripe.redirectUrl) {
+ throw new Error("stripe onramp url not returned");
+ }
return {
type: "embeddedWidget",
sdk: "stripeOnramp",
From b52ab02282386841dd0d835fcbca814a2b2ba1bf Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:05:08 -0700
Subject: [PATCH 4/6] preserve legacy payment handoff defaults
---
packages/sdk/src/web/api/navTree.test.ts | 2 ++
packages/sdk/src/web/api/navTree.ts | 1 +
.../src/web/components/PaymentActionPage.tsx | 23 +++++++++++--------
3 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/packages/sdk/src/web/api/navTree.test.ts b/packages/sdk/src/web/api/navTree.test.ts
index b5259a76..c5824de1 100644
--- a/packages/sdk/src/web/api/navTree.test.ts
+++ b/packages/sdk/src/web/api/navTree.test.ts
@@ -64,6 +64,7 @@ describe("payment method nav policy", () => {
});
expect(getNavExternalHandoff(node)).toEqual({
desktopBehavior: "popup",
+ popupName: "coinbase",
legacyQrPlaceholderDensity: "short",
});
});
@@ -111,6 +112,7 @@ describe("payment method nav policy", () => {
expect(getNavExternalHandoff(node)).toEqual({
desktopBehavior,
+ popupName: exchangeId.toLowerCase(),
legacyQrPlaceholderDensity,
});
},
diff --git a/packages/sdk/src/web/api/navTree.ts b/packages/sdk/src/web/api/navTree.ts
index e1393b1f..feaf2e35 100644
--- a/packages/sdk/src/web/api/navTree.ts
+++ b/packages/sdk/src/web/api/navTree.ts
@@ -284,6 +284,7 @@ function getLegacyNavExternalHandoff(
return {
desktopBehavior:
exchangeId === "Coinbase" || exchangeId === "MtPelerin" ? "popup" : "qr",
+ popupName: exchangeId.toLowerCase(),
legacyQrPlaceholderDensity:
exchangeId === "Binance" ||
exchangeId === "BinanceUSDC" ||
diff --git a/packages/sdk/src/web/components/PaymentActionPage.tsx b/packages/sdk/src/web/components/PaymentActionPage.tsx
index d9a2008c..994d999a 100644
--- a/packages/sdk/src/web/components/PaymentActionPage.tsx
+++ b/packages/sdk/src/web/components/PaymentActionPage.tsx
@@ -31,16 +31,23 @@ export function PaymentActionPage({
}: PaymentActionPageProps) {
const openUrl = action?.type === "openUrl" ? action : undefined;
const embeddedWidget = action?.type === "embeddedWidget" ? action : undefined;
- const presentation = openUrl?.presentation ?? "popup";
+ const legacyHandoff =
+ node.type === "Exchange" || node.type === "CashApp"
+ ? getNavExternalHandoff(node)
+ : undefined;
+ const presentation =
+ openUrl?.presentation ?? legacyHandoff?.desktopBehavior ?? "popup";
const usesDesktopQR = isDesktop(platform) && presentation === "qr";
const url = openUrl?.url ?? embeddedWidget?.fallbackUrl;
const sourcePolicy = getNavSourceAmount(node);
const sourceUnits = formatNavSourceAmountUnits(sourceAmount, sourcePolicy);
const formattedSourceAmount = `${sourcePolicy.currencySymbol}${sourceUnits} ${sourcePolicy.currency}`;
- const placeholderDensity =
- node.type === "Exchange" || node.type === "CashApp"
- ? getNavExternalHandoff(node).legacyQrPlaceholderDensity
- : undefined;
+ const placeholderDensity = legacyHandoff?.legacyQrPlaceholderDensity;
+ const popupName =
+ openUrl?.popupName ??
+ (node.type === "Stripe"
+ ? "stripe"
+ : embeddedWidget?.sdk ?? node.id.toLowerCase());
return (
Date: Thu, 30 Jul 2026 10:12:03 -0700
Subject: [PATCH 5/6] localize hosted payment amounts
---
packages/sdk/src/web/components/PaymentActionPage.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/sdk/src/web/components/PaymentActionPage.tsx b/packages/sdk/src/web/components/PaymentActionPage.tsx
index 994d999a..8bc1b9c1 100644
--- a/packages/sdk/src/web/components/PaymentActionPage.tsx
+++ b/packages/sdk/src/web/components/PaymentActionPage.tsx
@@ -1,11 +1,11 @@
import type { PaymentAction } from "../../common/api.js";
import {
- formatNavSourceAmountUnits,
getNavExternalHandoff,
getNavSourceAmount,
type NavActionPaymentMethodNode,
} from "../api/navTree.js";
+import { formatFixedAmount } from "../formatAmount.js";
import { t } from "../hooks/locale.js";
import { isDesktop, type DaimoPlatform } from "../platform.js";
import { HostedPaymentPage } from "./HostedPaymentPage.js";
@@ -40,7 +40,7 @@ export function PaymentActionPage({
const usesDesktopQR = isDesktop(platform) && presentation === "qr";
const url = openUrl?.url ?? embeddedWidget?.fallbackUrl;
const sourcePolicy = getNavSourceAmount(node);
- const sourceUnits = formatNavSourceAmountUnits(sourceAmount, sourcePolicy);
+ const sourceUnits = formatFixedAmount(sourceAmount, sourcePolicy.decimals);
const formattedSourceAmount = `${sourcePolicy.currencySymbol}${sourceUnits} ${sourcePolicy.currency}`;
const placeholderDensity = legacyHandoff?.legacyQrPlaceholderDensity;
const popupName =
From 3da07991ea479f7411e8df5377676cfacd6cd5de Mon Sep 17 00:00:00 2001
From: a16i <151609257+a16i@users.noreply.github.com>
Date: Thu, 30 Jul 2026 10:16:32 -0700
Subject: [PATCH 6/6] harden hosted payment fallbacks
---
packages/sdk/src/web/components/PaymentActionPage.tsx | 10 +++++++---
packages/sdk/src/web/hooks/useSessionNav.ts | 3 +++
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/packages/sdk/src/web/components/PaymentActionPage.tsx b/packages/sdk/src/web/components/PaymentActionPage.tsx
index 8bc1b9c1..b852de35 100644
--- a/packages/sdk/src/web/components/PaymentActionPage.tsx
+++ b/packages/sdk/src/web/components/PaymentActionPage.tsx
@@ -45,9 +45,13 @@ export function PaymentActionPage({
const placeholderDensity = legacyHandoff?.legacyQrPlaceholderDensity;
const popupName =
openUrl?.popupName ??
- (node.type === "Stripe"
- ? "stripe"
- : embeddedWidget?.sdk ?? node.id.toLowerCase());
+ (node.type === "Exchange"
+ ? node.exchangeId.toLowerCase()
+ : node.type === "CashApp"
+ ? "cashapp"
+ : node.type === "Stripe"
+ ? "stripe"
+ : embeddedWidget?.sdk ?? node.id.toLowerCase());
return (