moltspay 1.6.0 → 2.4.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.
Files changed (57) hide show
  1. package/.env.example +23 -0
  2. package/CHANGELOG.md +551 -0
  3. package/README.md +466 -7
  4. package/dist/cdp/index.js.map +1 -1
  5. package/dist/cdp/index.mjs.map +1 -1
  6. package/dist/{cdp-DeohBe1o.d.ts → cdp-B5PhDUTC.d.ts} +1 -1
  7. package/dist/{cdp-p_eHuQpb.d.mts → cdp-D-5Hg3y_.d.mts} +1 -1
  8. package/dist/chains/index.d.mts +102 -1
  9. package/dist/chains/index.d.ts +102 -1
  10. package/dist/chains/index.js +66 -0
  11. package/dist/chains/index.js.map +1 -1
  12. package/dist/chains/index.mjs +57 -0
  13. package/dist/chains/index.mjs.map +1 -1
  14. package/dist/cli/index.js +6850 -2127
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/cli/index.mjs +6857 -2128
  17. package/dist/cli/index.mjs.map +1 -1
  18. package/dist/client/index.d.mts +348 -1
  19. package/dist/client/index.d.ts +348 -1
  20. package/dist/client/index.js +1462 -43
  21. package/dist/client/index.js.map +1 -1
  22. package/dist/client/index.mjs +1469 -51
  23. package/dist/client/index.mjs.map +1 -1
  24. package/dist/client/web/index.d.mts +17 -1
  25. package/dist/client/web/index.mjs +11 -0
  26. package/dist/client/web/index.mjs.map +1 -1
  27. package/dist/facilitators/index.d.mts +663 -4
  28. package/dist/facilitators/index.d.ts +663 -4
  29. package/dist/facilitators/index.js +1292 -10
  30. package/dist/facilitators/index.js.map +1 -1
  31. package/dist/facilitators/index.mjs +1268 -9
  32. package/dist/facilitators/index.mjs.map +1 -1
  33. package/dist/index.d.mts +78 -3
  34. package/dist/index.d.ts +78 -3
  35. package/dist/index.js +4229 -556
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +4214 -548
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/mcp/index.js +1462 -45
  40. package/dist/mcp/index.js.map +1 -1
  41. package/dist/mcp/index.mjs +1473 -56
  42. package/dist/mcp/index.mjs.map +1 -1
  43. package/dist/{registry-OsEO2dOu.d.mts → registry-DIo0WNoO.d.mts} +8 -1
  44. package/dist/{registry-OsEO2dOu.d.ts → registry-DIo0WNoO.d.ts} +8 -1
  45. package/dist/server/index.d.mts +336 -2
  46. package/dist/server/index.d.ts +336 -2
  47. package/dist/server/index.js +2516 -188
  48. package/dist/server/index.js.map +1 -1
  49. package/dist/server/index.mjs +2516 -188
  50. package/dist/server/index.mjs.map +1 -1
  51. package/dist/verify/index.js.map +1 -1
  52. package/dist/verify/index.mjs.map +1 -1
  53. package/dist/wallet/index.js.map +1 -1
  54. package/dist/wallet/index.mjs.map +1 -1
  55. package/package.json +20 -2
  56. package/schemas/moltspay.services.schema.json +211 -6
  57. package/scripts/postinstall.js +91 -0
@@ -33,9 +33,10 @@ __export(server_exports, {
33
33
  MoltsPayServer: () => MoltsPayServer
34
34
  });
35
35
  module.exports = __toCommonJS(server_exports);
36
- var import_fs2 = require("fs");
36
+ var import_fs3 = require("fs");
37
37
  var import_http = require("http");
38
- var path2 = __toESM(require("path"));
38
+ var path3 = __toESM(require("path"));
39
+ var import_node_crypto7 = __toESM(require("crypto"));
39
40
 
40
41
  // src/facilitators/interface.ts
41
42
  var BaseFacilitator = class {
@@ -432,6 +433,28 @@ var CHAINS = {
432
433
  requiresApproval: true
433
434
  }
434
435
  };
436
+ function getChain(name) {
437
+ const config = CHAINS[name];
438
+ if (!config) {
439
+ throw new Error(`Unsupported chain: ${name}. Supported: ${Object.keys(CHAINS).join(", ")}`);
440
+ }
441
+ return config;
442
+ }
443
+ function getChainById(chainId) {
444
+ return Object.values(CHAINS).find((c) => c.chainId === chainId);
445
+ }
446
+ var ALIPAY_CHAIN_ID = "alipay";
447
+ function isAlipayChainId(id) {
448
+ return id === ALIPAY_CHAIN_ID;
449
+ }
450
+ var WECHAT_CHAIN_ID = "wechat";
451
+ function isWechatChainId(id) {
452
+ return id === WECHAT_CHAIN_ID;
453
+ }
454
+ var BALANCE_CHAIN_ID = "balance";
455
+ function isBalanceChainId(id) {
456
+ return id === BALANCE_CHAIN_ID;
457
+ }
435
458
 
436
459
  // src/facilitators/tempo.ts
437
460
  var TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
@@ -890,12 +913,12 @@ var BNBFacilitator = class extends BaseFacilitator {
890
913
  return this.spenderAddress;
891
914
  }
892
915
  async getServerAddress() {
893
- const { ethers: ethers2 } = await import("ethers");
894
- const wallet = new ethers2.Wallet(this.serverPrivateKey);
916
+ const { ethers: ethers4 } = await import("ethers");
917
+ const wallet = new ethers4.Wallet(this.serverPrivateKey);
895
918
  return wallet.address;
896
919
  }
