@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/README.md +58 -0
- package/dist/__tests__/checkout-card.test.d.ts +2 -0
- package/dist/__tests__/checkout-card.test.d.ts.map +1 -0
- package/dist/__tests__/checkout-card.test.js +315 -0
- package/dist/__tests__/checkout-card.test.js.map +1 -0
- package/dist/checkout-card.generated.d.ts +2 -0
- package/dist/checkout-card.generated.d.ts.map +1 -0
- package/dist/checkout-card.generated.js +6 -0
- package/dist/checkout-card.generated.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2262 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
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
|