@curless/sinocare-demo 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2262 @@
1
+ #!/usr/bin/env node
2
+ // Buyer-side AP2: sign the PaymentMandate as a real ES256 JWS bound to the cart.
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ // Buyer-side MPP · Tempo — parse the 402 challenge, pay the on-chain TIP-20
8
+ // transfer, build the Payment credential. Same SDK, different subpath.
9
+ import { completeCheckout, purchaseOf, readCheckoutSession, totalOf, } from '@curless/agentbank-protocols/acp';
10
+ import { signIntentMandate, signPaymentMandate, } from '@curless/agentbank-protocols/ap2';
11
+ // Buyer-side USDC-over-Solana x402 — build + partially sign an SPL transfer, the
12
+ // facilitator co-signs feePayer + submits. Same SDK, different subpath.
13
+ import { addressFromSecretKey, secretKeyFromString, signSolanaX402Payment, } from '@curless/agentbank-protocols/solana';
14
+ import { payMppTempoChallenge } from '@curless/agentbank-protocols/tempo';
15
+ // Buyer-side x402 lives in the SDK — sign EIP-3009, encode X-PAYMENT, read USDC
16
+ // balance. The demo never hand-rolls the signing.
17
+ import { cdpAccountSigner, encodeXPaymentHeader, signX402Payment, signerFromPrivateKey, usdcBalance, } from '@curless/agentbank-protocols/x402';
18
+ // Buyer-side RLUSD/XRPL x402 — sign an Ed25519 Payment, encode X-PAYMENT. Same
19
+ // SDK, different subpath. encodeXPaymentHeader collides with /x402's, so alias it.
20
+ import { encodeXPaymentHeader as encodeXrplXPaymentHeader, signXrplPayment, } from '@curless/agentbank-protocols/xrpl';
21
+ import { createBuyerSession } from '@curless/agentbank-sdk';
22
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
23
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
24
+ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
25
+ import { generatePrivateKey } from 'viem/accounts';
26
+ import { Client as XrplClient, Wallet as XrplWallet } from 'xrpl';
27
+ // The checkout MCP App card (a single self-contained HTML), built from
28
+ // examples/sinocare-demo/widget and served as a ui:// resource.
29
+ import { CHECKOUT_CARD_HTML } from './checkout-card.generated.js';
30
+ // @curless/sinocare-demo — a SIMULATED "Sinocare" storefront MCP for the BUYER.
31
+ // Booking is end-to-end agentic:
32
+ // 1. browse + start a booking via Sinocare's merchant backend
33
+ // (mcp.curless.ai/sinocare) — the merchant prices it and opens a
34
+ // merchant-quoted ACP checkout session with its own key;
35
+ // 2. THIS buyer agent completes the charge over ACP with its own payment
36
+ // credential (test card) — the merchant set the price, the agent pays.
37
+ // The storefront never sees the merchant's key; it holds only its OWN buyer
38
+ // token, exactly like a real agent buyer.
39
+ //
40
+ // Run (Claude Desktop / any MCP client):
41
+ // npx -y @curless/sinocare-demo
42
+ // with env:
43
+ // AGENTBANK_AGENT_TOKEN the buyer's agentbank agent key (agb_…, agent:execute).
44
+ // SINOCARE_BUYER_NAME / SINOCARE_BUYER_EMAIL who this agent buys for (optional;
45
+ // unset = anonymous bookings, as before)
46
+ // Use a TEST, no-org key (the demo org is shared).
47
+ // AGENTBANK_API_URL base URL (default https://mcp.curless.ai; set
48
+ // http://localhost:3000 for local dev).
49
+ // AGENTBANK_PAYMENT_TOKEN delegated card token (default pm_card_visa).
50
+ // Real ACP credential (optional) — mint a Shared Payment Token per booking
51
+ // instead of reusing a plain card token. This is what a buyer's WALLET does
52
+ // when an agent asks to spend: the token is scoped to one seller, one amount,
53
+ // one currency and a short expiry, so it can buy nothing else.
54
+ // STRIPE_AGENT_SECRET_KEY the BUYER side's Stripe key (the wallet's PSP).
55
+ // Must be a DIFFERENT Stripe account from the
56
+ // seller's — an SPT cannot be granted to yourself.
57
+ // AGENTBANK_SELLER_PROFILE the seller's Stripe profile id (profile_…) that
58
+ // the gateway redeems with.
59
+ // STRIPE_AGENT_PAYMENT_METHOD optional pm_… to mint from; in test mode a
60
+ // throwaway one is created from tok_visa.
61
+ // Set all of these and buy_sinocare_product_acp pays with a real spt_; leave
62
+ // them unset and it falls back to AGENTBANK_PAYMENT_TOKEN.
63
+ // Two guards, both OFF by default — a booking here is one click in the card,
64
+ // and a Sinocare room is €1,280–2,900:
65
+ // SINOCARE_ALLOW_LIVE_WALLET=1 allow minting with a LIVE key (REAL money).
66
+ // SINOCARE_WALLET_FALLBACK=1 if minting fails, pay with the plain card
67
+ // token instead of failing the booking.
68
+ // Without them a live key refuses to mint, and a mint failure fails the
69
+ // booking rather than silently paying with an unscoped credential.
70
+ // x402 USDC (EVM) — pick ONE signer:
71
+ // X402_SIGNER=cdp a CDP Server Wallet, zero raw key (for MAINNET);
72
+ // needs CDP_API_KEY_ID/_SECRET/_WALLET_SECRET.
73
+ // X402_PAYER_PRIVATE_KEY a local funded wallet key (testnet or mainnet).
74
+ // The gateway's mode (test/live key) decides the network it quotes; fund the
75
+ // buyer wallet with USDC on that network.
76
+ // x402 RLUSD (XRP Ledger) — for buy_sinocare_product_rlusd / xrpl_buyer_wallet:
77
+ // XRPL_BUYER_SEED an `sEd…` testnet seed (Ed25519) holding RLUSD + a
78
+ // little test XRP for the fee/reserve.
79
+ // XRPL_TESTNET_WSS RPC (default wss://s.altnet.rippletest.net:51233).
80
+ // XRPL_RLUSD_ISSUER RLUSD issuer r-address (default = testnet issuer).
81
+ //
82
+ // IMPORTANT: only JSON-RPC may be written to stdout (the MCP stdio transport).
83
+ // Never console.log here — diagnostics go to stderr.
84
+ const API_BASE = (process.env.AGENTBANK_API_URL ?? 'https://mcp.curless.ai').replace(/\/$/, '');
85
+ const SINOCARE = `${API_BASE}/sinocare`;
86
+ // The buyer's ACP client. Every booking below reads the session's real total
87
+ // and completes through this rather than hand-writing /acp/... — the two calls
88
+ // were written out twice already, and both copies re-derived that the
89
+ // authoritative amount is on the session, not in the backend's display string.
90
+ const acp = (merchantId) => {
91
+ // Throw rather than send an empty bearer. Every caller below already refuses
92
+ // to open a checkout without a token, so reaching here without one is a bug
93
+ // in this file — and an empty Authorization header would surface as a 401
94
+ // from the gateway, which reads like a credential problem on the buyer's end.
95
+ if (!TOKEN)
96
+ throw new Error('AGENTBANK_AGENT_TOKEN is not set');
97
+ return { baseUrl: API_BASE, merchantId, token: TOKEN };
98
+ };
99
+ const TOKEN = process.env.AGENTBANK_AGENT_TOKEN;
100
+ // WHO this agent is buying for. A buyer agent is the only party that knows its
101
+ // user's identity — the merchant never meets them — so it is the only place this
102
+ // can enter the system. Sent on every checkout, and from there it reaches the
103
+ // order, the card provider's customer record, the Curless mirror and the Shopify
104
+ // order.
105
+ //
106
+ // Unset → nothing is sent, and every booking stays anonymous exactly as before.
107
+ // Deliberately NOT defaulted to a placeholder: a fabricated buyer would travel
108
+ // into a merchant's financial records and Shopify would email the address.
109
+ const BUYER = (() => {
110
+ const name = process.env.SINOCARE_BUYER_NAME?.trim();
111
+ const email = process.env.SINOCARE_BUYER_EMAIL?.trim();
112
+ if (!name && !email)
113
+ return undefined;
114
+ return { ...(name ? { name } : {}), ...(email ? { email } : {}) };
115
+ })();
116
+ const PAYMENT_TOKEN = process.env.AGENTBANK_PAYMENT_TOKEN ?? 'pm_card_visa';
117
+ const STRIPE_AGENT_KEY = process.env.STRIPE_AGENT_SECRET_KEY;
118
+ const SELLER_PROFILE = process.env.AGENTBANK_SELLER_PROFILE;
119
+ // The BUYER's Curless wallet, signed into at runtime by the person booking —
120
+ // not configuration. Two ways to pay by card now sit side by side: the agent's
121
+ // own Stripe key (STRIPE_AGENT_SECRET_KEY above, the demo's original path), and
122
+ // this, where the credential is minted from the card the buyer bound and capped
123
+ // by the limits the buyer set. Both stay: the wallet is the one being built,
124
+ // and the agent-key path is what still works when nobody has signed in.
125
+ const wallet = createBuyerSession({ baseUrl: API_BASE });
126
+ // One browser sign-in at a time, shared by whoever asks. Two cards (or a card
127
+ // and the model) each starting their own would show the person two different
128
+ // codes for the same wallet, and only one of them would ever complete.
129
+ let pending = null;
130
+ /**
131
+ * The signed-in wallet owner, as a buyer block — or undefined when nobody is
132
+ * signed in (the agent-key ACP path, which keeps SINOCARE_BUYER_EMAIL).
133
+ */
134
+ const walletBuyer = () => {
135
+ const who = wallet.current();
136
+ if (!who)
137
+ return undefined;
138
+ return { buyer: { email: who.email, ...(who.name ? { name: who.name } : {}) } };
139
+ };
140
+ // Whether a sign-in was ever STARTED here. `pending` alone cannot say: it is
141
+ // null both before anyone begins and after one times out, and reporting those
142
+ // two as the same thing is how "nobody has signed in yet" got told to someone
143
+ // as "your code expired".
144
+ let loginEverStarted = false;
145
+ const pendingLogin = async () => {
146
+ if (pending)
147
+ return pending;
148
+ const started = await wallet.startDeviceLogin();
149
+ pending = started;
150
+ loginEverStarted = true;
151
+ // The session adopts itself when the person finishes; this just clears the
152
+ // slot so a later sign-in starts fresh. Errors are the poller's to report —
153
+ // swallowing them here would leave a dead login pinned forever.
154
+ started
155
+ .wait()
156
+ .catch(() => undefined)
157
+ .finally(() => {
158
+ pending = null;
159
+ });
160
+ return started;
161
+ };
162
+ // The gateway's spend-policy reasons, said to the PERSON. Mirrors the codes
163
+ // `evaluatePolicy` emits; anything unmapped falls through to the gateway's own
164
+ // wording rather than being swallowed, so a new code shows up as English text
165
+ // instead of disappearing.
166
+ // The gateway's refusals, said to the PERSON — each with its OWN next step.
167
+ // One shared "想付的话可以先调高限额" tail was wrong for every reason that is
168
+ // not a limit: it told someone whose wallet has no card at all to go raise a
169
+ // cap, which sends them to the wrong screen and leaves the actual problem
170
+ // unmentioned.
171
+ const REFUSALS = [
172
+ [/per_transaction_limit/, '超过了你设的单笔上限', '可以在钱包里调高单笔上限。'],
173
+ [/daily_limit/, '超过了你设的当日上限', '可以调高当日上限,或者明天再来。'],
174
+ [/monthly_limit/, '超过了你设的当月上限', '可以调高当月上限,或者下个月再来。'],
175
+ [/merchant_not_allowed/, '这个商家不在你的白名单里', '把它加进白名单就能付。'],
176
+ [/mcc_not_allowed/, '这个商家类别不在你的白名单里', '把这个类别加进白名单就能付。'],
177
+ [
178
+ /no approval step yet/,
179
+ '金额超过了你设的"需要我批准"的门槛,而现在还没有审批环节',
180
+ '把这个门槛调高或清掉就能付。',
181
+ ],
182
+ [/no card is bound/, '这个钱包还没绑卡', '去钱包页面绑一张卡再回来付。'],
183
+ ];
184
+ // A wallet refusal is not a payment failure, and the difference matters to the
185
+ // person: nothing was charged. Rendered through the card's `message`, so it has
186
+ // to be JSON and it has to be readable.
187
+ const walletRefusal = (err) => {
188
+ const raw = err.message;
189
+ const hit = REFUSALS.find(([re]) => re.test(raw));
190
+ return {
191
+ content: [
192
+ {
193
+ type: 'text',
194
+ text: JSON.stringify({
195
+ walletRefused: true,
196
+ message: hit
197
+ ? `钱包没付这笔 —— ${hit[1]}。没有扣款。${hit[2]}`
198
+ : `钱包没付这笔:${raw}。没有扣款。`,
199
+ // The wallet page is where every one of these is fixed, so the card
200
+ // can hand the person somewhere to go rather than a dead end.
201
+ walletUrl: `${API_BASE}/buyer/wallet`,
202
+ next: "this is the BUYER'S OWN limit, not a payment failure. Tell them which limit stopped it and offer to raise it with wallet_set_limits — do NOT retry with another payment method.",
203
+ }),
204
+ },
205
+ ],
206
+ isError: true,
207
+ };
208
+ };
209
+ const AGENT_PM = process.env.STRIPE_AGENT_PAYMENT_METHOD;
210
+ const STRIPE_AGENT_SECRET_KEY_IS_LIVE = !!STRIPE_AGENT_KEY?.startsWith('sk_live_');
211
+ // Two deliberate escape hatches, both off by default:
212
+ // SINOCARE_ALLOW_LIVE_WALLET=1 — mint with a live key (real money, see below).
213
+ // SINOCARE_WALLET_FALLBACK=1 — if minting fails, pay with the plain card
214
+ // token anyway instead of failing the booking.
215
+ const WALLET_LIVE_OK = process.env.SINOCARE_ALLOW_LIVE_WALLET === '1';
216
+ const WALLET_FALLBACK_OK = process.env.SINOCARE_WALLET_FALLBACK === '1';
217
+ // Mint a REAL ACP Shared Payment Token for exactly this booking — the buyer
218
+ // wallet's half of agentic commerce. Scoped to the seller's profile, this
219
+ // amount, this currency, 15 minutes; the agent can spend it nowhere else.
220
+ // Returns null when the wallet side isn't configured, so the demo falls back to
221
+ // the plain card token it has always used. Plain fetch — no Stripe SDK here.
222
+ // ⚠️ The throwaway card is minted FRESH per booking, deliberately. A
223
+ // PaymentMethod built from a single-use token (tok_visa) and never attached to
224
+ // a Customer is CONSUMED once a PaymentIntent draws on it — Stripe then refuses
225
+ // to issue an SPT against it ("The payment method is in a consumed or deleted
226
+ // state"). Caching one per process therefore broke every booking after the
227
+ // first. One extra test-mode API call per booking is the price of a wallet that
228
+ // works twice.
229
+ const mintSharedPaymentToken = async (amountMinor, currency) => {
230
+ if (!STRIPE_AGENT_KEY || !SELLER_PROFILE)
231
+ return null;
232
+ // A live key here means a booking charges a REAL card for the full resort
233
+ // price (€1,280–2,900) the moment someone clicks pay in the card — with no
234
+ // confirmation step anywhere. Refuse unless that is explicitly what you want.
235
+ if (STRIPE_AGENT_SECRET_KEY_IS_LIVE && !WALLET_LIVE_OK) {
236
+ throw new Error('refusing to mint with a LIVE Stripe key — a booking would charge a real card for the full resort price. Set SINOCARE_ALLOW_LIVE_WALLET=1 if that is genuinely intended.');
237
+ }
238
+ const post = async (path, body, preview = false) => {
239
+ const res = await fetch(`https://api.stripe.com/v1/${path}`, {
240
+ method: 'POST',
241
+ headers: {
242
+ authorization: `Bearer ${STRIPE_AGENT_KEY}`,
243
+ 'content-type': 'application/x-www-form-urlencoded',
244
+ ...(preview ? { 'stripe-version': '2026-04-22.preview' } : {}),
245
+ },
246
+ body: new URLSearchParams(body),
247
+ });
248
+ const json = (await res.json());
249
+ if (!res.ok)
250
+ throw new Error(`stripe ${path} -> ${res.status}: ${json.error?.message ?? ''}`);
251
+ return json;
252
+ };
253
+ // The card the token draws on. A real wallet uses the customer's saved card;
254
+ // in test mode a fresh throwaway one stands in for every booking (see above).
255
+ const pm = AGENT_PM ??
256
+ String((await post('payment_methods', { type: 'card', 'card[token]': 'tok_visa' })).id);
257
+ const spt = await post('shared_payment/issued_tokens', {
258
+ payment_method: pm,
259
+ 'seller_details[network_business_profile]': SELLER_PROFILE,
260
+ 'usage_limits[currency]': currency.toLowerCase(),
261
+ 'usage_limits[max_amount]': String(amountMinor),
262
+ 'usage_limits[expires_at]': String(Math.floor(Date.now() / 1000) + 900),
263
+ }, true).catch((err) => {
264
+ // A PINNED card hits the same one-shot wall, and there we can't just mint a
265
+ // new one — say so instead of surfacing Stripe's bare message.
266
+ if (AGENT_PM && /consumed or deleted/i.test(String(err))) {
267
+ throw new Error(`STRIPE_AGENT_PAYMENT_METHOD (${AGENT_PM}) is spent — a payment method built from a single-use token can back exactly one charge. Unset it and the demo mints a fresh throwaway card per booking, or pin one attached to a Customer.`);
268
+ }
269
+ throw err;
270
+ });
271
+ return String(spt.id);
272
+ };
273
+ // Sinocare backend (public — no buyer token; the merchant key lives there).
274
+ const backend = async (path, init = {}) => {
275
+ const res = await fetch(`${SINOCARE}${path}`, {
276
+ ...init,
277
+ headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
278
+ });
279
+ if (!res.ok) {
280
+ throw new Error(`sinocare-backend ${path} -> ${res.status}: ${await res.text().catch(() => '')}`);
281
+ }
282
+ return (await res.json());
283
+ };
284
+ // --- x402 (stablecoin) payment --------------------------------------------
285
+ // x402 settles USDC. The amount is now MERCHANT-QUOTED: sinocare-backend opens a
286
+ // merchant-quoted x402 session via the SDK and sets the USDC price (a small
287
+ // fixed test charge on testnet). The buyer just signs + pays the session — it
288
+ // never computes or passes the amount.
289
+ // The buyer's x402 signer, resolved once and cached:
290
+ // • X402_SIGNER=cdp → a Coinbase CDP Server Wallet (zero raw key; for
291
+ // mainnet — needs CDP_API_KEY_ID/_SECRET/_WALLET_SECRET)
292
+ // • X402_PAYER_PRIVATE_KEY → a local key (testnet/mainnet)
293
+ // • neither → null (signX402 falls back to a throwaway key,
294
+ // which only settles under a STUB facilitator)
295
+ // Returning the Eip712Signer (address + signTypedData) lets both the balance
296
+ // pre-flight and the wallet tool read .address regardless of signer kind.
297
+ const useCdpSigner = process.env.X402_SIGNER === 'cdp';
298
+ let buyerSignerPromise;
299
+ const buyerSigner = () => {
300
+ buyerSignerPromise ??= (async () => {
301
+ if (useCdpSigner)
302
+ return cdpAccountSigner();
303
+ const k = process.env.X402_PAYER_PRIVATE_KEY;
304
+ return k ? signerFromPrivateKey(k) : null;
305
+ })();
306
+ return buyerSignerPromise;
307
+ };
308
+ const fmtUsdc = (baseUnits) => `${(Number(baseUnits) / 1e6).toFixed(2)} USDC`;
309
+ // Answer an x402 402 challenge: sign an EIP-3009 transferWithAuthorization (via
310
+ // the buyer x402 SDK) → base64 X-PAYMENT header. With a REAL facilitator (Base
311
+ // Sepolia or mainnet) the signer must hold USDC on that chain — configure a CDP
312
+ // wallet or X402_PAYER_PRIVATE_KEY; without either a throwaway key signs, which
313
+ // only settles under a STUB facilitator.
314
+ const signX402 = async (r) => {
315
+ const signer = (await buyerSigner()) ?? (await signerFromPrivateKey(generatePrivateKey()));
316
+ return encodeXPaymentHeader(await signX402Payment({ signer, requirements: r }));
317
+ };
318
+ // --- RLUSD (XRP Ledger) x402 payment --------------------------------------
319
+ // The buyer's XRPL wallet, from XRPL_BUYER_SEED (an `sEd…` seed → Ed25519). A
320
+ // real buyer holds this in a wallet; the demo derives it from env. Null when
321
+ // unset — the RLUSD tool then tells the user to configure it. Unlike USDC there
322
+ // is no throwaway fallback: settling RLUSD needs a funded, trust-lined account.
323
+ const XRPL_SEED = process.env.XRPL_BUYER_SEED;
324
+ const XRPL_RPC = process.env.XRPL_TESTNET_WSS ?? 'wss://s.altnet.rippletest.net:51233';
325
+ let xrplWalletCache = null;
326
+ const xrplBuyer = () => {
327
+ if (xrplWalletCache)
328
+ return xrplWalletCache;
329
+ if (!XRPL_SEED)
330
+ return null;
331
+ xrplWalletCache = XrplWallet.fromSeed(XRPL_SEED);
332
+ return xrplWalletCache;
333
+ };
334
+ // USDC-over-Solana buyer: SOLANA_BUYER_SECRET is a devnet Solana secret key (a
335
+ // solana-keygen JSON array or a base58 string) holding devnet USDC. Cached as a
336
+ // promise (base58 decode is async). SOLANA_RPC fetches balances + a blockhash.
337
+ const SOLANA_RPC = process.env.SOLANA_RPC_URL ?? 'https://api.devnet.solana.com';
338
+ let solanaSecretPromise;
339
+ const solanaBuyerSecret = () => {
340
+ solanaSecretPromise ??= (async () => {
341
+ const s = process.env.SOLANA_BUYER_SECRET;
342
+ if (!s)
343
+ return null;
344
+ try {
345
+ return await secretKeyFromString(s);
346
+ }
347
+ catch {
348
+ return null;
349
+ }
350
+ })();
351
+ return solanaSecretPromise;
352
+ };
353
+ // The buyer's USDC balance (base units) across its token accounts for a mint, via
354
+ // a raw Solana JSON-RPC call (no SPL SDK). null on any RPC error → skip pre-flight.
355
+ const solanaUsdcBalance = async (owner, mint) => {
356
+ try {
357
+ const res = await fetch(SOLANA_RPC, {
358
+ method: 'POST',
359
+ headers: { 'content-type': 'application/json' },
360
+ body: JSON.stringify({
361
+ jsonrpc: '2.0',
362
+ id: 1,
363
+ method: 'getTokenAccountsByOwner',
364
+ params: [owner, { mint }, { encoding: 'jsonParsed' }],
365
+ }),
366
+ });
367
+ // biome-ignore lint/suspicious/noExplicitAny: raw JSON-RPC shape
368
+ const j = (await res.json());
369
+ const accounts = j?.result?.value ?? [];
370
+ let total = 0;
371
+ for (const a of accounts) {
372
+ total += Number(a?.account?.data?.parsed?.info?.tokenAmount?.amount ?? 0);
373
+ }
374
+ return total;
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ };
380
+ // Skyfire KYAPay — the BUYER mints a Skyfire-SIGNED PAY token (Skyfire's key signs
381
+ // it; unlike the crypto rails the buyer can't self-sign) and presents it to the
382
+ // MERCHANT backend, which charges it via the merchant SDK. SKYFIRE_BUYER_API_KEY
383
+ // mints (buyer agent key from the Skyfire dashboard); SKYFIRE_SELLER_SERVICE_ID is
384
+ // the seller service the payment authorizes. The agentbank key that verifies +
385
+ // charges is the MERCHANT backend's, not the demo's.
386
+ const SKYFIRE_API = process.env.SKYFIRE_API_URL ?? 'https://api.skyfire.xyz';
387
+ const SKYFIRE_BUYER_API_KEY = process.env.SKYFIRE_BUYER_API_KEY;
388
+ const SKYFIRE_SELLER_SERVICE_ID = process.env.SKYFIRE_SELLER_SERVICE_ID;
389
+ // A small fixed test charge (USD quote → settles USDC). Pin with SKYFIRE_TEST_AMOUNT.
390
+ // 0.001 keeps each test charge tiny so a small Skyfire balance covers many runs.
391
+ const SKYFIRE_TEST_AMOUNT = process.env.SKYFIRE_TEST_AMOUNT ?? '0.001';
392
+ // --- MPP · Tempo — on-chain TIP-20 settlement (Tempo payments chain, testnet) ---
393
+ // MPP's `tempo` method: agentbank answers with a 402 whose signed challenge carries
394
+ // the on-chain terms; the buyer wallet (TEMPO_BUYER_PRIVATE_KEY, funded pathUSD via
395
+ // the Tempo faucet) pays with transferWithMemo; agentbank verifies the transfer
396
+ // on-chain by tx hash. The buyer side (pay + credential) is the SDK
397
+ // @curless/agentbank-protocols/tempo — the demo only holds config. A tiny fixed
398
+ // amount so a small balance covers many runs.
399
+ const TEMPO_RPC = process.env.TEMPO_RPC_URL ?? 'https://rpc.moderato.tempo.xyz';
400
+ const TEMPO_BUYER_PRIVATE_KEY = process.env.TEMPO_BUYER_PRIVATE_KEY;
401
+ const MPP_TEMPO_TEST_AMOUNT = Number(process.env.MPP_TEMPO_TEST_AMOUNT ?? 10000); // 0.01 pathUSD (6dp)
402
+ // Mint a Skyfire PAY token (Skyfire ES256-signs it) authorizing `amountUsd` to our
403
+ // seller service — returned as a JWT the buyer presents in the x-kyapay-token header.
404
+ const mintSkyfirePayToken = async (amountUsd) => {
405
+ if (!SKYFIRE_BUYER_API_KEY)
406
+ throw new Error('set SKYFIRE_BUYER_API_KEY');
407
+ if (!SKYFIRE_SELLER_SERVICE_ID)
408
+ throw new Error('set SKYFIRE_SELLER_SERVICE_ID');
409
+ const expiresAt = Math.floor(Date.now() / 1000) + 300;
410
+ const res = await fetch(`${SKYFIRE_API}/api/v1/tokens`, {
411
+ method: 'POST',
412
+ headers: { 'content-type': 'application/json', 'skyfire-api-key': SKYFIRE_BUYER_API_KEY },
413
+ body: JSON.stringify({
414
+ type: 'pay',
415
+ buyerTag: `sinocare-${Date.now()}`,
416
+ tokenAmount: amountUsd,
417
+ sellerServiceId: SKYFIRE_SELLER_SERVICE_ID,
418
+ expiresAt,
419
+ }),
420
+ });
421
+ if (!res.ok) {
422
+ throw new Error(`skyfire mint -> ${res.status}: ${(await res.text()).slice(0, 200)}`);
423
+ }
424
+ const { token } = (await res.json());
425
+ if (!token)
426
+ throw new Error('skyfire mint returned no token');
427
+ return token;
428
+ };
429
+ // Run a callback with a connected XRPL client, always disconnecting after — so a
430
+ // booking opens ONE connection for the balance pre-flight AND the signing autofill
431
+ // instead of one each.
432
+ const withXrplClient = async (fn) => {
433
+ const client = new XrplClient(XRPL_RPC);
434
+ await client.connect();
435
+ try {
436
+ return await fn(client);
437
+ }
438
+ finally {
439
+ await client.disconnect().catch(() => undefined);
440
+ }
441
+ };
442
+ // Read the buyer's balance of an XRPL IOU (RLUSD) via account_lines — the trust
443
+ // line to the issuer for that currency. Returns the decimal string balance, '0'
444
+ // if no trust line / no funds. Uses the caller's connected client.
445
+ const rlusdBalance = async (client, owner, currencyHex, issuer) => {
446
+ try {
447
+ const res = await client.request({ command: 'account_lines', account: owner, peer: issuer });
448
+ const line = res.result.lines.find((l) => l.currency === currencyHex && l.account === issuer);
449
+ return line?.balance ?? '0';
450
+ }
451
+ catch {
452
+ return '0';
453
+ }
454
+ };
455
+ // Answer an XRPL x402 402 challenge: sign a standard Payment (Ed25519, invoice
456
+ // bound as a Memo) via the buyer /xrpl SDK → base64 X-PAYMENT, using the caller's
457
+ // connected client for autofill. The wallet must hold RLUSD + a little test XRP.
458
+ const signXrpl = async (client, r, wallet) => encodeXrplXPaymentHeader(await signXrplPayment({ requirements: r, wallet, client }));
459
+ // Persist the standing authorization across restarts. It's a delegation the user
460
+ // signs ONCE (authorize_agent_budget); keeping it only in memory meant a process
461
+ // restart silently dropped the cap, letting an over-budget booking slip through.
462
+ // The mandate is a self-contained signed JWS, so persisting + re-presenting it
463
+ // stays cryptographically valid (we store it, we don't re-sign). Keyed to the
464
+ // agent token so a different buyer can't inherit a stale grant; an expired grant
465
+ // is dropped on load. Override the path with SINOCARE_DEMO_BUDGET_FILE.
466
+ const BUDGET_FILE = process.env.SINOCARE_DEMO_BUDGET_FILE ?? join(homedir(), '.agentbank-sinocare-budget.json');
467
+ const tokenFp = TOKEN ? createHash('sha256').update(TOKEN).digest('hex').slice(0, 16) : '';
468
+ const saveBudget = (b) => {
469
+ try {
470
+ // mode 0o600: the file holds a signed IntentMandate (a bearer authorization);
471
+ // keep it owner-only, not world-readable, even in a demo home dir.
472
+ if (b)
473
+ writeFileSync(BUDGET_FILE, JSON.stringify({ tokenFp, budget: b }), {
474
+ encoding: 'utf8',
475
+ mode: 0o600,
476
+ });
477
+ else
478
+ rmSync(BUDGET_FILE, { force: true });
479
+ }
480
+ catch {
481
+ /* best-effort: a demo persistence hiccup must never break booking */
482
+ }
483
+ };
484
+ const loadBudget = () => {
485
+ try {
486
+ const saved = JSON.parse(readFileSync(BUDGET_FILE, 'utf8'));
487
+ const b = saved.budget;
488
+ // Ignore a grant signed for a different token, or one that has expired.
489
+ if (saved.tokenFp !== tokenFp || !b?.expiresAt || Date.parse(b.expiresAt) < Date.now()) {
490
+ rmSync(BUDGET_FILE, { force: true });
491
+ return null;
492
+ }
493
+ return b;
494
+ }
495
+ catch {
496
+ return null;
497
+ }
498
+ };
499
+ let agentBudget = loadBudget();
500
+ let lastCheckout = null;
501
+ const completedBookings = new Map();
502
+ // Persist the card's checkout state (last pick + booking receipts) across restarts
503
+ // and conversations, so returning to a paid resort's card shows its receipt
504
+ // instead of resetting to the picker — and so a fresh "book it again" starts
505
+ // clean. Keyed to the agent token so a different buyer starts empty. Override the
506
+ // path with SINOCARE_DEMO_STATE_FILE.
507
+ const STATE_FILE = process.env.SINOCARE_DEMO_STATE_FILE ?? join(homedir(), '.agentbank-sinocare-checkout.json');
508
+ const saveCheckoutState = () => {
509
+ // No buyer token → no stable identity to key on. Persisting under an empty
510
+ // tokenFp would pool every anonymous buyer into one shared file (and let one
511
+ // clobber another), so keep this session's state in memory only.
512
+ if (!tokenFp)
513
+ return;
514
+ try {
515
+ // Read-merge-write: a sibling process (another conversation for the SAME
516
+ // buyer) may have booked between our startup snapshot and now. Union its
517
+ // bookings with ours — ours wins ties (we just acted) — so our stale
518
+ // snapshot never clobbers a booking another conversation recorded.
519
+ const merged = new Map(completedBookings);
520
+ try {
521
+ const disk = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
522
+ if (disk.tokenFp === tokenFp) {
523
+ for (const [k, v] of disk.bookings ?? [])
524
+ if (!merged.has(k))
525
+ merged.set(k, v);
526
+ }
527
+ }
528
+ catch {
529
+ /* no readable prior file — just write ours */
530
+ }
531
+ // Fold the union back into memory so this long-lived process also sees a
532
+ // sibling conversation's bookings on its next rehydrate (no restart needed).
533
+ for (const [k, v] of merged)
534
+ if (!completedBookings.has(k))
535
+ completedBookings.set(k, v);
536
+ // Atomic replace: write a temp then rename, so a concurrent reader never
537
+ // observes a half-written file.
538
+ const tmp = `${STATE_FILE}.${process.pid}.tmp`;
539
+ writeFileSync(tmp, JSON.stringify({ tokenFp, lastCheckout, bookings: [...merged] }), {
540
+ encoding: 'utf8',
541
+ mode: 0o600,
542
+ });
543
+ renameSync(tmp, STATE_FILE);
544
+ }
545
+ catch {
546
+ /* best-effort: a demo persistence hiccup must never break the card */
547
+ }
548
+ };
549
+ if (tokenFp) {
550
+ try {
551
+ const saved = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
552
+ // Ignore state saved under a different buyer token.
553
+ if (saved.tokenFp === tokenFp) {
554
+ // ⚠️ `bookings` ONLY. `lastCheckout` is deliberately NOT restored.
555
+ //
556
+ // It exists for one case: a card self-healing with no arguments, where
557
+ // the server replays the last pick so the widget fills instead of
558
+ // hanging. Restored from disk it does that to a card belonging to a
559
+ // DIFFERENT resort — restart the app, ask for a new hotel, and the card
560
+ // renders the previous booking's receipt, which reads as having booked
561
+ // something you did not.
562
+ //
563
+ // `bookings` is safe to restore because it is keyed by sku: it is only
564
+ // ever consulted for a resort someone actually asked about.
565
+ for (const [k, v] of saved.bookings ?? [])
566
+ completedBookings.set(k, v);
567
+ }
568
+ }
569
+ catch {
570
+ /* no persisted state yet — start empty */
571
+ }
572
+ }
573
+ // Record a completed booking (only when it actually settled) and return the tool
574
+ // result — so every buy_sinocare_product_* success is remembered for rehydration
575
+ // in ONE place. Pulls the human fields off the result value the tool already
576
+ // built (total shape differs by rail: total / paidUsdc / paidRlusd / priceEur).
577
+ const finishBooking = (sku, value) => {
578
+ if (sku && value.booked) {
579
+ // Record the sale on the Shopify side, if the catalog is Shopify-backed.
580
+ // Fire-and-forget: the booking already settled through agentbank, so a
581
+ // writeback failure must never affect the tool result. The backend no-ops
582
+ // when Shopify isn't configured, so this is safe to call unconditionally.
583
+ void backend('/shopify-order', {
584
+ method: 'POST',
585
+ body: JSON.stringify({
586
+ sku,
587
+ quantity: value.quantity,
588
+ protocol: value.protocol,
589
+ // Canonical writeback idempotency key: the settled order/PI id (also the
590
+ // searchable Shopify tag), falling back to the checkout session id so
591
+ // EVERY protocol dedups. A missing key means no dedup, and a hung-call
592
+ // re-fire would then create a duplicate Shopify order.
593
+ orderId: value.orderId ?? value.sessionId ?? value.paymentIntentId,
594
+ // So the Shopify order lands under a customer, not anonymously.
595
+ ...(BUYER ? { buyer: BUYER } : {}),
596
+ }),
597
+ }).catch(() => { });
598
+ completedBookings.set(sku, {
599
+ sku,
600
+ name: String(value.resort ?? ''),
601
+ protocol: value.protocol,
602
+ total: (value.total ?? value.paidUsdc ?? value.paidRlusd ?? value.priceEur),
603
+ status: value.status,
604
+ orderId: value.orderId,
605
+ txHash: value.txHash,
606
+ explorer: value.explorer,
607
+ message: value.message,
608
+ });
609
+ // Mark this pick as paid so a later rehydrate resolves to the receipt — not
610
+ // by the presence of a booking record (which we now keep across re-opens),
611
+ // but by whether THIS checkout was actually completed.
612
+ if (lastCheckout?.sku === sku)
613
+ lastCheckout.booked = true;
614
+ saveCheckoutState();
615
+ }
616
+ return text(value);
617
+ };
618
+ // The 'booked' card view: a receipt shown when a rehydrated checkout resolves to
619
+ // an already-booked resort. Carries the pick fields too so the card's "再订一次"
620
+ // button can drop straight back into the picker without another round-trip.
621
+ const bookedView = (pick, r) => ({
622
+ view: 'booked',
623
+ sku: pick.sku,
624
+ name: pick.name,
625
+ priceEur: pick.priceEur,
626
+ quantity: pick.quantity,
627
+ protocol: r.protocol,
628
+ total: r.total ?? pick.priceEur,
629
+ status: r.status,
630
+ orderId: r.orderId,
631
+ txHash: r.txHash,
632
+ explorer: r.explorer,
633
+ message: r.message,
634
+ });
635
+ // MCP App card (a single ui:// resource, TWO views chosen by structuredContent.
636
+ // view). start_sinocare_checkout → the payment-method picker; list_my_orders → the
637
+ // buyer's bookings (each refundable). Both advertise it on BOTH their tool
638
+ // definition AND their result via _meta.ui.resourceUri (mirrors the merchant card
639
+ // — the host needs it on the tool to know it renders a widget); each returns its
640
+ // data as structuredContent, and the card calls the book_* / request_refund /
641
+ // create_sinocare_payment_link tools back.
642
+ // ⚠️ BUMP THIS WHENEVER widget/ CHANGES. The host caches the card's HTML by
643
+ // this URI, so shipping new widget code under the old one leaves every
644
+ // existing conversation rendering the previous build — the server returns
645
+ // corrected data and the old JS draws the old picture. Three widget changes
646
+ // went out today without a bump, and the receipt/picker fix appeared to have
647
+ // no effect at all because of it.
648
+ const CHECKOUT_URI = 'ui://sinocare/card-v23.html';
649
+ const CARD_MIME = 'text/html;profile=mcp-app';
650
+ const CHECKOUT_UI_META = { ui: { resourceUri: CHECKOUT_URI }, 'ui/resourceUri': CHECKOUT_URI };
651
+ const TOOLS = [
652
+ {
653
+ name: 'list_sinocare_products',
654
+ description: 'Browse Sinocare resorts worldwide (Alps & Japan ski; Mediterranean, Indian Ocean, Caribbean & Americas sun) — shows the per-person/week price. Optional country (ISO-2, e.g. FR/IT/JP/MV/MQ/MX) or kind (ski|sun) filter.',
655
+ inputSchema: {
656
+ type: 'object',
657
+ properties: {
658
+ country: { type: 'string', description: 'ISO-2, e.g. FR / IT / JP / MV / MU / MQ / MX' },
659
+ kind: { type: 'string', enum: ['ski', 'sun'] },
660
+ },
661
+ },
662
+ },
663
+ {
664
+ name: 'get_sinocare_product',
665
+ description: 'Get one Sinocare resort by its sku (from list_sinocare_products).',
666
+ inputSchema: {
667
+ type: 'object',
668
+ required: ['sku'],
669
+ properties: { sku: { type: 'string' } },
670
+ },
671
+ },
672
+ {
673
+ name: 'start_sinocare_checkout',
674
+ description: "THE DEFAULT WAY TO BOOK. Open a visual checkout card for a Sinocare resort so the user PICKS a payment method by clicking (instead of typing) — Card (ACP) / UCP / USDC (x402) / RLUSD (XRP Ledger) in-conversation, or a Payment Link hosted page. When the user wants to book, CALL THIS — do not list the payment methods as text and ask which one; the card is where the user chooses and pays. ALWAYS prefer this over buy_sinocare_product_*. YOU MUST identify the resort: pass its `sku` OR a `query` name (e.g. 'Val d'Isère', 'Saint-Moritz') — do NOT call this with no resort (the card can't render without one). If one resort matches, open its card directly rather than asking to confirm.",
675
+ inputSchema: {
676
+ type: 'object',
677
+ properties: {
678
+ sku: {
679
+ type: 'string',
680
+ description: "the resort's exact sku (from list_sinocare_products)",
681
+ },
682
+ query: {
683
+ type: 'string',
684
+ description: "the resort NAME (e.g. 'Val d'Isère') — used if you don't have the exact sku; resolved server-side. Pass `sku` OR `query`.",
685
+ },
686
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
687
+ },
688
+ },
689
+ _meta: CHECKOUT_UI_META,
690
+ },
691
+ {
692
+ name: 'create_sinocare_payment_link',
693
+ description: "Create a Payment Link for a resort — a PUBLIC hosted checkout page (like a Stripe Payment Link) the user opens in a browser to pay (test-mode card). Returns the URL. Mostly invoked by the checkout card's 'Payment Link' button; the external-link payment path.",
694
+ inputSchema: {
695
+ type: 'object',
696
+ required: ['sku'],
697
+ properties: {
698
+ sku: { type: 'string' },
699
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
700
+ },
701
+ },
702
+ },
703
+ {
704
+ name: 'curless_wallet_logout',
705
+ description: 'Sign the buyer out of their Curless wallet and revoke the session, so it cannot be used again. Use when the person asks to sign out, or is done and someone else may use this machine. After this, paying from the wallet needs a fresh sign-in.',
706
+ inputSchema: { type: 'object', properties: {} },
707
+ },
708
+ {
709
+ name: 'curless_wallet_login_status',
710
+ description: "Whether the buyer has signed in to the wallet session THIS STOREFRONT holds — the one the in-card 'Curless 买家钱包' method uses. It says nothing about a separately-installed Curless wallet connector, which keeps its own session: for that one, call its own wallet_login. The checkout card polls this by itself — you do not normally need to.",
711
+ inputSchema: { type: 'object', properties: {} },
712
+ },
713
+ {
714
+ name: 'buy_sinocare_product_wallet',
715
+ description: "Book a Sinocare resort over ACP, paying from the buyer's own Curless wallet: the wallet mints a credential from the card THEY bound, capped by the limits THEY set, scoped to this seller and this amount for 15 minutes. If nobody is signed in, this returns a sign-in link — show it to the person; the card polls on its own and pays once they finish in the browser. Do NOT ask them for a password in the chat. If the wallet refuses because the booking is over their limit, tell the person it is their own limit and offer to raise it — do not retry with another payment method.",
716
+ inputSchema: {
717
+ type: 'object',
718
+ required: ['sku'],
719
+ properties: {
720
+ sku: { type: 'string' },
721
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
722
+ },
723
+ },
724
+ },
725
+ {
726
+ // ── mode 2: two independent MCP servers, the model carries the credential ──
727
+ // Nothing here knows the wallet exists. It opens a checkout, states the
728
+ // amount, and says what to do next; the wallet is a separate connector the
729
+ // buyer installed, and the MODEL is the only thing that can reach both.
730
+ // That is not a workaround — MCP servers cannot call each other, so the
731
+ // handoff is a tool description on each side and a string in between.
732
+ name: 'start_sinocare_wallet_handoff',
733
+ description: "Open a Sinocare checkout to be paid by a SEPARATELY-INSTALLED Curless wallet connector (@curless/agentbank-mcp). Returns {sessionId, merchantId, amount, currency}. Next: call that connector's `wallet_pay_credential` with exactly this amount, currency and merchantRef=merchantId, then pass the token it returns to `pay_sinocare_with_token`. Use this only when the buyer HAS the Curless wallet connector installed; if they do not, use buy_sinocare_product_wallet or pay_sinocare_hosted instead. ⚠️ If wallet_pay_credential fails — not signed in, over their own limit, no card bound — STOP and tell the person what it said. Do NOT book this another way: they chose to pay from their own wallet, and settling it with the agent's credential instead spends different money and reports it as theirs.",
734
+ inputSchema: {
735
+ type: 'object',
736
+ required: ['sku'],
737
+ properties: {
738
+ sku: { type: 'string' },
739
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
740
+ },
741
+ },
742
+ },
743
+ {
744
+ name: 'pay_sinocare_with_token',
745
+ description: 'Complete a Sinocare checkout opened by start_sinocare_wallet_handoff, using a payment credential minted elsewhere (a Stripe shared payment token, spt_…). Sinocare completes the charge with its own merchant key — it never sees a card. Pass the sessionId from the handoff and the token from the wallet.',
746
+ inputSchema: {
747
+ type: 'object',
748
+ required: ['sessionId', 'merchantId', 'token'],
749
+ properties: {
750
+ sessionId: { type: 'string' },
751
+ merchantId: { type: 'string', description: 'from start_sinocare_wallet_handoff' },
752
+ token: { type: 'string', description: 'spt_… from the buyer wallet' },
753
+ },
754
+ },
755
+ },
756
+ {
757
+ // ── mode 3: hosted approval, the buyer needs nothing installed ──
758
+ // The shape that works for a person with only a browser. Same idea as a
759
+ // Stripe Checkout link: we hand over a URL, they see what they are paying
760
+ // and to whom, and approve it on the wallet's own page.
761
+ name: 'pay_sinocare_hosted',
762
+ description: 'Open a Sinocare checkout and return a Curless approval LINK for the buyer to open in their browser. They see the amount and the merchant, sign in if needed, and approve; Curless charges the card they bound. Use this when the buyer has NOTHING installed — no wallet connector, no agent credential. Show them the link; then call get_sinocare_order_status with the sessionId and merchantId to see whether it has been paid. Do not claim it is paid until that says so, and do NOT book it another way while they are still on the page.',
763
+ inputSchema: {
764
+ type: 'object',
765
+ required: ['sku'],
766
+ properties: {
767
+ sku: { type: 'string' },
768
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
769
+ },
770
+ },
771
+ },
772
+ {
773
+ name: 'get_sinocare_order_status',
774
+ description: 'Check whether a checkout session has been paid. Use after pay_sinocare_hosted, since the approval happens in the browser and this server is not told when it does. Returns the session status — `completed` means the money moved.',
775
+ inputSchema: {
776
+ type: 'object',
777
+ required: ['sessionId', 'merchantId'],
778
+ properties: {
779
+ sessionId: { type: 'string' },
780
+ merchantId: { type: 'string', description: 'from the tool that opened this checkout' },
781
+ },
782
+ },
783
+ },
784
+ {
785
+ name: 'buy_sinocare_product_acp',
786
+ description: 'Book a Sinocare resort over ACP (Agentic Commerce Protocol) — Sinocare prices the resort and opens a merchant-quoted checkout session; this agent completes it with a delegated card credential, settling on the card rail (test mode). One of THREE ways to pay: ACP (card) / UCP (card) / x402 (USDC). The card rail is an implementation detail, not a separate payment choice. Returns the booked order.',
787
+ inputSchema: {
788
+ type: 'object',
789
+ required: ['sku'],
790
+ properties: {
791
+ sku: { type: 'string' },
792
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
793
+ },
794
+ },
795
+ },
796
+ {
797
+ name: 'buy_sinocare_product_ucp',
798
+ description: 'Book a Sinocare resort over UCP (Universal Commerce Protocol, ucp.dev) — Sinocare prices it and opens a UCP checkout session. UCP "supports x402", and x402 is chain-agnostic, so the SAME UCP session settles FOUR ways (pass `pay`): `card` (default) → the agent authorizes with an AP2 PaymentMandate bound to the cart, settled on the card rail (test mode, same as ACP); `usdc` → the gateway freezes an x402 challenge and the agent pays with a signed EIP-3009 X-PAYMENT (USDC on Base/EVM); `usdc-sol` → same, on Solana — the agent partially signs an SPL transfer the facilitator co-signs + submits (USDC on Solana, buyer needs no SOL); `rlusd` → an XRPL x402 challenge, a signed XRPL Payment (Ed25519, no gas), on the XRP Ledger. The three stablecoin ways all settle on-chain over the stablecoin rail — currency + chain just pick the rail. The standing authorization (authorize_agent_budget) is attached on the card path. Returns the booked order.',
799
+ inputSchema: {
800
+ type: 'object',
801
+ required: ['sku'],
802
+ properties: {
803
+ sku: { type: 'string' },
804
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
805
+ pay: {
806
+ type: 'string',
807
+ enum: ['card', 'usdc', 'usdc-sol', 'rlusd'],
808
+ description: 'payment method for this UCP session: card (default), usdc (x402 · USDC on Base/EVM), usdc-sol (x402 · USDC on Solana), or rlusd (x402 · RLUSD on the XRP Ledger)',
809
+ },
810
+ },
811
+ },
812
+ },
813
+ {
814
+ name: 'authorize_agent_budget',
815
+ description: 'Delegate autonomous Sinocare booking to this agent via an AP2 IntentMandate: you sign a STANDING authorization (a spend cap per booking + a time window) ONCE, and the agent can then complete UCP bookings within it without confirming each purchase. Bookings over the cap (or after it expires) are rejected by the gateway. This is the AP2 "user authorizes intent, agent acts within it" delegation.',
816
+ inputSchema: {
817
+ type: 'object',
818
+ required: ['maxAmountEur'],
819
+ properties: {
820
+ maxAmountEur: {
821
+ type: 'number',
822
+ description: 'spend cap PER booking, in EUR (e.g. 2000)',
823
+ },
824
+ hours: {
825
+ type: 'integer',
826
+ description: 'how long the authorization lasts, in hours (default 24)',
827
+ },
828
+ },
829
+ },
830
+ },
831
+ {
832
+ name: 'agent_budget_status',
833
+ description: 'Show the current standing AP2 booking authorization (spend cap + expiry), or that none is set.',
834
+ inputSchema: { type: 'object', properties: {} },
835
+ },
836
+ {
837
+ name: 'revoke_agent_budget',
838
+ description: 'Revoke the standing AP2 booking authorization — the agent must get a fresh one (authorize_agent_budget) to book autonomously again.',
839
+ inputSchema: { type: 'object', properties: {} },
840
+ },
841
+ {
842
+ name: 'buy_sinocare_product_x402',
843
+ description: 'Book a Sinocare resort and pay in USDC over x402. `chain` picks the USDC chain: `base` (default) → EVM, a signed EIP-3009 authorization; `solana` → an SVM transfer the buyer partially signs and the facilitator co-signs (feePayer) + submits (buyer needs no SOL). The merchant receives USDC at its address on that chain. Returns the booking + the USDC amount paid (+ the Solana tx on the solana path).',
844
+ inputSchema: {
845
+ type: 'object',
846
+ required: ['sku'],
847
+ properties: {
848
+ sku: { type: 'string' },
849
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
850
+ chain: {
851
+ type: 'string',
852
+ enum: ['base', 'solana'],
853
+ description: 'USDC chain: base (default, EVM) or solana',
854
+ },
855
+ },
856
+ },
857
+ },
858
+ {
859
+ name: 'x402_buyer_wallet',
860
+ description: 'Show the wallet that pays for x402 (USDC) bookings — its address and USDC balance on Base Sepolia (testnet) + Base (mainnet), and where to top it up. Use this to check funds before booking with USDC.',
861
+ inputSchema: { type: 'object', properties: {} },
862
+ },
863
+ {
864
+ name: 'buy_sinocare_product_rlusd',
865
+ description: "Book a Sinocare resort and pay in RLUSD (Ripple USD) over x402 on the XRP Ledger. Sinocare opens a merchant-quoted RLUSD session; this agent signs a standard XRPL Payment (Ed25519, no gas — the buyer pays the tiny XRPL fee) and Curless's self-hosted facilitator submits it on-ledger. Same x402 protocol as the USDC tool, different chain. Returns the booking + the RLUSD amount paid + the XRPL tx.",
866
+ inputSchema: {
867
+ type: 'object',
868
+ required: ['sku'],
869
+ properties: {
870
+ sku: { type: 'string' },
871
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
872
+ },
873
+ },
874
+ },
875
+ {
876
+ name: 'buy_sinocare_product_skyfire',
877
+ description: "Book a Sinocare resort over Skyfire KYAPay (agent-native JWT payments). The buyer mints a Skyfire-SIGNED PAY token (via Skyfire, authorizing a small USDC charge to Sinocare — unlike the crypto rails the buyer can't self-sign, Skyfire signs it) and presents it to the Sinocare merchant backend, which charges it via the merchant SDK; agentbank verifies it against Skyfire's live JWKS — proving the agent's identity (Know Your Agent) AND the payment authorization — and records the USDC payment intent. PRODUCTION Skyfire (real token, real verify). Needs SKYFIRE_BUYER_API_KEY + SKYFIRE_SELLER_SERVICE_ID (to mint). Returns the verification + the recorded payment intent (agentbank verifies + records the authorization; it does not auto-capture).",
878
+ inputSchema: {
879
+ type: 'object',
880
+ required: ['sku'],
881
+ properties: {
882
+ sku: { type: 'string' },
883
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
884
+ },
885
+ },
886
+ },
887
+ {
888
+ name: 'buy_sinocare_product_mpp',
889
+ description: "Book a Sinocare resort over MPP (Machine Payments Protocol) settled on the Tempo payments chain. agentbank answers the unpaid request with an HTTP-402 Payment challenge whose SIGNED terms carry the on-chain payment (TIP-20 pathUSD token, merchant recipient, memo=challenge binding); the buyer's Tempo wallet pays it with transferWithMemo; the credential proves it by tx HASH and agentbank VERIFIES the on-chain transfer read-only, then captures the payment intent. Real Tempo testnet, on-chain stablecoin — no card, no gateway signing. Needs AGENTBANK_AGENT_TOKEN + TEMPO_BUYER_PRIVATE_KEY (a funded Tempo testnet key).",
890
+ inputSchema: {
891
+ type: 'object',
892
+ required: ['sku'],
893
+ properties: {
894
+ sku: { type: 'string' },
895
+ quantity: { type: 'integer', description: 'number of guests/weeks (default 1)' },
896
+ },
897
+ },
898
+ },
899
+ {
900
+ name: 'xrpl_buyer_wallet',
901
+ description: 'Show the XRPL wallet that pays for RLUSD bookings — its classic r-address and RLUSD trust-line balance on the XRP Ledger testnet. Use this to check funds before booking with RLUSD.',
902
+ inputSchema: { type: 'object', properties: {} },
903
+ },
904
+ {
905
+ name: 'list_my_orders',
906
+ description: 'List my Sinocare bookings (orders) — newest first, with id, resort, amount, and status. Renders an interactive card: each settled booking has a 申请退款 (request refund) button. Use the order id with request_refund to ask for a refund.',
907
+ inputSchema: { type: 'object', properties: {} },
908
+ _meta: CHECKOUT_UI_META,
909
+ },
910
+ {
911
+ name: 'request_refund',
912
+ description: 'Request a refund on one of my bookings. Pass the order id (ord_…) from list_my_orders and an optional reason. The merchant (Sinocare) reviews and approves/rejects it.',
913
+ inputSchema: {
914
+ type: 'object',
915
+ required: ['orderId'],
916
+ properties: {
917
+ orderId: { type: 'string', description: 'order id from list_my_orders (ord_…)' },
918
+ reason: { type: 'string' },
919
+ },
920
+ },
921
+ },
922
+ ];
923
+ const text = (value) => ({
924
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
925
+ });
926
+ // Server instructions — hosts (Claude Desktop) inject these into the model's
927
+ // context. The point: when the user wants to book, OPEN THE CARD, don't retype
928
+ // the payment menu as prose. Left to its own devices the model reads the four
929
+ // book_* tools and helpfully lists ACP / UCP / x402 / RLUSD as a text bullet
930
+ // list and asks — but the whole UX is the visual card, where the user clicks a
931
+ // method and pays in place. So make the card the default and the text list a
932
+ // non-UI fallback only.
933
+ const INSTRUCTIONS = [
934
+ 'Sinocare storefront (buyer side).',
935
+ 'When the user wants to book a resort — any phrasing like "book …", "预订…", "reserve …" — call start_sinocare_checkout (pass the resort `sku`, or a `query` name such as "Saint-Moritz" / "Val d\'Isère") to open the visual checkout card. The user picks a payment method and pays by clicking IN THE CARD.',
936
+ 'Do NOT enumerate the payment methods (ACP / UCP / x402 / RLUSD / Payment Link) as text and ask which one — that is what the card is for. Only fall back to describing options in prose if the checkout card genuinely cannot be shown (a non-UI host).',
937
+ 'If the resort is ambiguous or unknown, resolve it first with list_sinocare_products, then open the checkout card for the chosen resort. If exactly one resort matches, open its card directly rather than asking to confirm.',
938
+ 'Call the buy_sinocare_product_* tools directly only when the card is unavailable or the user explicitly asked for a specific rail in a plain-text flow.',
939
+ ].join(' ');
940
+ const server = new Server({ name: 'sinocare', version: '0.5.0' }, { capabilities: { tools: {}, resources: {} }, instructions: INSTRUCTIONS });
941
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
942
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
943
+ resources: [{ uri: CHECKOUT_URI, name: 'Sinocare checkout', mimeType: CARD_MIME }],
944
+ }));
945
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
946
+ if (req.params.uri !== CHECKOUT_URI)
947
+ throw new Error(`unknown resource: ${req.params.uri}`);
948
+ return { contents: [{ uri: CHECKOUT_URI, mimeType: CARD_MIME, text: CHECKOUT_CARD_HTML }] };
949
+ });
950
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
951
+ const args = (req.params.arguments ?? {});
952
+ try {
953
+ if (req.params.name === 'list_sinocare_products') {
954
+ const country = typeof args.country === 'string' ? args.country.toUpperCase() : undefined;
955
+ const kind = typeof args.kind === 'string' ? `?kind=${encodeURIComponent(args.kind)}` : '';
956
+ const { items } = await backend(`/catalog${kind}`);
957
+ const resorts = country ? items.filter((r) => r.country === country) : items;
958
+ return text({ currency: 'EUR', count: resorts.length, resorts });
959
+ }
960
+ if (req.params.name === 'get_sinocare_product') {
961
+ const sku = String(args.sku ?? '');
962
+ const resort = await backend(`/catalog/${encodeURIComponent(sku)}`);
963
+ return text(resort);
964
+ }
965
+ // Visual checkout: return the MCP App card (payment picker) + the resort as
966
+ // structuredContent. The card's buttons call the book_* /
967
+ // create_sinocare_payment_link tools; the card renders their results.
968
+ if (req.params.name === 'start_sinocare_checkout') {
969
+ // Accept a `sku` (exact) OR a `query`/`resort`/`name` (e.g. "Val d'Isère")
970
+ // so the agent doesn't need the exact sku to open the card.
971
+ const idOrName = String(args.sku ?? args.query ?? args.resort ?? args.name ?? '').trim();
972
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
973
+ // The card sets __rehydrate when it re-invokes to fill itself (reload /
974
+ // self-heal), never on a fresh agent-initiated checkout. Only on rehydrate
975
+ // do we show a completed booking's receipt instead of the picker — so an
976
+ // explicit "book X again" from the agent still opens the picker.
977
+ const rehydrate = args.__rehydrate === true || args.__rehydrate === 1 || args.__rehydrate === '1';
978
+ // No resort given = the card self-healing (empty-arg re-invoke) — replay the
979
+ // last pick so the widget fills in instead of hanging on "Loading…". If that
980
+ // last checkout has since been booked, show its receipt on rehydrate.
981
+ if (!idOrName) {
982
+ if (rehydrate && lastCheckout && lastCheckout.booked) {
983
+ const receipt = completedBookings.get(lastCheckout.sku);
984
+ if (receipt) {
985
+ return {
986
+ content: [{ type: 'text', text: `Booked ${lastCheckout.name}.` }],
987
+ structuredContent: bookedView(lastCheckout, receipt),
988
+ _meta: CHECKOUT_UI_META,
989
+ };
990
+ }
991
+ }
992
+ return {
993
+ content: [
994
+ {
995
+ type: 'text',
996
+ text: lastCheckout
997
+ ? `Checkout for ${lastCheckout.name}.`
998
+ : 'Pick a resort first (list_sinocare_products), then open its checkout.',
999
+ },
1000
+ ],
1001
+ structuredContent: lastCheckout ?? {
1002
+ view: 'pick',
1003
+ sku: '',
1004
+ name: '',
1005
+ priceEur: '',
1006
+ quantity: 1,
1007
+ },
1008
+ _meta: CHECKOUT_UI_META,
1009
+ };
1010
+ }
1011
+ // Resolve: try exact sku first, then a name search over the catalog.
1012
+ let resort = null;
1013
+ try {
1014
+ resort = await backend(`/catalog/${encodeURIComponent(idOrName)}`);
1015
+ }
1016
+ catch {
1017
+ const { items } = await backend('/catalog');
1018
+ const q = idOrName.toLowerCase();
1019
+ resort =
1020
+ items.find((r) => r.sku.toLowerCase() === q) ??
1021
+ items.find((r) => r.name.toLowerCase() === q) ??
1022
+ items.find((r) => r.name.toLowerCase().includes(q)) ??
1023
+ null;
1024
+ }
1025
+ if (!resort) {
1026
+ return {
1027
+ content: [
1028
+ {
1029
+ type: 'text',
1030
+ text: `找不到度假村 "${idOrName}" — 用 list_sinocare_products 看可选项,或传它的 sku。`,
1031
+ },
1032
+ ],
1033
+ isError: true,
1034
+ };
1035
+ }
1036
+ const priceEur = resort.priceDisplay ?? `€${((resort.price ?? 0) / 100).toLocaleString('en-IE')}`;
1037
+ // Rehydrating an already-BOOKED resort's card → show its receipt, not the
1038
+ // picker (fixes: pay, leave the conversation, come back, and the card had
1039
+ // reset to the payment selection). The card self-heals by re-invoking with
1040
+ // its ORIGINAL args (the sku) + __rehydrate. EXCEPTION: a deliberate "book
1041
+ // it again" is a fresh (non-rehydrate) open that leaves THIS sku's pick
1042
+ // unbooked; its immediate self-heal must resolve to the picker, so suppress
1043
+ // the receipt only while this sku is the one being freshly re-opened. Past
1044
+ // receipts are kept intact (never deleted), so #6's cross-conversation
1045
+ // records — and OTHER booked cards in the same conversation — still
1046
+ // rehydrate to their receipts.
1047
+ // Is THIS checkout paid? Not "has this resort ever been bought".
1048
+ //
1049
+ // The receipt used to be looked up by sku, which treats a resort as a
1050
+ // purchase. It is not: opening a checkout for somewhere you booked last
1051
+ // week is a NEW order. With 42 of 44 resorts recorded, that lookup made
1052
+ // almost every card render as an already-paid receipt the moment it
1053
+ // self-healed — no payment involved — and hid the payment picker behind
1054
+ // a booking that had happened days ago.
1055
+ //
1056
+ // So the question is answered by the checkout in hand: the pick we just
1057
+ // had open, for this same resort, that was actually paid. A fresh open
1058
+ // replaces it with an unpaid one (below), which is what makes "book it
1059
+ // again" resolve to the picker.
1060
+ const rehydratedReceipt = rehydrate && lastCheckout?.sku === resort.sku && lastCheckout.booked
1061
+ ? completedBookings.get(resort.sku)
1062
+ : undefined;
1063
+ lastCheckout = {
1064
+ view: 'pick',
1065
+ sku: resort.sku,
1066
+ name: resort.name,
1067
+ priceEur,
1068
+ quantity,
1069
+ imageUrl: resort.imageUrl,
1070
+ externalShopify: resort.source === 'shopify',
1071
+ // Preserve booked only when rehydrating a paid pick; a fresh open is a
1072
+ // new, unpaid order (booked stays falsy → the card shows the picker).
1073
+ booked: rehydratedReceipt ? true : undefined,
1074
+ };
1075
+ saveCheckoutState(); // persist the new lastCheckout across processes
1076
+ const receipt = rehydratedReceipt;
1077
+ if (receipt) {
1078
+ return {
1079
+ content: [{ type: 'text', text: `Booked ${resort.name} (${priceEur}).` }],
1080
+ structuredContent: bookedView(lastCheckout, receipt),
1081
+ _meta: CHECKOUT_UI_META,
1082
+ };
1083
+ }
1084
+ return {
1085
+ content: [
1086
+ {
1087
+ type: 'text',
1088
+ text: `Checkout for ${resort.name} (${priceEur}). Pick a payment method in the card — Card (ACP) / UCP / USDC (x402) / RLUSD (XRP Ledger), or a Payment Link (hosted page).`,
1089
+ },
1090
+ ],
1091
+ structuredContent: lastCheckout,
1092
+ _meta: CHECKOUT_UI_META,
1093
+ };
1094
+ }
1095
+ if (req.params.name === 'create_sinocare_payment_link') {
1096
+ const sku = String(args.sku ?? '');
1097
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1098
+ const link = await backend('/checkout-link', {
1099
+ method: 'POST',
1100
+ // Paying from the wallet means the WALLET's owner is the buyer, so
1101
+ // the merchant records that email. Otherwise the order is filed under
1102
+ // SINOCARE_BUYER_EMAIL and never appears in the wallet's own 订单 —
1103
+ // "我的订单" is joined on the email the order was placed with.
1104
+ body: JSON.stringify({
1105
+ sku,
1106
+ quantity,
1107
+ ...(walletBuyer() ?? (BUYER ? { buyer: BUYER } : {})),
1108
+ }),
1109
+ });
1110
+ return text({
1111
+ url: link.url,
1112
+ amountDisplay: link.amountDisplay,
1113
+ name: link.name,
1114
+ message: `Payment page for ${link.name} — open ${link.url} to pay (${link.amountDisplay}).`,
1115
+ });
1116
+ }
1117
+ if (req.params.name === 'curless_wallet_logout') {
1118
+ const who = wallet.current();
1119
+ await wallet.logout();
1120
+ // Drop the pending browser sign-in too, if one is mid-flight — otherwise
1121
+ // "signed out" would be followed by that login completing and silently
1122
+ // signing them back in.
1123
+ pending = null;
1124
+ return {
1125
+ content: [
1126
+ {
1127
+ type: 'text',
1128
+ text: JSON.stringify({
1129
+ signedOut: true,
1130
+ was: who?.email ?? null,
1131
+ message: who ? `已退出 ${who.email}。` : '本来就没有登录。',
1132
+ }),
1133
+ },
1134
+ ],
1135
+ };
1136
+ }
1137
+ if (req.params.name === 'curless_wallet_login_status') {
1138
+ const who = wallet.current();
1139
+ return {
1140
+ content: [
1141
+ {
1142
+ type: 'text',
1143
+ text: JSON.stringify(who
1144
+ ? { signedIn: true, email: who.email, mode: who.mode, wallet: 'storefront' }
1145
+ : {
1146
+ signedIn: false,
1147
+ // Three answers, not two. "waiting" is a sign-in in flight,
1148
+ // "expired" is one that ran out, and neither is "nobody
1149
+ // started one" — which used to be reported as expired and
1150
+ // read back to the person as "your code timed out" when
1151
+ // they had never been given a code.
1152
+ waiting: Boolean(pending),
1153
+ expired: !pending && loginEverStarted,
1154
+ neverStarted: !pending && !loginEverStarted,
1155
+ // WHICH wallet. This storefront holds its own buyer session
1156
+ // for the in-card method; a separately-installed Curless
1157
+ // wallet connector holds a different one, and this tool can
1158
+ // say nothing about that. Reported as contradicting each
1159
+ // other otherwise — reasonably, since both were called "the
1160
+ // wallet".
1161
+ wallet: 'storefront',
1162
+ note: 'this is the session THIS storefront holds (the in-card wallet method). A separately-installed Curless wallet connector has its own session — ask that connector, not this tool.',
1163
+ }),
1164
+ },
1165
+ ],
1166
+ };
1167
+ }
1168
+ // ACP, twice over: the agent mints the credential from ITS OWN Stripe key
1169
+ // (buy_sinocare_product_acp), or the buyer's Curless wallet mints it from the
1170
+ // card that buyer bound (buy_sinocare_product_wallet). Everything after the
1171
+ // credential — the session, the complete, the writeback — is identical, so
1172
+ // it is one handler: two code paths would drift, and the difference that
1173
+ // matters is only WHERE the token comes from and WHOSE limits applied.
1174
+ if (req.params.name === 'buy_sinocare_product_acp' ||
1175
+ req.params.name === 'buy_sinocare_product_wallet') {
1176
+ const viaWallet = req.params.name === 'buy_sinocare_product_wallet';
1177
+ // Check the signature BEFORE asking Sinocare to price anything. Opening a
1178
+ // checkout session we already know cannot be paid leaves the merchant an
1179
+ // order that will never complete, and shows the person a backend error
1180
+ // when the actual problem is that nobody signed in.
1181
+ if (viaWallet && !wallet.current()) {
1182
+ // Start the browser sign-in HERE, before Sinocare is asked to price
1183
+ // anything: a checkout session opened for someone who cannot pay is an
1184
+ // order the merchant will never see completed.
1185
+ //
1186
+ // JSON, not prose — the card renders `message` and the model reads
1187
+ // `next`. Prose fails the card's JSON.parse and gets shown to the person
1188
+ // verbatim, which is how an instruction meant for the model once ended
1189
+ // up on screen in English.
1190
+ const login = await pendingLogin();
1191
+ return {
1192
+ content: [
1193
+ {
1194
+ type: 'text',
1195
+ text: JSON.stringify({
1196
+ needsLogin: true,
1197
+ loginUrl: login.verificationUriComplete,
1198
+ userCode: login.userCode,
1199
+ message: '在浏览器里登录你的 Curless 钱包,登录完这张卡会自己接上。登录后就能用你自己绑的卡付款,并受你自己设的限额约束。',
1200
+ next: 'show the person the login link; the card polls on its own. Do NOT ask them for an email here — the sign-in happens in their browser.',
1201
+ }),
1202
+ },
1203
+ ],
1204
+ isError: true,
1205
+ };
1206
+ }
1207
+ const sku = String(args.sku ?? '');
1208
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1209
+ // 1. Sinocare prices the resort + opens a merchant-quoted ACP session.
1210
+ const session = await backend('/checkout-acp', {
1211
+ method: 'POST',
1212
+ body: JSON.stringify({ sku, quantity, ...(BUYER ? { buyer: BUYER } : {}) }),
1213
+ });
1214
+ // 2. Ask the buyer's wallet for a credential scoped to THIS booking. The
1215
+ // session's real total has to come off the session itself — the backend
1216
+ // only hands back a display string ("€1,920"), and an SPT's cap is an
1217
+ // integer in minor units. No wallet configured → keep the card token.
1218
+ let token = PAYMENT_TOKEN;
1219
+ if (viaWallet) {
1220
+ // The buyer's wallet decides. It reads the same real total off the ACP
1221
+ // session, checks the limits THAT PERSON set, and mints against the card
1222
+ // THEY bound — so a refusal here is the buyer's own cap, not a payment
1223
+ // failure, and it is reported that way rather than retried around.
1224
+ const { amount, currency } = totalOf(await readCheckoutSession(acp(session.merchantId), session.sessionId));
1225
+ let credential;
1226
+ try {
1227
+ credential = await wallet.payCredential({
1228
+ amount,
1229
+ currency,
1230
+ merchantRef: session.merchantId,
1231
+ });
1232
+ }
1233
+ catch (err) {
1234
+ // The checkout session stays open and unpaid, which is correct — the
1235
+ // person may raise their limit and pay it. Nothing was charged.
1236
+ return walletRefusal(err);
1237
+ }
1238
+ token = credential.token;
1239
+ process.stderr.write(`sinocare-demo: wallet minted ${credential.token} for ${amount} ${currency}\n`);
1240
+ }
1241
+ else if (!STRIPE_AGENT_KEY || !SELLER_PROFILE) {
1242
+ // Say so once, out loud: a silent fallback here looks identical to a
1243
+ // successful SPT payment from the outside (the seller mints its own pm_
1244
+ // either way), which is exactly how you end up believing you tested
1245
+ // something you didn't.
1246
+ process.stderr.write('sinocare-demo: paying ACP with the plain card token — set STRIPE_AGENT_SECRET_KEY and AGENTBANK_SELLER_PROFILE to mint a real SPT per booking\n');
1247
+ }
1248
+ else {
1249
+ // Which fetch failed matters, and node's "fetch failed" hides the real
1250
+ // reason in .cause — without both you get a message that says nothing.
1251
+ let step = 'read session';
1252
+ try {
1253
+ const { amount, currency } = totalOf(await readCheckoutSession(acp(session.merchantId), session.sessionId));
1254
+ step = 'mint SPT';
1255
+ const minted = await mintSharedPaymentToken(amount, currency);
1256
+ if (minted) {
1257
+ token = minted;
1258
+ process.stderr.write(`sinocare-demo: minted ${minted} for ${amount} ${currency}\n`);
1259
+ }
1260
+ }
1261
+ catch (err) {
1262
+ const e = err;
1263
+ const why = e.cause?.code ?? e.cause?.message ?? '';
1264
+ const what = `SPT FAILED at [${step}]: ${e.message}${why ? ` (cause: ${why})` : ''}`;
1265
+ // Strict by default. Configuring a wallet says you want a credential
1266
+ // bound to THIS seller, amount and expiry; quietly paying with an
1267
+ // unscoped card token instead is a downgrade, not graceful
1268
+ // degradation — and the booking still succeeds, so nobody notices.
1269
+ if (!WALLET_FALLBACK_OK) {
1270
+ throw new Error(`${what} — refusing to fall back to an unscoped card token. Set SINOCARE_WALLET_FALLBACK=1 to allow that.`);
1271
+ }
1272
+ process.stderr.write(`sinocare-demo: ${what} — falling back to ${PAYMENT_TOKEN}\n`);
1273
+ }
1274
+ }
1275
+ // 3. This buyer agent completes the charge over ACP with that credential.
1276
+ const order = (await completeCheckout(acp(session.merchantId), session.sessionId, token));
1277
+ return finishBooking(sku, {
1278
+ booked: order.status === 'completed',
1279
+ resort: session.name,
1280
+ quantity,
1281
+ total: session.amountDisplay,
1282
+ protocol: 'acp',
1283
+ payer: viaWallet ? 'curless-wallet' : 'agent-key',
1284
+ status: order.status,
1285
+ // An ACP complete can omit order.id; fall back to the (always-present,
1286
+ // stable) checkout session id so the writeback always has a dedup key.
1287
+ orderId: order.order?.id ?? session.sessionId,
1288
+ message: `Booked ${session.name} × ${quantity} — ${session.amountDisplay} charged (${order.status}).`,
1289
+ });
1290
+ }
1291
+ // ── mode 2 · step 1: open a checkout for a wallet this server cannot reach ──
1292
+ if (req.params.name === 'start_sinocare_wallet_handoff') {
1293
+ if (!TOKEN) {
1294
+ return text({ error: 'sinocare-demo: AGENTBANK_AGENT_TOKEN is not set' });
1295
+ }
1296
+ const sku = String(args.sku ?? '');
1297
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1298
+ const session = await backend('/checkout-acp', {
1299
+ method: 'POST',
1300
+ body: JSON.stringify({ sku, quantity, ...(BUYER ? { buyer: BUYER } : {}) }),
1301
+ });
1302
+ // The authoritative total, off the session — the backend's amountDisplay
1303
+ // is a string for a human ("€1,920") and a credential is capped by an
1304
+ // integer in minor units.
1305
+ const { amount, currency } = totalOf(await readCheckoutSession(acp(session.merchantId), session.sessionId));
1306
+ return text({
1307
+ // This OPENED a checkout; it did not pay for one. Said explicitly
1308
+ // because a result that mentions neither used to render as a receipt.
1309
+ booked: false,
1310
+ awaitingPayment: true,
1311
+ message: '结账已开好。接下来在你自己的 Curless 钱包连接器里铸一张凭据,再回来完成付款。',
1312
+ sessionId: session.sessionId,
1313
+ merchantId: session.merchantId,
1314
+ amount,
1315
+ currency,
1316
+ resort: session.name,
1317
+ total: session.amountDisplay,
1318
+ // Spells out the sign-in step. Without it the model calls
1319
+ // wallet_pay_credential, gets "nobody is signed in", and has to invent
1320
+ // what to do next — which is how a booking the person asked to pay
1321
+ // from their own wallet ends up settled some other way.
1322
+ next: `on the Curless wallet connector: if nobody is signed in, call wallet_login FIRST, show the person the link it returns, and wait until they say they have finished in their browser. Then wallet_pay_credential({ amount: ${amount}, currency: "${currency}", merchantRef: "${session.merchantId}" }), then pay_sinocare_with_token({ sessionId: "${session.sessionId}", merchantId: "${session.merchantId}", token }). If the wallet still refuses, STOP and tell the person what it said — do not pay this another way.`,
1323
+ });
1324
+ }
1325
+ // ── mode 2 · step 2: the merchant completes, with its own key ──
1326
+ if (req.params.name === 'pay_sinocare_with_token') {
1327
+ if (!TOKEN) {
1328
+ return text({ error: 'sinocare-demo: AGENTBANK_AGENT_TOKEN is not set' });
1329
+ }
1330
+ const sessionId = String(args.sessionId ?? '');
1331
+ const token = String(args.token ?? '');
1332
+ const merchantId = String(args.merchantId ?? '');
1333
+ if (!sessionId || !token || !merchantId) {
1334
+ return text({ error: 'pay_sinocare_with_token needs sessionId, merchantId and token' });
1335
+ }
1336
+ // What this checkout was for comes off the checkout, not out of this
1337
+ // process's memory — the confirmation can arrive in a different one.
1338
+ const bought = purchaseOf(await readCheckoutSession(acp(merchantId), sessionId));
1339
+ const order = (await completeCheckout(acp(merchantId), sessionId, token));
1340
+ const booked = order.status === 'completed';
1341
+ // Same exit as the other ten booking paths. Returning a bare result here
1342
+ // is what let someone pay the same holiday three times: the card had no
1343
+ // idea it had been bought, so it rehydrated to the picker.
1344
+ return finishBooking(bought?.sku ?? '', {
1345
+ booked,
1346
+ resort: bought?.name,
1347
+ quantity: bought?.quantity,
1348
+ protocol: 'acp',
1349
+ payer: 'curless-wallet (separate connector)',
1350
+ status: order.status,
1351
+ orderId: order.order?.id ?? sessionId,
1352
+ message: `Checkout ${sessionId} completed with the credential the wallet minted (${order.status}).`,
1353
+ });
1354
+ }
1355
+ // ── mode 3: hand the person a link and let them approve in a browser ──
1356
+ if (req.params.name === 'pay_sinocare_hosted') {
1357
+ if (!TOKEN) {
1358
+ return text({ error: 'sinocare-demo: AGENTBANK_AGENT_TOKEN is not set' });
1359
+ }
1360
+ const sku = String(args.sku ?? '');
1361
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1362
+ const session = await backend('/checkout-acp', {
1363
+ method: 'POST',
1364
+ body: JSON.stringify({ sku, quantity, ...(BUYER ? { buyer: BUYER } : {}) }),
1365
+ });
1366
+ return text({
1367
+ booked: false,
1368
+ awaitingPayment: true,
1369
+ message: '在浏览器里打开这个链接,看清金额再批准。这里还没有扣任何款。',
1370
+ payUrl: `${API_BASE}/buyer/pay?session=${encodeURIComponent(session.sessionId)}`,
1371
+ sessionId: session.sessionId,
1372
+ resort: session.name,
1373
+ total: session.amountDisplay,
1374
+ // Say plainly that nothing has been paid yet. A tool that returns a
1375
+ // link and a resort name reads like a receipt if it does not.
1376
+ paid: false,
1377
+ merchantId: session.merchantId,
1378
+ next: 'show the person this link. Nothing has been charged yet — they approve it in their browser. Then call get_sinocare_order_status with this sessionId.',
1379
+ });
1380
+ }
1381
+ if (req.params.name === 'get_sinocare_order_status') {
1382
+ if (!TOKEN) {
1383
+ return text({ error: 'sinocare-demo: AGENTBANK_AGENT_TOKEN is not set' });
1384
+ }
1385
+ const sessionId = String(args.sessionId ?? '');
1386
+ const merchantId = String(args.merchantId ?? '');
1387
+ const session = await readCheckoutSession(acp(merchantId), sessionId);
1388
+ const paid = session.status === 'completed';
1389
+ const bought = purchaseOf(session);
1390
+ // The approval happened in a browser, so THIS is the only moment the demo
1391
+ // learns of it. Record the booking here or it is never recorded at all.
1392
+ return finishBooking(paid ? (bought?.sku ?? '') : '', {
1393
+ booked: paid,
1394
+ paid,
1395
+ sessionId,
1396
+ status: session.status,
1397
+ resort: bought?.name,
1398
+ quantity: bought?.quantity,
1399
+ protocol: 'acp',
1400
+ payer: 'curless-wallet (hosted approval)',
1401
+ orderId: sessionId,
1402
+ message: paid
1403
+ ? 'Paid — the buyer approved it in their browser.'
1404
+ : 'Not paid yet. They have not finished approving it.',
1405
+ });
1406
+ }
1407
+ if (req.params.name === 'buy_sinocare_product_ucp') {
1408
+ if (!TOKEN) {
1409
+ return {
1410
+ content: [{ type: 'text', text: 'set AGENTBANK_AGENT_TOKEN (a test agent:execute key)' }],
1411
+ isError: true,
1412
+ };
1413
+ }
1414
+ const sku = String(args.sku ?? '');
1415
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1416
+ const pay = args.pay === 'usdc'
1417
+ ? 'usdc'
1418
+ : args.pay === 'usdc-sol'
1419
+ ? 'usdc-sol'
1420
+ : args.pay === 'rlusd'
1421
+ ? 'rlusd'
1422
+ : 'card';
1423
+ // USDC path: the merchant quotes the UCP session in USDC (a small test
1424
+ // charge); the gateway freezes an x402 challenge on it, and this buyer pays
1425
+ // with an X-PAYMENT (EIP-3009) at /complete — settled over the stablecoin
1426
+ // rail, same money-movement as buy_sinocare_product_x402 but via a UCP
1427
+ // session (one protocol, two payment methods).
1428
+ if (pay === 'usdc') {
1429
+ const session = await backend('/checkout-ucp', {
1430
+ method: 'POST',
1431
+ body: JSON.stringify({ sku, quantity, pay: 'usdc', ...(BUYER ? { buyer: BUYER } : {}) }),
1432
+ });
1433
+ const reqs = session.x402?.accepts?.[0];
1434
+ if (!reqs)
1435
+ throw new Error('USDC UCP session missing its x402 requirements');
1436
+ // Pre-flight balance check (skipped in throwaway-key / STUB mode).
1437
+ const buyer = await buyerSigner();
1438
+ if (buyer) {
1439
+ const have = await usdcBalance({
1440
+ network: reqs.network,
1441
+ owner: buyer.address,
1442
+ token: reqs.asset,
1443
+ });
1444
+ if (have < BigInt(session.amount)) {
1445
+ const onTestnet = reqs.network === 'base-sepolia';
1446
+ return text({
1447
+ booked: false,
1448
+ reason: 'insufficient_usdc',
1449
+ wallet: buyer.address,
1450
+ network: reqs.network,
1451
+ have: fmtUsdc(have),
1452
+ need: fmtUsdc(BigInt(session.amount)),
1453
+ faucet: onTestnet ? 'https://faucet.circle.com' : undefined,
1454
+ message: `Buyer wallet ${buyer.address} has ${fmtUsdc(have)} on ${reqs.network}, needs ${fmtUsdc(BigInt(session.amount))}.${onTestnet ? ' Top up at https://faucet.circle.com and retry.' : ' Fund it with USDC on Base and retry.'}`,
1455
+ });
1456
+ }
1457
+ }
1458
+ // Sign the frozen challenge, then complete the UCP session with an
1459
+ // X-PAYMENT header — the gateway settles it over the stablecoin rail.
1460
+ const xPayment = await signX402(reqs);
1461
+ const paidRes = await fetch(`${API_BASE}/ucp/${encodeURIComponent(session.merchantId)}/checkout-sessions/${encodeURIComponent(session.sessionId)}/complete`, {
1462
+ method: 'POST',
1463
+ headers: {
1464
+ 'content-type': 'application/json',
1465
+ authorization: `Bearer ${TOKEN}`,
1466
+ 'x-payment': xPayment,
1467
+ },
1468
+ body: JSON.stringify({ payment: {} }),
1469
+ });
1470
+ const paid = (await paidRes.json());
1471
+ if (paidRes.status !== 200) {
1472
+ throw new Error(`ucp usdc settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1473
+ }
1474
+ const paidUsdc = fmtUsdc(BigInt(session.amount));
1475
+ return finishBooking(sku, {
1476
+ booked: paid.status === 'completed',
1477
+ resort: session.name,
1478
+ quantity,
1479
+ protocol: 'ucp',
1480
+ payer: 'agent-key (UCP)',
1481
+ payWith: 'usdc',
1482
+ paidUsdc: `${paidUsdc} (fixed test charge, merchant-quoted)`,
1483
+ payTo: reqs.payTo,
1484
+ network: reqs.network,
1485
+ paymentAuthorization: 'x402 EIP-3009 (signed + settled over the stablecoin rail)',
1486
+ status: paid.status,
1487
+ orderId: paid.id,
1488
+ sessionId: session.sessionId,
1489
+ message: `Booked ${session.name} × ${quantity} — paid ${paidUsdc} via UCP + USDC (x402) to ${reqs.payTo} (${paid.status}).`,
1490
+ });
1491
+ }
1492
+ // USDC-on-Solana path: the merchant quotes the UCP session in USDC on Solana;
1493
+ // the gateway freezes an SVM x402 challenge on it, and this buyer PARTIALLY
1494
+ // signs an SPL transfer (facilitator co-signs feePayer + submits) at /complete —
1495
+ // same UCP wire as the EVM USDC path, only the chain flips. Proves "UCP supports
1496
+ // x402" is chain-agnostic.
1497
+ if (pay === 'usdc-sol') {
1498
+ const secretKey = await solanaBuyerSecret();
1499
+ if (!secretKey) {
1500
+ return {
1501
+ content: [
1502
+ {
1503
+ type: 'text',
1504
+ text: 'set SOLANA_BUYER_SECRET (a devnet Solana secret key holding USDC) to pay USDC-on-Solana over UCP',
1505
+ },
1506
+ ],
1507
+ isError: true,
1508
+ };
1509
+ }
1510
+ const session = await backend('/checkout-ucp', {
1511
+ method: 'POST',
1512
+ body: JSON.stringify({
1513
+ sku,
1514
+ quantity,
1515
+ pay: 'usdc-sol',
1516
+ ...(BUYER ? { buyer: BUYER } : {}),
1517
+ }),
1518
+ });
1519
+ const reqs = session.x402?.accepts?.[0];
1520
+ if (!reqs)
1521
+ throw new Error('USDC-Solana UCP session missing its x402 requirements');
1522
+ const owner = await addressFromSecretKey(secretKey);
1523
+ const have = await solanaUsdcBalance(owner, reqs.asset);
1524
+ const need = Number(reqs.maxAmountRequired);
1525
+ if (have !== null && have < need) {
1526
+ return text({
1527
+ booked: false,
1528
+ reason: 'insufficient_usdc_solana',
1529
+ wallet: owner,
1530
+ network: reqs.network,
1531
+ have: `${(have / 1e6).toFixed(6)} USDC`,
1532
+ need: `${(need / 1e6).toFixed(6)} USDC`,
1533
+ faucet: 'https://faucet.circle.com',
1534
+ message: `Buyer wallet ${owner} has ${(have / 1e6).toFixed(6)} USDC on ${reqs.network}, needs ${(need / 1e6).toFixed(6)}. Top up devnet USDC at https://faucet.circle.com (Solana Devnet) and retry.`,
1535
+ });
1536
+ }
1537
+ // Partially sign the frozen SVM challenge, then complete the UCP session with
1538
+ // the X-PAYMENT — the gateway settles it over the stablecoin rail (x402.org).
1539
+ const xPayment = await signSolanaX402Payment({
1540
+ requirements: reqs,
1541
+ secretKey,
1542
+ rpcUrl: SOLANA_RPC,
1543
+ });
1544
+ const paidRes = await fetch(`${API_BASE}/ucp/${encodeURIComponent(session.merchantId)}/checkout-sessions/${encodeURIComponent(session.sessionId)}/complete`, {
1545
+ method: 'POST',
1546
+ headers: {
1547
+ 'content-type': 'application/json',
1548
+ authorization: `Bearer ${TOKEN}`,
1549
+ 'x-payment': xPayment,
1550
+ },
1551
+ body: JSON.stringify({ payment: {} }),
1552
+ });
1553
+ const paid = (await paidRes.json());
1554
+ if (paidRes.status !== 200) {
1555
+ throw new Error(`ucp usdc-sol settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1556
+ }
1557
+ const paidUsdc = `${(need / 1e6).toFixed(6)} USDC`;
1558
+ return finishBooking(sku, {
1559
+ booked: paid.status === 'completed',
1560
+ resort: session.name,
1561
+ quantity,
1562
+ protocol: 'ucp',
1563
+ payer: 'agent-key (UCP)',
1564
+ payWith: 'usdc-sol',
1565
+ paidUsdc: `${paidUsdc} (fixed test charge, merchant-quoted)`,
1566
+ payTo: reqs.payTo,
1567
+ network: reqs.network,
1568
+ paymentAuthorization: 'x402 SVM transfer (partially signed; facilitator co-signs feePayer + submits)',
1569
+ status: paid.status,
1570
+ orderId: paid.id,
1571
+ sessionId: session.sessionId,
1572
+ message: `Booked ${session.name} × ${quantity} — paid ${paidUsdc} via UCP + USDC on Solana to ${reqs.payTo} (${paid.status}).`,
1573
+ });
1574
+ }
1575
+ // RLUSD path: the merchant quotes the UCP session in RLUSD; the gateway
1576
+ // freezes an XRPL x402 challenge on it, and this buyer signs a standard XRPL
1577
+ // Payment (Ed25519, no gas — buyer pays the tiny XRPL fee) which the self-
1578
+ // hosted facilitator submits on-ledger. Same money-movement as
1579
+ // buy_sinocare_product_rlusd, but over a UCP session — the SAME UCP wire the
1580
+ // card/USDC paths use, only the currency flips the chain to the XRP Ledger.
1581
+ if (pay === 'rlusd') {
1582
+ const wallet = xrplBuyer();
1583
+ if (!wallet) {
1584
+ return {
1585
+ content: [
1586
+ {
1587
+ type: 'text',
1588
+ text: 'set XRPL_BUYER_SEED (an sEd… testnet seed holding RLUSD + a little test XRP) to pay RLUSD bookings',
1589
+ },
1590
+ ],
1591
+ isError: true,
1592
+ };
1593
+ }
1594
+ const session = await backend('/checkout-ucp', {
1595
+ method: 'POST',
1596
+ body: JSON.stringify({ sku, quantity, pay: 'rlusd', ...(BUYER ? { buyer: BUYER } : {}) }),
1597
+ });
1598
+ const reqs = session.x402?.accepts?.[0];
1599
+ if (!reqs)
1600
+ throw new Error('RLUSD UCP session missing its x402 requirements');
1601
+ // One XRPL connection for the balance pre-flight AND the signing autofill
1602
+ // (same as buy_sinocare_product_rlusd): sign the merchant's frozen quote.
1603
+ const issuer = reqs.extra?.issuer;
1604
+ const preflight = await withXrplClient(async (client) => {
1605
+ const have = issuer
1606
+ ? await rlusdBalance(client, wallet.classicAddress, reqs.asset, issuer)
1607
+ : '0';
1608
+ if (Number(have) < Number(reqs.maxAmountRequired)) {
1609
+ return { have, xPayment: null };
1610
+ }
1611
+ return { have, xPayment: await signXrpl(client, reqs, wallet) };
1612
+ });
1613
+ if (preflight.xPayment === null) {
1614
+ return text({
1615
+ booked: false,
1616
+ reason: 'insufficient_rlusd',
1617
+ wallet: wallet.classicAddress,
1618
+ network: reqs.network,
1619
+ have: `${preflight.have} RLUSD`,
1620
+ need: `${reqs.maxAmountRequired} RLUSD`,
1621
+ message: `Buyer wallet ${wallet.classicAddress} holds ${preflight.have} RLUSD, needs ${reqs.maxAmountRequired}. Fund it with testnet RLUSD (+ a little test XRP for the fee) and retry.`,
1622
+ });
1623
+ }
1624
+ // Complete the UCP session with the signed XRPL Payment as an X-PAYMENT —
1625
+ // the gateway settles it over the stablecoin rail (XRPL facilitator).
1626
+ const paidRes = await fetch(`${API_BASE}/ucp/${encodeURIComponent(session.merchantId)}/checkout-sessions/${encodeURIComponent(session.sessionId)}/complete`, {
1627
+ method: 'POST',
1628
+ headers: {
1629
+ 'content-type': 'application/json',
1630
+ authorization: `Bearer ${TOKEN}`,
1631
+ 'x-payment': preflight.xPayment,
1632
+ },
1633
+ body: JSON.stringify({ payment: {} }),
1634
+ });
1635
+ const paid = (await paidRes.json());
1636
+ if (paidRes.status !== 200) {
1637
+ throw new Error(`ucp rlusd settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1638
+ }
1639
+ return finishBooking(sku, {
1640
+ booked: paid.status === 'completed',
1641
+ resort: session.name,
1642
+ quantity,
1643
+ protocol: 'ucp',
1644
+ payer: 'agent-key (UCP)',
1645
+ payWith: 'rlusd',
1646
+ paidRlusd: `${reqs.maxAmountRequired} RLUSD (fixed test charge, merchant-quoted)`,
1647
+ payTo: reqs.payTo,
1648
+ network: reqs.network,
1649
+ paymentAuthorization: 'x402 XRPL Payment (Ed25519, signed + settled over the stablecoin rail)',
1650
+ status: paid.status,
1651
+ orderId: paid.id,
1652
+ sessionId: session.sessionId,
1653
+ message: `Booked ${session.name} × ${quantity} — paid ${reqs.maxAmountRequired} RLUSD via UCP + RLUSD (x402 on the XRP Ledger) to ${reqs.payTo} (${paid.status}).`,
1654
+ });
1655
+ }
1656
+ // CARD path (default).
1657
+ // 1. Sinocare prices the resort + opens a merchant-quoted UCP session,
1658
+ // returning a merchant-signed AP2 CartMandate for it.
1659
+ const session = await backend('/checkout-ucp', {
1660
+ method: 'POST',
1661
+ body: JSON.stringify({ sku, quantity, ...(BUYER ? { buyer: BUYER } : {}) }),
1662
+ });
1663
+ // 2. This buyer agent authorizes payment with an AP2 PaymentMandate — a
1664
+ // real ES256 JWS bound to the cart's hash (UCP AP2 Mandates extension).
1665
+ // Programmatic completion, no human trusted-UI handoff; the gateway
1666
+ // cryptographically verifies the mandate, then settles on the card rail.
1667
+ const paymentMandate = await signPaymentMandate({
1668
+ cartHash: session.cartMandate.cartHash,
1669
+ method: { handler: 'card', type: 'card', credential: PAYMENT_TOKEN },
1670
+ amount: session.amount,
1671
+ currency: session.currency,
1672
+ });
1673
+ // Attach the standing authorization if one is active — the gateway then
1674
+ // checks this booking is within the user's delegated cap. Drop it if expired.
1675
+ const mandate = { payment_mandate: paymentMandate };
1676
+ let underAuthorization = false;
1677
+ if (agentBudget) {
1678
+ if (Date.parse(agentBudget.expiresAt) < Date.now()) {
1679
+ agentBudget = null; // expired — book normally (no standing auth)
1680
+ saveBudget(null);
1681
+ }
1682
+ else {
1683
+ mandate.intent_mandate = agentBudget.mandate;
1684
+ underAuthorization = true;
1685
+ }
1686
+ }
1687
+ const url = `${API_BASE}/ucp/${encodeURIComponent(session.merchantId)}/checkout-sessions/${encodeURIComponent(session.sessionId)}/complete`;
1688
+ const res = await fetch(url, {
1689
+ method: 'POST',
1690
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
1691
+ body: JSON.stringify({ payment: { mandate } }),
1692
+ });
1693
+ if (!res.ok) {
1694
+ const errText = await res.text().catch(() => '');
1695
+ // A standing-authorization rejection (over cap / expired) → a clean,
1696
+ // explanatory result instead of a raw error, so the agent can react.
1697
+ if (underAuthorization &&
1698
+ /intent mandate rejected|exceeds_intent_max_amount|intent_mandate_expired/.test(errText)) {
1699
+ return text({
1700
+ booked: false,
1701
+ blockedBy: 'standing-authorization',
1702
+ resort: session.name,
1703
+ total: session.amountDisplay,
1704
+ authorizedCap: `€${agentBudget?.maxAmountEur.toLocaleString('en-IE')}`,
1705
+ message: `Blocked: ${session.name} (${session.amountDisplay}) is over your standing authorization (cap €${agentBudget?.maxAmountEur}, or it expired). Raise it with authorize_agent_budget, or book a cheaper resort.`,
1706
+ });
1707
+ }
1708
+ throw new Error(`ucp complete -> ${res.status}: ${errText}`);
1709
+ }
1710
+ const out = (await res.json());
1711
+ return finishBooking(sku, {
1712
+ booked: out.status === 'completed',
1713
+ resort: session.name,
1714
+ quantity,
1715
+ total: session.amountDisplay,
1716
+ protocol: 'ucp',
1717
+ payer: 'agent-key (UCP)',
1718
+ // The payment was ALWAYS authorized by a signed AP2 PaymentMandate — this
1719
+ // says so explicitly so it can't be misread as "unauthorized".
1720
+ paymentAuthorization: 'AP2 PaymentMandate (signed + verified)',
1721
+ // The OPTIONAL standing budget (authorize_agent_budget): applied here, or
1722
+ // not set for this booking. "not set" ≠ unauthorized — see above.
1723
+ standingBudget: underAuthorization
1724
+ ? `applied (within your €${agentBudget?.maxAmountEur} cap, no per-booking confirm)`
1725
+ : 'not set',
1726
+ status: out.status,
1727
+ orderId: out.id,
1728
+ message: `Booked ${session.name} × ${quantity} — ${session.amountDisplay} charged via UCP + AP2 PaymentMandate (${out.status})${underAuthorization ? ` — under your standing €${agentBudget?.maxAmountEur} authorization, no per-booking confirm` : ''}.`,
1729
+ });
1730
+ }
1731
+ if (req.params.name === 'buy_sinocare_product_x402') {
1732
+ if (!TOKEN) {
1733
+ return {
1734
+ content: [{ type: 'text', text: 'set AGENTBANK_AGENT_TOKEN (a test agent:execute key)' }],
1735
+ isError: true,
1736
+ };
1737
+ }
1738
+ const sku = String(args.sku ?? '');
1739
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1740
+ // Solana path (chain=solana): USDC over Solana (SVM). The merchant quotes a
1741
+ // Solana USDC session; this buyer builds + PARTIALLY signs an SPL transfer,
1742
+ // the x402.org facilitator co-signs as feePayer + sponsors the fee + submits.
1743
+ // Same merchant-quoted session shape as the EVM/USDC path, only the chain flips.
1744
+ if (args.chain === 'solana') {
1745
+ const secretKey = await solanaBuyerSecret();
1746
+ if (!secretKey) {
1747
+ return {
1748
+ content: [
1749
+ {
1750
+ type: 'text',
1751
+ text: 'set SOLANA_BUYER_SECRET (a devnet Solana secret key — solana-keygen JSON array or base58 — holding devnet USDC) to pay USDC-on-Solana bookings',
1752
+ },
1753
+ ],
1754
+ isError: true,
1755
+ };
1756
+ }
1757
+ const { merchantId } = await backend('/health');
1758
+ const session = await backend('/checkout-x402', {
1759
+ method: 'POST',
1760
+ body: JSON.stringify({ sku, quantity, currency: 'USDC', chain: 'solana' }),
1761
+ });
1762
+ const reqs = session.accepts?.[0];
1763
+ if (!reqs)
1764
+ throw new Error('merchant Solana USDC session missing payment requirements');
1765
+ // Balance pre-flight (skip on RPC error) against the merchant's quote.
1766
+ const owner = await addressFromSecretKey(secretKey);
1767
+ const have = await solanaUsdcBalance(owner, reqs.asset);
1768
+ const need = Number(reqs.maxAmountRequired);
1769
+ if (have !== null && have < need) {
1770
+ return text({
1771
+ booked: false,
1772
+ reason: 'insufficient_usdc_solana',
1773
+ wallet: owner,
1774
+ network: reqs.network,
1775
+ have: `${(have / 1e6).toFixed(6)} USDC`,
1776
+ need: `${(need / 1e6).toFixed(6)} USDC`,
1777
+ faucet: 'https://faucet.circle.com',
1778
+ message: `Buyer wallet ${owner} has ${(have / 1e6).toFixed(6)} USDC on ${reqs.network}, needs ${(need / 1e6).toFixed(6)}. Top up devnet USDC at https://faucet.circle.com (Solana Devnet) and retry.`,
1779
+ });
1780
+ }
1781
+ // Sign the frozen quote (facilitator sponsors the fee), then pay { sessionId }.
1782
+ const xPayment = await signSolanaX402Payment({
1783
+ requirements: reqs,
1784
+ secretKey,
1785
+ rpcUrl: SOLANA_RPC,
1786
+ });
1787
+ const paidRes = await fetch(`${API_BASE}/x402/${encodeURIComponent(merchantId)}/checkout`, {
1788
+ method: 'POST',
1789
+ headers: {
1790
+ 'content-type': 'application/json',
1791
+ authorization: `Bearer ${TOKEN}`,
1792
+ 'x-payment': xPayment,
1793
+ },
1794
+ body: JSON.stringify({ sessionId: session.sessionId }),
1795
+ });
1796
+ const paid = (await paidRes.json());
1797
+ if (paidRes.status !== 200) {
1798
+ throw new Error(`solana x402 settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1799
+ }
1800
+ const paidUsdc = `${(need / 1e6).toFixed(6)} USDC`;
1801
+ const cluster = reqs.network === 'solana-devnet' ? '?cluster=devnet' : '';
1802
+ // finishBooking (not text) so a Solana booking is recorded like every other
1803
+ // rail — the card rehydrates to a receipt on reload instead of the picker.
1804
+ return finishBooking(sku, {
1805
+ booked: paid.status === 'captured',
1806
+ resort: session.name,
1807
+ quantity,
1808
+ protocol: 'x402',
1809
+ payer: 'buyer chain wallet (x402)',
1810
+ priceEur: session.priceEur,
1811
+ paidUsdc: `${paidUsdc} (fixed test charge, merchant-quoted)`,
1812
+ payTo: reqs.payTo,
1813
+ network: reqs.network,
1814
+ paymentAuthorization: 'x402 SVM transfer (partially signed; facilitator co-signs feePayer + submits + sponsors gas)',
1815
+ status: paid.status,
1816
+ txHash: paid.txHash,
1817
+ explorer: paid.txHash
1818
+ ? `https://explorer.solana.com/tx/${paid.txHash}${cluster}`
1819
+ : undefined,
1820
+ paymentIntentId: paid.paymentIntentId,
1821
+ sessionId: session.sessionId,
1822
+ message: `Booked ${session.name} × ${quantity} — paid ${paidUsdc} via x402 · USDC on Solana to ${reqs.payTo} (${paid.status}).`,
1823
+ });
1824
+ }
1825
+ const { merchantId } = await backend('/health');
1826
+ // The MERCHANT opens a merchant-quoted x402 session — IT sets the USDC
1827
+ // price (the gateway has no Sinocare catalog). We just pay it; we never
1828
+ // compute or pass the amount. Same shape as the ACP `/checkout-acp` flow.
1829
+ const session = await backend('/checkout-x402', {
1830
+ method: 'POST',
1831
+ body: JSON.stringify({ sku, quantity, ...(BUYER ? { buyer: BUYER } : {}) }),
1832
+ });
1833
+ const reqs = session.accepts?.[0];
1834
+ if (!reqs)
1835
+ throw new Error('merchant x402 session missing payment requirements');
1836
+ // Pre-flight: a real payer key + a real facilitator + an empty wallet = a
1837
+ // cryptic on-chain decline. Check the balance first (skipped in throwaway-
1838
+ // key / STUB mode) against the MERCHANT's quoted amount.
1839
+ const buyer = await buyerSigner();
1840
+ if (buyer) {
1841
+ const have = await usdcBalance({
1842
+ network: reqs.network,
1843
+ owner: buyer.address,
1844
+ token: reqs.asset,
1845
+ });
1846
+ if (have < BigInt(session.amount)) {
1847
+ const onTestnet = reqs.network === 'base-sepolia';
1848
+ return text({
1849
+ booked: false,
1850
+ reason: 'insufficient_usdc',
1851
+ wallet: buyer.address,
1852
+ network: reqs.network,
1853
+ have: fmtUsdc(have),
1854
+ need: fmtUsdc(BigInt(session.amount)),
1855
+ faucet: onTestnet ? 'https://faucet.circle.com' : undefined,
1856
+ message: `Buyer wallet ${buyer.address} has ${fmtUsdc(have)} on ${reqs.network}, needs ${fmtUsdc(BigInt(session.amount))}.${onTestnet ? ' Top up at https://faucet.circle.com (20 USDC / 2h) and retry.' : ' Fund it with USDC on Base and retry.'}`,
1857
+ });
1858
+ }
1859
+ }
1860
+ // Sign the merchant's frozen quote, then pay the session — the amount is
1861
+ // set by the merchant; we just reference { sessionId }.
1862
+ const xPayment = await signX402(reqs);
1863
+ const paidRes = await fetch(`${API_BASE}/x402/${encodeURIComponent(merchantId)}/checkout`, {
1864
+ method: 'POST',
1865
+ headers: {
1866
+ 'content-type': 'application/json',
1867
+ authorization: `Bearer ${TOKEN}`,
1868
+ 'x-payment': xPayment,
1869
+ },
1870
+ body: JSON.stringify({ sessionId: session.sessionId }),
1871
+ });
1872
+ const paid = (await paidRes.json());
1873
+ if (paidRes.status !== 200) {
1874
+ throw new Error(`x402 settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1875
+ }
1876
+ const paidUsdc = fmtUsdc(BigInt(session.amount));
1877
+ return finishBooking(sku, {
1878
+ booked: paid.status === 'captured',
1879
+ resort: session.name,
1880
+ quantity,
1881
+ protocol: 'x402',
1882
+ payer: 'buyer chain wallet (x402)',
1883
+ priceEur: session.priceEur,
1884
+ paidUsdc: `${paidUsdc} (fixed test charge, merchant-quoted)`,
1885
+ payTo: reqs.payTo,
1886
+ network: reqs.network,
1887
+ status: paid.status,
1888
+ txHash: paid.txHash,
1889
+ paymentIntentId: paid.paymentIntentId,
1890
+ sessionId: session.sessionId,
1891
+ message: `Booked ${session.name} × ${quantity} — paid ${paidUsdc} via x402 (merchant-quoted) to ${reqs.payTo} (${paid.status}).`,
1892
+ });
1893
+ }
1894
+ if (req.params.name === 'x402_buyer_wallet') {
1895
+ const buyer = await buyerSigner();
1896
+ if (!buyer) {
1897
+ return text({
1898
+ configured: false,
1899
+ message: 'No buyer signer configured — x402 signs with a throwaway, unfunded key (only settles under a STUB facilitator). Set X402_SIGNER=cdp (+ CDP_API_KEY_ID/_SECRET/_WALLET_SECRET) for a CDP Server Wallet, or X402_PAYER_PRIVATE_KEY for a local key, to pay real testnet/mainnet USDC.',
1900
+ });
1901
+ }
1902
+ const [sepolia, mainnet] = await Promise.all([
1903
+ usdcBalance({ network: 'base-sepolia', owner: buyer.address }),
1904
+ usdcBalance({ network: 'base', owner: buyer.address }),
1905
+ ]);
1906
+ return text({
1907
+ address: buyer.address,
1908
+ balances: { 'base-sepolia': fmtUsdc(sepolia), base: fmtUsdc(mainnet) },
1909
+ faucet: 'https://faucet.circle.com (Base Sepolia USDC — 20 / 2h per address)',
1910
+ note: 'Fund this address with USDC to pay for x402 bookings. Testnet: use the faucet. Mainnet: send real USDC on Base.',
1911
+ });
1912
+ }
1913
+ if (req.params.name === 'buy_sinocare_product_rlusd') {
1914
+ if (!TOKEN) {
1915
+ return {
1916
+ content: [{ type: 'text', text: 'set AGENTBANK_AGENT_TOKEN (a test agent:execute key)' }],
1917
+ isError: true,
1918
+ };
1919
+ }
1920
+ const wallet = xrplBuyer();
1921
+ if (!wallet) {
1922
+ return {
1923
+ content: [
1924
+ {
1925
+ type: 'text',
1926
+ text: 'set XRPL_BUYER_SEED (an sEd… testnet seed holding RLUSD + a little test XRP) to pay RLUSD bookings',
1927
+ },
1928
+ ],
1929
+ isError: true,
1930
+ };
1931
+ }
1932
+ const sku = String(args.sku ?? '');
1933
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
1934
+ const { merchantId } = await backend('/health');
1935
+ // The MERCHANT opens a merchant-quoted RLUSD session — IT sets the price
1936
+ // (the gateway holds no Sinocare catalog). We just pay it. Same shape as the
1937
+ // USDC x402 flow, only `currency: 'RLUSD'` flips the chain to the XRP Ledger.
1938
+ const session = await backend('/checkout-x402', {
1939
+ method: 'POST',
1940
+ body: JSON.stringify({ sku, quantity, currency: 'RLUSD' }),
1941
+ });
1942
+ const reqs = session.accepts?.[0];
1943
+ if (!reqs)
1944
+ throw new Error('merchant RLUSD session missing payment requirements');
1945
+ // One XRPL connection for both the balance pre-flight AND the signing
1946
+ // autofill. Pre-flight catches an unfunded / trust-line-less wallet before
1947
+ // we bother signing; on enough balance we sign the merchant's frozen quote.
1948
+ const issuer = reqs.extra?.issuer;
1949
+ const preflight = await withXrplClient(async (client) => {
1950
+ const have = issuer
1951
+ ? await rlusdBalance(client, wallet.classicAddress, reqs.asset, issuer)
1952
+ : '0';
1953
+ if (Number(have) < Number(reqs.maxAmountRequired)) {
1954
+ return { have, xPayment: null };
1955
+ }
1956
+ return { have, xPayment: await signXrpl(client, reqs, wallet) };
1957
+ });
1958
+ if (preflight.xPayment === null) {
1959
+ return text({
1960
+ booked: false,
1961
+ reason: 'insufficient_rlusd',
1962
+ wallet: wallet.classicAddress,
1963
+ network: reqs.network,
1964
+ have: `${preflight.have} RLUSD`,
1965
+ need: `${reqs.maxAmountRequired} RLUSD`,
1966
+ message: `Buyer wallet ${wallet.classicAddress} holds ${preflight.have} RLUSD, needs ${reqs.maxAmountRequired}. Fund it with testnet RLUSD (+ a little test XRP for the fee) and retry.`,
1967
+ });
1968
+ }
1969
+ // Pay the session (amount is merchant-set; we just reference { sessionId }).
1970
+ const xPayment = preflight.xPayment;
1971
+ const paidRes = await fetch(`${API_BASE}/x402/${encodeURIComponent(merchantId)}/checkout`, {
1972
+ method: 'POST',
1973
+ headers: {
1974
+ 'content-type': 'application/json',
1975
+ authorization: `Bearer ${TOKEN}`,
1976
+ 'x-payment': xPayment,
1977
+ },
1978
+ body: JSON.stringify({ sessionId: session.sessionId }),
1979
+ });
1980
+ const paid = (await paidRes.json());
1981
+ if (paidRes.status !== 200) {
1982
+ throw new Error(`rlusd settle -> ${paidRes.status}: ${JSON.stringify(paid)}`);
1983
+ }
1984
+ return finishBooking(sku, {
1985
+ booked: paid.status === 'captured',
1986
+ resort: session.name,
1987
+ quantity,
1988
+ protocol: 'x402',
1989
+ payer: 'buyer chain wallet (x402)',
1990
+ priceEur: session.priceEur,
1991
+ paidRlusd: `${reqs.maxAmountRequired} RLUSD (fixed test charge, merchant-quoted)`,
1992
+ payTo: reqs.payTo,
1993
+ network: reqs.network,
1994
+ status: paid.status,
1995
+ txHash: paid.txHash,
1996
+ explorer: paid.txHash ? `https://testnet.xrpl.org/transactions/${paid.txHash}` : undefined,
1997
+ paymentIntentId: paid.paymentIntentId,
1998
+ sessionId: session.sessionId,
1999
+ message: `Booked ${session.name} × ${quantity} — paid ${reqs.maxAmountRequired} RLUSD via x402 on the XRP Ledger to ${reqs.payTo} (${paid.status}).`,
2000
+ });
2001
+ }
2002
+ if (req.params.name === 'buy_sinocare_product_skyfire') {
2003
+ if (!SKYFIRE_BUYER_API_KEY || !SKYFIRE_SELLER_SERVICE_ID) {
2004
+ return {
2005
+ content: [
2006
+ {
2007
+ type: 'text',
2008
+ text: 'set SKYFIRE_BUYER_API_KEY + SKYFIRE_SELLER_SERVICE_ID to mint a Skyfire pay token (the merchant backend charges it via the merchant SDK)',
2009
+ },
2010
+ ],
2011
+ isError: true,
2012
+ };
2013
+ }
2014
+ const sku = String(args.sku ?? '');
2015
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
2016
+ const resort = await backend(`/catalog/${encodeURIComponent(sku)}`).catch(() => null);
2017
+ const resortName = resort?.name ?? sku;
2018
+ // The BUYER mints a Skyfire-signed PAY token (Skyfire's key signs it — the
2019
+ // buyer can't self-sign), then presents it to the MERCHANT backend, which
2020
+ // charges it via the merchant SDK (checkout.chargeSkyfire → the gateway
2021
+ // verifies it against Skyfire's live JWKS and records/settles). Three-layer:
2022
+ // buyer mints, merchant charges — the demo never calls the gateway directly.
2023
+ const token = await mintSkyfirePayToken(SKYFIRE_TEST_AMOUNT);
2024
+ const out = await backend('/checkout-skyfire', { method: 'POST', body: JSON.stringify({ sku, token }) });
2025
+ const amountStr = out.amount != null && out.currency
2026
+ ? `${(out.amount / 1e6).toFixed(6)} ${out.currency}`
2027
+ : `~${SKYFIRE_TEST_AMOUNT} USD`;
2028
+ const status = out.status ?? 'initiated';
2029
+ const captured = out.settled === true || status === 'captured';
2030
+ // Two terminal states, both a successful booking: CAPTURED (seller-charge
2031
+ // configured → real USDC moved) vs VERIFIED+RECORDED (no seller key → PI
2032
+ // initiated, no money moved).
2033
+ const message = captured
2034
+ ? `Booked ${resortName} × ${quantity} via Skyfire KYAPay — ${amountStr} charged (Skyfire redeemed the PAY token buyer → seller) and the payment intent is captured (PI ${status}). agentbank verified the agent + payment authorization against Skyfire's live JWKS, then settled it via Skyfire seller-charge.`
2035
+ : `Booked ${resortName} × ${quantity} via Skyfire KYAPay — ${amountStr} verified against Skyfire's live JWKS + recorded (PI ${status}). agentbank verified the agent + payment authorization and recorded it; no seller key is configured, so it did not auto-capture and no money moved.`;
2036
+ return finishBooking(sku, {
2037
+ booked: out.agent?.verified === true,
2038
+ resort: resortName,
2039
+ quantity,
2040
+ protocol: 'skyfire',
2041
+ payer: 'skyfire buyer token',
2042
+ paidUsdc: captured
2043
+ ? `${amountStr} (Skyfire KYAPay — charged, captured)`
2044
+ : `${amountStr} (Skyfire KYAPay authorization, recorded)`,
2045
+ buyerAgent: out.agent?.subject,
2046
+ paymentAuthorization: 'Skyfire KYAPay PAY token (Skyfire ES256-signed, verified against the live JWKS)',
2047
+ status,
2048
+ orderId: out.paymentIntentId,
2049
+ message,
2050
+ });
2051
+ }
2052
+ if (req.params.name === 'buy_sinocare_product_mpp') {
2053
+ if (!TEMPO_BUYER_PRIVATE_KEY) {
2054
+ return {
2055
+ content: [
2056
+ {
2057
+ type: 'text',
2058
+ text: 'set TEMPO_BUYER_PRIVATE_KEY (a funded Tempo testnet key) to pay via MPP · Tempo',
2059
+ },
2060
+ ],
2061
+ isError: true,
2062
+ };
2063
+ }
2064
+ const sku = String(args.sku ?? '');
2065
+ const quantity = Math.max(Number(args.quantity) || 1, 1);
2066
+ const resort = await backend(`/catalog/${encodeURIComponent(sku)}`).catch(() => null);
2067
+ const resortName = resort?.name ?? sku;
2068
+ // Three-layer MPP · Tempo — the demo talks only to the MERCHANT backend,
2069
+ // which mediates the HTTP-402 dance via the merchant SDK (checkout.openMPP):
2070
+ // 1. ask the backend to open the charge → it returns the 402 challenge;
2071
+ // 2. the buyer pays it on Tempo (SDK @curless/agentbank-protocols/tempo);
2072
+ // 3. hand the credential back to the backend → it settles.
2073
+ const opened = await backend('/checkout-mpp', {
2074
+ method: 'POST',
2075
+ body: JSON.stringify({ sku }),
2076
+ });
2077
+ if (opened.status !== 'payment_required' || !opened.wwwAuthenticate) {
2078
+ throw new Error(`mpp open expected a challenge, got ${JSON.stringify(opened)}`);
2079
+ }
2080
+ const { credential, txHash } = await payMppTempoChallenge({
2081
+ wwwAuthenticate: opened.wwwAuthenticate,
2082
+ privateKey: TEMPO_BUYER_PRIVATE_KEY,
2083
+ rpcUrl: TEMPO_RPC,
2084
+ }).catch((e) => {
2085
+ throw new Error(`mpp tempo pay failed (${e.message}) — is the gateway's TEMPO_RECIPIENT set + the buyer wallet funded?`);
2086
+ });
2087
+ const out = await backend('/checkout-mpp', { method: 'POST', body: JSON.stringify({ sku, credential }) });
2088
+ const paidStr = `${(MPP_TEMPO_TEST_AMOUNT / 1e6).toFixed(6)} pathUSD (Tempo TIP-20, on-chain)`;
2089
+ const settled = out.status === 'settled';
2090
+ return finishBooking(sku, {
2091
+ booked: settled,
2092
+ resort: resortName,
2093
+ quantity,
2094
+ protocol: 'mpp',
2095
+ payer: 'buyer chain wallet (MPP·Tempo)',
2096
+ total: paidStr,
2097
+ paidUsdc: paidStr,
2098
+ status: settled ? 'captured' : out.status,
2099
+ orderId: out.paymentIntentId,
2100
+ txHash,
2101
+ message: `Booked ${resortName} × ${quantity} via MPP · Tempo — paid ${paidStr} to Sinocare on the Tempo chain (tx ${txHash.slice(0, 14)}…). The merchant backend issued the HTTP-402 challenge (via the merchant SDK) and, after the buyer paid on-chain, VERIFIED the TIP-20 transfer by hash — capturing the payment intent${out.paymentIntentId ? ` (PI ${out.paymentIntentId})` : ''}.`,
2102
+ });
2103
+ }
2104
+ if (req.params.name === 'xrpl_buyer_wallet') {
2105
+ const wallet = xrplBuyer();
2106
+ if (!wallet) {
2107
+ return text({
2108
+ configured: false,
2109
+ message: 'No XRPL buyer wallet — set XRPL_BUYER_SEED (an sEd… testnet seed) to pay RLUSD bookings. Fund it with testnet RLUSD (a trust line to the RLUSD issuer) + a little test XRP for the fee/reserve.',
2110
+ });
2111
+ }
2112
+ const issuer = process.env.XRPL_RLUSD_ISSUER ?? 'rQhWct2fv4Vc4KRjRgMrxa8xPN9Zx9iLKV';
2113
+ const RLUSD_HEX = '524C555344000000000000000000000000000000';
2114
+ const have = await withXrplClient((client) => rlusdBalance(client, wallet.classicAddress, RLUSD_HEX, issuer));
2115
+ return text({
2116
+ address: wallet.classicAddress,
2117
+ network: 'xrpl:1 (testnet)',
2118
+ rlusd: `${have} RLUSD`,
2119
+ issuer,
2120
+ explorer: `https://testnet.xrpl.org/accounts/${wallet.classicAddress}`,
2121
+ note: 'Fund this address with testnet RLUSD (needs a trust line to the RLUSD issuer) + a little test XRP for the fee/reserve to pay RLUSD bookings.',
2122
+ });
2123
+ }
2124
+ if (req.params.name === 'list_my_orders') {
2125
+ // The merchant backend lists its orders (single-merchant demo = my orders).
2126
+ const { orders } = await backend('/orders');
2127
+ // Amount is minor units at the order's currency scale: 2 dp for fiat (EUR/USD),
2128
+ // 6 dp for stablecoins (USDC/RLUSD). Dividing by 100 for a 50000 RLUSD order
2129
+ // would show 500 instead of 0.05 — scale by the currency's own decimals.
2130
+ const dp = (cur) => (['USDC', 'RLUSD', 'USDT'].includes(cur) ? 6 : 2);
2131
+ const fmt = (amt, cur) => `${(amt / 10 ** dp(cur)).toLocaleString('en-IE', { maximumFractionDigits: dp(cur) })} ${cur}`;
2132
+ const view = {
2133
+ view: 'orders',
2134
+ orders: orders.map((o) => ({
2135
+ orderId: o.id,
2136
+ // A pending request outranks the payment status HERE, because this list
2137
+ // is where the buyer decides whether to ask. `paid` beside a live
2138
+ // "申请退款" button, on a booking they already asked about, invites them
2139
+ // to ask twice. Kept in the gateway's own English vocabulary — the rest
2140
+ // of the pills are (robin, 2026-07-28).
2141
+ status: o.refundRequest
2142
+ ? o.refundRequest.status === 'approved'
2143
+ ? 'refund approved'
2144
+ : 'refund requested'
2145
+ : o.status,
2146
+ // The card hides its button on this — an explicit flag, not a substring
2147
+ // of the status, so the two can never drift apart.
2148
+ refundPending: Boolean(o.refundRequest),
2149
+ resort: o.lineItems?.map((li) => `${li.name} ×${li.quantity}`).join(', ') ?? '',
2150
+ total: fmt(o.amount, o.currency),
2151
+ protocol: o.protocol,
2152
+ date: o.createdAt?.slice(0, 10) ?? '',
2153
+ })),
2154
+ };
2155
+ // Render the orders CARD (structuredContent + _meta), not a plain JSON table —
2156
+ // the card lists each booking with a 申请退款 button.
2157
+ return {
2158
+ content: [
2159
+ {
2160
+ type: 'text',
2161
+ text: orders.length
2162
+ ? `You have ${orders.length} Sinocare booking(s) — shown in the card (each settled one has a 申请退款 button).`
2163
+ : 'No Sinocare bookings yet.',
2164
+ },
2165
+ ],
2166
+ structuredContent: view,
2167
+ _meta: CHECKOUT_UI_META,
2168
+ };
2169
+ }
2170
+ if (req.params.name === 'request_refund') {
2171
+ if (!TOKEN) {
2172
+ return {
2173
+ content: [{ type: 'text', text: 'set AGENTBANK_AGENT_TOKEN (a test agent:execute key)' }],
2174
+ isError: true,
2175
+ };
2176
+ }
2177
+ const orderId = String(args.orderId ?? '');
2178
+ if (!orderId)
2179
+ throw new Error('orderId is required (from list_my_orders)');
2180
+ // The BUYER asks the gateway directly (its own agent token) — the merchant
2181
+ // then approves/rejects from its MCP. Money moves only on approval+Curless.
2182
+ const res = await fetch(`${API_BASE}/v1/refund-requests`, {
2183
+ method: 'POST',
2184
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${TOKEN}` },
2185
+ body: JSON.stringify({ orderId, reason: args.reason ? String(args.reason) : undefined }),
2186
+ });
2187
+ const out = (await res.json());
2188
+ if (!res.ok) {
2189
+ // The gateway's typed errors carry a message written for a human ("this
2190
+ // order has already been refunded", "already has an open refund
2191
+ // request"). Surface THAT — a raw JSON dump of the envelope is what the
2192
+ // buyer used to get, which tells them nothing about what to do next.
2193
+ const detail = (typeof out.error === 'object' && out.error?.message) ||
2194
+ (typeof out.error === 'string' ? out.error : '') ||
2195
+ out.message ||
2196
+ `HTTP ${res.status}`;
2197
+ throw new Error(String(detail));
2198
+ }
2199
+ return text({
2200
+ requested: true,
2201
+ refundRequestId: out.refundRequest?.id,
2202
+ status: out.refundRequest?.status,
2203
+ message: `Refund requested for ${orderId}. Sinocare will review and approve or reject it.`,
2204
+ });
2205
+ }
2206
+ if (req.params.name === 'authorize_agent_budget') {
2207
+ const maxAmountEur = Number(args.maxAmountEur);
2208
+ if (!Number.isFinite(maxAmountEur) || maxAmountEur <= 0) {
2209
+ throw new Error('maxAmountEur must be a positive number (EUR)');
2210
+ }
2211
+ const hours = Math.max(1, Math.floor(Number(args.hours) || 24));
2212
+ const expiresAt = new Date(Date.now() + hours * 3_600_000).toISOString();
2213
+ const maxAmount = Math.round(maxAmountEur * 100); // EUR minor units (cents)
2214
+ // The user signs the delegation as a real AP2 IntentMandate (ES256 JWS).
2215
+ const mandate = await signIntentMandate({
2216
+ prompt: `Autonomous Sinocare booking — up to €${maxAmountEur} for ${hours}h`,
2217
+ constraints: { maxAmount },
2218
+ expiresAt,
2219
+ });
2220
+ agentBudget = { mandate, maxAmountEur, expiresAt };
2221
+ saveBudget(agentBudget); // survive a process restart
2222
+ return text({
2223
+ authorized: true,
2224
+ maxBudgetPerBooking: `€${maxAmountEur.toLocaleString('en-IE')}`,
2225
+ expiresAt,
2226
+ message: `Agent authorized to book Sinocare up to €${maxAmountEur} per booking until ${expiresAt}. It can now complete UCP bookings under that cap without asking you to confirm each one; over-cap or expired bookings are rejected by the gateway.`,
2227
+ });
2228
+ }
2229
+ if (req.params.name === 'agent_budget_status') {
2230
+ if (!agentBudget || Date.parse(agentBudget.expiresAt) < Date.now()) {
2231
+ agentBudget = null;
2232
+ saveBudget(null);
2233
+ return text({
2234
+ authorized: false,
2235
+ message: 'No active booking authorization — use authorize_agent_budget to delegate one.',
2236
+ });
2237
+ }
2238
+ return text({
2239
+ authorized: true,
2240
+ maxBudgetPerBooking: `€${agentBudget.maxAmountEur.toLocaleString('en-IE')}`,
2241
+ expiresAt: agentBudget.expiresAt,
2242
+ });
2243
+ }
2244
+ if (req.params.name === 'revoke_agent_budget') {
2245
+ const had = agentBudget !== null;
2246
+ agentBudget = null;
2247
+ saveBudget(null);
2248
+ return text({
2249
+ revoked: had,
2250
+ message: had
2251
+ ? 'Booking authorization revoked — the agent needs a fresh one to book autonomously.'
2252
+ : 'No authorization was set.',
2253
+ });
2254
+ }
2255
+ return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true };
2256
+ }
2257
+ catch (err) {
2258
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
2259
+ }
2260
+ });
2261
+ await server.connect(new StdioServerTransport());
2262
+ //# sourceMappingURL=index.js.map