897
920
  async recoverIntentSigner(intent, chainId) {
898
- const { ethers: ethers2 } = await import("ethers");
921
+ const { ethers: ethers4 } = await import("ethers");
899
922
  const domain = {
900
923
  ...EIP712_DOMAIN,
901
924
  chainId
@@ -909,7 +932,7 @@ var BNBFacilitator = class extends BaseFacilitator {
909
932
  nonce: intent.nonce,
910
933
  deadline: intent.deadline
911
934
  };
912
- const recoveredAddress = ethers2.verifyTypedData(
935
+ const recoveredAddress = ethers4.verifyTypedData(
913
936
  domain,
914
937
  INTENT_TYPES,
915
938
  message,
@@ -953,10 +976,10 @@ var BNBFacilitator = class extends BaseFacilitator {
953
976
  return result.result || "0x0";
954
977
  }
955
978
  async executeTransferFrom(from, to, amount, token, rpcUrl) {
956
- const { ethers: ethers2 } = await import("ethers");
957
- const provider = new ethers2.JsonRpcProvider(rpcUrl);
958
- const wallet = new ethers2.Wallet(this.serverPrivateKey, provider);
959
- const tokenContract = new ethers2.Contract(token, [
979
+ const { ethers: ethers4 } = await import("ethers");
980
+ const provider = new ethers4.JsonRpcProvider(rpcUrl);
981
+ const wallet = new ethers4.Wallet(this.serverPrivateKey, provider);
982
+ const tokenContract = new ethers4.Contract(token, [
960
983
  "function transferFrom(address from, address to, uint256 amount) returns (bool)"
961
984
  ], wallet);
962
985
  const tx = await tokenContract.transferFrom(from, to, amount);
@@ -1183,166 +1206,1411 @@ var SolanaFacilitator = class extends BaseFacilitator {
1183
1206
  }
1184
1207
  };
1185
1208
 
1186
- // src/facilitators/registry.ts
1187
- var import_web33 = require("@solana/web3.js");
1188
- var import_bs58 = __toESM(require("bs58"));
1189
- var FacilitatorRegistry = class {
1190
- factories = /* @__PURE__ */ new Map();
1191
- instances = /* @__PURE__ */ new Map();
1192
- selection;
1193
- roundRobinIndex = 0;
1194
- constructor(selection) {
1195
- this.registerFactory("cdp", (config) => new CDPFacilitator(config));
1196
- this.registerFactory("tempo", () => new TempoFacilitator());
1197
- this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
1198
- this.registerFactory("solana", (config) => {
1199
- let feePayerKeypair;
1200
- const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
1201
- if (feePayerKey) {
1202
- try {
1203
- feePayerKeypair = import_web33.Keypair.fromSecretKey(import_bs58.default.decode(feePayerKey));
1204
- } catch (e) {
1205
- console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
1206
- }
1207
- }
1208
- return new SolanaFacilitator({ feePayerKeypair });
1209
- });
1210
- this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
1209
+ // src/facilitators/alipay.ts
1210
+ var import_node_crypto2 = __toESM(require("crypto"));
1211
+
1212
+ // src/facilitators/alipay/rsa2.ts
1213
+ var import_node_crypto = __toESM(require("crypto"));
1214
+ function rsa2Sign(data, privateKeyPem) {
1215
+ const signer = import_node_crypto.default.createSign("RSA-SHA256");
1216
+ signer.update(data, "utf-8");
1217
+ signer.end();
1218
+ return signer.sign(privateKeyPem, "base64");
1219
+ }
1220
+
1221
+ // src/facilitators/alipay/encoding.ts
1222
+ function base64url(input) {
1223
+ return Buffer.from(input, "utf-8").toString("base64url");
1224
+ }
1225
+ function decodeBase64UrlWithPadFix(input) {
1226
+ const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
1227
+ const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
1228
+ return Buffer.from(padded, "base64").toString("utf-8");
1229
+ }
1230
+ function toPem(key, kind) {
1231
+ const trimmed = key.trim();
1232
+ if (trimmed.includes("-----BEGIN")) return trimmed;
1233
+ const label = kind === "PRIVATE" ? "PRIVATE KEY" : "PUBLIC KEY";
1234
+ const body = trimmed.replace(/\s+/g, "").match(/.{1,64}/g)?.join("\n") ?? "";
1235
+ return `-----BEGIN ${label}-----
1236
+ ${body}
1237
+ -----END ${label}-----
1238
+ `;
1239
+ }
1240
+
1241
+ // src/facilitators/alipay/openapi.ts
1242
+ function formatAlipayTimestamp(d = /* @__PURE__ */ new Date()) {
1243
+ const pad = (n) => String(n).padStart(2, "0");
1244
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1245
+ }
1246
+ function buildSigningString(params) {
1247
+ return Object.keys(params).sort().map((k) => `${k}=${params[k]}`).join("&");
1248
+ }
1249
+ function responseWrapperKey(method) {
1250
+ return `${method.replace(/\./g, "_")}_response`;
1251
+ }
1252
+ async function alipayOpenApiCall(method, bizContent, config) {
1253
+ const publicParams = {
1254
+ app_id: config.app_id,
1255
+ method,
1256
+ format: "JSON",
1257
+ charset: "utf-8",
1258
+ sign_type: config.sign_type ?? "RSA2",
1259
+ timestamp: formatAlipayTimestamp(),
1260
+ version: "1.0",
1261
+ biz_content: JSON.stringify(bizContent)
1262
+ };
1263
+ const signingString = buildSigningString(publicParams);
1264
+ const sign = rsa2Sign(signingString, config.private_key_pem);
1265
+ const body = new URLSearchParams({ ...publicParams, sign }).toString();
1266
+ const response = await fetch(config.gateway_url, {
1267
+ method: "POST",
1268
+ headers: {
1269
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8"
1270
+ },
1271
+ body
1272
+ });
1273
+ if (!response.ok) {
1274
+ const text = await response.text().catch(() => "<unreadable>");
1275
+ throw new Error(
1276
+ `Alipay Open API HTTP ${response.status} for ${method}: ${text.slice(0, 500)}`
1277
+ );
1211
1278
  }
1212
- /**
1213
- * Register a new facilitator factory
1214
- */
1215
- registerFactory(name, factory) {
1216
- this.factories.set(name, factory);
1279
+ const json = await response.json();
1280
+ const wrapperKey = responseWrapperKey(method);
1281
+ const business = json[wrapperKey];
1282
+ if (business === void 0 || business === null || typeof business !== "object") {
1283
+ throw new Error(
1284
+ `Alipay Open API response missing "${wrapperKey}" wrapper for ${method}: ${JSON.stringify(json).slice(0, 500)}`
1285
+ );
1286
+ }
1287
+ return business;
1288
+ }
1289
+
1290
+ // src/facilitators/alipay.ts
1291
+ var ALIPAY_NETWORK = "alipay";
1292
+ var ALIPAY_SCHEME = "alipay-aipay";
1293
+ var ALIPAY_GATEWAY_PROD = "https://openapi.alipay.com/gateway.do";
1294
+ var ALIPAY_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
1295
+ var ALIPAY_PAY_BEFORE_MS = 30 * 60 * 1e3;
1296
+ var ALIPAY_SIGNING_FIELDS = [
1297
+ "amount",
1298
+ "currency",
1299
+ "goods_name",
1300
+ "out_trade_no",
1301
+ "pay_before",
1302
+ "resource_id",
1303
+ "seller_id",
1304
+ "service_id"
1305
+ ];
1306
+ var AlipayFacilitator = class extends BaseFacilitator {
1307
+ name = "alipay";
1308
+ displayName = "Alipay AI Pay";
1309
+ supportedNetworks = [ALIPAY_NETWORK];
1310
+ config;
1311
+ constructor(config) {
1312
+ super();
1313
+ this.config = {
1314
+ gateway_url: ALIPAY_GATEWAY_PROD,
1315
+ sign_type: "RSA2",
1316
+ ...config
1317
+ };
1217
1318
  }
1218
1319
  /**
1219
- * Get or create a facilitator instance
1320
+ * Build the 402 challenge for a service: signs the 8-field payload with
1321
+ * RSA2, packages the nested `{protocol, method}` JSON as Base64URL for
1322
+ * `Payment-Needed`, and emits the parallel x402 `accepts[]` entry.
1220
1323
  */
1221
- get(name, config) {
1222
- if (this.instances.has(name)) {
1223
- return this.instances.get(name);
1224
- }
1225
- const factory = this.factories.get(name);
1226
- if (!factory) {
1227
- throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
1324
+ async createPaymentRequirements(opts) {
1325
+ if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
1326
+ throw new Error(
1327
+ `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
1328
+ );
1228
1329
  }
1229
- const mergedConfig = {
1230
- ...this.selection.config?.[name],
1231
- ...config
1330
+ const now = /* @__PURE__ */ new Date();
1331
+ const outTradeNo = opts.outTradeNo ?? generateOutTradeNo();
1332
+ const payBefore = formatPayBefore(now);
1333
+ const signedFields = {
1334
+ amount: opts.priceCny,
1335
+ currency: "CNY",
1336
+ goods_name: opts.goodsName,
1337
+ out_trade_no: outTradeNo,
1338
+ pay_before: payBefore,
1339
+ resource_id: opts.resourceId,
1340
+ seller_id: this.config.seller_id,
1341
+ service_id: opts.serviceId
1232
1342
  };
1233
- const instance = factory(mergedConfig);
1234
- this.instances.set(name, instance);
1235
- return instance;
1343
+ const signingString = ALIPAY_SIGNING_FIELDS.map((k) => `${k}=${signedFields[k]}`).join("&");
1344
+ const seller_signature = rsa2Sign(signingString, this.config.private_key_pem);
1345
+ const challenge = {
1346
+ protocol: {
1347
+ out_trade_no: outTradeNo,
1348
+ amount: signedFields.amount,
1349
+ currency: signedFields.currency,
1350
+ resource_id: signedFields.resource_id,
1351
+ pay_before: payBefore,
1352
+ seller_signature,
1353
+ seller_sign_type: this.config.sign_type ?? "RSA2",
1354
+ seller_unique_id: this.config.seller_id
1355
+ },
1356
+ method: {
1357
+ seller_name: this.config.seller_name,
1358
+ seller_id: this.config.seller_id,
1359
+ seller_app_id: this.config.app_id,
1360
+ goods_name: signedFields.goods_name,
1361
+ seller_unique_id_key: "seller_id",
1362
+ service_id: signedFields.service_id
1363
+ }
1364
+ };
1365
+ const paymentNeededHeader = base64url(JSON.stringify(challenge));
1366
+ const x402Accepts = {
1367
+ scheme: ALIPAY_SCHEME,
1368
+ network: ALIPAY_NETWORK,
1369
+ asset: "CNY",
1370
+ amount: opts.priceCny,
1371
+ payTo: this.config.seller_id,
1372
+ maxTimeoutSeconds: ALIPAY_PAY_BEFORE_MS / 1e3,
1373
+ extra: {
1374
+ payment_needed_header: paymentNeededHeader,
1375
+ out_trade_no: outTradeNo,
1376
+ pay_before: payBefore,
1377
+ service_id: signedFields.service_id
1378
+ }
1379
+ };
1380
+ return { x402Accepts, paymentNeededHeader };
1236
1381
  }
1237
1382
  /**
1238
- * Get all configured facilitator names
1383
+ * Verify a `Payment-Proof` by calling
1384
+ * `alipay.aipay.agent.payment.verify` against the Alipay Open API.
1385
+ *
1386
+ * The proof is conveyed via `paymentPayload.payload`, which may be:
1387
+ * - a raw Base64URL string (the `Payment-Proof` header value), or
1388
+ * - an object `{ paymentProof: string }` / `{ proofHeader: string }`.
1389
+ *
1390
+ * All failure modes (malformed payload, network errors, Alipay
1391
+ * `code != 10000`) return `{ valid: false, error }`. No exception
1392
+ * escapes, regardless of client-supplied input.
1239
1393
  */
1240
- getConfiguredNames() {
1241
- const names = [this.selection.primary];
1242
- if (this.selection.fallback) {
1243
- names.push(...this.selection.fallback);
1394
+ async verify(paymentPayload, _requirements) {
1395
+ try {
1396
+ const proofHeader = extractProofHeader(paymentPayload.payload);
1397
+ const decoded = decodeProof(proofHeader);
1398
+ const response = await alipayOpenApiCall(
1399
+ "alipay.aipay.agent.payment.verify",
1400
+ {
1401
+ payment_proof: decoded.protocol.payment_proof,
1402
+ trade_no: decoded.protocol.trade_no,
1403
+ client_session: decoded.method.client_session
1404
+ },
1405
+ this.getOpenApiConfig()
1406
+ );
1407
+ if (response.code !== "10000") {
1408
+ return {
1409
+ valid: false,
1410
+ error: `alipay verify ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
1411
+ details: {
1412
+ code: response.code,
1413
+ sub_code: response.sub_code,
1414
+ sub_msg: response.sub_msg
1415
+ }
1416
+ };
1417
+ }
1418
+ return {
1419
+ valid: true,
1420
+ details: {
1421
+ trade_no: response.trade_no ?? decoded.protocol.trade_no,
1422
+ amount: response.amount,
1423
+ out_trade_no: response.out_trade_no,
1424
+ resource_id: response.resource_id,
1425
+ active: response.active
1426
+ }
1427
+ };
1428
+ } catch (e) {
1429
+ return {
1430
+ valid: false,
1431
+ error: e instanceof Error ? e.message : String(e)
1432
+ };
1244
1433
  }
1245
- return names;
1246
1434
  }
1247
1435
  /**
1248
- * Get list of facilitators based on selection strategy
1436
+ * Settle by calling `alipay.aipay.agent.fulfillment.confirm` after the
1437
+ * service resource has been returned to the buyer.
1438
+ *
1439
+ * Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
1440
+ * (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
1441
+ * server) is expected to log fulfillment failures but NOT roll back
1442
+ * the already-delivered resource.
1443
+ *
1444
+ * Re-decodes `paymentPayload.payload` to extract `trade_no` (verify
1445
+ * does the same; the redundant Base64URL decode is negligible).
1249
1446
  */
1250
- async getOrderedFacilitators(network) {
1251
- const names = this.getConfiguredNames();
1252
- const facilitators = [];
1253
- for (const name of names) {
1254
- try {
1255
- const f = this.get(name);
1256
- if (f.supportsNetwork(network)) {
1257
- facilitators.push(f);
1258
- }
1259
- } catch (err) {
1260
- console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
1447
+ async settle(paymentPayload, _requirements) {
1448
+ try {
1449
+ const proofHeader = extractProofHeader(paymentPayload.payload);
1450
+ const decoded = decodeProof(proofHeader);
1451
+ const tradeNo = decoded.protocol.trade_no;
1452
+ const response = await alipayOpenApiCall(
1453
+ "alipay.aipay.agent.fulfillment.confirm",
1454
+ { trade_no: tradeNo },
1455
+ this.getOpenApiConfig()
1456
+ );
1457
+ if (response.code !== "10000") {
1458
+ return {
1459
+ success: false,
1460
+ transaction: tradeNo,
1461
+ error: `alipay fulfillment ${response.code}: ${response.sub_msg ?? response.msg ?? "unknown"}`,
1462
+ status: "fulfillment_failed"
1463
+ };
1261
1464
  }
1465
+ return {
1466
+ success: true,
1467
+ transaction: tradeNo,
1468
+ status: "fulfilled"
1469
+ };
1470
+ } catch (e) {
1471
+ return {
1472
+ success: false,
1473
+ error: e instanceof Error ? e.message : String(e)
1474
+ };
1262
1475
  }
1263
- if (facilitators.length === 0) {
1264
- throw new Error(`No facilitators available for network: ${network}`);
1476
+ }
1477
+ /**
1478
+ * Validate that the configured RSA keys parse and that the Open API
1479
+ * gateway is reachable. Does NOT make a real business API call (would
1480
+ * burn quota and could appear as a legitimate verify attempt in logs).
1481
+ */
1482
+ async healthCheck() {
1483
+ const start = Date.now();
1484
+ try {
1485
+ import_node_crypto2.default.createPrivateKey(this.config.private_key_pem);
1486
+ } catch (e) {
1487
+ return {
1488
+ healthy: false,
1489
+ error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1490
+ };
1265
1491
  }
1266
- switch (this.selection.strategy) {
1267
- case "random":
1268
- return this.shuffle(facilitators);
1269
- case "roundrobin":
1270
- this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
1271
- return [
1272
- ...facilitators.slice(this.roundRobinIndex),
1273
- ...facilitators.slice(0, this.roundRobinIndex)
1274
- ];
1275
- case "cheapest":
1276
- return this.sortByCheapest(facilitators);
1277
- case "fastest":
1278
- return this.sortByFastest(facilitators);
1279
- case "failover":
1280
- default:
1281
- return facilitators;
1492
+ try {
1493
+ import_node_crypto2.default.createPublicKey(this.config.alipay_public_key_pem);
1494
+ } catch (e) {
1495
+ return {
1496
+ healthy: false,
1497
+ error: `alipay_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1498
+ };
1499
+ }
1500
+ const gatewayUrl = this.config.gateway_url ?? ALIPAY_GATEWAY_PROD;
1501
+ const controller = new AbortController();
1502
+ const timeout = setTimeout(() => controller.abort(), 5e3);
1503
+ const response = await fetch(gatewayUrl, {
1504
+ method: "HEAD",
1505
+ signal: controller.signal
1506
+ }).catch(() => null);
1507
+ clearTimeout(timeout);
1508
+ const latencyMs = Date.now() - start;
1509
+ if (!response) {
1510
+ return { healthy: false, error: `gateway unreachable: ${gatewayUrl}`, latencyMs };
1282
1511
  }
1512
+ return { healthy: true, latencyMs };
1283
1513
  }
1284
- shuffle(array) {
1285
- const result = [...array];
1286
- for (let i = result.length - 1; i > 0; i--) {
1287
- const j = Math.floor(Math.random() * (i + 1));
1288
- [result[i], result[j]] = [result[j], result[i]];
1514
+ /** Bundle the facilitator config into the shape openapi.ts wants. */
1515
+ getOpenApiConfig() {
1516
+ return {
1517
+ gateway_url: this.config.gateway_url ?? ALIPAY_GATEWAY_PROD,
1518
+ app_id: this.config.app_id,
1519
+ private_key_pem: this.config.private_key_pem,
1520
+ alipay_public_key_pem: this.config.alipay_public_key_pem,
1521
+ sign_type: this.config.sign_type
1522
+ };
1523
+ }
1524
+ };
1525
+ function generateOutTradeNo() {
1526
+ return "VID" + import_node_crypto2.default.randomBytes(22).toString("base64url").slice(0, 29);
1527
+ }
1528
+ function formatPayBefore(now) {
1529
+ const expiry = new Date(now.getTime() + ALIPAY_PAY_BEFORE_MS);
1530
+ return expiry.toISOString().replace(/\.\d{3}Z$/, "Z");
1531
+ }
1532
+ function extractProofHeader(payload) {
1533
+ if (typeof payload === "string") {
1534
+ if (payload.length === 0) {
1535
+ throw new Error("alipay Payment-Proof is empty");
1289
1536
  }
1290
- return result;
1537
+ return payload;
1291
1538
  }
1292
- async sortByCheapest(facilitators) {
1293
- const withFees = await Promise.all(
1294
- facilitators.map(async (f) => {
1295
- try {
1296
- const fee = await f.getFee?.();
1297
- return { facilitator: f, perTx: fee?.perTx ?? Infinity };
1298
- } catch {
1299
- return { facilitator: f, perTx: Infinity };
1300
- }
1301
- })
1539
+ if (payload !== null && typeof payload === "object") {
1540
+ const obj = payload;
1541
+ const candidate = obj.paymentProof ?? obj.proofHeader;
1542
+ if (typeof candidate === "string" && candidate.length > 0) {
1543
+ return candidate;
1544
+ }
1545
+ }
1546
+ throw new Error(
1547
+ "alipay payment payload must be a Base64URL string or {paymentProof: string} / {proofHeader: string}"
1548
+ );
1549
+ }
1550
+ function decodeProof(proofHeader) {
1551
+ let parsed;
1552
+ try {
1553
+ parsed = JSON.parse(decodeBase64UrlWithPadFix(proofHeader));
1554
+ } catch (e) {
1555
+ throw new Error(
1556
+ `failed to decode Payment-Proof: ${e instanceof Error ? e.message : String(e)}`
1302
1557
  );
1303
- withFees.sort((a, b) => a.perTx - b.perTx);
1304
- return withFees.map((w) => w.facilitator);
1305
1558
  }
1306
- async sortByFastest(facilitators) {
1307
- const withLatency = await Promise.all(
1308
- facilitators.map(async (f) => {
1309
- try {
1310
- const health = await f.healthCheck();
1311
- return { facilitator: f, latency: health.latencyMs ?? Infinity };
1312
- } catch {
1313
- return { facilitator: f, latency: Infinity };
1314
- }
1315
- })
1559
+ if (parsed === null || typeof parsed !== "object") {
1560
+ throw new Error("decoded Payment-Proof is not an object");
1561
+ }
1562
+ const obj = parsed;
1563
+ const protocol = obj.protocol;
1564
+ const method = obj.method;
1565
+ if (!protocol || typeof protocol.payment_proof !== "string") {
1566
+ throw new Error("decoded Payment-Proof missing protocol.payment_proof");
1567
+ }
1568
+ if (typeof protocol.trade_no !== "string") {
1569
+ throw new Error("decoded Payment-Proof missing protocol.trade_no");
1570
+ }
1571
+ if (!method || typeof method.client_session !== "string") {
1572
+ throw new Error("decoded Payment-Proof missing method.client_session");
1573
+ }
1574
+ return parsed;
1575
+ }
1576
+
1577
+ // src/facilitators/wechat.ts
1578
+ var import_node_crypto4 = __toESM(require("crypto"));
1579
+
1580
+ // src/facilitators/wechat/sign.ts
1581
+ var import_node_crypto3 = __toESM(require("crypto"));
1582
+ var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
1583
+ function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
1584
+ return `${method.toUpperCase()}
1585
+ ${urlPath}
1586
+ ${timestamp}
1587
+ ${nonce}
1588
+ ${body}
1589
+ `;
1590
+ }
1591
+ function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
1592
+ const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
1593
+ const signer = import_node_crypto3.default.createSign("RSA-SHA256");
1594
+ signer.update(message, "utf-8");
1595
+ signer.end();
1596
+ return signer.sign(privateKeyPem, "base64");
1597
+ }
1598
+ function buildAuthorizationToken(args) {
1599
+ const fields = [
1600
+ `mchid="${args.mchid}"`,
1601
+ `nonce_str="${args.nonce}"`,
1602
+ `signature="${args.signature}"`,
1603
+ `timestamp="${args.timestamp}"`,
1604
+ `serial_no="${args.serialNo}"`
1605
+ ].join(",");
1606
+ return `${WECHAT_AUTH_SCHEMA} ${fields}`;
1607
+ }
1608
+ function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
1609
+ try {
1610
+ const message = `${timestamp}
1611
+ ${nonce}
1612
+ ${body}
1613
+ `;
1614
+ const verifier = import_node_crypto3.default.createVerify("RSA-SHA256");
1615
+ verifier.update(message, "utf-8");
1616
+ verifier.end();
1617
+ return verifier.verify(platformPublicKeyPem, signature, "base64");
1618
+ } catch {
1619
+ return false;
1620
+ }
1621
+ }
1622
+ function generateNonce() {
1623
+ return import_node_crypto3.default.randomBytes(16).toString("hex");
1624
+ }
1625
+
1626
+ // src/facilitators/wechat/api.ts
1627
+ var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
1628
+ var WechatApiError = class extends Error {
1629
+ status;
1630
+ code;
1631
+ constructor(message, status, code) {
1632
+ super(message);
1633
+ this.name = "WechatApiError";
1634
+ this.status = status;
1635
+ this.code = code;
1636
+ }
1637
+ };
1638
+ async function wechatV3Call(method, urlPath, body, config) {
1639
+ const base = config.api_base ?? WECHAT_API_BASE;
1640
+ const bodyStr = body === null ? "" : JSON.stringify(body);
1641
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1642
+ const nonce = generateNonce();
1643
+ const signature = wechatV3Sign(
1644
+ method,
1645
+ urlPath,
1646
+ timestamp,
1647
+ nonce,
1648
+ bodyStr,
1649
+ config.private_key_pem
1650
+ );
1651
+ const authorization = buildAuthorizationToken({
1652
+ mchid: config.mchid,
1653
+ serialNo: config.serial_no,
1654
+ nonce,
1655
+ timestamp,
1656
+ signature
1657
+ });
1658
+ const response = await fetch(`${base}${urlPath}`, {
1659
+ method,
1660
+ headers: {
1661
+ Authorization: authorization,
1662
+ Accept: "application/json",
1663
+ "Content-Type": "application/json",
1664
+ // WeChat requires a non-empty UA; some edge nodes 403 a blank one.
1665
+ "User-Agent": "moltspay-wechat/1.0"
1666
+ },
1667
+ body: method === "GET" ? void 0 : bodyStr
1668
+ });
1669
+ const text = await response.text();
1670
+ if (config.platform_public_key_pem && text.length > 0) {
1671
+ const ts = response.headers.get("Wechatpay-Timestamp");
1672
+ const nc = response.headers.get("Wechatpay-Nonce");
1673
+ const sig = response.headers.get("Wechatpay-Signature");
1674
+ if (!ts || !nc || !sig) {
1675
+ throw new Error(
1676
+ `WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
1677
+ );
1678
+ }
1679
+ if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
1680
+ throw new Error(
1681
+ `WeChat v3 ${method} ${urlPath}: response signature verification failed`
1682
+ );
1683
+ }
1684
+ }
1685
+ let json = {};
1686
+ if (text.length > 0) {
1687
+ try {
1688
+ json = JSON.parse(text);
1689
+ } catch {
1690
+ throw new Error(
1691
+ `WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
1692
+ );
1693
+ }
1694
+ }
1695
+ if (!response.ok) {
1696
+ const code = typeof json.code === "string" ? json.code : void 0;
1697
+ const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
1698
+ throw new WechatApiError(
1699
+ `WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
1700
+ response.status,
1701
+ code
1316
1702
  );
1317
- withLatency.sort((a, b) => a.latency - b.latency);
1318
- return withLatency.map((w) => w.facilitator);
1703
+ }
1704
+ return { status: response.status, body: json };
1705
+ }
1706
+
1707
+ // src/facilitators/wechat.ts
1708
+ var WECHAT_NETWORK = "wechat";
1709
+ var WECHAT_SCHEME = "wechatpay-native";
1710
+ var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
1711
+ var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
1712
+ var WechatFacilitator = class extends BaseFacilitator {
1713
+ name = "wechat";
1714
+ displayName = "WeChat Pay";
1715
+ supportedNetworks = [WECHAT_NETWORK];
1716
+ config;
1717
+ constructor(config) {
1718
+ super();
1719
+ this.config = { api_base: WECHAT_API_BASE, ...config };
1319
1720
  }
1320
1721
  /**
1321
- * Verify payment using configured facilitators
1722
+ * Place a Native order and build the 402 challenge. The returned
1723
+ * `code_url` is payer-agnostic — any WeChat user may scan it.
1322
1724
  */
1323
- async verify(paymentPayload, requirements) {
1324
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1325
- const facilitators = await this.getOrderedFacilitators(network);
1326
- let lastError;
1327
- for (const f of facilitators) {
1328
- try {
1329
- console.log(`[Registry] Trying ${f.name} for verify...`);
1330
- const result = await f.verify(paymentPayload, requirements);
1331
- if (result.valid) {
1332
- console.log(`[Registry] ${f.name} verify succeeded`);
1333
- return { ...result, facilitator: f.name };
1334
- }
1335
- lastError = result.error;
1336
- console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
1337
- if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
1338
- break;
1339
- }
1340
- } catch (err) {
1341
- lastError = err.message;
1342
- console.error(`[Registry] ${f.name} error:`, err.message);
1343
- }
1725
+ async createPaymentRequirements(opts) {
1726
+ if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
1727
+ throw new Error(
1728
+ `WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
1729
+ );
1344
1730
  }
1345
- return {
1731
+ const total = cnyToFen(opts.priceCny);
1732
+ if (total < 1) {
1733
+ throw new Error(
1734
+ `WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
1735
+ );
1736
+ }
1737
+ const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
1738
+ const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
1739
+ const body = {
1740
+ appid: this.config.appid,
1741
+ mchid: this.config.mchid,
1742
+ description: opts.description,
1743
+ out_trade_no: outTradeNo,
1744
+ notify_url: this.config.notify_url,
1745
+ time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
1746
+ amount: { total, currency: "CNY" }
1747
+ };
1748
+ if (opts.attach) {
1749
+ const attachStr = JSON.stringify(opts.attach);
1750
+ if (Buffer.byteLength(attachStr, "utf8") > 128) {
1751
+ throw new Error(
1752
+ `WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
1753
+ );
1754
+ }
1755
+ body.attach = attachStr;
1756
+ }
1757
+ const { body: resp } = await wechatV3Call(
1758
+ "POST",
1759
+ "/v3/pay/transactions/native",
1760
+ body,
1761
+ this.getApiConfig()
1762
+ );
1763
+ const codeUrl = resp.code_url;
1764
+ if (typeof codeUrl !== "string" || codeUrl.length === 0) {
1765
+ throw new Error(
1766
+ `WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
1767
+ );
1768
+ }
1769
+ const x402Accepts = {
1770
+ scheme: WECHAT_SCHEME,
1771
+ network: WECHAT_NETWORK,
1772
+ asset: "CNY",
1773
+ amount: opts.priceCny,
1774
+ payTo: this.config.mchid,
1775
+ maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
1776
+ extra: {
1777
+ code_url: codeUrl,
1778
+ out_trade_no: outTradeNo
1779
+ }
1780
+ };
1781
+ return { x402Accepts, codeUrl, outTradeNo };
1782
+ }
1783
+ /**
1784
+ * Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
1785
+ * (missing out_trade_no, gateway error, not-yet-paid) return
1786
+ * `{ valid: false, error }`; no exception escapes.
1787
+ */
1788
+ async verify(paymentPayload, requirements) {
1789
+ try {
1790
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1791
+ const resp = await this.queryOrder(outTradeNo);
1792
+ const tradeState = resp.trade_state;
1793
+ if (tradeState !== "SUCCESS") {
1794
+ return {
1795
+ valid: false,
1796
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
1797
+ details: { trade_state: tradeState, out_trade_no: outTradeNo }
1798
+ };
1799
+ }
1800
+ return {
1801
+ valid: true,
1802
+ details: {
1803
+ trade_state: tradeState,
1804
+ transaction_id: resp.transaction_id,
1805
+ out_trade_no: resp.out_trade_no ?? outTradeNo,
1806
+ amount: resp.amount,
1807
+ attach: resp.attach,
1808
+ // Payer identity, gateway-attested. Present on a SUCCESS Native
1809
+ // order even though order creation was payer-agnostic; anchors the
1810
+ // custodial balance to a real WeChat user. @see WECHAT fiat auth design.
1811
+ openid: resp.payer?.openid
1812
+ }
1813
+ };
1814
+ } catch (e) {
1815
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
1816
+ }
1817
+ }
1818
+ /**
1819
+ * Confirm settlement. Native captures funds at SUCCESS, so this is an
1820
+ * idempotent re-confirm that returns the `transaction_id`. Like Alipay's
1821
+ * fulfillment confirm, failures are surfaced but non-fatal to an
1822
+ * already-delivered resource (caller logs, does not roll back).
1823
+ */
1824
+ async settle(paymentPayload, requirements) {
1825
+ try {
1826
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1827
+ const resp = await this.queryOrder(outTradeNo);
1828
+ const tradeState = resp.trade_state;
1829
+ const transactionId = resp.transaction_id;
1830
+ if (tradeState !== "SUCCESS") {
1831
+ return {
1832
+ success: false,
1833
+ transaction: transactionId,
1834
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
1835
+ status: tradeState
1836
+ };
1837
+ }
1838
+ return { success: true, transaction: transactionId, status: "fulfilled" };
1839
+ } catch (e) {
1840
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1841
+ }
1842
+ }
1843
+ /**
1844
+ * Validate keys parse, apiv3 key length, and gateway reachability. Does
1845
+ * NOT make a business API call.
1846
+ */
1847
+ async healthCheck() {
1848
+ const start = Date.now();
1849
+ try {
1850
+ import_node_crypto4.default.createPrivateKey(this.config.private_key_pem);
1851
+ } catch (e) {
1852
+ return {
1853
+ healthy: false,
1854
+ error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1855
+ };
1856
+ }
1857
+ if (this.config.platform_public_key_pem) {
1858
+ try {
1859
+ import_node_crypto4.default.createPublicKey(this.config.platform_public_key_pem);
1860
+ } catch (e) {
1861
+ return {
1862
+ healthy: false,
1863
+ error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1864
+ };
1865
+ }
1866
+ }
1867
+ if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
1868
+ return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
1869
+ }
1870
+ const base = this.config.api_base ?? WECHAT_API_BASE;
1871
+ const controller = new AbortController();
1872
+ const timeout = setTimeout(() => controller.abort(), 5e3);
1873
+ const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
1874
+ clearTimeout(timeout);
1875
+ const latencyMs = Date.now() - start;
1876
+ if (!response) {
1877
+ return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
1878
+ }
1879
+ return { healthy: true, latencyMs };
1880
+ }
1881
+ /** Query a Native order by out_trade_no. The query string is part of the signed path. */
1882
+ async queryOrder(outTradeNo) {
1883
+ const path4 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
1884
+ const { body } = await wechatV3Call("GET", path4, null, this.getApiConfig());
1885
+ return body;
1886
+ }
1887
+ /** Project the facilitator config down to what api.ts needs. */
1888
+ getApiConfig() {
1889
+ return {
1890
+ mchid: this.config.mchid,
1891
+ serial_no: this.config.serial_no,
1892
+ private_key_pem: this.config.private_key_pem,
1893
+ platform_public_key_pem: this.config.platform_public_key_pem,
1894
+ api_base: this.config.api_base
1895
+ };
1896
+ }
1897
+ };
1898
+ function cnyToFen(cny) {
1899
+ return Math.round(parseFloat(cny) * 100);
1900
+ }
1901
+ function generateOutTradeNo2() {
1902
+ return "WX" + import_node_crypto4.default.randomBytes(15).toString("hex");
1903
+ }
1904
+ function parseWechatAttach(attach) {
1905
+ if (typeof attach !== "string" || attach.length === 0) return null;
1906
+ try {
1907
+ const parsed = JSON.parse(attach);
1908
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1909
+ } catch {
1910
+ return null;
1911
+ }
1912
+ }
1913
+ function formatTimeExpire(d) {
1914
+ const pad = (n) => String(n).padStart(2, "0");
1915
+ const offMin = -d.getTimezoneOffset();
1916
+ const sign = offMin >= 0 ? "+" : "-";
1917
+ const abs = Math.abs(offMin);
1918
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
1919
+ }
1920
+ function extractOutTradeNo(paymentPayload, requirements) {
1921
+ const p = paymentPayload?.payload;
1922
+ if (typeof p === "string" && p.length > 0) return p;
1923
+ if (p !== null && typeof p === "object") {
1924
+ const obj = p;
1925
+ const cand = obj.out_trade_no ?? obj.outTradeNo;
1926
+ if (typeof cand === "string" && cand.length > 0) return cand;
1927
+ }
1928
+ const fromReq = requirements?.extra?.out_trade_no;
1929
+ if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
1930
+ throw new Error(
1931
+ "wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
1932
+ );
1933
+ }
1934
+
1935
+ // src/facilitators/balance/ledger.ts
1936
+ var import_node_crypto5 = require("crypto");
1937
+ var DEFAULT_SINGLE_LIMIT_SAT = 500;
1938
+ var DEFAULT_DAILY_LIMIT_SAT = 1e3;
1939
+ function toSat(amount) {
1940
+ const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
1941
+ if (!/^\d+(\.\d{1,2})?$/.test(s)) {
1942
+ throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
1943
+ }
1944
+ const [whole, frac = ""] = s.split(".");
1945
+ return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
1946
+ }
1947
+ function fromSat(sat) {
1948
+ return (sat / 100).toFixed(2);
1949
+ }
1950
+ var BalanceLedger = class {
1951
+ db;
1952
+ defaultSingleLimitSat;
1953
+ defaultDailyLimitSat;
1954
+ constructor(config) {
1955
+ const getBuiltin = process.getBuiltinModule;
1956
+ const sqlite = getBuiltin?.("node:sqlite");
1957
+ if (!sqlite?.DatabaseSync) {
1958
+ throw new Error(
1959
+ `The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
1960
+ );
1961
+ }
1962
+ const { DatabaseSync } = sqlite;
1963
+ this.db = new DatabaseSync(config.dbPath);
1964
+ this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
1965
+ this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
1966
+ this.db.exec("PRAGMA journal_mode = WAL");
1967
+ this.db.exec(`
1968
+ CREATE TABLE IF NOT EXISTS buyers (
1969
+ buyer_id TEXT PRIMARY KEY,
1970
+ display_name TEXT,
1971
+ balance_sat INTEGER NOT NULL DEFAULT 0,
1972
+ total_topup_sat INTEGER NOT NULL DEFAULT 0,
1973
+ total_spent_sat INTEGER NOT NULL DEFAULT 0,
1974
+ daily_limit_sat INTEGER NOT NULL,
1975
+ single_limit_sat INTEGER NOT NULL,
1976
+ status TEXT NOT NULL DEFAULT 'active',
1977
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1978
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1979
+ );
1980
+ CREATE TABLE IF NOT EXISTS ledger_transactions (
1981
+ id TEXT PRIMARY KEY,
1982
+ buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
1983
+ type TEXT NOT NULL,
1984
+ amount_sat INTEGER NOT NULL,
1985
+ service TEXT,
1986
+ description TEXT,
1987
+ request_id TEXT,
1988
+ external_ref TEXT,
1989
+ refunds_tx_id TEXT,
1990
+ status TEXT NOT NULL DEFAULT 'completed',
1991
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1992
+ );
1993
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
1994
+ ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
1995
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
1996
+ ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
1997
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
1998
+ ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
1999
+ CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
2000
+ ON ledger_transactions(buyer_id, created_at);
2001
+ CREATE TABLE IF NOT EXISTS ledger_meta (
2002
+ key TEXT PRIMARY KEY,
2003
+ value TEXT NOT NULL
2004
+ );
2005
+ `);
2006
+ const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
2007
+ if (!cols.includes("signer_address")) {
2008
+ this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
2009
+ }
2010
+ if (!cols.includes("wechat_openid")) {
2011
+ this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
2012
+ }
2013
+ const currency = config.currency ?? "USD";
2014
+ const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
2015
+ if (existingCurrency) {
2016
+ if (existingCurrency.value !== currency) {
2017
+ throw new Error(
2018
+ `Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
2019
+ );
2020
+ }
2021
+ } else {
2022
+ this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
2023
+ }
2024
+ }
2025
+ /** Fetch a buyer, or null. */
2026
+ getBuyer(buyerId) {
2027
+ const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
2028
+ return row ?? null;
2029
+ }
2030
+ /** Fetch a buyer, creating an empty active account on first sight. */
2031
+ getOrCreateBuyer(buyerId, displayName) {
2032
+ const existing = this.getBuyer(buyerId);
2033
+ if (existing) return existing;
2034
+ this.db.prepare(
2035
+ `INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
2036
+ VALUES (?, ?, ?, ?)`
2037
+ ).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
2038
+ return this.getBuyer(buyerId);
2039
+ }
2040
+ /**
2041
+ * Record the gateway-attested WeChat payer openid that funded a buyer, on
2042
+ * top-up confirm. Idempotent and observational (stage 1a): it never
2043
+ * overwrites an existing binding with a *different* openid — a conflict is
2044
+ * reported to the caller (possible account sharing / spoof) but not
2045
+ * enforced here. Creates the buyer if absent.
2046
+ */
2047
+ bindOpenid(buyerId, openid) {
2048
+ const buyer = this.getOrCreateBuyer(buyerId);
2049
+ if (buyer.wechat_openid === openid) {
2050
+ return { bound: true, conflict: false, existing: openid };
2051
+ }
2052
+ if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
2053
+ return { bound: false, conflict: true, existing: buyer.wechat_openid };
2054
+ }
2055
+ this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
2056
+ return { bound: true, conflict: false, existing: null };
2057
+ }
2058
+ /**
2059
+ * TOFU-bind the account's spending signer address (EVM, lowercase). First
2060
+ * signed request records it; later requests must match. A mismatch is
2061
+ * reported (`conflict`) so the caller can reject under `enforce` — it is
2062
+ * never silently overwritten. Creates the buyer if absent.
2063
+ */
2064
+ bindSigner(buyerId, address) {
2065
+ const a = address.toLowerCase();
2066
+ const buyer = this.getOrCreateBuyer(buyerId);
2067
+ if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
2068
+ if (buyer.signer_address && buyer.signer_address !== a) {
2069
+ return { bound: false, conflict: true, existing: buyer.signer_address };
2070
+ }
2071
+ this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
2072
+ return { bound: true, conflict: false, existing: null };
2073
+ }
2074
+ /** Sum of today's (UTC) completed deducts minus refunds issued against them. */
2075
+ spentTodaySat(buyerId) {
2076
+ const row = this.db.prepare(
2077
+ `SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
2078
+ FROM ledger_transactions
2079
+ WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
2080
+ ).get(buyerId);
2081
+ return Math.max(0, row.spent);
2082
+ }
2083
+ /**
2084
+ * Read-only deduction precheck (the rail's `verify`). Never mutates.
2085
+ * Returns the same error codes `deduct` would.
2086
+ */
2087
+ checkDeduct(buyerId, amountSat) {
2088
+ const buyer = this.getBuyer(buyerId);
2089
+ if (!buyer) return { success: false, error: "buyer_not_found" };
2090
+ if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
2091
+ if (amountSat > buyer.single_limit_sat) {
2092
+ return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
2093
+ }
2094
+ const spent = this.spentTodaySat(buyerId);
2095
+ if (spent + amountSat > buyer.daily_limit_sat) {
2096
+ return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
2097
+ }
2098
+ if (buyer.balance_sat < amountSat) {
2099
+ return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
2100
+ }
2101
+ return { success: true, balanceSat: buyer.balance_sat };
2102
+ }
2103
+ /**
2104
+ * Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
2105
+ * SQLite transaction; a `request_id` replay returns the original tx
2106
+ * without charging again.
2107
+ */
2108
+ deduct(opts) {
2109
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2110
+ throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
2111
+ }
2112
+ if (opts.requestId) {
2113
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
2114
+ if (prior) {
2115
+ const buyer = this.getBuyer(prior.buyer_id);
2116
+ return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
2117
+ }
2118
+ }
2119
+ this.db.exec("BEGIN IMMEDIATE");
2120
+ try {
2121
+ const check = this.checkDeduct(opts.buyerId, opts.amountSat);
2122
+ if (!check.success) {
2123
+ this.db.exec("ROLLBACK");
2124
+ return check;
2125
+ }
2126
+ const updated = this.db.prepare(
2127
+ `UPDATE buyers SET
2128
+ balance_sat = balance_sat - ?,
2129
+ total_spent_sat = total_spent_sat + ?,
2130
+ updated_at = datetime('now')
2131
+ WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
2132
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
2133
+ if (Number(updated.changes) !== 1) {
2134
+ this.db.exec("ROLLBACK");
2135
+ return { success: false, error: "insufficient_balance" };
2136
+ }
2137
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2138
+ this.db.prepare(
2139
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
2140
+ VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
2141
+ ).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
2142
+ this.db.exec("COMMIT");
2143
+ const buyer = this.getBuyer(opts.buyerId);
2144
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2145
+ } catch (err) {
2146
+ try {
2147
+ this.db.exec("ROLLBACK");
2148
+ } catch {
2149
+ }
2150
+ throw err;
2151
+ }
2152
+ }
2153
+ /**
2154
+ * Credit a top-up. `externalRef` is the settlement proof reference
2155
+ * (on-chain tx hash / fiat trade number) and is unique: replaying the same
2156
+ * reference returns the original row without crediting twice.
2157
+ */
2158
+ topup(opts) {
2159
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2160
+ throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
2161
+ }
2162
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
2163
+ if (prior) {
2164
+ const buyer2 = this.getBuyer(prior.buyer_id);
2165
+ return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
2166
+ }
2167
+ this.getOrCreateBuyer(opts.buyerId);
2168
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2169
+ this.db.exec("BEGIN IMMEDIATE");
2170
+ try {
2171
+ this.db.prepare(
2172
+ `UPDATE buyers SET
2173
+ balance_sat = balance_sat + ?,
2174
+ total_topup_sat = total_topup_sat + ?,
2175
+ updated_at = datetime('now')
2176
+ WHERE buyer_id = ?`
2177
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId);
2178
+ this.db.prepare(
2179
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
2180
+ VALUES (?, ?, 'topup', ?, ?, ?)`
2181
+ ).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
2182
+ this.db.exec("COMMIT");
2183
+ } catch (err) {
2184
+ try {
2185
+ this.db.exec("ROLLBACK");
2186
+ } catch {
2187
+ }
2188
+ throw err;
2189
+ }
2190
+ const buyer = this.getBuyer(opts.buyerId);
2191
+ return { txId, balanceSat: buyer.balance_sat };
2192
+ }
2193
+ /**
2194
+ * Reverse a deduct (service failed after the charge). Idempotent: the
2195
+ * unique index on `refunds_tx_id` means a second refund of the same deduct
2196
+ * returns the original refund row.
2197
+ */
2198
+ refund(deductTxId, reason) {
2199
+ const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
2200
+ if (!deductRow) return { success: false, error: "tx_not_found" };
2201
+ if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
2202
+ const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
2203
+ if (priorRefund) {
2204
+ const buyer = this.getBuyer(deductRow.buyer_id);
2205
+ return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
2206
+ }
2207
+ this.db.exec("BEGIN IMMEDIATE");
2208
+ try {
2209
+ this.db.prepare(
2210
+ `UPDATE buyers SET
2211
+ balance_sat = balance_sat + ?,
2212
+ total_spent_sat = total_spent_sat - ?,
2213
+ updated_at = datetime('now')
2214
+ WHERE buyer_id = ?`
2215
+ ).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
2216
+ this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
2217
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2218
+ this.db.prepare(
2219
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
2220
+ VALUES (?, ?, 'refund', ?, ?, ?)`
2221
+ ).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
2222
+ this.db.exec("COMMIT");
2223
+ const buyer = this.getBuyer(deductRow.buyer_id);
2224
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2225
+ } catch (err) {
2226
+ try {
2227
+ this.db.exec("ROLLBACK");
2228
+ } catch {
2229
+ }
2230
+ throw err;
2231
+ }
2232
+ }
2233
+ /** Paged transaction history, newest first (rowid breaks same-second ties). */
2234
+ listTransactions(buyerId, limit = 20, offset = 0) {
2235
+ return this.db.prepare(
2236
+ `SELECT * FROM ledger_transactions WHERE buyer_id = ?
2237
+ ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
2238
+ ).all(buyerId, limit, offset);
2239
+ }
2240
+ /** Quick integrity probe for healthCheck(). */
2241
+ integrityOk() {
2242
+ const row = this.db.prepare(`PRAGMA quick_check`).get();
2243
+ return row.quick_check === "ok";
2244
+ }
2245
+ close() {
2246
+ this.db.close();
2247
+ }
2248
+ };
2249
+
2250
+ // src/facilitators/balance/auth.ts
2251
+ var import_ethers2 = require("ethers");
2252
+ var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
2253
+ var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
2254
+ function extractBalanceAuth(raw) {
2255
+ if (!raw || typeof raw !== "object") return null;
2256
+ const a = raw;
2257
+ if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
2258
+ return { timestamp: a.timestamp, signature: a.signature };
2259
+ }
2260
+ return null;
2261
+ }
2262
+ function buildDeductMessage(f) {
2263
+ return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
2264
+ }
2265
+ function verifyDeductAuth(opts) {
2266
+ const { auth } = opts;
2267
+ if (!auth) return { ok: false, reason: "no_signature" };
2268
+ if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
2269
+ if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
2270
+ return { ok: false, reason: "timestamp_skew" };
2271
+ }
2272
+ const message = buildDeductMessage({
2273
+ buyerId: opts.buyerId,
2274
+ requestId: opts.requestId,
2275
+ service: opts.service,
2276
+ timestamp: auth.timestamp
2277
+ });
2278
+ try {
2279
+ const recovered = import_ethers2.ethers.verifyMessage(message, auth.signature).toLowerCase();
2280
+ return { ok: true, recovered };
2281
+ } catch {
2282
+ return { ok: false, reason: "bad_signature" };
2283
+ }
2284
+ }
2285
+
2286
+ // src/facilitators/balance.ts
2287
+ var BALANCE_NETWORK = "balance";
2288
+ var BALANCE_SCHEME = "balance";
2289
+ function extractBalancePayload(payment) {
2290
+ const p = payment.payload;
2291
+ if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
2292
+ return {
2293
+ buyer_id: p.buyer_id,
2294
+ request_id: typeof p.request_id === "string" ? p.request_id : void 0,
2295
+ auth: extractBalanceAuth(p.auth) ?? void 0
2296
+ };
2297
+ }
2298
+ return null;
2299
+ }
2300
+ var BalanceFacilitator = class extends BaseFacilitator {
2301
+ name = "balance";
2302
+ displayName = "Custodial Balance";
2303
+ supportedNetworks = [BALANCE_NETWORK];
2304
+ currency;
2305
+ /** User-auth rollout gate for deductions (off | shadow | enforce). */
2306
+ authMode;
2307
+ ledger;
2308
+ constructor(config) {
2309
+ super();
2310
+ this.currency = config.currency ?? "USD";
2311
+ this.authMode = config.auth_mode ?? "off";
2312
+ const ledgerConfig = {
2313
+ dbPath: config.db_path,
2314
+ defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
2315
+ defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
2316
+ currency: this.currency
2317
+ };
2318
+ this.ledger = new BalanceLedger(ledgerConfig);
2319
+ }
2320
+ /** Direct ledger access for the server's balance-management endpoints. */
2321
+ getLedger() {
2322
+ return this.ledger;
2323
+ }
2324
+ /**
2325
+ * Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
2326
+ */
2327
+ createPaymentRequirements(opts) {
2328
+ toSat(opts.price);
2329
+ return {
2330
+ scheme: BALANCE_SCHEME,
2331
+ network: BALANCE_NETWORK,
2332
+ asset: this.currency,
2333
+ amount: opts.price,
2334
+ payTo: "custodial",
2335
+ maxTimeoutSeconds: 30,
2336
+ extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
2337
+ };
2338
+ }
2339
+ /** Read-only funds/limits precheck. Never mutates the ledger. */
2340
+ async verify(paymentPayload, requirements) {
2341
+ const payload = extractBalancePayload(paymentPayload);
2342
+ if (!payload) {
2343
+ return { valid: false, error: "Missing buyer_id in balance payment payload" };
2344
+ }
2345
+ let amountSat;
2346
+ try {
2347
+ amountSat = toSat(requirements.amount);
2348
+ } catch (err) {
2349
+ return { valid: false, error: err.message };
2350
+ }
2351
+ const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
2352
+ if (!check.success) {
2353
+ return {
2354
+ valid: false,
2355
+ error: this.describeDeductError(check),
2356
+ details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
2357
+ };
2358
+ }
2359
+ return { valid: true, details: { balance: fromSat(check.balanceSat) } };
2360
+ }
2361
+ /**
2362
+ * The atomic deduction. Idempotent on `request_id`; the returned
2363
+ * `transaction` is the ledger tx id (usable for `refund`).
2364
+ */
2365
+ async settle(paymentPayload, requirements) {
2366
+ const payload = extractBalancePayload(paymentPayload);
2367
+ if (!payload) {
2368
+ return { success: false, error: "Missing buyer_id in balance payment payload" };
2369
+ }
2370
+ let amountSat;
2371
+ try {
2372
+ amountSat = toSat(requirements.amount);
2373
+ } catch (err) {
2374
+ return { success: false, error: err.message };
2375
+ }
2376
+ const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
2377
+ let result;
2378
+ try {
2379
+ result = this.ledger.deduct({
2380
+ buyerId: payload.buyer_id,
2381
+ amountSat,
2382
+ requestId: payload.request_id,
2383
+ service: serviceId
2384
+ });
2385
+ } catch (err) {
2386
+ return { success: false, error: `Ledger deduct failed: ${err.message}` };
2387
+ }
2388
+ if (!result.success) {
2389
+ return { success: false, error: this.describeDeductError(result), status: result.error };
2390
+ }
2391
+ return {
2392
+ success: true,
2393
+ transaction: result.txId,
2394
+ status: result.replayed ? "replayed" : "deducted"
2395
+ };
2396
+ }
2397
+ /** Reverse a deduct after a downstream failure. Idempotent per deduct. */
2398
+ refund(deductTxId, reason) {
2399
+ return this.ledger.refund(deductTxId, reason);
2400
+ }
2401
+ /**
2402
+ * Credit a gateway-verified external settlement to a buyer's balance. The
2403
+ * single entry point for every funding path -- WeChat callback, WeChat
2404
+ * polling, and the operator `/balance/topup` endpoint -- so they share one
2405
+ * idempotency boundary: `externalRef` is unique in the ledger, so a replay
2406
+ * credits nothing and returns the original transaction (`replayed: true`).
2407
+ * Callers must pass a gateway-verified `amountSat`, never a client-declared
2408
+ * amount.
2409
+ */
2410
+ credit(opts) {
2411
+ const result = this.ledger.topup({
2412
+ buyerId: opts.buyerId,
2413
+ amountSat: opts.amountSat,
2414
+ externalRef: opts.externalRef,
2415
+ description: opts.description
2416
+ });
2417
+ return {
2418
+ txId: result.txId,
2419
+ balance: fromSat(result.balanceSat),
2420
+ balanceSat: result.balanceSat,
2421
+ replayed: result.replayed ?? false
2422
+ };
2423
+ }
2424
+ async healthCheck() {
2425
+ const start = Date.now();
2426
+ try {
2427
+ const ok = this.ledger.integrityOk();
2428
+ return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
2429
+ } catch (err) {
2430
+ return { healthy: false, error: err.message };
2431
+ }
2432
+ }
2433
+ describeDeductError(result) {
2434
+ switch (result.error) {
2435
+ case "buyer_not_found":
2436
+ return "Unknown buyer: top up first to create an account";
2437
+ case "buyer_not_active":
2438
+ return "Buyer account is frozen or banned";
2439
+ case "insufficient_balance":
2440
+ return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
2441
+ case "exceeds_single_limit":
2442
+ return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2443
+ case "exceeds_daily_limit":
2444
+ return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2445
+ default:
2446
+ return "Deduction failed";
2447
+ }
2448
+ }
2449
+ };
2450
+
2451
+ // src/facilitators/registry.ts
2452
+ var import_web33 = require("@solana/web3.js");
2453
+ var import_bs58 = __toESM(require("bs58"));
2454
+ var FacilitatorRegistry = class {
2455
+ factories = /* @__PURE__ */ new Map();
2456
+ instances = /* @__PURE__ */ new Map();
2457
+ selection;
2458
+ roundRobinIndex = 0;
2459
+ constructor(selection) {
2460
+ this.registerFactory("cdp", (config) => new CDPFacilitator(config));
2461
+ this.registerFactory("tempo", () => new TempoFacilitator());
2462
+ this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
2463
+ this.registerFactory("solana", (config) => {
2464
+ let feePayerKeypair;
2465
+ const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
2466
+ if (feePayerKey) {
2467
+ try {
2468
+ feePayerKeypair = import_web33.Keypair.fromSecretKey(import_bs58.default.decode(feePayerKey));
2469
+ } catch (e) {
2470
+ console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
2471
+ }
2472
+ }
2473
+ return new SolanaFacilitator({ feePayerKeypair });
2474
+ });
2475
+ this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
2476
+ this.registerFactory("wechat", (config) => new WechatFacilitator(config));
2477
+ this.registerFactory("balance", (config) => new BalanceFacilitator(config));
2478
+ this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
2479
+ }
2480
+ /**
2481
+ * Register a new facilitator factory
2482
+ */
2483
+ registerFactory(name, factory) {
2484
+ this.factories.set(name, factory);
2485
+ }
2486
+ /**
2487
+ * Get or create a facilitator instance
2488
+ */
2489
+ get(name, config) {
2490
+ if (this.instances.has(name)) {
2491
+ return this.instances.get(name);
2492
+ }
2493
+ const factory = this.factories.get(name);
2494
+ if (!factory) {
2495
+ throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
2496
+ }
2497
+ const mergedConfig = {
2498
+ ...this.selection.config?.[name],
2499
+ ...config
2500
+ };
2501
+ const instance = factory(mergedConfig);
2502
+ this.instances.set(name, instance);
2503
+ return instance;
2504
+ }
2505
+ /**
2506
+ * Get all configured facilitator names
2507
+ */
2508
+ getConfiguredNames() {
2509
+ const names = [this.selection.primary];
2510
+ if (this.selection.fallback) {
2511
+ names.push(...this.selection.fallback);
2512
+ }
2513
+ return names;
2514
+ }
2515
+ /**
2516
+ * Get list of facilitators based on selection strategy
2517
+ */
2518
+ async getOrderedFacilitators(network) {
2519
+ const names = this.getConfiguredNames();
2520
+ const facilitators = [];
2521
+ for (const name of names) {
2522
+ try {
2523
+ const f = this.get(name);
2524
+ if (f.supportsNetwork(network)) {
2525
+ facilitators.push(f);
2526
+ }
2527
+ } catch (err) {
2528
+ console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
2529
+ }
2530
+ }
2531
+ if (facilitators.length === 0) {
2532
+ throw new Error(`No facilitators available for network: ${network}`);
2533
+ }
2534
+ switch (this.selection.strategy) {
2535
+ case "random":
2536
+ return this.shuffle(facilitators);
2537
+ case "roundrobin":
2538
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
2539
+ return [
2540
+ ...facilitators.slice(this.roundRobinIndex),
2541
+ ...facilitators.slice(0, this.roundRobinIndex)
2542
+ ];
2543
+ case "cheapest":
2544
+ return this.sortByCheapest(facilitators);
2545
+ case "fastest":
2546
+ return this.sortByFastest(facilitators);
2547
+ case "failover":
2548
+ default:
2549
+ return facilitators;
2550
+ }
2551
+ }
2552
+ shuffle(array) {
2553
+ const result = [...array];
2554
+ for (let i = result.length - 1; i > 0; i--) {
2555
+ const j = Math.floor(Math.random() * (i + 1));
2556
+ [result[i], result[j]] = [result[j], result[i]];
2557
+ }
2558
+ return result;
2559
+ }
2560
+ async sortByCheapest(facilitators) {
2561
+ const withFees = await Promise.all(
2562
+ facilitators.map(async (f) => {
2563
+ try {
2564
+ const fee = await f.getFee?.();
2565
+ return { facilitator: f, perTx: fee?.perTx ?? Infinity };
2566
+ } catch {
2567
+ return { facilitator: f, perTx: Infinity };
2568
+ }
2569
+ })
2570
+ );
2571
+ withFees.sort((a, b) => a.perTx - b.perTx);
2572
+ return withFees.map((w) => w.facilitator);
2573
+ }
2574
+ async sortByFastest(facilitators) {
2575
+ const withLatency = await Promise.all(
2576
+ facilitators.map(async (f) => {
2577
+ try {
2578
+ const health = await f.healthCheck();
2579
+ return { facilitator: f, latency: health.latencyMs ?? Infinity };
2580
+ } catch {
2581
+ return { facilitator: f, latency: Infinity };
2582
+ }
2583
+ })
2584
+ );
2585
+ withLatency.sort((a, b) => a.latency - b.latency);
2586
+ return withLatency.map((w) => w.facilitator);
2587
+ }
2588
+ /**
2589
+ * Verify payment using configured facilitators
2590
+ */
2591
+ async verify(paymentPayload, requirements) {
2592
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2593
+ const facilitators = await this.getOrderedFacilitators(network);
2594
+ let lastError;
2595
+ for (const f of facilitators) {
2596
+ try {
2597
+ console.log(`[Registry] Trying ${f.name} for verify...`);
2598
+ const result = await f.verify(paymentPayload, requirements);
2599
+ if (result.valid) {
2600
+ console.log(`[Registry] ${f.name} verify succeeded`);
2601
+ return { ...result, facilitator: f.name };
2602
+ }
2603
+ lastError = result.error;
2604
+ console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
2605
+ if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
2606
+ break;
2607
+ }
2608
+ } catch (err) {
2609
+ lastError = err.message;
2610
+ console.error(`[Registry] ${f.name} error:`, err.message);
2611
+ }
2612
+ }
2613
+ return {
1346
2614
  valid: false,
1347
2615
  error: lastError || "All facilitators failed",
1348
2616
  facilitator: "none"
@@ -1423,7 +2691,95 @@ var FacilitatorRegistry = class {
1423
2691
  }
1424
2692
  };
1425
2693
 
1426
- // src/server/index.ts
2694
+ // src/server/balance-endpoints.ts
2695
+ var import_node_crypto6 = __toESM(require("crypto"));
2696
+
2697
+ // src/verify/index.ts
2698
+ var import_ethers3 = require("ethers");
2699
+ var TRANSFER_EVENT_TOPIC3 = import_ethers3.ethers.id("Transfer(address,address,uint256)");
2700
+ async function verifyPayment(params) {
2701
+ const { txHash, expectedAmount, expectedTo, expectedToken } = params;
2702
+ let chain;
2703
+ try {
2704
+ if (typeof params.chain === "number") {
2705
+ chain = getChainById(params.chain);
2706
+ } else {
2707
+ chain = getChain(params.chain || "base");
2708
+ }
2709
+ if (!chain) {
2710
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2711
+ }
2712
+ } catch (e) {
2713
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2714
+ }
2715
+ try {
2716
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
2717
+ const receipt = await provider.getTransactionReceipt(txHash);
2718
+ if (!receipt) {
2719
+ return { verified: false, error: "Transaction not found or not confirmed" };
2720
+ }
2721
+ if (receipt.status !== 1) {
2722
+ return { verified: false, error: "Transaction failed" };
2723
+ }
2724
+ const tokenAddresses = {};
2725
+ if (!expectedToken || expectedToken === "USDC") {
2726
+ tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
2727
+ }
2728
+ if (!expectedToken || expectedToken === "USDT") {
2729
+ tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
2730
+ }
2731
+ if (Object.keys(tokenAddresses).length === 0) {
2732
+ return { verified: false, error: `No token addresses configured for ${chain.name}` };
2733
+ }
2734
+ for (const log of receipt.logs) {
2735
+ const logAddress = log.address.toLowerCase();
2736
+ const detectedToken = tokenAddresses[logAddress];
2737
+ if (!detectedToken) {
2738
+ continue;
2739
+ }
2740
+ if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
2741
+ continue;
2742
+ }
2743
+ const from = "0x" + log.topics[1].slice(-40);
2744
+ const to = "0x" + log.topics[2].slice(-40);
2745
+ const amountRaw = BigInt(log.data);
2746
+ const tokenConfig = chain.tokens[detectedToken];
2747
+ const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
2748
+ if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
2749
+ continue;
2750
+ }
2751
+ if (amount < expectedAmount) {
2752
+ return {
2753
+ verified: false,
2754
+ error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
2755
+ amount,
2756
+ token: detectedToken,
2757
+ from,
2758
+ to,
2759
+ txHash,
2760
+ blockNumber: receipt.blockNumber
2761
+ };
2762
+ }
2763
+ return {
2764
+ verified: true,
2765
+ amount,
2766
+ token: detectedToken,
2767
+ from,
2768
+ to,
2769
+ txHash,
2770
+ blockNumber: receipt.blockNumber
2771
+ };
2772
+ }
2773
+ const tokenList = expectedToken ? expectedToken : "USDC/USDT";
2774
+ return { verified: false, error: `No ${tokenList} transfer found` };
2775
+ } catch (e) {
2776
+ return { verified: false, error: e.message || String(e) };
2777
+ }
2778
+ }
2779
+
2780
+ // src/server/internal.ts
2781
+ var import_fs2 = require("fs");
2782
+ var path2 = __toESM(require("path"));
1427
2783
  var X402_VERSION2 = 2;
1428
2784
  var PAYMENT_REQUIRED_HEADER = "x-payment-required";
1429
2785
  var PAYMENT_HEADER = "x-payment";
@@ -1431,6 +2787,20 @@ var PAYMENT_RESPONSE_HEADER = "x-payment-response";
1431
2787
  var MPP_AUTH_HEADER = "authorization";
1432
2788
  var MPP_WWW_AUTH_HEADER = "www-authenticate";
1433
2789
  var MPP_RECEIPT_HEADER = "payment-receipt";
2790
+ var ALIPAY_PAYMENT_NEEDED_HEADER = "payment-needed";
2791
+ var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
2792
+ function headerSafe(value) {
2793
+ return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
2794
+ }
2795
+ function canonicalJson(value) {
2796
+ if (value === null || typeof value !== "object") {
2797
+ return JSON.stringify(value) ?? "null";
2798
+ }
2799
+ if (Array.isArray(value)) {
2800
+ return "[" + value.map(canonicalJson).join(",") + "]";
2801
+ }
2802
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
2803
+ }
1434
2804
  var TOKEN_ADDRESSES = {
1435
2805
  "eip155:8453": {
1436
2806
  USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
@@ -1558,7 +2928,340 @@ function loadEnvFile2() {
1558
2928
  }
1559
2929
  }
1560
2930
  }
1561
- }
2931
+ }
2932
+
2933
+ // src/server/balance-endpoints.ts
2934
+ var BalanceEndpoints = class {
2935
+ constructor(deps) {
2936
+ this.deps = deps;
2937
+ }
2938
+ /** GET /balance?buyer_id= -- balance, limits, and today's spend. */
2939
+ handleQuery(url, res) {
2940
+ const { balance, sendJson } = this.deps;
2941
+ const buyerId = url.searchParams.get("buyer_id");
2942
+ if (!buyerId) {
2943
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
2944
+ }
2945
+ const ledger = balance.getLedger();
2946
+ const buyer = ledger.getBuyer(buyerId);
2947
+ if (!buyer) {
2948
+ return sendJson(res, 200, {
2949
+ buyer_id: buyerId,
2950
+ balance: "0.00",
2951
+ currency: balance.currency,
2952
+ exists: false
2953
+ });
2954
+ }
2955
+ sendJson(res, 200, {
2956
+ buyer_id: buyerId,
2957
+ balance: fromSat(buyer.balance_sat),
2958
+ currency: balance.currency,
2959
+ single_limit: fromSat(buyer.single_limit_sat),
2960
+ daily_limit: fromSat(buyer.daily_limit_sat),
2961
+ today_spent: fromSat(ledger.spentTodaySat(buyerId)),
2962
+ status: buyer.status,
2963
+ wechat_openid: buyer.wechat_openid ?? null,
2964
+ signer_address: buyer.signer_address ?? null,
2965
+ exists: true
2966
+ });
2967
+ }
2968
+ /**
2969
+ * Extract the gateway-confirmed paid amount (fen) from a WeChat verify
2970
+ * result. `payer_total` is what the buyer actually paid; falls back to
2971
+ * `total`. Returns null if no usable positive integer is present, so the
2972
+ * client-declared amount can never be trusted for crediting.
2973
+ */
2974
+ wechatPaidFen(check) {
2975
+ const amount = check.details?.amount;
2976
+ const paid = amount?.payer_total ?? amount?.total;
2977
+ return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
2978
+ }
2979
+ /**
2980
+ * POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
2981
+ * configured top-up pack. The buyer_id rides in the WeChat `attach` so the
2982
+ * later confirm/callback credits the correct balance. Reuses the pending
2983
+ * order cache (keyed by buyer_id + pack) so concurrent requests share one
2984
+ * order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
2985
+ * pack, max_timeout_seconds }`.
2986
+ */
2987
+ async handleTopupOrder(body, res) {
2988
+ const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
2989
+ const { buyer_id, pack } = body || {};
2990
+ if (typeof buyer_id !== "string" || !buyer_id) {
2991
+ return sendJson(res, 400, { error: "buyer_id is required" });
2992
+ }
2993
+ const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
2994
+ if (!wechat) {
2995
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
2996
+ }
2997
+ const balCfg = manifest.provider.balance;
2998
+ const packs = balCfg?.topup_packs ?? [];
2999
+ const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
3000
+ if (!chosen) {
3001
+ return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
3002
+ }
3003
+ let chosenSat;
3004
+ try {
3005
+ chosenSat = toSat(chosen);
3006
+ if (chosenSat <= 0) throw new Error("pack must be positive");
3007
+ } catch (err) {
3008
+ return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
3009
+ }
3010
+ const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
3011
+ if (!packs.includes(chosen) && !withinMax) {
3012
+ return sendJson(res, 400, {
3013
+ error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
3014
+ });
3015
+ }
3016
+ const nonce = import_node_crypto6.default.randomBytes(8).toString("hex");
3017
+ const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
3018
+ const cacheKey = import_node_crypto6.default.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
3019
+ const result = await getOrCreatePendingWechatOrder(
3020
+ cacheKey,
3021
+ `topup:${buyer_id}`,
3022
+ () => wechat.createPaymentRequirements({
3023
+ priceCny: chosen,
3024
+ description: `Balance top-up ${chosen}`,
3025
+ attach
3026
+ })
3027
+ );
3028
+ if (!result) {
3029
+ return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
3030
+ }
3031
+ return sendJson(res, 200, {
3032
+ code_url: result.codeUrl,
3033
+ out_trade_no: result.outTradeNo,
3034
+ pack: chosen,
3035
+ max_timeout_seconds: result.accepts.maxTimeoutSeconds
3036
+ });
3037
+ }
3038
+ /**
3039
+ * POST /balance/topup/confirm -- the polling-fallback credit path. The client
3040
+ * polls this after a scan; the server verifies the order with the WeChat
3041
+ * gateway and, on SUCCESS, credits the buyer bound in `attach` with the
3042
+ * gateway-confirmed `payer_total` (never a client-declared amount).
3043
+ * Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
3044
+ */
3045
+ async handleTopupConfirm(body, res) {
3046
+ const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
3047
+ const outTradeNo = body?.out_trade_no;
3048
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3049
+ return sendJson(res, 400, { error: "out_trade_no is required" });
3050
+ }
3051
+ if (!wechat) {
3052
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3053
+ }
3054
+ const wxPayload = {
3055
+ x402Version: X402_VERSION2,
3056
+ scheme: WECHAT_SCHEME,
3057
+ network: WECHAT_NETWORK,
3058
+ payload: { out_trade_no: outTradeNo }
3059
+ };
3060
+ const wxReqs = {
3061
+ scheme: WECHAT_SCHEME,
3062
+ network: WECHAT_NETWORK,
3063
+ asset: "CNY",
3064
+ amount: "0",
3065
+ payTo: manifest.provider.wechat?.mchid || "",
3066
+ maxTimeoutSeconds: 30,
3067
+ extra: { out_trade_no: outTradeNo }
3068
+ };
3069
+ const check = await wechat.verify(wxPayload, wxReqs);
3070
+ if (!check.valid) {
3071
+ return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
3072
+ }
3073
+ const paidFen = this.wechatPaidFen(check);
3074
+ if (paidFen === null) {
3075
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3076
+ }
3077
+ const attach = parseWechatAttach(check.details?.attach);
3078
+ const buyerId = attach?.buyer_id;
3079
+ if (!buyerId) {
3080
+ return sendJson(res, 422, {
3081
+ error: "top-up order has no buyer binding (attach.buyer_id missing)"
3082
+ });
3083
+ }
3084
+ const credited = balance.credit({
3085
+ buyerId,
3086
+ amountSat: paidFen,
3087
+ externalRef: `wechat:${outTradeNo}`,
3088
+ description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
3089
+ });
3090
+ invalidateWechatChallenge(outTradeNo);
3091
+ const openid = check.details?.openid;
3092
+ let openidBinding;
3093
+ if (typeof openid === "string" && openid) {
3094
+ openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
3095
+ if (openidBinding.conflict) {
3096
+ console.warn(
3097
+ `[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
3098
+ );
3099
+ }
3100
+ }
3101
+ const signer = attach?.signer;
3102
+ if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
3103
+ const sb = balance.getLedger().bindSigner(buyerId, signer);
3104
+ if (sb.conflict) {
3105
+ console.warn(
3106
+ `[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
3107
+ );
3108
+ }
3109
+ }
3110
+ console.log(
3111
+ `[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
3112
+ );
3113
+ return sendJson(res, 200, {
3114
+ credited: true,
3115
+ buyer_id: buyerId,
3116
+ tx_id: credited.txId,
3117
+ balance: credited.balance,
3118
+ replayed: credited.replayed,
3119
+ openid_bound: openidBinding?.bound ?? false,
3120
+ openid_conflict: openidBinding?.conflict ?? false
3121
+ });
3122
+ }
3123
+ /**
3124
+ * POST /balance/topup -- verify an externally settled payment and credit the
3125
+ * buyer's ledger balance (operator/recovery path). Per rail: `crypto`
3126
+ * verifies the tx on-chain; `wechat` queries the order and credits the
3127
+ * gateway-confirmed payer_total (never the client-declared amount);
3128
+ * `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
3129
+ */
3130
+ async handleTopup(body, res) {
3131
+ const { manifest, balance, wechat, sendJson } = this.deps;
3132
+ const { buyer_id, rail, amount } = body || {};
3133
+ if (typeof buyer_id !== "string" || !buyer_id) {
3134
+ return sendJson(res, 400, { error: "buyer_id is required" });
3135
+ }
3136
+ let amountSat;
3137
+ try {
3138
+ amountSat = toSat(String(amount));
3139
+ if (amountSat <= 0) throw new Error("amount must be positive");
3140
+ } catch (err) {
3141
+ return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
3142
+ }
3143
+ let externalRef;
3144
+ let description;
3145
+ if (rail === "crypto") {
3146
+ const txHash = body.tx_hash;
3147
+ if (typeof txHash !== "string" || !txHash) {
3148
+ return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
3149
+ }
3150
+ const check = await verifyPayment({
3151
+ txHash,
3152
+ expectedAmount: amountSat / 100,
3153
+ expectedTo: manifest.provider.wallet,
3154
+ chain: body.chain || "base"
3155
+ });
3156
+ if (!check.verified) {
3157
+ return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
3158
+ }
3159
+ externalRef = txHash.toLowerCase();
3160
+ description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
3161
+ } else if (rail === "wechat") {
3162
+ const outTradeNo = body.out_trade_no;
3163
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3164
+ return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
3165
+ }
3166
+ if (!wechat) {
3167
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3168
+ }
3169
+ const wxPayload = {
3170
+ x402Version: X402_VERSION2,
3171
+ scheme: WECHAT_SCHEME,
3172
+ network: WECHAT_NETWORK,
3173
+ payload: { out_trade_no: outTradeNo }
3174
+ };
3175
+ const wxReqs = {
3176
+ scheme: WECHAT_SCHEME,
3177
+ network: WECHAT_NETWORK,
3178
+ asset: "CNY",
3179
+ amount: "0",
3180
+ payTo: manifest.provider.wechat?.mchid || "",
3181
+ maxTimeoutSeconds: 30,
3182
+ extra: { out_trade_no: outTradeNo }
3183
+ };
3184
+ const check = await wechat.verify(wxPayload, wxReqs);
3185
+ if (!check.valid) {
3186
+ return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
3187
+ }
3188
+ const paidFen = this.wechatPaidFen(check);
3189
+ if (paidFen === null) {
3190
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3191
+ }
3192
+ if (paidFen !== amountSat) {
3193
+ console.warn(
3194
+ `[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
3195
+ );
3196
+ }
3197
+ amountSat = paidFen;
3198
+ externalRef = `wechat:${outTradeNo}`;
3199
+ description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
3200
+ console.log(`[MoltsPay] WeChat topup verified: ${description}`);
3201
+ } else if (rail === "alipay") {
3202
+ const tradeNo = body.trade_no;
3203
+ if (typeof tradeNo !== "string" || !tradeNo) {
3204
+ return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
3205
+ }
3206
+ externalRef = `alipay:${tradeNo}`;
3207
+ description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
3208
+ console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
3209
+ } else {
3210
+ return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
3211
+ }
3212
+ const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
3213
+ sendJson(res, 200, {
3214
+ success: true,
3215
+ tx_id: result.txId,
3216
+ balance: fromSat(result.balanceSat),
3217
+ replayed: result.replayed ?? false
3218
+ });
3219
+ }
3220
+ /** POST /balance/refund -- reverse a deduct (operator/agent use). */
3221
+ handleRefund(body, res) {
3222
+ const { balance, sendJson } = this.deps;
3223
+ const { tx_id, reason } = body || {};
3224
+ if (typeof tx_id !== "string" || !tx_id) {
3225
+ return sendJson(res, 400, { error: "tx_id is required" });
3226
+ }
3227
+ const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
3228
+ if (!result.success) {
3229
+ return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
3230
+ }
3231
+ sendJson(res, 200, {
3232
+ success: true,
3233
+ tx_id: result.txId,
3234
+ balance: fromSat(result.balanceSat),
3235
+ replayed: result.replayed ?? false
3236
+ });
3237
+ }
3238
+ /** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
3239
+ handleTransactions(url, res) {
3240
+ const { balance, sendJson } = this.deps;
3241
+ const buyerId = url.searchParams.get("buyer_id");
3242
+ if (!buyerId) {
3243
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3244
+ }
3245
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
3246
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
3247
+ const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
3248
+ sendJson(res, 200, {
3249
+ buyer_id: buyerId,
3250
+ transactions: rows.map((r) => ({
3251
+ tx_id: r.id,
3252
+ type: r.type,
3253
+ amount: fromSat(r.amount_sat),
3254
+ service: r.service,
3255
+ description: r.description,
3256
+ external_ref: r.external_ref,
3257
+ status: r.status,
3258
+ created_at: r.created_at
3259
+ }))
3260
+ });
3261
+ }
3262
+ };
3263
+
3264
+ // src/server/index.ts
1562
3265
  var MoltsPayServer = class {
1563
3266
  manifest;
1564
3267
  skills = /* @__PURE__ */ new Map();
@@ -1566,9 +3269,32 @@ var MoltsPayServer = class {
1566
3269
  registry;
1567
3270
  networkId;
1568
3271
  useMainnet;
3272
+ /** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
3273
+ alipayFacilitator = null;
3274
+ /** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
3275
+ wechatFacilitator = null;
3276
+ /** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
3277
+ balanceFacilitator = null;
3278
+ balanceEndpoints = null;
3279
+ /**
3280
+ * Pending WeChat Native order cache — the double-charge fix.
3281
+ *
3282
+ * Every `buildWechatChallenge` used to place a NEW Native order, so a client
3283
+ * that received two 402s (e.g. initial challenge + one poll re-request that
3284
+ * raced ahead of payment) could surface two live QRs and a buyer could pay
3285
+ * both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
3286
+ * under a content-derived key — sha256(service id | canonical params |
3287
+ * price_cny) — and reused until it is paid or its `time_expire` window
3288
+ * nears expiry, so any number of 402 emits for the same purchase intent
3289
+ * share ONE order, even across separate client processes.
3290
+ * Storing the in-flight promise also dedupes concurrent 402 builds.
3291
+ * In-memory by design: on restart the worst case is one extra unpaid order,
3292
+ * which expires server-side per `time_expire` — never a double charge.
3293
+ */
3294
+ wechatPendingChallenges = /* @__PURE__ */ new Map();
1569
3295
  constructor(servicesPath, options = {}) {
1570
3296
  loadEnvFile2();
1571
- const content = (0, import_fs2.readFileSync)(servicesPath, "utf-8");
3297
+ const content = (0, import_fs3.readFileSync)(servicesPath, "utf-8");
1572
3298
  this.manifest = JSON.parse(content);
1573
3299
  this.options = {
1574
3300
  port: options.port || 3e3,
@@ -1587,7 +3313,101 @@ var MoltsPayServer = class {
1587
3313
  cdp: { useMainnet: this.useMainnet }
1588
3314
  }
1589
3315
  };
3316
+ const providerAlipay = this.manifest.provider.alipay;
3317
+ if (providerAlipay) {
3318
+ try {
3319
+ const baseDir = path3.dirname(servicesPath);
3320
+ const resolvePem = (p, kind) => toPem((0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
3321
+ const alipayFacilitatorConfig = {
3322
+ seller_id: providerAlipay.seller_id,
3323
+ app_id: providerAlipay.app_id,
3324
+ seller_name: providerAlipay.seller_name,
3325
+ service_id_default: providerAlipay.service_id_default,
3326
+ private_key_pem: resolvePem(providerAlipay.private_key_path, "PRIVATE"),
3327
+ alipay_public_key_pem: resolvePem(providerAlipay.alipay_public_key_path, "PUBLIC"),
3328
+ gateway_url: providerAlipay.gateway_url,
3329
+ sign_type: providerAlipay.sign_type
3330
+ };
3331
+ facilitatorConfig.config = {
3332
+ ...facilitatorConfig.config,
3333
+ alipay: alipayFacilitatorConfig
3334
+ };
3335
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3336
+ if (facilitatorConfig.primary !== "alipay" && !facilitatorConfig.fallback.includes("alipay")) {
3337
+ facilitatorConfig.fallback.push("alipay");
3338
+ }
3339
+ } catch (err) {
3340
+ throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
3341
+ }
3342
+ }
3343
+ const providerWechat = this.manifest.provider.wechat;
3344
+ if (providerWechat) {
3345
+ try {
3346
+ const baseDir = path3.dirname(servicesPath);
3347
+ const readPem = (p) => (0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
3348
+ const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new import_node_crypto7.default.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
3349
+ const wechatFacilitatorConfig = {
3350
+ mchid: providerWechat.mchid,
3351
+ appid: providerWechat.appid,
3352
+ serial_no: providerWechat.serial_no,
3353
+ private_key_pem: readPem(providerWechat.private_key_path),
3354
+ platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
3355
+ apiv3_key: providerWechat.apiv3_key,
3356
+ notify_url: providerWechat.notify_url,
3357
+ api_base: providerWechat.api_base
3358
+ };
3359
+ facilitatorConfig.config = {
3360
+ ...facilitatorConfig.config,
3361
+ wechat: wechatFacilitatorConfig
3362
+ };
3363
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3364
+ if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
3365
+ facilitatorConfig.fallback.push("wechat");
3366
+ }
3367
+ } catch (err) {
3368
+ throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
3369
+ }
3370
+ }
3371
+ const providerBalance = this.manifest.provider.balance;
3372
+ if (providerBalance) {
3373
+ const baseDir = path3.dirname(servicesPath);
3374
+ const balanceFacilitatorConfig = {
3375
+ db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
3376
+ currency: providerBalance.currency,
3377
+ single_limit: providerBalance.single_limit,
3378
+ daily_limit: providerBalance.daily_limit,
3379
+ auth_mode: providerBalance.auth_mode
3380
+ };
3381
+ facilitatorConfig.config = {
3382
+ ...facilitatorConfig.config,
3383
+ balance: balanceFacilitatorConfig
3384
+ };
3385
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3386
+ if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
3387
+ facilitatorConfig.fallback.push("balance");
3388
+ }
3389
+ }
1590
3390
  this.registry = new FacilitatorRegistry(facilitatorConfig);
3391
+ if (providerAlipay) {
3392
+ this.alipayFacilitator = this.registry.get("alipay");
3393
+ console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
3394
+ }
3395
+ if (providerWechat) {
3396
+ this.wechatFacilitator = this.registry.get("wechat");
3397
+ console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
3398
+ }
3399
+ if (providerBalance) {
3400
+ this.balanceFacilitator = this.registry.get("balance");
3401
+ this.balanceEndpoints = new BalanceEndpoints({
3402
+ manifest: this.manifest,
3403
+ balance: this.balanceFacilitator,
3404
+ wechat: this.wechatFacilitator,
3405
+ sendJson: (res, status, data) => this.sendJson(res, status, data),
3406
+ getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
3407
+ invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
3408
+ });
3409
+ console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
3410
+ }
1591
3411
  const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
1592
3412
  console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
1593
3413
  console.log(`[MoltsPay] Provider: ${this.manifest.provider.name}`);
@@ -1628,7 +3448,10 @@ var MoltsPayServer = class {
1628
3448
  return provider.wallet;
1629
3449
  };
1630
3450
  if (provider.chains && provider.chains.length > 0) {
1631
- return provider.chains.map((c) => {
3451
+ return provider.chains.filter((c) => {
3452
+ const chainName = typeof c === "string" ? c : c.chain;
3453
+ return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
3454
+ }).map((c) => {
1632
3455
  const chainName = typeof c === "string" ? c : c.chain;
1633
3456
  const explicitWallet = typeof c === "object" ? c.wallet : null;
1634
3457
  return {
@@ -1723,10 +3546,10 @@ var MoltsPayServer = class {
1723
3546
  writeCorsHeaders(res, origin) {
1724
3547
  res.setHeader("Access-Control-Allow-Origin", origin);
1725
3548
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1726
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Payment, Authorization");
3549
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Payment, Authorization, Payment-Proof");
1727
3550
  res.setHeader(
1728
3551
  "Access-Control-Expose-Headers",
1729
- "X-Payment-Required, X-Payment-Response, WWW-Authenticate, Payment-Receipt"
3552
+ "X-Payment-Required, X-Payment-Response, WWW-Authenticate, Payment-Receipt, Payment-Needed"
1730
3553
  );
1731
3554
  }
1732
3555
  /**
@@ -1747,13 +3570,41 @@ var MoltsPayServer = class {
1747
3570
  if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
1748
3571
  return this.handleAgentServicesDiscovery(res);
1749
3572
  }
3573
+ if (url.pathname === "/" && req.method === "GET") {
3574
+ return this.handleAgentServicesDiscovery(res);
3575
+ }
1750
3576
  if (url.pathname === "/health" && req.method === "GET") {
1751
3577
  return await this.handleHealthCheck(res);
1752
3578
  }
3579
+ if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
3580
+ if (url.pathname === "/balance" && req.method === "GET") {
3581
+ return this.balanceEndpoints.handleQuery(url, res);
3582
+ }
3583
+ if (url.pathname === "/balance/topup/order" && req.method === "POST") {
3584
+ const body = await this.readBody(req);
3585
+ return await this.balanceEndpoints.handleTopupOrder(body, res);
3586
+ }
3587
+ if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
3588
+ const body = await this.readBody(req);
3589
+ return await this.balanceEndpoints.handleTopupConfirm(body, res);
3590
+ }
3591
+ if (url.pathname === "/balance/topup" && req.method === "POST") {
3592
+ const body = await this.readBody(req);
3593
+ return await this.balanceEndpoints.handleTopup(body, res);
3594
+ }
3595
+ if (url.pathname === "/balance/refund" && req.method === "POST") {
3596
+ const body = await this.readBody(req);
3597
+ return this.balanceEndpoints.handleRefund(body, res);
3598
+ }
3599
+ if (url.pathname === "/balance/transactions" && req.method === "GET") {
3600
+ return this.balanceEndpoints.handleTransactions(url, res);
3601
+ }
3602
+ }
1753
3603
  if (url.pathname === "/execute" && req.method === "POST") {
1754
3604
  const body = await this.readBody(req);
1755
3605
  const paymentHeader = req.headers[PAYMENT_HEADER];
1756
- return await this.handleExecute(body, paymentHeader, res);
3606
+ const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
3607
+ return await this.handleExecute(body, paymentHeader, res, proofHeader);
1757
3608
  }
1758
3609
  if (url.pathname === "/proxy" && req.method === "POST") {
1759
3610
  const clientIP = req.headers["x-real-ip"] || req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || req.socket.remoteAddress || "";
@@ -1771,29 +3622,81 @@ var MoltsPayServer = class {
1771
3622
  const body = req.method === "POST" ? await this.readBody(req) : {};
1772
3623
  const authHeader = req.headers[MPP_AUTH_HEADER];
1773
3624
  const x402Header = req.headers[PAYMENT_HEADER];
1774
- return await this.handleMPPRequest(skill, body, authHeader, x402Header, res);
3625
+ const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
3626
+ return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
1775
3627
  }
1776
- this.sendJson(res, 404, { error: "Not found" });
3628
+ this.sendJson(res, 404, {
3629
+ error: "Not found",
3630
+ discovery: `${this.publicBase}/.well-known/agent-services.json`,
3631
+ endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
3632
+ });
1777
3633
  } catch (err) {
1778
3634
  console.error("[MoltsPay] Error:", err);
1779
3635
  this.sendJson(res, 500, { error: err.message || "Internal error" });
1780
3636
  }
1781
3637
  }
1782
3638
  /**
1783
- * GET /.well-known/agent-services.json - Standard discovery endpoint
3639
+ * Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
3640
+ * (trailing slash stripped). Empty when unset, so emitted paths stay
3641
+ * root-relative — behavior is unchanged for local / no-prefix deploys.
3642
+ *
3643
+ * Needed because nginx rewrites the deployment prefix (e.g.
3644
+ * `/t/moltspay-server`) away before proxying, so the process cannot infer
3645
+ * its own public prefix; a root-relative `/services` would otherwise
3646
+ * resolve against the domain root and hit the wrong backend.
1784
3647
  */
1785
- handleAgentServicesDiscovery(res) {
1786
- const services = this.manifest.services.map((s) => ({
3648
+ get publicBase() {
3649
+ return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
3650
+ }
3651
+ /**
3652
+ * Per-service pricing across every configured rail, for the discovery
3653
+ * payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
3654
+ * for back-compat; this surfaces the fiat + balance rails (CNY) that were
3655
+ * previously invisible in discovery even though the manifest defines them
3656
+ * and the 402 challenge already quotes them. `acceptedCurrencies` becomes
3657
+ * the union across rails so a client can see CNY is accepted without
3658
+ * first triggering a 402.
3659
+ */
3660
+ describeServicePricing(s) {
3661
+ const pricing = [];
3662
+ if (this.getProviderChains().length > 0) {
3663
+ for (const currency of getAcceptedCurrencies(s)) {
3664
+ pricing.push({ rail: "crypto", currency, amount: String(s.price) });
3665
+ }
3666
+ }
3667
+ if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
3668
+ if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
3669
+ if (s.balance) {
3670
+ pricing.push({
3671
+ rail: "balance",
3672
+ currency: this.manifest.provider.balance?.currency ?? "CNY",
3673
+ amount: s.balance.price ?? s.price.toFixed(2)
3674
+ });
3675
+ }
3676
+ return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
3677
+ }
3678
+ /** Shared service-list entry for the discovery and /services endpoints. */
3679
+ buildDiscoveryService(s) {
3680
+ const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
3681
+ const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
3682
+ return {
1787
3683
  id: s.id,
1788
3684
  name: s.name,
1789
3685
  description: s.description,
1790
- price: s.price,
1791
- currency: s.currency,
1792
- acceptedCurrencies: getAcceptedCurrencies(s),
3686
+ price: headline ? Number(headline.amount) : s.price,
3687
+ currency: headline ? headline.currency : s.currency,
3688
+ acceptedCurrencies,
3689
+ pricing,
1793
3690
  input: s.input,
1794
3691
  output: s.output,
1795
3692
  available: this.skills.has(s.id)
1796
- }));
3693
+ };
3694
+ }
3695
+ /**
3696
+ * GET /.well-known/agent-services.json - Standard discovery endpoint
3697
+ */
3698
+ handleAgentServicesDiscovery(res) {
3699
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
1797
3700
  this.sendJson(res, 200, {
1798
3701
  version: "1.0",
1799
3702
  provider: {
@@ -1806,9 +3709,9 @@ var MoltsPayServer = class {
1806
3709
  },
1807
3710
  services,
1808
3711
  endpoints: {
1809
- services: "/services",
1810
- execute: "/execute",
1811
- health: "/health"
3712
+ services: `${this.publicBase}/services`,
3713
+ execute: `${this.publicBase}/execute`,
3714
+ health: `${this.publicBase}/health`
1812
3715
  },
1813
3716
  payment: {
1814
3717
  protocol: "x402",
@@ -1823,17 +3726,7 @@ var MoltsPayServer = class {
1823
3726
  * GET /services - List available services
1824
3727
  */
1825
3728
  handleGetServices(res) {
1826
- const services = this.manifest.services.map((s) => ({
1827
- id: s.id,
1828
- name: s.name,
1829
- description: s.description,
1830
- price: s.price,
1831
- currency: s.currency,
1832
- acceptedCurrencies: getAcceptedCurrencies(s),
1833
- input: s.input,
1834
- output: s.output,
1835
- available: this.skills.has(s.id)
1836
- }));
3729
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
1837
3730
  const selection = this.registry.getSelection();
1838
3731
  this.sendJson(res, 200, {
1839
3732
  provider: this.manifest.provider,
@@ -1868,7 +3761,7 @@ var MoltsPayServer = class {
1868
3761
  /**
1869
3762
  * POST /execute - Execute service with x402 payment
1870
3763
  */
1871
- async handleExecute(body, paymentHeader, res) {
3764
+ async handleExecute(body, paymentHeader, res, proofHeader) {
1872
3765
  const { service, params } = body;
1873
3766
  if (!service) {
1874
3767
  return this.sendJson(res, 400, { error: "Missing service" });
@@ -1882,8 +3775,17 @@ var MoltsPayServer = class {
1882
3775
  return this.sendJson(res, 400, { error: `Missing required param: ${key}` });
1883
3776
  }
1884
3777
  }
3778
+ if (proofHeader) {
3779
+ const alipayPayment = {
3780
+ x402Version: X402_VERSION2,
3781
+ scheme: ALIPAY_SCHEME,
3782
+ network: ALIPAY_NETWORK,
3783
+ payload: proofHeader
3784
+ };
3785
+ return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
3786
+ }
1885
3787
  if (!paymentHeader) {
1886
- return this.sendPaymentRequired(skill.config, res);
3788
+ return this.sendPaymentRequired(skill.config, res, params || {});
1887
3789
  }
1888
3790
  let payment;
1889
3791
  try {
@@ -1892,6 +3794,17 @@ var MoltsPayServer = class {
1892
3794
  } catch {
1893
3795
  return this.sendJson(res, 400, { error: "Invalid X-Payment header" });
1894
3796
  }
3797
+ const payScheme = payment.accepted?.scheme || payment.scheme;
3798
+ const payNetwork = payment.accepted?.network || payment.network;
3799
+ if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
3800
+ return this.handleAlipayExecute(skill, params || {}, payment, res);
3801
+ }
3802
+ if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
3803
+ return this.handleWechatExecute(skill, params || {}, payment, res);
3804
+ }
3805
+ if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
3806
+ return this.handleBalanceExecute(skill, params || {}, payment, res);
3807
+ }
1895
3808
  const validation = this.validatePayment(payment, skill.config);
1896
3809
  if (!validation.valid) {
1897
3810
  return this.sendJson(res, 402, { error: validation.error });
@@ -1984,20 +3897,402 @@ var MoltsPayServer = class {
1984
3897
  payment: settlement?.success ? { transaction: settlement.transaction, status: "settled", facilitator: settlement.facilitator } : { status: "pending" }
1985
3898
  }, responseHeaders);
1986
3899
  }
3900
+ /**
3901
+ * Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
3902
+ *
3903
+ * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
3904
+ * validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
3905
+ * (`fulfillment.confirm`) is FIRE-AND-FORGET per ALIPAY-INTEGRATION-DESIGN
3906
+ * §5.1: a confirm failure is logged but does NOT fail the already-delivered
3907
+ * response (the buyer's payment proof was already verified).
3908
+ */
3909
+ async handleAlipayExecute(skill, params, payment, res) {
3910
+ if (!this.alipayFacilitator) {
3911
+ return this.sendJson(res, 402, { error: "Alipay rail not configured on this server" });
3912
+ }
3913
+ const requirements = {
3914
+ scheme: ALIPAY_SCHEME,
3915
+ network: ALIPAY_NETWORK,
3916
+ asset: "CNY",
3917
+ amount: skill.config.alipay?.price_cny || "0",
3918
+ payTo: this.manifest.provider.alipay?.seller_id || "",
3919
+ maxTimeoutSeconds: 1800
3920
+ };
3921
+ console.log(`[MoltsPay] Verifying Alipay payment...`);
3922
+ const verifyResult = await this.registry.verify(payment, requirements);
3923
+ if (!verifyResult.valid) {
3924
+ return this.sendJson(res, 402, {
3925
+ error: `Payment verification failed: ${verifyResult.error}`,
3926
+ facilitator: verifyResult.facilitator
3927
+ });
3928
+ }
3929
+ console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
3930
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
3931
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
3932
+ let result;
3933
+ try {
3934
+ result = await Promise.race([
3935
+ skill.handler(params),
3936
+ new Promise(
3937
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
3938
+ )
3939
+ ]);
3940
+ } catch (err) {
3941
+ console.error("[MoltsPay] Skill execution failed:", err.message);
3942
+ return this.sendJson(res, 500, {
3943
+ error: "Service execution failed",
3944
+ message: err.message
3945
+ });
3946
+ }
3947
+ let settlement;
3948
+ try {
3949
+ settlement = await this.registry.settle(payment, requirements);
3950
+ if (settlement.success) {
3951
+ console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
3952
+ } else {
3953
+ console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
3954
+ }
3955
+ } catch (err) {
3956
+ console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
3957
+ settlement = { success: false, error: err.message, facilitator: "alipay" };
3958
+ }
3959
+ const responseHeaders = {};
3960
+ if (settlement.success) {
3961
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
3962
+ success: true,
3963
+ transaction: settlement.transaction,
3964
+ network: ALIPAY_NETWORK,
3965
+ facilitator: settlement.facilitator
3966
+ })).toString("base64");
3967
+ }
3968
+ this.sendJson(res, 200, {
3969
+ success: true,
3970
+ result,
3971
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
3972
+ }, responseHeaders);
3973
+ }
3974
+ /**
3975
+ * Build the Alipay 402 challenge for a service, or null when the alipay rail
3976
+ * isn't configured for this server or this service. Returns the x402
3977
+ * `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
3978
+ * 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
3979
+ */
3980
+ async buildAlipayChallenge(config) {
3981
+ if (!this.alipayFacilitator || !config.alipay) return null;
3982
+ try {
3983
+ const req = await this.alipayFacilitator.createPaymentRequirements({
3984
+ serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
3985
+ priceCny: config.alipay.price_cny,
3986
+ goodsName: config.alipay.goods_name,
3987
+ resourceId: `/execute?service=${config.id}`
3988
+ });
3989
+ return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
3990
+ } catch (err) {
3991
+ console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
3992
+ return null;
3993
+ }
3994
+ }
3995
+ /**
3996
+ * Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
3997
+ *
3998
+ * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
3999
+ * validation. The buyer (a human) scanned the Native QR and paid; the
4000
+ * client re-requests carrying `out_trade_no` in the X-Payment payload.
4001
+ * Verify queries the order (`trade_state === SUCCESS`). Settlement is an
4002
+ * idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
4003
+ * a confirm failure is logged but does NOT fail the delivered response —
4004
+ * the order was already verified SUCCESS.
4005
+ */
4006
+ async handleWechatExecute(skill, params, payment, res) {
4007
+ if (!this.wechatFacilitator) {
4008
+ return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
4009
+ }
4010
+ const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
4011
+ const requirements = {
4012
+ scheme: WECHAT_SCHEME,
4013
+ network: WECHAT_NETWORK,
4014
+ asset: "CNY",
4015
+ amount: skill.config.wechat?.price_cny || "0",
4016
+ payTo: this.manifest.provider.wechat?.mchid || "",
4017
+ maxTimeoutSeconds: 300,
4018
+ extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
4019
+ };
4020
+ console.log(`[MoltsPay] Verifying WeChat payment...`);
4021
+ const verifyResult = await this.registry.verify(payment, requirements);
4022
+ if (!verifyResult.valid) {
4023
+ return this.sendJson(res, 402, {
4024
+ error: `Payment verification failed: ${verifyResult.error}`,
4025
+ facilitator: verifyResult.facilitator
4026
+ });
4027
+ }
4028
+ console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
4029
+ if (outTradeNo) {
4030
+ this.invalidateWechatChallenge(outTradeNo);
4031
+ }
4032
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4033
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4034
+ let result;
4035
+ try {
4036
+ result = await Promise.race([
4037
+ skill.handler(params),
4038
+ new Promise(
4039
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4040
+ )
4041
+ ]);
4042
+ } catch (err) {
4043
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4044
+ return this.sendJson(res, 500, {
4045
+ error: "Service execution failed",
4046
+ message: err.message
4047
+ });
4048
+ }
4049
+ let settlement;
4050
+ try {
4051
+ settlement = await this.registry.settle(payment, requirements);
4052
+ if (settlement.success) {
4053
+ console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
4054
+ } else {
4055
+ console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
4056
+ }
4057
+ } catch (err) {
4058
+ console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
4059
+ settlement = { success: false, error: err.message, facilitator: "wechat" };
4060
+ }
4061
+ const responseHeaders = {};
4062
+ if (settlement.success) {
4063
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4064
+ success: true,
4065
+ transaction: settlement.transaction,
4066
+ network: WECHAT_NETWORK,
4067
+ facilitator: settlement.facilitator
4068
+ })).toString("base64");
4069
+ }
4070
+ this.sendJson(res, 200, {
4071
+ success: true,
4072
+ result,
4073
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4074
+ }, responseHeaders);
4075
+ }
4076
+ /**
4077
+ * Build the WeChat 402 challenge for a service, or null when the wechat rail
4078
+ * isn't configured for this server or this service. Placing a Native order
4079
+ * is a network call that returns a fresh `code_url` + `out_trade_no`; the
4080
+ * x402 `accepts[]` entry carries both in `extra` so the client can render
4081
+ * the QR and later echo `out_trade_no` back for verification.
4082
+ *
4083
+ * DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
4084
+ * `wechatPendingChallenges`), so repeated 402 emits within the order's
4085
+ * `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
4086
+ * minting a new payable order each time. The entry is dropped once the
4087
+ * order is paid (`invalidateWechatChallenge`) or shortly before it expires
4088
+ * (refresh margin, so clients never receive a nearly-dead QR). A build
4089
+ * failure is not cached and degrades gracefully (the other rails'
4090
+ * accepts[] still ship).
4091
+ */
4092
+ async buildWechatChallenge(config, params) {
4093
+ if (!this.wechatFacilitator || !config.wechat) return null;
4094
+ const cacheKey = import_node_crypto7.default.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
4095
+ const result = await this.getOrCreatePendingWechatOrder(
4096
+ cacheKey,
4097
+ config.id,
4098
+ () => this.wechatFacilitator.createPaymentRequirements({
4099
+ priceCny: config.wechat.price_cny,
4100
+ description: config.wechat.description
4101
+ })
4102
+ );
4103
+ return result ? { accepts: result.accepts } : null;
4104
+ }
4105
+ /**
4106
+ * Get-or-create a pending WeChat Native order under `cacheKey`, deduping
4107
+ * concurrent builds and reusing an unpaid order until it nears expiry.
4108
+ * Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
4109
+ * balance top-up order path ({@link handleBalanceTopupOrder}). See the
4110
+ * `wechatPendingChallenges` doc for the double-charge rationale.
4111
+ */
4112
+ async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
4113
+ const now = Date.now();
4114
+ const cached = this.wechatPendingChallenges.get(cacheKey);
4115
+ if (cached) {
4116
+ if (now < cached.expiresAtMs) {
4117
+ const hit = await cached.promise;
4118
+ if (hit) {
4119
+ console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
4120
+ return hit;
4121
+ }
4122
+ }
4123
+ this.wechatPendingChallenges.delete(cacheKey);
4124
+ }
4125
+ const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
4126
+ const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
4127
+ const entry = {
4128
+ expiresAtMs: now + cacheTtlMs,
4129
+ promise: Promise.resolve(null)
4130
+ };
4131
+ entry.promise = (async () => {
4132
+ try {
4133
+ const req = await create();
4134
+ entry.outTradeNo = req.outTradeNo;
4135
+ return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
4136
+ } catch (err) {
4137
+ console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
4138
+ this.wechatPendingChallenges.delete(cacheKey);
4139
+ return null;
4140
+ }
4141
+ })();
4142
+ this.wechatPendingChallenges.set(cacheKey, entry);
4143
+ return entry.promise;
4144
+ }
4145
+ /**
4146
+ * Drop the cached pending WeChat order that matches a paid `out_trade_no`,
4147
+ * so the next 402 mints a fresh order instead of re-serving a consumed one
4148
+ * (Native is one-code-one-payment).
4149
+ */
4150
+ invalidateWechatChallenge(outTradeNo) {
4151
+ for (const [cacheKey, entry] of this.wechatPendingChallenges) {
4152
+ if (entry.outTradeNo === outTradeNo) {
4153
+ this.wechatPendingChallenges.delete(cacheKey);
4154
+ }
4155
+ }
4156
+ }
4157
+ /**
4158
+ * Handle /execute for the custodial balance rail (2.2.0).
4159
+ *
4160
+ * Execution order is INVERTED relative to the QR rails: the deduction IS
4161
+ * the settlement, so it must land before the skill runs, and a skill
4162
+ * failure refunds it. `settle()` is idempotent on the client's
4163
+ * `request_id`, so a retried request never double-charges.
4164
+ *
4165
+ * QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
4166
+ * balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
4167
+ */
4168
+ async handleBalanceExecute(skill, params, payment, res) {
4169
+ if (!this.balanceFacilitator) {
4170
+ return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
4171
+ }
4172
+ const requirements = this.balanceRequirementsFor(skill.config);
4173
+ const authMode = this.balanceFacilitator.authMode;
4174
+ if (authMode !== "off") {
4175
+ const bp = extractBalancePayload(payment);
4176
+ const buyerId = bp?.buyer_id ?? "";
4177
+ const requestId = bp?.request_id ?? "";
4178
+ const av = verifyDeductAuth({
4179
+ auth: bp?.auth ?? null,
4180
+ buyerId,
4181
+ requestId,
4182
+ service: skill.id,
4183
+ nowMs: Date.now()
4184
+ });
4185
+ const ledger = this.balanceFacilitator.getLedger();
4186
+ let denyReason;
4187
+ if (av.ok && av.recovered) {
4188
+ const bind = ledger.bindSigner(buyerId, av.recovered);
4189
+ if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
4190
+ } else {
4191
+ denyReason = `signature ${av.reason}`;
4192
+ }
4193
+ if (denyReason) {
4194
+ if (authMode === "enforce") {
4195
+ console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4196
+ return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
4197
+ }
4198
+ console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4199
+ } else {
4200
+ console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
4201
+ }
4202
+ }
4203
+ console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
4204
+ const settlement = await this.balanceFacilitator.settle(payment, requirements);
4205
+ if (!settlement.success) {
4206
+ return this.sendJson(res, 402, {
4207
+ error: `Balance deduction failed: ${settlement.error}`,
4208
+ code: settlement.status,
4209
+ facilitator: "balance"
4210
+ });
4211
+ }
4212
+ console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
4213
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4214
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4215
+ let result;
4216
+ try {
4217
+ result = await Promise.race([
4218
+ skill.handler(params),
4219
+ new Promise(
4220
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4221
+ )
4222
+ ]);
4223
+ } catch (err) {
4224
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4225
+ const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
4226
+ if (!refund.success) {
4227
+ console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
4228
+ } else {
4229
+ console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
4230
+ }
4231
+ return this.sendJson(res, 500, {
4232
+ error: "Service execution failed",
4233
+ message: err.message,
4234
+ refunded: refund.success
4235
+ });
4236
+ }
4237
+ const responseHeaders = {
4238
+ [PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
4239
+ success: true,
4240
+ transaction: settlement.transaction,
4241
+ network: "balance",
4242
+ facilitator: "balance"
4243
+ })).toString("base64")
4244
+ };
4245
+ this.sendJson(res, 200, {
4246
+ success: true,
4247
+ result,
4248
+ payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
4249
+ }, responseHeaders);
4250
+ }
4251
+ /** The balance rail's requirements for a service (price defaults to `config.price`). */
4252
+ balanceRequirementsFor(config) {
4253
+ const price = config.balance?.price ?? config.price.toFixed(2);
4254
+ return {
4255
+ scheme: BALANCE_SCHEME,
4256
+ network: "balance",
4257
+ asset: this.balanceFacilitator?.currency ?? "USD",
4258
+ amount: price,
4259
+ payTo: "custodial",
4260
+ maxTimeoutSeconds: 30,
4261
+ extra: { service_id: config.id }
4262
+ };
4263
+ }
4264
+ /**
4265
+ * Build the balance 402 challenge for a service, or null when the rail
4266
+ * isn't configured for this server or this service. Pure — nothing is
4267
+ * minted, so unlike the QR rails a 402 emit has no side effects.
4268
+ */
4269
+ buildBalanceChallenge(config) {
4270
+ if (!this.balanceFacilitator || !config.balance) return null;
4271
+ try {
4272
+ return { accepts: this.balanceRequirementsFor(config) };
4273
+ } catch (err) {
4274
+ console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
4275
+ return null;
4276
+ }
4277
+ }
4278
+ /** GET /balance?buyer_id= — balance, limits, and today's spend. */
1987
4279
  /**
1988
4280
  * Handle MPP (Machine Payments Protocol) request
1989
4281
  * Supports both x402 and MPP protocols on service endpoints
1990
4282
  */
1991
- async handleMPPRequest(skill, body, authHeader, x402Header, res) {
4283
+ async handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader) {
1992
4284
  const config = skill.config;
1993
4285
  const params = body || {};
4286
+ if (proofHeader) {
4287
+ return await this.handleExecute({ service: config.id, params }, void 0, res, proofHeader);
4288
+ }
1994
4289
  if (x402Header) {
1995
4290
  return await this.handleExecute({ service: config.id, params }, x402Header, res);
1996
4291
  }
1997
4292
  if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
1998
4293
  return await this.handleMPPPayment(skill, params, authHeader, res);
1999
4294
  }
2000
- return this.sendMPPPaymentRequired(config, res);
4295
+ return this.sendMPPPaymentRequired(config, res, params);
2001
4296
  }
2002
4297
  /**
2003
4298
  * Handle MPP payment verification and service execution
@@ -2096,7 +4391,7 @@ var MoltsPayServer = class {
2096
4391
  /**
2097
4392
  * Return 402 with both x402 and MPP payment requirements
2098
4393
  */
2099
- sendMPPPaymentRequired(config, res) {
4394
+ async sendMPPPaymentRequired(config, res, params) {
2100
4395
  const acceptedTokens = getAcceptedCurrencies(config);
2101
4396
  const providerChains = this.getProviderChains();
2102
4397
  const accepts = [];
@@ -2107,6 +4402,18 @@ var MoltsPayServer = class {
2107
4402
  }
2108
4403
  }
2109
4404
  }
4405
+ const alipayChallenge = await this.buildAlipayChallenge(config);
4406
+ if (alipayChallenge) {
4407
+ accepts.push(alipayChallenge.accepts);
4408
+ }
4409
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4410
+ if (wechatChallenge) {
4411
+ accepts.push(wechatChallenge.accepts);
4412
+ }
4413
+ const balanceChallenge = this.buildBalanceChallenge(config);
4414
+ if (balanceChallenge) {
4415
+ accepts.push(balanceChallenge.accepts);
4416
+ }
2110
4417
  const x402PaymentRequired = {
2111
4418
  x402Version: X402_VERSION2,
2112
4419
  accepts,
@@ -2134,7 +4441,7 @@ var MoltsPayServer = class {
2134
4441
  };
2135
4442
  const mppRequestEncoded = Buffer.from(JSON.stringify(mppRequest)).toString("base64");
2136
4443
  const expiresAt = new Date(Date.now() + 5 * 60 * 1e3).toISOString();
2137
- mppWwwAuth = `Payment id="${challengeId}", realm="${this.manifest.provider.name}", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${config.name}", expires="${expiresAt}"`;
4444
+ mppWwwAuth = `Payment id="${challengeId}", realm="${headerSafe(this.manifest.provider.name)}", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${headerSafe(config.name)}", expires="${expiresAt}"`;
2138
4445
  }
2139
4446
  const headers = {
2140
4447
  "Content-Type": "application/problem+json",
@@ -2143,6 +4450,9 @@ var MoltsPayServer = class {
2143
4450
  if (mppWwwAuth) {
2144
4451
  headers[MPP_WWW_AUTH_HEADER] = mppWwwAuth;
2145
4452
  }
4453
+ if (alipayChallenge) {
4454
+ headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
4455
+ }
2146
4456
  res.writeHead(402, headers);
2147
4457
  res.end(JSON.stringify({
2148
4458
  type: "https://paymentauth.org/problems/payment-required",
@@ -2169,7 +4479,7 @@ var MoltsPayServer = class {
2169
4479
  * Return 402 with x402 payment requirements (v2 format)
2170
4480
  * Includes requirements for all chains and all accepted currencies
2171
4481
  */
2172
- sendPaymentRequired(config, res) {
4482
+ async sendPaymentRequired(config, res, params) {
2173
4483
  const acceptedTokens = getAcceptedCurrencies(config);
2174
4484
  const providerChains = this.getProviderChains();
2175
4485
  const accepts = [];
@@ -2180,6 +4490,18 @@ var MoltsPayServer = class {
2180
4490
  }
2181
4491
  }
2182
4492
  }
4493
+ const alipayChallenge = await this.buildAlipayChallenge(config);
4494
+ if (alipayChallenge) {
4495
+ accepts.push(alipayChallenge.accepts);
4496
+ }
4497
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4498
+ if (wechatChallenge) {
4499
+ accepts.push(wechatChallenge.accepts);
4500
+ }
4501
+ const balanceChallenge = this.buildBalanceChallenge(config);
4502
+ if (balanceChallenge) {
4503
+ accepts.push(balanceChallenge.accepts);
4504
+ }
2183
4505
  const acceptedChains = providerChains.map((c) => {
2184
4506
  if (c.network === "eip155:8453") return "base";
2185
4507
  if (c.network === "eip155:137") return "polygon";
@@ -2191,20 +4513,26 @@ var MoltsPayServer = class {
2191
4513
  acceptedCurrencies: acceptedTokens,
2192
4514
  acceptedChains,
2193
4515
  resource: {
2194
- url: `/execute?service=${config.id}`,
4516
+ url: `${this.publicBase}/execute?service=${config.id}`,
2195
4517
  description: `${config.name} - $${config.price} ${config.currency}`,
2196
4518
  mimeType: "application/json"
2197
4519
  }
2198
4520
  };
2199
4521
  const encoded = Buffer.from(JSON.stringify(paymentRequired)).toString("base64");
2200
- res.writeHead(402, {
4522
+ const headers = {
2201
4523
  "Content-Type": "application/json",
2202
4524
  [PAYMENT_REQUIRED_HEADER]: encoded
2203
- });
4525
+ };
4526
+ if (alipayChallenge) {
4527
+ headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
4528
+ }
4529
+ const offered = this.describeServicePricing(config);
4530
+ const message = offered.pricing.length ? `Payment required \u2014 ${offered.pricing.map((p) => `${p.rail}: ${p.amount} ${p.currency}`).join(" | ")}` : `Service requires $${config.price} ${config.currency}`;
4531
+ res.writeHead(402, headers);
2204
4532
  res.end(JSON.stringify({
2205
4533
  error: "Payment required",
2206
- message: `Service requires $${config.price} ${config.currency}`,
2207
- acceptedCurrencies: acceptedTokens,
4534
+ message,
4535
+ acceptedCurrencies: offered.acceptedCurrencies,
2208
4536
  acceptedChains,
2209
4537
  x402: paymentRequired
2210
4538
  }, null, 2));
@@ -2307,12 +4635,12 @@ var MoltsPayServer = class {
2307
4635
  return accepted.includes(token);
2308
4636
  }
2309
4637
  async readBody(req) {
2310
- return new Promise((resolve, reject) => {
4638
+ return new Promise((resolve2, reject) => {
2311
4639
  let body = "";
2312
4640
  req.on("data", (chunk) => body += chunk);
2313
4641
  req.on("end", () => {
2314
4642
  try {
2315
- resolve(body ? JSON.parse(body) : {});
4643
+ resolve2(body ? JSON.parse(body) : {});
2316
4644
  } catch {
2317
4645
  reject(new Error("Invalid JSON"));
2318
4646
  }
@@ -2570,7 +4898,7 @@ var MoltsPayServer = class {
2570
4898
  };
2571
4899
  const mppRequestEncoded = Buffer.from(JSON.stringify(mppRequest)).toString("base64");
2572
4900
  const expiresAt = new Date(Date.now() + 5 * 60 * 1e3).toISOString();
2573
- const wwwAuth = `Payment id="${challengeId}", realm="MoltsPay Proxy", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${config.name}", expires="${expiresAt}"`;
4901
+ const wwwAuth = `Payment id="${challengeId}", realm="MoltsPay Proxy", method="tempo", intent="charge", request="${mppRequestEncoded}", description="${headerSafe(config.name)}", expires="${expiresAt}"`;
2574
4902
  res.writeHead(402, {
2575
4903
  "Content-Type": "application/problem+json",
2576
4904
  [MPP_WWW_AUTH_HEADER]: wwwAuth