diff --git a/src/lib/sidebar-coverage.test.ts b/src/lib/sidebar-coverage.test.ts index 002fcc3c8..b5d8583c9 100644 --- a/src/lib/sidebar-coverage.test.ts +++ b/src/lib/sidebar-coverage.test.ts @@ -32,6 +32,21 @@ const supportingPages = new Map([ ['/docs/guide/bridge-relay', '/docs/partners/bridges'], ['/docs/partners/developer-tools', '/docs/partners/explorers-and-debugging'], ['/docs/guide/payments/send-a-payment/examples', '/docs/guide/payments/send-a-payment'], + // Framework guides stay out of the sidebar and are linked from their workflow guide. + [ + '/docs/resources/guides/accept-stablecoin-payments-nextjs', + '/docs/resources/guides/how-to-accept-stablecoin-payments', + ], + [ + '/docs/resources/guides/charge-for-an-api-express', + '/docs/resources/guides/402-payment-required', + ], + [ + '/docs/resources/guides/charge-for-an-api-nextjs', + '/docs/resources/guides/402-payment-required', + ], + ['/docs/resources/guides/stablecoin-payouts-nodejs', '/docs/resources/guides/global-payouts'], + ['/docs/resources/guides/stablecoin-payouts-python', '/docs/resources/guides/global-payouts'], ]) const routes = readdirSync(new URL('../pages/', import.meta.url), { recursive: true }) .filter( diff --git a/src/pages/docs/guide/machine-payments/server.mdx b/src/pages/docs/guide/machine-payments/server.mdx index 6b899acbe..160579619 100644 --- a/src/pages/docs/guide/machine-payments/server.mdx +++ b/src/pages/docs/guide/machine-payments/server.mdx @@ -85,6 +85,10 @@ app.get( ::: +:::warning[Next.js with Turbopack] +Add `serverExternalPackages: ['mppx']` to `next.config.ts`. Without it, Turbopack tries to bundle optional `mppx` integrations and the route fails with `Can't resolve '@modelcontextprotocol/sdk/types.js'`. `mppx/nextjs` requires Next.js 16.2.6 or later. +::: + :::tip You can override `currency` and `recipient` per call if different routes need different payment configurations. diff --git a/src/pages/docs/resources/guides/402-payment-required.mdx b/src/pages/docs/resources/guides/402-payment-required.mdx index b9a7a5441..bf2a722be 100644 --- a/src/pages/docs/resources/guides/402-payment-required.mdx +++ b/src/pages/docs/resources/guides/402-payment-required.mdx @@ -76,6 +76,11 @@ Check the unpaid challenge, successful payment, receipt, and delivered resource. ::::: +## Charge for an API by framework + +- [Charge for an API with MPP in Express](/docs/resources/guides/charge-for-an-api-express) +- [Charge for an API route with MPP in Next.js](/docs/resources/guides/charge-for-an-api-nextjs) + ## HTTP 402 questions ### Does every browser or agent automatically pay a 402 response? diff --git a/src/pages/docs/resources/guides/accept-stablecoin-payments-nextjs.mdx b/src/pages/docs/resources/guides/accept-stablecoin-payments-nextjs.mdx new file mode 100644 index 000000000..f903721fe --- /dev/null +++ b/src/pages/docs/resources/guides/accept-stablecoin-payments-nextjs.mdx @@ -0,0 +1,209 @@ +--- +title: "Accept stablecoin payments in Next.js" +seoTitle: "Accept Stablecoin Payments in Next.js | Tempo Docs" +description: "Create an invoice with a payment reference, verify the stablecoin transfer in a Next.js route handler, and mark the invoice paid once." +--- + +[Guides](/docs/resources/guides) / [Plan stablecoin payment acceptance](/docs/resources/guides/how-to-accept-stablecoin-payments) / Next.js + +# Accept stablecoin payments in Next.js + +This guide adds stablecoin invoice payments to a Next.js App Router project. One route handler creates an invoice with an onchain payment reference, and a second verifies the customer's transfer before marking the invoice paid. Payments on Tempo settle onchain in under a second, so the customer can see the invoice marked paid right after they pay. + +The examples use AlphaUSD on Moderato testnet and [Viem](/docs/sdk/typescript), which includes Tempo support. + +::::steps + +## Install Viem + +```bash [Terminal] +npm install viem +``` + +## Configure the Tempo client + +Set `TEMPO_RECIPIENT` to an address you control. Customers pay this address, and the verifier only accepts transfers to it. + +```ts [lib/tempo.ts] +import { tempoModerato } from 'viem/chains' +import { createClient } from 'viem/tempo' + +// Moderato testnet. Use `tempo` from 'viem/chains' for mainnet. +export const chain = tempoModerato +export const client = createClient({ chain }) + +// AlphaUSD on Moderato. On mainnet, use the stablecoin you accept. +export const token = '0x20c0000000000000000000000000000000000001' + +export const recipient = process.env.TEMPO_RECIPIENT as `0x${string}` +if (!recipient) throw new Error('Set TEMPO_RECIPIENT to your receiving address') +``` + +This example keeps invoices in memory. Replace the store with your database. + +```ts [lib/invoices.ts] +// In-memory store for the example. Use your database in production. +export type Invoice = { + id: string + amount: string // decimal string, such as '25.00' + status: 'open' | 'paid' + eventKey?: string +} + +const db = globalThis as unknown as { + invoices?: Map + eventKeys?: Set +} +export const invoices = (db.invoices ??= new Map()) +export const eventKeys = (db.eventKeys ??= new Set()) +``` + +## Create an invoice with a payment reference + +The route returns everything the customer needs to pay: network, token, recipient, amount, and a 32-byte memo that carries the invoice ID onchain. See [payment references](/docs/guide/payments/transfer-memos) for the memo encoding. + +```ts [app/api/invoices/route.ts] +import { toHex } from 'viem' +import { chain, recipient, token } from '@/lib/tempo' +import { invoices } from '@/lib/invoices' + +export async function POST(request: Request) { + const { amount } = await request.json() + + // The invoice ID becomes the onchain memo, so keep it under 32 bytes. + const id = `INV-${Date.now()}` + invoices.set(id, { id, amount, status: 'open' }) + + return Response.json({ + id, + chainId: chain.id, + token, + recipient, + amount, + memo: toHex(id, { size: 32 }), + }) +} +``` + +## Verify the payment and mark the invoice paid + +The customer's wallet or your checkout sends the transaction hash to this route. The handler checks the confirmed receipt instead of trusting the client: the memo must match the invoice, the funds must reach your address, and each transfer can pay only one invoice. + +```ts [app/api/invoices/[id]/verify/route.ts] +import { parseEventLogs, parseUnits, toHex, type Hash } from 'viem' +import { Abis } from 'viem/tempo' +import { chain, client, recipient, token } from '@/lib/tempo' +import { eventKeys, invoices } from '@/lib/invoices' + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const { hash } = (await request.json()) as { hash: Hash } + + const invoice = invoices.get(id) + if (!invoice) return Response.json({ error: 'Unknown invoice' }, { status: 404 }) + if (invoice.status === 'paid') return Response.json(invoice) + + const receipt = await client.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') { + return Response.json({ error: 'Transaction failed' }, { status: 400 }) + } + + const amount = parseUnits(invoice.amount, 6) + const memo = toHex(id, { size: 32 }) + const logs = receipt.logs.filter((log) => log.address.toLowerCase() === token) + + // 1. Find the transfer that carries this invoice's memo. + const payment = parseEventLogs({ abi: Abis.tip20, eventName: 'TransferWithMemo', logs }) + .find((log) => + log.args.to.toLowerCase() === recipient.toLowerCase() && + log.args.amount === amount && + log.args.memo === memo, + ) + if (!payment) return Response.json({ error: 'No matching payment' }, { status: 400 }) + + // 2. Confirm the funds were delivered to your address. + const delivery = parseEventLogs({ abi: Abis.tip20, eventName: 'Transfer', logs }) + .find((log) => + log.args.from.toLowerCase() === payment.args.from.toLowerCase() && + log.args.to.toLowerCase() === recipient.toLowerCase() && + log.args.amount === amount, + ) + if (!delivery) return Response.json({ error: 'Payment not delivered' }, { status: 400 }) + + // 3. Record each transfer once, so a replayed hash can't pay twice. + const eventKey = `${chain.id}:${receipt.transactionHash}:${delivery.logIndex}` + if (eventKeys.has(eventKey)) { + return Response.json({ error: 'Payment already used' }, { status: 409 }) + } + eventKeys.add(eventKey) + invoices.set(id, { ...invoice, status: 'paid', eventKey }) + + return Response.json(invoices.get(id)) +} +``` + +In production, save the event key and the invoice status in one database transaction, with a unique constraint on the event key. + +## Pay a test invoice + +Fund a payer account with AlphaUSD from the [faucet](/docs/quickstart/faucet) and set `PAYER_PRIVATE_KEY` to its key. Start the app with `npm run dev`, then run this script with `npx tsx pay-invoice.ts`. It creates an invoice, pays it with the memo, and asks the app to verify it. + +```ts [pay-invoice.ts] +import { parseUnits } from 'viem' +import { tempoModerato } from 'viem/chains' +import { Account, createClient } from 'viem/tempo' + +const client = createClient({ + account: Account.fromSecp256k1(process.env.PAYER_PRIVATE_KEY as `0x${string}`), + chain: tempoModerato, + feeToken: '0x20c0000000000000000000000000000000000001', +}) + +const invoice = await fetch('http://localhost:3000/api/invoices', { + method: 'POST', + body: JSON.stringify({ amount: '25.00' }), +}).then((res) => res.json()) + +const { receipt } = await client.token.transferSync({ + token: invoice.token, + to: invoice.recipient, + amount: parseUnits(invoice.amount, 6), + memo: invoice.memo, +}) + +const result = await fetch(`http://localhost:3000/api/invoices/${invoice.id}/verify`, { + method: 'POST', + body: JSON.stringify({ hash: receipt.transactionHash }), +}).then((res) => res.json()) + +console.log(result) +// { id: 'INV-…', amount: '25.00', status: 'paid', eventKey: '42431:0x…:29' } +``` + +In a browser checkout, send the same transfer with the Wagmi `Hooks.token.useTransferSync` hook. See [Browser payments](/docs/guide/payments/send-a-payment/browser). + +:::: + +## Go to mainnet + +- Change `tempoModerato` to `tempo` in `lib/tempo.ts`. +- Set `token` to the mainnet stablecoin you accept. Find contract addresses in [Stablecoins on Tempo](/get-started/stablecoins). +- Store invoices and event keys in your database. +- To credit payments without waiting for the customer to report a hash, subscribe to [webhooks](/docs/api/webhooks) or watch transfers to your address as shown in [Receive payments](/docs/guide/payments/accept-a-payment#detect-payments). + +## Next.js payment questions + +### Does the customer need to report the transaction hash? + +No. This guide uses the hash because it is the shortest path to a working checkout. A backend can also find payments by watching `TransferWithMemo` events to the recipient address, or by receiving [webhooks](/docs/api/webhooks), and then run the same checks. + +### Can customers get a dedicated deposit address instead of a memo? + +Yes. [Customer deposit addresses](/docs/guide/payments/virtual-addresses) give each customer an address that credits your account, so the payer doesn't need to attach a memo. + +### Who pays the network fee? + +The payer pays the fee in a supported stablecoin. To cover it for your customers, [sponsor fees](/docs/api/fee-payer) from your backend. diff --git a/src/pages/docs/resources/guides/charge-for-an-api-express.mdx b/src/pages/docs/resources/guides/charge-for-an-api-express.mdx new file mode 100644 index 000000000..ad4718d1d --- /dev/null +++ b/src/pages/docs/resources/guides/charge-for-an-api-express.mdx @@ -0,0 +1,129 @@ +--- +title: "Charge for an API with MPP in Express" +seoTitle: "Charge for an API in Express with MPP | Tempo Docs" +description: "Charge per request for an Express route with the mppx middleware, settle stablecoin payments on Tempo, and test with a paying client." +--- + +[Guides](/docs/resources/guides) / [HTTP 402 Payment Required](/docs/resources/guides/402-payment-required) / Express + +# Charge for an API with MPP in Express + +This guide charges $0.01 per request for an Express route with the [Machine Payments Protocol](/docs/agents) (MPP). The `mppx` middleware answers unpaid requests with HTTP `402 Payment Required`, verifies the client's stablecoin payment on Tempo, and then runs your handler. Clients and agents pay inside the request, with no account signup or API key. + +The examples use OUSD on Moderato testnet. + +::::steps + +## Install mppx + +```bash [Terminal] +npm install express mppx viem +``` + +## Set the server secret and recipient + +`mppx` signs payment challenges with a server secret. Generate one, and set `TEMPO_RECIPIENT` to the address that receives payments. + +```bash [Terminal] +export MPP_SECRET_KEY="$(openssl rand -base64 32)" +export TEMPO_RECIPIENT= +``` + +Store `MPP_SECRET_KEY` in your secret manager and use the same value on every server instance. + +## Add a paid route + +Pass `mppx.charge()` as route middleware. The handler runs only after the payment is verified, and the response carries a `Payment-Receipt` header. + +```ts [server.ts] +import express from 'express' +import { Mppx, tempo } from 'mppx/express' + +const recipient = process.env.TEMPO_RECIPIENT as `0x${string}` +if (!recipient) throw new Error('Set TEMPO_RECIPIENT to the address that receives payments') + +const app = express() + +const mppx = Mppx.create({ + methods: [ + tempo({ + currency: '0x20c0000000000000000000006a37da5c996874be', // OUSD + recipient, + testnet: true, // Moderato testnet. Remove for mainnet. + }), + ], +}) + +// Free route +app.get('/health', (req, res) => { + res.json({ ok: true }) +}) + +// Paid route: $0.01 per request +app.get( + '/api/forecast', + mppx.charge({ amount: '0.01', description: 'Weather forecast' }), + (req, res) => { + res.json({ city: 'Lisbon', forecast: 'Sunny, 22°C' }) + }, +) + +app.listen(3000, () => console.log('Listening on http://localhost:3000')) +``` + +Start the server with `npx tsx server.ts`. A request without payment returns the challenge: + +```bash [Terminal] +curl -i http://localhost:3000/api/forecast +# HTTP/1.1 402 Payment Required +# www-authenticate: Payment id="…", realm="localhost", method="tempo", intent="charge", … +``` + +## Pay from a client + +Fund a payer account with OUSD from the [faucet](/docs/quickstart/faucet) and set `PAYER_PRIVATE_KEY` to its key. `mppx.fetch` reads the `402` challenge, pays it, and retries the request. + +```ts [client.ts] +import { Receipt } from 'mppx' +import { Mppx, tempo } from 'mppx/client' +import { privateKeyToAccount } from 'viem/accounts' + +const account = privateKeyToAccount(process.env.PAYER_PRIVATE_KEY as `0x${string}`) + +const mppx = Mppx.create({ + polyfill: false, + methods: [tempo({ account, allowedChainIds: [42431] })], // Moderato testnet +}) + +const response = await mppx.fetch('http://localhost:3000/api/forecast') +console.log(response.status, await response.json()) +// 200 { city: 'Lisbon', forecast: 'Sunny, 22°C' } + +const receipt = Receipt.fromResponse(response) +console.log(receipt.status, receipt.reference) +// success 0x… +``` + +Agents with the [Tempo CLI](/docs/cli/request) can call the same route with `tempo request`. + +:::: + +## Go to mainnet + +- Remove `testnet: true` from `tempo()`, and set `allowedChainIds: [4217]` on clients. +- Set `realm` in `Mppx.create()` or the `MPP_REALM` variable to your public domain when the server sits behind a proxy. See [MPP payment realm](/docs/guide/machine-payments/server#mpp-payment-realm). +- To pay clients' network fees, pass a `feePayer` account to `tempo()`. See [MPP fee sponsorship](/docs/guide/machine-payments/server#mpp-fee-sponsorship). + +## Express MPP questions + +### Can different routes charge different prices? + +Yes. Pass a different `amount` to each `mppx.charge()` call. You can also override `currency` and `recipient` per route. + +### How do I bill high-frequency calls without a transaction per request? + +Use a payment session. The client opens a channel once and signs vouchers for each request. See [pay-as-you-go payments](/docs/guide/machine-payments/pay-as-you-go). + +### Does this work with other Node.js frameworks? + +Yes. `mppx` ships middleware for [Next.js](/docs/resources/guides/charge-for-an-api-nextjs), Hono, and Elysia, and `mppx/server` works with any Fetch API server. See [Accept payments for an API](/docs/guide/machine-payments/server). diff --git a/src/pages/docs/resources/guides/charge-for-an-api-nextjs.mdx b/src/pages/docs/resources/guides/charge-for-an-api-nextjs.mdx new file mode 100644 index 000000000..ee5ca9d85 --- /dev/null +++ b/src/pages/docs/resources/guides/charge-for-an-api-nextjs.mdx @@ -0,0 +1,122 @@ +--- +title: "Charge for an API route with MPP in Next.js" +seoTitle: "Charge for a Next.js API Route with MPP | Tempo Docs" +description: "Charge per request for a Next.js App Router route handler with mppx, settle stablecoin payments on Tempo, and test with a paying client." +--- + +[Guides](/docs/resources/guides) / [HTTP 402 Payment Required](/docs/resources/guides/402-payment-required) / Next.js + +# Charge for an API route with MPP in Next.js + +This guide charges $0.01 per request for a Next.js App Router route handler with the [Machine Payments Protocol](/docs/agents) (MPP). `mppx` wraps the handler: unpaid requests get HTTP `402 Payment Required`, and the handler runs once the client's stablecoin payment on Tempo is verified. Clients and agents pay inside the request, with no account signup or API key. + +The examples use OUSD on Moderato testnet and require Next.js 16.2.6 or later. + +::::steps + +## Install mppx + +```bash [Terminal] +npm install mppx viem +``` + +## Load mppx outside the bundler + +Add `mppx` to `serverExternalPackages`. Without it, Turbopack tries to bundle optional `mppx` integrations and the route fails with `Can't resolve '@modelcontextprotocol/sdk/types.js'`. + +```ts [next.config.ts] +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + serverExternalPackages: ['mppx'], +} + +export default nextConfig +``` + +## Set the server secret and recipient + +Add these to `.env.local` and to your deployment's environment variables. `MPP_SECRET_KEY` signs payment challenges; generate it with `openssl rand -base64 32`. `TEMPO_RECIPIENT` is the address that receives payments. + +```bash [.env.local] +MPP_SECRET_KEY= +TEMPO_RECIPIENT= +``` + +## Create a shared payment handler + +```ts [lib/mppx.ts] +import { Mppx, tempo } from 'mppx/nextjs' + +export const mppx = Mppx.create({ + methods: [ + tempo({ + currency: '0x20c0000000000000000000006a37da5c996874be', // OUSD + recipient: process.env.TEMPO_RECIPIENT as `0x${string}`, + testnet: true, // Moderato testnet. Remove for mainnet. + }), + ], +}) +``` + +## Wrap a route handler + +`mppx.charge()` returns a wrapper for the handler. The response carries a `Payment-Receipt` header. + +```ts [app/api/forecast/route.ts] +import { mppx } from '@/lib/mppx' + +// $0.01 per request +export const GET = mppx.charge({ amount: '0.01', description: 'Weather forecast' })( + () => Response.json({ city: 'Lisbon', forecast: 'Sunny, 22°C' }), +) +``` + +Start the app with `npm run dev`. `curl -i http://localhost:3000/api/forecast` returns `402 Payment Required` with the payment challenge in the `www-authenticate` header. + +## Pay from a client + +Fund a payer account with OUSD from the [faucet](/docs/quickstart/faucet) and set `PAYER_PRIVATE_KEY` to its key. Run this script with `npx tsx client.ts`. + +```ts [client.ts] +import { Receipt } from 'mppx' +import { Mppx, tempo } from 'mppx/client' +import { privateKeyToAccount } from 'viem/accounts' + +const account = privateKeyToAccount(process.env.PAYER_PRIVATE_KEY as `0x${string}`) + +const mppx = Mppx.create({ + polyfill: false, + methods: [tempo({ account, allowedChainIds: [42431] })], // Moderato testnet +}) + +const response = await mppx.fetch('http://localhost:3000/api/forecast') +console.log(response.status, await response.json()) +// 200 { city: 'Lisbon', forecast: 'Sunny, 22°C' } + +const receipt = Receipt.fromResponse(response) +console.log(receipt.status, receipt.reference) +// success 0x… +``` + +:::: + +## Go to mainnet + +- Remove `testnet: true` from `tempo()`, and set `allowedChainIds: [4217]` on clients. +- On Vercel, `mppx` uses `VERCEL_URL` as the payment realm. Set `MPP_REALM` to your public domain when you serve the API from a custom domain. See [MPP payment realm](/docs/guide/machine-payments/server#mpp-payment-realm). +- To pay clients' network fees, pass a `feePayer` account to `tempo()`. See [MPP fee sponsorship](/docs/guide/machine-payments/server#mpp-fee-sponsorship). + +## Next.js MPP questions + +### Does this work in the Pages Router? + +`mppx/nextjs` wraps App Router route handlers, which take a `Request` and return a `Response`. For other handlers, call `mppx/server` directly as shown in [Manual MPP server mode](/docs/guide/machine-payments/server#manual-mpp-server-mode). + +### Can different routes charge different prices? + +Yes. Pass a different `amount` to each `mppx.charge()` call. You can also override `currency` and `recipient` per route. + +### How do I bill high-frequency calls without a transaction per request? + +Use a payment session. The client opens a channel once and signs vouchers for each request. See [pay-as-you-go payments](/docs/guide/machine-payments/pay-as-you-go). diff --git a/src/pages/docs/resources/guides/global-payouts.mdx b/src/pages/docs/resources/guides/global-payouts.mdx index 2cc92b4f1..27eae9403 100644 --- a/src/pages/docs/resources/guides/global-payouts.mdx +++ b/src/pages/docs/resources/guides/global-payouts.mdx @@ -72,6 +72,11 @@ Marketplace payouts need seller eligibility, release rules, and traceable order For urgent or humanitarian payouts, verify connectivity, recipient access, and an actual way to use the money. +## Send payouts by language + +- [Send stablecoin payouts with Node.js](/docs/resources/guides/stablecoin-payouts-nodejs) +- [Send stablecoin payouts with Python](/docs/resources/guides/stablecoin-payouts-python) + ## Global payout questions ### Does the business have to hold stablecoins? diff --git a/src/pages/docs/resources/guides/how-to-accept-stablecoin-payments.mdx b/src/pages/docs/resources/guides/how-to-accept-stablecoin-payments.mdx index 536ad47d7..c5065338c 100644 --- a/src/pages/docs/resources/guides/how-to-accept-stablecoin-payments.mdx +++ b/src/pages/docs/resources/guides/how-to-accept-stablecoin-payments.mdx @@ -86,6 +86,10 @@ Check that each delivered payment maps to one invoice and ERP record, with fees ::::: +## Accept stablecoin payments by framework + +- [Accept stablecoin payments in Next.js](/docs/resources/guides/accept-stablecoin-payments-nextjs): create an invoice with a payment reference and verify the transfer in a route handler. + ## Payment acceptance questions ### Do stablecoin payments replace ACH and wires? diff --git a/src/pages/docs/resources/guides/stablecoin-payouts-nodejs.mdx b/src/pages/docs/resources/guides/stablecoin-payouts-nodejs.mdx new file mode 100644 index 000000000..ba6d96c29 --- /dev/null +++ b/src/pages/docs/resources/guides/stablecoin-payouts-nodejs.mdx @@ -0,0 +1,99 @@ +--- +title: "Send stablecoin payouts with Node.js" +seoTitle: "Send Stablecoin Payouts with Node.js | Tempo Docs" +description: "Pay a list of recipients from a CSV file in one Tempo transaction with Viem, with a payment reference on every transfer." +--- + +[Guides](/docs/resources/guides) / [Plan global payouts](/docs/resources/guides/global-payouts) / Node.js + +# Send stablecoin payouts with Node.js + +This guide pays every recipient in a CSV file with one Tempo transaction, using [Viem](/docs/sdk/typescript). Each transfer carries a payment reference, such as a payout ID, so recipients and your ledger can match it. The batch settles onchain in under a second, and if any transfer fails, the whole batch reverts. + +The examples use AlphaUSD on Moderato testnet. + +::::steps + +## Install Viem + +```bash [Terminal] +npm install viem +``` + +## Prepare the payout file + +Each row has the recipient address, the amount in dollars, and a reference of up to 32 bytes. + +```csv [payouts.csv] +address,amount,reference +0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbb,125.50,PAY-2026-11-001 +0x70997970C51812dc3A010C7d01b50e0d17dc79C8,80,PAY-2026-11-002 +``` + +## Send the batch + +Fund a sender account with AlphaUSD from the [faucet](/docs/quickstart/faucet) and set `TEMPO_PRIVATE_KEY` to its key. The script turns each row into a transfer call and sends all calls in one [Tempo Transaction](/docs/guide/tempo-transaction). + +```ts [payouts.ts] +import { readFileSync } from 'node:fs' +import { parseUnits, toHex } from 'viem' +import { tempoModerato } from 'viem/chains' +import { Account, Actions, createClient } from 'viem/tempo' + +const privateKey = process.env.TEMPO_PRIVATE_KEY as `0x${string}` +const token = '0x20c0000000000000000000000000000000000001' // AlphaUSD on Moderato + +const client = createClient({ + account: Account.fromSecp256k1(privateKey), + chain: tempoModerato, + feeToken: token, +}) + +// address,amount,reference +const rows = readFileSync('payouts.csv', 'utf8').trim().split('\n').slice(1) + +const calls = rows.map((row) => { + const [to, amount, reference] = row.split(',') + if (new TextEncoder().encode(reference).length > 32) { + throw new Error(`Reference longer than 32 bytes: ${reference}`) + } + return Actions.token.transfer.call({ + token, + to: to as `0x${string}`, + amount: parseUnits(amount, 6), + memo: toHex(reference, { size: 32 }), + }) +}) + +const hash = await client.sendTransaction({ calls }) +const receipt = await client.waitForTransactionReceipt({ hash }) +if (receipt.status !== 'success') throw new Error(`Payout batch failed: ${hash}`) + +console.log(`Paid ${calls.length} recipients in ${hash}`) +``` + +Run it with `npx tsx payouts.ts`. Look up the hash in the [testnet explorer](https://explore.testnet.tempo.xyz) to see each transfer and its memo. + +:::: + +## Run payouts in production + +- Store the transaction hash with each payout ID before you mark payouts as sent. +- Split large files into batches of a few hundred transfers. A transfer to an address that has never held the token uses more gas than a repeat transfer, and one transaction can't exceed the block gas limit. +- To send transfers as independent transactions, so one failure doesn't revert the rest, use [parallel transactions](/docs/guide/payments/send-parallel-transactions). +- For recipients who want local currency in a bank account, use a payout provider. See [Fund accounts and pay out](/docs/resources/guides/funding-and-payouts). +- On mainnet, change `tempoModerato` to `tempo` and use the stablecoin you hold. Find contract addresses in [Stablecoins on Tempo](/get-started/stablecoins). + +## Node.js payout questions + +### What does a payout batch cost? + +The batch pays one network fee in a stablecoin, and a standard transfer costs less than a cent. See [Understanding transaction fees](/docs/protocol/fees) for the current base fee. + +### What happens if one address in the file is wrong? + +A malformed address fails before sending. A transfer that reverts onchain, for example because a [receive policy](/docs/accounts/policies/receive-policies) rejects it, reverts the whole batch, and no recipient is paid. Fix the row and send the batch again. + +### Can recipients see the payout reference? + +Yes. The memo is part of the `TransferWithMemo` event, so wallets, explorers, and the recipient's own reconciliation can read it. diff --git a/src/pages/docs/resources/guides/stablecoin-payouts-python.mdx b/src/pages/docs/resources/guides/stablecoin-payouts-python.mdx new file mode 100644 index 000000000..258cecb8d --- /dev/null +++ b/src/pages/docs/resources/guides/stablecoin-payouts-python.mdx @@ -0,0 +1,123 @@ +--- +title: "Send stablecoin payouts with Python" +seoTitle: "Send Stablecoin Payouts with Python | Tempo Docs" +description: "Pay a list of recipients from a CSV file in one Tempo transaction with the Python SDK, with a payment reference on every transfer." +--- + +[Guides](/docs/resources/guides) / [Plan global payouts](/docs/resources/guides/global-payouts) / Python + +# Send stablecoin payouts with Python + +This guide pays every recipient in a CSV file with one Tempo transaction, using the [Tempo Python SDK](/docs/sdk/python) for web3.py. Each transfer carries a payment reference, such as a payout ID, so recipients and your ledger can match it. The batch settles onchain in under a second, and if any transfer fails, the whole batch reverts. + +The examples use AlphaUSD on Moderato testnet and require Python 3.10 or later. + +::::steps + +## Install the Python SDK + +```bash [Terminal] +python -m pip install pytempo==0.6.1 +``` + +## Prepare the payout file + +Each row has the recipient address, the amount in dollars, and a reference of up to 32 bytes. + +```csv [payouts.csv] +address,amount,reference +0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbb,125.50,PAY-2026-11-001 +0x70997970C51812dc3A010C7d01b50e0d17dc79C8,80,PAY-2026-11-002 +``` + +## Send the batch + +Fund a sender account with AlphaUSD from the [faucet](/docs/quickstart/faucet) and set `TEMPO_PRIVATE_KEY` to its key. The script turns each row into a `transferWithMemo` call, estimates gas for the batch, and sends all calls in one [Tempo Transaction](/docs/guide/tempo-transaction). + +```python [payouts.py] +import csv +import os +from decimal import Decimal + +from web3 import Web3 +from pytempo import TempoTransaction +from pytempo.contracts import ALPHA_USD, TIP20 + +w3 = Web3(Web3.HTTPProvider("https://rpc.moderato.tempo.xyz")) +account = w3.eth.account.from_key(os.environ["TEMPO_PRIVATE_KEY"]) +token = TIP20(ALPHA_USD) + + +def memo(reference: str) -> bytes: + data = reference.encode("utf-8") + if len(data) > 32: + raise ValueError(f"Reference longer than 32 bytes: {reference}") + return data.ljust(32, b"\x00") + + +calls = [] +with open("payouts.csv", newline="") as f: + for row in csv.DictReader(f): + calls.append( + token.transfer_with_memo( + to=Web3.to_checksum_address(row["address"]), + amount=int(Decimal(row["amount"]) * 10**6), # 6 decimals + memo=memo(row["reference"]), + ) + ) + +# Transfers to new addresses cost more gas, so estimate the whole batch. +estimate = w3.provider.make_request( + "eth_estimateGas", + [{ + "from": account.address, + "calls": [{"to": w3.to_hex(c.to), "data": w3.to_hex(c.data), "value": "0x0"} for c in calls], + }], +) +if "error" in estimate: + raise RuntimeError(estimate["error"]) +gas_limit = int(int(estimate["result"], 16) * 1.2) # 20% headroom + +tx = TempoTransaction.create( + chain_id=w3.eth.chain_id, + gas_limit=gas_limit, + max_fee_per_gas=w3.eth.gas_price * 2, + max_priority_fee_per_gas=w3.eth.gas_price, + nonce=w3.eth.get_transaction_count(account.address), + fee_token=ALPHA_USD, + calls=tuple(calls), +) +signed = tx.sign(account.key.hex()) +tx_hash = w3.eth.send_raw_transaction(signed.encode()) +receipt = w3.eth.wait_for_transaction_receipt(tx_hash) +if receipt.status != 1: + raise RuntimeError(f"Payout batch failed: {w3.to_hex(tx_hash)}") + +print(f"Paid {len(calls)} recipients in {w3.to_hex(tx_hash)} (gas used: {receipt.gasUsed})") +``` + +Run it with `python payouts.py`. Look up the hash in the [testnet explorer](https://explore.testnet.tempo.xyz) to see each transfer and its memo. + +:::: + +## Run payouts in production + +- Store the transaction hash with each payout ID before you mark payouts as sent. +- Split large files into batches of a few hundred transfers. One transaction can't exceed the block gas limit. +- Keep amounts as `Decimal` or integer base units. Floats round incorrectly for money. +- For recipients who want local currency in a bank account, use a payout provider. See [Fund accounts and pay out](/docs/resources/guides/funding-and-payouts). +- On mainnet, connect to a mainnet RPC endpoint and use the stablecoin you hold. Find contract addresses in [Stablecoins on Tempo](/get-started/stablecoins). + +## Python payout questions + +### What does a payout batch cost? + +The batch pays one network fee in the stablecoin set with `fee_token`, and a standard transfer costs less than a cent. See [Understanding transaction fees](/docs/protocol/fees) for the current base fee. + +### What happens if one transfer fails? + +The whole batch reverts, and no recipient is paid. For example, a [receive policy](/docs/accounts/policies/receive-policies) can reject a transfer. Fix the row and send the batch again. To send transfers as independent transactions instead, use [parallel transactions](/docs/guide/payments/send-parallel-transactions). + +### Can recipients see the payout reference? + +Yes. The memo is part of the `TransferWithMemo` event, so wallets, explorers, and the recipient's own reconciliation can read it.