Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,44 @@ See [docs.daimo.com](https://docs.daimo.com) for more.
- `@daimo/sdk/web` — React modal (`<DaimoModal>`) and hooks for the built-in deposit UI
- `@daimo/sdk/native` — React Native deposit UI (`<DaimoFrameRN>`) 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.
Expand Down
129 changes: 113 additions & 16 deletions packages/sdk/src/common/api.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
93 changes: 68 additions & 25 deletions packages/sdk/src/common/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,32 +24,64 @@ export type ExchangeId = z.infer<typeof zExchangeId>;

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<typeof zActionPaymentMethodId>;

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<typeof zActionPaymentMethodRequest>;

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,
]),
});

Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand All @@ -128,7 +163,7 @@ export type CreatePaymentMethodResponse = {
/** Selected fiat method for this hosted flow, when pinned to one method. */
fiatMethod?: z.infer<typeof zAccountRail>;
};
/** Stripe Onramp details, present when payment method is Stripe. */
/** @deprecated Use action. */
stripe?: {
/**
* Stripe OnrampSession client secret, scoped to this onramp session.
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
26 changes: 26 additions & 0 deletions packages/sdk/src/common/paymentMethod.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
Loading