diff --git a/package.json b/package.json index f3d03092..a471fef8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@stripe/stripe-js": "^8.9.0", "@vercel/blob": "^2.3.1", "mermaid": "^11.12.2", - "mppx": "https://pkg.pr.new/mppx@235", + "mppx": "https://pkg.pr.new/mppx@231", "react": "^19", "react-dom": "^19", "stripe": "^20.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45014d5c..04c29ae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: ^11.12.2 version: 11.12.2 mppx: - specifier: https://pkg.pr.new/mppx@235 - version: https://pkg.pr.new/mppx@235(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.46.2(typescript@5.9.3)(zod@4.3.6)) + specifier: https://pkg.pr.new/mppx@231 + version: https://pkg.pr.new/mppx@231(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.46.2(typescript@5.9.3)(zod@4.3.6)) react: specifier: ^19 version: 19.2.4 @@ -2713,9 +2713,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mppx@https://pkg.pr.new/mppx@235: - resolution: {tarball: https://pkg.pr.new/mppx@235} - version: 0.4.9 + mppx@https://pkg.pr.new/mppx@231: + resolution: {tarball: https://pkg.pr.new/mppx@231} + version: 0.4.11 hasBin: true peerDependencies: '@modelcontextprotocol/sdk': '>=1.25.0' @@ -6700,7 +6700,7 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 - mppx@https://pkg.pr.new/mppx@235(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.46.2(typescript@5.9.3)(zod@4.3.6)): + mppx@https://pkg.pr.new/mppx@231(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(express@5.2.1)(hono@4.11.9)(openapi-types@12.1.3)(typescript@5.9.3)(viem@2.46.2(typescript@5.9.3)(zod@4.3.6)): dependencies: '@remix-run/fetch-proxy': 0.7.1 '@remix-run/node-fetch-server': 0.13.0 diff --git a/src/pages/guides/split-payments.mdx b/src/pages/guides/split-payments.mdx new file mode 100644 index 00000000..d31c2746 --- /dev/null +++ b/src/pages/guides/split-payments.mdx @@ -0,0 +1,151 @@ +--- +imageDescription: "Split a single charge across multiple recipients for marketplaces, referral fees, and revenue sharing" +--- + +import { Cards } from 'vocs' +import { OneTimePaymentsCard, PayAsYouGoCard, ServerQuickstartCard } from '../../components/cards' + +# Accept split payments [Distribute a charge across multiple recipients] + +Split a single charge across multiple recipients in one atomic transaction. The primary recipient receives the remainder after all splits are deducted. + +Split payments are useful for: + +- **Marketplaces** — route a platform fee to yourself and the rest to the seller +- **Referral programs** — pay a bounty to the referrer on every purchase +- **Revenue sharing** — distribute earnings across partners or contributors + +## How it works + +When you add `splits` to a charge, the SDK constructs multiple on-chain transfers in a single transaction: + +1. Each split recipient receives their declared amount +2. The primary `recipient` receives `amount - sum(splits)` +3. The server verifies all transfers atomically + +:::info +Split amounts are in human-readable units, the same as the top-level `amount`. The primary recipient's share is always implicit — you only declare the splits. +::: + +## Server + +Add a `splits` array to any `mppx.charge` call. Each entry specifies a `recipient` and `amount`. + +```ts twoslash +import { Mppx, tempo } from 'mppx/server' + +const mppx = Mppx.create({ methods: [tempo()] }) +// ---cut--- +export async function handler(request: Request) { + const result = await mppx.charge({ + amount: '1.00', + currency: '0x20c0000000000000000000000000000000000000', // pathUSD + recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller + splits: [ // [!code hl] + { // [!code hl] + amount: '0.10', // [!code hl] + recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform fee // [!code hl] + }, // [!code hl] + ], // [!code hl] + })(request) + + // seller receives $0.90, platform receives $0.10 + if (result.status === 402) return result.challenge + return result.withReceipt(Response.json({ data: '...' })) +} +``` + +### With per-split memos + +Each split can carry its own on-chain memo for reconciliation: + +```ts twoslash +import { Mppx, tempo } from 'mppx/server' + +const mppx = Mppx.create({ methods: [tempo()] }) + +declare const request: Request +// ---cut--- +const result = await mppx.charge({ + amount: '1.00', + currency: '0x20c0000000000000000000000000000000000000', // pathUSD + memo: '0x6f726465722d313233', // order-123 + recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller + splits: [ + { + amount: '0.10', + memo: '0x706c6174666f726d2d666565', // platform-fee // [!code hl] + recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform + }, + ], +})(request) +``` + +### With fee sponsorship + +Split payments work with [fee sponsorship](/payment-methods/tempo#fee-sponsorship). The server co-signs the multi-transfer transaction so the client doesn't need gas tokens. + +```ts twoslash +import { Mppx, tempo } from 'mppx/server' + +const mppx = Mppx.create({ methods: [tempo()] }) + +declare const request: Request +// ---cut--- +const result = await mppx.charge({ + amount: '1.00', + currency: '0x20c0000000000000000000000000000000000000', // pathUSD + feePayer: true, // [!code hl] + recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller + splits: [ + { amount: '0.05', recipient: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC' }, // referrer + { amount: '0.10', recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, // platform + ], +})(request) +``` + +## Client + +The client SDK handles split payments automatically — no client-side configuration is needed. When the server includes `splits` in the Challenge, the client constructs the matching multi-transfer transaction. + +### Validating split recipients + +Use `expectedRecipients` to restrict which split recipients the client will sign for. This prevents a compromised server from redirecting funds to unexpected addresses. + +```ts twoslash +import { Mppx, tempo } from 'mppx/client' +import { privateKeyToAccount } from 'viem/accounts' + +const account = privateKeyToAccount('0xabc…123') +// ---cut--- +Mppx.create({ + methods: [ + tempo.charge({ + account, + expectedRecipients: [ // [!code hl] + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // platform // [!code hl] + '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', // referrer // [!code hl] + ], // [!code hl] + }), + ], +}) +``` + +If the server sends a Challenge with a split recipient not in the allowlist, the client throws an error instead of signing. + +## Constraints + +| Rule | Limit | +|------|-------| +| Splits per charge | 1–10 | +| Each split amount | Must be > 0 | +| Sum of all splits | Must be strictly less than `amount` | +| Split memo | Optional, 32-byte hex hash | + +## Next steps + + + + + + diff --git a/src/pages/payment-methods/tempo/charge.mdx b/src/pages/payment-methods/tempo/charge.mdx index a071b3cb..72756e92 100644 --- a/src/pages/payment-methods/tempo/charge.mdx +++ b/src/pages/payment-methods/tempo/charge.mdx @@ -78,6 +78,32 @@ const result = await mppx.charge({ When `feePayer` is `true`, the server adds a fee payer signature (domain `0x78`) before broadcasting. The client doesn't need gas tokens. See [fee sponsorship](/payment-methods/tempo#fee-sponsorship) for details. +### With split payments + +Split a charge across multiple recipients in a single transaction. The primary `recipient` receives `amount` minus the sum of all splits. + +```ts twoslash +import { Mppx, tempo } from "mppx/server"; + +const mppx = Mppx.create({ methods: [tempo()] }); + +declare const request: Request; +// ---cut--- +const result = await mppx.charge({ + amount: "1.00", + currency: "0x20c0000000000000000000000000000000000000", // pathUSD + recipient: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", // seller + splits: [ // [!code hl] + { // [!code hl] + amount: "0.10", // [!code hl] + recipient: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", // platform fee // [!code hl] + }, // [!code hl] + ], // [!code hl] +})(request); +``` + +Up to 10 splits per charge. Each split must have a positive amount, and the sum of all splits must be less than the total `amount`. See the [split payments guide](/guides/split-payments) for more details. + ### With Stripe ```ts twoslash diff --git a/src/pages/sdk/typescript/client/Method.tempo.charge.mdx b/src/pages/sdk/typescript/client/Method.tempo.charge.mdx index 3f285fbe..78207e34 100644 --- a/src/pages/sdk/typescript/client/Method.tempo.charge.mdx +++ b/src/pages/sdk/typescript/client/Method.tempo.charge.mdx @@ -76,6 +76,24 @@ Client identifier used to derive the client fingerprint in attribution memos. Function that returns a viem client for the given chain ID. +### expectedRecipients (optional) + +- **Type:** `readonly string[]` + +Allowlist of addresses the client will accept as split recipients. When set, the client rejects any Challenge whose split recipients are not in this list, preventing a compromised server from redirecting funds. + +```ts twoslash +import { tempo } from 'mppx/client' +import { privateKeyToAccount } from 'viem/accounts' + +const method = tempo.charge({ + account: privateKeyToAccount('0xabc…123'), + expectedRecipients: [ // [!code focus] + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', // [!code focus] + ], // [!code focus] +}) +``` + ### mode (optional) - **Type:** `'push' | 'pull'` diff --git a/src/pages/sdk/typescript/server/Method.tempo.charge.mdx b/src/pages/sdk/typescript/server/Method.tempo.charge.mdx index 364db1ee..194a205e 100644 --- a/src/pages/sdk/typescript/server/Method.tempo.charge.mdx +++ b/src/pages/sdk/typescript/server/Method.tempo.charge.mdx @@ -176,3 +176,35 @@ Server-defined correlation data, serialized as `opaque` on the Challenge. - **Type:** `string` Address to receive the payment. + +### splits (optional) + +- **Type:** `Array<{ amount: string; memo?: string; recipient: string }>` + +Split the charge across additional recipients. Each entry specifies an `amount` (in human-readable units) and a `recipient` address. The primary `recipient` receives `amount` minus the sum of all split amounts. + +| Constraint | Value | +|---|---| +| Array length | 1–10 | +| Each split amount | Must be > 0 | +| Sum of splits | Must be strictly less than `amount` | +| Split memo | Optional, 32-byte hex hash | + +```ts twoslash +import { Mppx, tempo } from 'mppx/server' + +const mppx = Mppx.create({ methods: [tempo.charge()] }) +// ---cut--- +export async function handler(request: Request) { + const response = await mppx.charge({ + amount: '1.00', + currency: '0x20c0000000000000000000000000000000000000', // pathUSD + recipient: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', // seller + splits: [ // [!code focus] + { amount: '0.10', recipient: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' }, // platform fee // [!code focus] + ], // [!code focus] + })(request) + + if (response.status === 402) return response.challenge + return response.withReceipt(Response.json({ data: '...' })) +} diff --git a/vocs.config.ts b/vocs.config.ts index 40e2218f..732475f8 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -287,6 +287,10 @@ export default defineConfig({ text: "Accept streamed payments", link: "/guides/streamed-payments", }, + { + text: "Accept split payments", + link: "/guides/split-payments", + }, { text: "Accept multiple payment methods", link: "/guides/multiple-payment-methods",