moltspay 2.0.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 (50) hide show
  1. package/.env.example +23 -0
  2. package/CHANGELOG.md +551 -0
  3. package/README.md +466 -8
  4. package/dist/cdp/index.js.map +1 -1
  5. package/dist/cdp/index.mjs.map +1 -1
  6. package/dist/chains/index.d.mts +69 -4
  7. package/dist/chains/index.d.ts +69 -4
  8. package/dist/chains/index.js +45 -1
  9. package/dist/chains/index.js.map +1 -1
  10. package/dist/chains/index.mjs +39 -1
  11. package/dist/chains/index.mjs.map +1 -1
  12. package/dist/cli/index.js +5444 -2126
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/cli/index.mjs +5457 -2133
  15. package/dist/cli/index.mjs.map +1 -1
  16. package/dist/client/index.d.mts +307 -1
  17. package/dist/client/index.d.ts +307 -1
  18. package/dist/client/index.js +639 -34
  19. package/dist/client/index.js.map +1 -1
  20. package/dist/client/index.mjs +656 -52
  21. package/dist/client/index.mjs.map +1 -1
  22. package/dist/client/web/index.mjs.map +1 -1
  23. package/dist/facilitators/index.d.mts +512 -10
  24. package/dist/facilitators/index.d.ts +512 -10
  25. package/dist/facilitators/index.js +925 -13
  26. package/dist/facilitators/index.js.map +1 -1
  27. package/dist/facilitators/index.mjs +906 -12
  28. package/dist/facilitators/index.mjs.map +1 -1
  29. package/dist/index.d.mts +2 -2
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +2843 -551
  32. package/dist/index.js.map +1 -1
  33. package/dist/index.mjs +2849 -558
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/mcp/index.js +635 -32
  36. package/dist/mcp/index.js.map +1 -1
  37. package/dist/mcp/index.mjs +660 -57
  38. package/dist/mcp/index.mjs.map +1 -1
  39. package/dist/server/index.d.mts +252 -11
  40. package/dist/server/index.d.ts +252 -11
  41. package/dist/server/index.js +2049 -261
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/server/index.mjs +2049 -261
  44. package/dist/server/index.mjs.map +1 -1
  45. package/dist/verify/index.js.map +1 -1
  46. package/dist/verify/index.mjs.map +1 -1
  47. package/dist/wallet/index.js.map +1 -1
  48. package/dist/wallet/index.mjs.map +1 -1
  49. package/package.json +14 -2
  50. package/schemas/moltspay.services.schema.json +127 -16
@@ -1,7 +1,8 @@
1
1
  // src/server/index.ts
2
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2
+ import { readFileSync as readFileSync3 } from "fs";
3
3
  import { createServer } from "http";
4
- import * as path2 from "path";
4
+ import * as path3 from "path";
5
+ import crypto6 from "crypto";
5
6
 
6
7
  // src/facilitators/interface.ts
7
8
  var BaseFacilitator = class {
@@ -398,10 +399,28 @@ var CHAINS = {
398
399
  requiresApproval: true
399
400
  }
400
401
  };
402
+ function getChain(name) {
403
+ const config = CHAINS[name];
404
+ if (!config) {
405
+ throw new Error(`Unsupported chain: ${name}. Supported: ${Object.keys(CHAINS).join(", ")}`);
406
+ }
407
+ return config;
408
+ }
409
+ function getChainById(chainId) {
410
+ return Object.values(CHAINS).find((c) => c.chainId === chainId);
411
+ }
401
412
  var ALIPAY_CHAIN_ID = "alipay";
402
413
  function isAlipayChainId(id) {
403
414
  return id === ALIPAY_CHAIN_ID;
404
415
  }
416
+ var WECHAT_CHAIN_ID = "wechat";
417
+ function isWechatChainId(id) {
418
+ return id === WECHAT_CHAIN_ID;
419
+ }
420
+ var BALANCE_CHAIN_ID = "balance";
421
+ function isBalanceChainId(id) {
422
+ return id === BALANCE_CHAIN_ID;
423
+ }
405
424
 
406
425
  // src/facilitators/tempo.ts
407
426
  var TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
@@ -860,12 +879,12 @@ var BNBFacilitator = class extends BaseFacilitator {
860
879
  return this.spenderAddress;
861
880
  }
862
881
  async getServerAddress() {
863
- const { ethers: ethers2 } = await import("ethers");
864
- const wallet = new ethers2.Wallet(this.serverPrivateKey);
882
+ const { ethers: ethers4 } = await import("ethers");
883
+ const wallet = new ethers4.Wallet(this.serverPrivateKey);
865
884
  return wallet.address;
866
885
  }
867
886
  async recoverIntentSigner(intent, chainId) {
868
- const { ethers: ethers2 } = await import("ethers");
887
+ const { ethers: ethers4 } = await import("ethers");
869
888
  const domain = {
870
889
  ...EIP712_DOMAIN,
871
890
  chainId
@@ -879,7 +898,7 @@ var BNBFacilitator = class extends BaseFacilitator {
879
898
  nonce: intent.nonce,
880
899
  deadline: intent.deadline
881
900
  };
882
- const recoveredAddress = ethers2.verifyTypedData(
901
+ const recoveredAddress = ethers4.verifyTypedData(
883
902
  domain,
884
903
  INTENT_TYPES,
885
904
  message,
@@ -923,10 +942,10 @@ var BNBFacilitator = class extends BaseFacilitator {
923
942
  return result.result || "0x0";
924
943
  }
925
944
  async executeTransferFrom(from, to, amount, token, rpcUrl) {
926
- const { ethers: ethers2 } = await import("ethers");
927
- const provider = new ethers2.JsonRpcProvider(rpcUrl);
928
- const wallet = new ethers2.Wallet(this.serverPrivateKey, provider);
929
- const tokenContract = new ethers2.Contract(token, [
945
+ const { ethers: ethers4 } = await import("ethers");
946
+ const provider = new ethers4.JsonRpcProvider(rpcUrl);
947
+ const wallet = new ethers4.Wallet(this.serverPrivateKey, provider);
948
+ const tokenContract = new ethers4.Contract(token, [
930
949
  "function transferFrom(address from, address to, uint256 amount) returns (bool)"
931
950
  ], wallet);
932
951
  const tx = await tokenContract.transferFrom(from, to, amount);
@@ -1262,7 +1281,7 @@ var ALIPAY_SIGNING_FIELDS = [
1262
1281
  ];
1263
1282
  var AlipayFacilitator = class extends BaseFacilitator {
1264
1283
  name = "alipay";
1265
- displayName = "Alipay AI \u6536";
1284
+ displayName = "Alipay AI Pay";
1266
1285
  supportedNetworks = [ALIPAY_NETWORK];
1267
1286
  config;
1268
1287
  constructor(config) {
@@ -1281,7 +1300,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1281
1300
  async createPaymentRequirements(opts) {
1282
1301
  if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
1283
1302
  throw new Error(
1284
- `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is \u5143, not \u5206; e.g. "1.00" not "100")`
1303
+ `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
1285
1304
  );
1286
1305
  }
1287
1306
  const now = /* @__PURE__ */ new Date();
@@ -1394,7 +1413,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1394
1413
  * service resource has been returned to the buyer.
1395
1414
  *
1396
1415
  * Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
1397
- * "履约确认失败"), this is **fire-and-forget**: the caller (registry /
1416
+ * (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
1398
1417
  * server) is expected to log fulfillment failures but NOT roll back
1399
1418
  * the already-delivered resource.
1400
1419
  *
@@ -1531,210 +1550,1086 @@ function decodeProof(proofHeader) {
1531
1550
  return parsed;
1532
1551
  }
1533
1552
 
1534
- // src/facilitators/registry.ts
1535
- import { Keypair as Keypair2 } from "@solana/web3.js";
1536
- import bs58 from "bs58";
1537
- var FacilitatorRegistry = class {
1538
- factories = /* @__PURE__ */ new Map();
1539
- instances = /* @__PURE__ */ new Map();
1540
- selection;
1541
- roundRobinIndex = 0;
1542
- constructor(selection) {
1543
- this.registerFactory("cdp", (config) => new CDPFacilitator(config));
1544
- this.registerFactory("tempo", () => new TempoFacilitator());
1545
- this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
1546
- this.registerFactory("solana", (config) => {
1547
- let feePayerKeypair;
1548
- const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
1549
- if (feePayerKey) {
1550
- try {
1551
- feePayerKeypair = Keypair2.fromSecretKey(bs58.decode(feePayerKey));
1552
- } catch (e) {
1553
- console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
1554
- }
1555
- }
1556
- return new SolanaFacilitator({ feePayerKeypair });
1557
- });
1558
- this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
1559
- this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
1553
+ // src/facilitators/wechat.ts
1554
+ import crypto4 from "crypto";
1555
+
1556
+ // src/facilitators/wechat/sign.ts
1557
+ import crypto3 from "crypto";
1558
+ var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
1559
+ function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
1560
+ return `${method.toUpperCase()}
1561
+ ${urlPath}
1562
+ ${timestamp}
1563
+ ${nonce}
1564
+ ${body}
1565
+ `;
1566
+ }
1567
+ function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
1568
+ const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
1569
+ const signer = crypto3.createSign("RSA-SHA256");
1570
+ signer.update(message, "utf-8");
1571
+ signer.end();
1572
+ return signer.sign(privateKeyPem, "base64");
1573
+ }
1574
+ function buildAuthorizationToken(args) {
1575
+ const fields = [
1576
+ `mchid="${args.mchid}"`,
1577
+ `nonce_str="${args.nonce}"`,
1578
+ `signature="${args.signature}"`,
1579
+ `timestamp="${args.timestamp}"`,
1580
+ `serial_no="${args.serialNo}"`
1581
+ ].join(",");
1582
+ return `${WECHAT_AUTH_SCHEMA} ${fields}`;
1583
+ }
1584
+ function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
1585
+ try {
1586
+ const message = `${timestamp}
1587
+ ${nonce}
1588
+ ${body}
1589
+ `;
1590
+ const verifier = crypto3.createVerify("RSA-SHA256");
1591
+ verifier.update(message, "utf-8");
1592
+ verifier.end();
1593
+ return verifier.verify(platformPublicKeyPem, signature, "base64");
1594
+ } catch {
1595
+ return false;
1560
1596
  }
1561
- /**
1562
- * Register a new facilitator factory
1563
- */
1564
- registerFactory(name, factory) {
1565
- this.factories.set(name, factory);
1597
+ }
1598
+ function generateNonce() {
1599
+ return crypto3.randomBytes(16).toString("hex");
1600
+ }
1601
+
1602
+ // src/facilitators/wechat/api.ts
1603
+ var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
1604
+ var WechatApiError = class extends Error {
1605
+ status;
1606
+ code;
1607
+ constructor(message, status, code) {
1608
+ super(message);
1609
+ this.name = "WechatApiError";
1610
+ this.status = status;
1611
+ this.code = code;
1566
1612
  }
1567
- /**
1568
- * Get or create a facilitator instance
1569
- */
1570
- get(name, config) {
1571
- if (this.instances.has(name)) {
1572
- return this.instances.get(name);
1613
+ };
1614
+ async function wechatV3Call(method, urlPath, body, config) {
1615
+ const base = config.api_base ?? WECHAT_API_BASE;
1616
+ const bodyStr = body === null ? "" : JSON.stringify(body);
1617
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1618
+ const nonce = generateNonce();
1619
+ const signature = wechatV3Sign(
1620
+ method,
1621
+ urlPath,
1622
+ timestamp,
1623
+ nonce,
1624
+ bodyStr,
1625
+ config.private_key_pem
1626
+ );
1627
+ const authorization = buildAuthorizationToken({
1628
+ mchid: config.mchid,
1629
+ serialNo: config.serial_no,
1630
+ nonce,
1631
+ timestamp,
1632
+ signature
1633
+ });
1634
+ const response = await fetch(`${base}${urlPath}`, {
1635
+ method,
1636
+ headers: {
1637
+ Authorization: authorization,
1638
+ Accept: "application/json",
1639
+ "Content-Type": "application/json",
1640
+ // WeChat requires a non-empty UA; some edge nodes 403 a blank one.
1641
+ "User-Agent": "moltspay-wechat/1.0"
1642
+ },
1643
+ body: method === "GET" ? void 0 : bodyStr
1644
+ });
1645
+ const text = await response.text();
1646
+ if (config.platform_public_key_pem && text.length > 0) {
1647
+ const ts = response.headers.get("Wechatpay-Timestamp");
1648
+ const nc = response.headers.get("Wechatpay-Nonce");
1649
+ const sig = response.headers.get("Wechatpay-Signature");
1650
+ if (!ts || !nc || !sig) {
1651
+ throw new Error(
1652
+ `WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
1653
+ );
1573
1654
  }
1574
- const factory = this.factories.get(name);
1575
- if (!factory) {
1576
- throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
1655
+ if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
1656
+ throw new Error(
1657
+ `WeChat v3 ${method} ${urlPath}: response signature verification failed`
1658
+ );
1577
1659
  }
1578
- const mergedConfig = {
1579
- ...this.selection.config?.[name],
1580
- ...config
1581
- };
1582
- const instance = factory(mergedConfig);
1583
- this.instances.set(name, instance);
1584
- return instance;
1585
1660
  }
1586
- /**
1587
- * Get all configured facilitator names
1588
- */
1589
- getConfiguredNames() {
1590
- const names = [this.selection.primary];
1591
- if (this.selection.fallback) {
1592
- names.push(...this.selection.fallback);
1661
+ let json = {};
1662
+ if (text.length > 0) {
1663
+ try {
1664
+ json = JSON.parse(text);
1665
+ } catch {
1666
+ throw new Error(
1667
+ `WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
1668
+ );
1593
1669
  }
1594
- return names;
1670
+ }
1671
+ if (!response.ok) {
1672
+ const code = typeof json.code === "string" ? json.code : void 0;
1673
+ const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
1674
+ throw new WechatApiError(
1675
+ `WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
1676
+ response.status,
1677
+ code
1678
+ );
1679
+ }
1680
+ return { status: response.status, body: json };
1681
+ }
1682
+
1683
+ // src/facilitators/wechat.ts
1684
+ var WECHAT_NETWORK = "wechat";
1685
+ var WECHAT_SCHEME = "wechatpay-native";
1686
+ var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
1687
+ var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
1688
+ var WechatFacilitator = class extends BaseFacilitator {
1689
+ name = "wechat";
1690
+ displayName = "WeChat Pay";
1691
+ supportedNetworks = [WECHAT_NETWORK];
1692
+ config;
1693
+ constructor(config) {
1694
+ super();
1695
+ this.config = { api_base: WECHAT_API_BASE, ...config };
1595
1696
  }
1596
1697
  /**
1597
- * Get list of facilitators based on selection strategy
1698
+ * Place a Native order and build the 402 challenge. The returned
1699
+ * `code_url` is payer-agnostic — any WeChat user may scan it.
1598
1700
  */
1599
- async getOrderedFacilitators(network) {
1600
- const names = this.getConfiguredNames();
1601
- const facilitators = [];
1602
- for (const name of names) {
1603
- try {
1604
- const f = this.get(name);
1605
- if (f.supportsNetwork(network)) {
1606
- facilitators.push(f);
1607
- }
1608
- } catch (err) {
1609
- console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
1610
- }
1611
- }
1612
- if (facilitators.length === 0) {
1613
- throw new Error(`No facilitators available for network: ${network}`);
1701
+ async createPaymentRequirements(opts) {
1702
+ if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
1703
+ throw new Error(
1704
+ `WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
1705
+ );
1614
1706
  }
1615
- switch (this.selection.strategy) {
1616
- case "random":
1617
- return this.shuffle(facilitators);
1618
- case "roundrobin":
1619
- this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
1620
- return [
1621
- ...facilitators.slice(this.roundRobinIndex),
1622
- ...facilitators.slice(0, this.roundRobinIndex)
1623
- ];
1624
- case "cheapest":
1625
- return this.sortByCheapest(facilitators);
1626
- case "fastest":
1627
- return this.sortByFastest(facilitators);
1628
- case "failover":
1629
- default:
1630
- return facilitators;
1707
+ const total = cnyToFen(opts.priceCny);
1708
+ if (total < 1) {
1709
+ throw new Error(
1710
+ `WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
1711
+ );
1631
1712
  }
1632
- }
1633
- shuffle(array) {
1634
- const result = [...array];
1635
- for (let i = result.length - 1; i > 0; i--) {
1636
- const j = Math.floor(Math.random() * (i + 1));
1637
- [result[i], result[j]] = [result[j], result[i]];
1713
+ const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
1714
+ const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
1715
+ const body = {
1716
+ appid: this.config.appid,
1717
+ mchid: this.config.mchid,
1718
+ description: opts.description,
1719
+ out_trade_no: outTradeNo,
1720
+ notify_url: this.config.notify_url,
1721
+ time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
1722
+ amount: { total, currency: "CNY" }
1723
+ };
1724
+ if (opts.attach) {
1725
+ const attachStr = JSON.stringify(opts.attach);
1726
+ if (Buffer.byteLength(attachStr, "utf8") > 128) {
1727
+ throw new Error(
1728
+ `WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
1729
+ );
1730
+ }
1731
+ body.attach = attachStr;
1638
1732
  }
1639
- return result;
1640
- }
1641
- async sortByCheapest(facilitators) {
1642
- const withFees = await Promise.all(
1643
- facilitators.map(async (f) => {
1644
- try {
1645
- const fee = await f.getFee?.();
1646
- return { facilitator: f, perTx: fee?.perTx ?? Infinity };
1647
- } catch {
1648
- return { facilitator: f, perTx: Infinity };
1649
- }
1650
- })
1651
- );
1652
- withFees.sort((a, b) => a.perTx - b.perTx);
1653
- return withFees.map((w) => w.facilitator);
1654
- }
1655
- async sortByFastest(facilitators) {
1656
- const withLatency = await Promise.all(
1657
- facilitators.map(async (f) => {
1658
- try {
1659
- const health = await f.healthCheck();
1660
- return { facilitator: f, latency: health.latencyMs ?? Infinity };
1661
- } catch {
1662
- return { facilitator: f, latency: Infinity };
1663
- }
1664
- })
1733
+ const { body: resp } = await wechatV3Call(
1734
+ "POST",
1735
+ "/v3/pay/transactions/native",
1736
+ body,
1737
+ this.getApiConfig()
1665
1738
  );
1666
- withLatency.sort((a, b) => a.latency - b.latency);
1667
- return withLatency.map((w) => w.facilitator);
1739
+ const codeUrl = resp.code_url;
1740
+ if (typeof codeUrl !== "string" || codeUrl.length === 0) {
1741
+ throw new Error(
1742
+ `WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
1743
+ );
1744
+ }
1745
+ const x402Accepts = {
1746
+ scheme: WECHAT_SCHEME,
1747
+ network: WECHAT_NETWORK,
1748
+ asset: "CNY",
1749
+ amount: opts.priceCny,
1750
+ payTo: this.config.mchid,
1751
+ maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
1752
+ extra: {
1753
+ code_url: codeUrl,
1754
+ out_trade_no: outTradeNo
1755
+ }
1756
+ };
1757
+ return { x402Accepts, codeUrl, outTradeNo };
1668
1758
  }
1669
1759
  /**
1670
- * Verify payment using configured facilitators
1760
+ * Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
1761
+ * (missing out_trade_no, gateway error, not-yet-paid) return
1762
+ * `{ valid: false, error }`; no exception escapes.
1671
1763
  */
1672
1764
  async verify(paymentPayload, requirements) {
1673
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1674
- const facilitators = await this.getOrderedFacilitators(network);
1675
- let lastError;
1676
- for (const f of facilitators) {
1677
- try {
1678
- console.log(`[Registry] Trying ${f.name} for verify...`);
1679
- const result = await f.verify(paymentPayload, requirements);
1680
- if (result.valid) {
1681
- console.log(`[Registry] ${f.name} verify succeeded`);
1682
- return { ...result, facilitator: f.name };
1683
- }
1684
- lastError = result.error;
1685
- console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
1686
- if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
1687
- break;
1688
- }
1689
- } catch (err) {
1690
- lastError = err.message;
1691
- console.error(`[Registry] ${f.name} error:`, err.message);
1765
+ try {
1766
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1767
+ const resp = await this.queryOrder(outTradeNo);
1768
+ const tradeState = resp.trade_state;
1769
+ if (tradeState !== "SUCCESS") {
1770
+ return {
1771
+ valid: false,
1772
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
1773
+ details: { trade_state: tradeState, out_trade_no: outTradeNo }
1774
+ };
1692
1775
  }
1776
+ return {
1777
+ valid: true,
1778
+ details: {
1779
+ trade_state: tradeState,
1780
+ transaction_id: resp.transaction_id,
1781
+ out_trade_no: resp.out_trade_no ?? outTradeNo,
1782
+ amount: resp.amount,
1783
+ attach: resp.attach,
1784
+ // Payer identity, gateway-attested. Present on a SUCCESS Native
1785
+ // order even though order creation was payer-agnostic; anchors the
1786
+ // custodial balance to a real WeChat user. @see WECHAT fiat auth design.
1787
+ openid: resp.payer?.openid
1788
+ }
1789
+ };
1790
+ } catch (e) {
1791
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
1693
1792
  }
1694
- return {
1695
- valid: false,
1696
- error: lastError || "All facilitators failed",
1697
- facilitator: "none"
1698
- };
1699
1793
  }
1700
1794
  /**
1701
- * Settle payment using configured facilitators
1795
+ * Confirm settlement. Native captures funds at SUCCESS, so this is an
1796
+ * idempotent re-confirm that returns the `transaction_id`. Like Alipay's
1797
+ * fulfillment confirm, failures are surfaced but non-fatal to an
1798
+ * already-delivered resource (caller logs, does not roll back).
1702
1799
  */
1703
1800
  async settle(paymentPayload, requirements) {
1704
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1705
- const facilitators = await this.getOrderedFacilitators(network);
1706
- let lastError;
1707
- for (const f of facilitators) {
1708
- try {
1709
- console.log(`[Registry] Trying ${f.name} for settle...`);
1710
- const result = await f.settle(paymentPayload, requirements);
1711
- if (result.success) {
1712
- console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
1713
- return { ...result, facilitator: f.name };
1714
- }
1715
- lastError = result.error;
1716
- console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
1717
- } catch (err) {
1718
- lastError = err.message;
1719
- console.error(`[Registry] ${f.name} error:`, err.message);
1801
+ try {
1802
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1803
+ const resp = await this.queryOrder(outTradeNo);
1804
+ const tradeState = resp.trade_state;
1805
+ const transactionId = resp.transaction_id;
1806
+ if (tradeState !== "SUCCESS") {
1807
+ return {
1808
+ success: false,
1809
+ transaction: transactionId,
1810
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
1811
+ status: tradeState
1812
+ };
1720
1813
  }
1814
+ return { success: true, transaction: transactionId, status: "fulfilled" };
1815
+ } catch (e) {
1816
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1721
1817
  }
1722
- return {
1723
- success: false,
1724
- error: lastError || "All facilitators failed",
1725
- facilitator: "none"
1726
- };
1727
1818
  }
1728
1819
  /**
1729
- * Check health of all configured facilitators
1820
+ * Validate keys parse, apiv3 key length, and gateway reachability. Does
1821
+ * NOT make a business API call.
1730
1822
  */
1731
- async healthCheckAll() {
1732
- const results = {};
1733
- for (const name of this.getConfiguredNames()) {
1734
- try {
1735
- const f = this.get(name);
1736
- results[name] = await f.healthCheck();
1737
- } catch (err) {
1823
+ async healthCheck() {
1824
+ const start = Date.now();
1825
+ try {
1826
+ crypto4.createPrivateKey(this.config.private_key_pem);
1827
+ } catch (e) {
1828
+ return {
1829
+ healthy: false,
1830
+ error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1831
+ };
1832
+ }
1833
+ if (this.config.platform_public_key_pem) {
1834
+ try {
1835
+ crypto4.createPublicKey(this.config.platform_public_key_pem);
1836
+ } catch (e) {
1837
+ return {
1838
+ healthy: false,
1839
+ error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1840
+ };
1841
+ }
1842
+ }
1843
+ if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
1844
+ return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
1845
+ }
1846
+ const base = this.config.api_base ?? WECHAT_API_BASE;
1847
+ const controller = new AbortController();
1848
+ const timeout = setTimeout(() => controller.abort(), 5e3);
1849
+ const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
1850
+ clearTimeout(timeout);
1851
+ const latencyMs = Date.now() - start;
1852
+ if (!response) {
1853
+ return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
1854
+ }
1855
+ return { healthy: true, latencyMs };
1856
+ }
1857
+ /** Query a Native order by out_trade_no. The query string is part of the signed path. */
1858
+ async queryOrder(outTradeNo) {
1859
+ const path4 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
1860
+ const { body } = await wechatV3Call("GET", path4, null, this.getApiConfig());
1861
+ return body;
1862
+ }
1863
+ /** Project the facilitator config down to what api.ts needs. */
1864
+ getApiConfig() {
1865
+ return {
1866
+ mchid: this.config.mchid,
1867
+ serial_no: this.config.serial_no,
1868
+ private_key_pem: this.config.private_key_pem,
1869
+ platform_public_key_pem: this.config.platform_public_key_pem,
1870
+ api_base: this.config.api_base
1871
+ };
1872
+ }
1873
+ };
1874
+ function cnyToFen(cny) {
1875
+ return Math.round(parseFloat(cny) * 100);
1876
+ }
1877
+ function generateOutTradeNo2() {
1878
+ return "WX" + crypto4.randomBytes(15).toString("hex");
1879
+ }
1880
+ function parseWechatAttach(attach) {
1881
+ if (typeof attach !== "string" || attach.length === 0) return null;
1882
+ try {
1883
+ const parsed = JSON.parse(attach);
1884
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1885
+ } catch {
1886
+ return null;
1887
+ }
1888
+ }
1889
+ function formatTimeExpire(d) {
1890
+ const pad = (n) => String(n).padStart(2, "0");
1891
+ const offMin = -d.getTimezoneOffset();
1892
+ const sign = offMin >= 0 ? "+" : "-";
1893
+ const abs = Math.abs(offMin);
1894
+ 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)}`;
1895
+ }
1896
+ function extractOutTradeNo(paymentPayload, requirements) {
1897
+ const p = paymentPayload?.payload;
1898
+ if (typeof p === "string" && p.length > 0) return p;
1899
+ if (p !== null && typeof p === "object") {
1900
+ const obj = p;
1901
+ const cand = obj.out_trade_no ?? obj.outTradeNo;
1902
+ if (typeof cand === "string" && cand.length > 0) return cand;
1903
+ }
1904
+ const fromReq = requirements?.extra?.out_trade_no;
1905
+ if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
1906
+ throw new Error(
1907
+ "wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
1908
+ );
1909
+ }
1910
+
1911
+ // src/facilitators/balance/ledger.ts
1912
+ import { randomUUID } from "crypto";
1913
+ var DEFAULT_SINGLE_LIMIT_SAT = 500;
1914
+ var DEFAULT_DAILY_LIMIT_SAT = 1e3;
1915
+ function toSat(amount) {
1916
+ const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
1917
+ if (!/^\d+(\.\d{1,2})?$/.test(s)) {
1918
+ throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
1919
+ }
1920
+ const [whole, frac = ""] = s.split(".");
1921
+ return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
1922
+ }
1923
+ function fromSat(sat) {
1924
+ return (sat / 100).toFixed(2);
1925
+ }
1926
+ var BalanceLedger = class {
1927
+ db;
1928
+ defaultSingleLimitSat;
1929
+ defaultDailyLimitSat;
1930
+ constructor(config) {
1931
+ const getBuiltin = process.getBuiltinModule;
1932
+ const sqlite = getBuiltin?.("node:sqlite");
1933
+ if (!sqlite?.DatabaseSync) {
1934
+ throw new Error(
1935
+ `The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
1936
+ );
1937
+ }
1938
+ const { DatabaseSync } = sqlite;
1939
+ this.db = new DatabaseSync(config.dbPath);
1940
+ this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
1941
+ this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
1942
+ this.db.exec("PRAGMA journal_mode = WAL");
1943
+ this.db.exec(`
1944
+ CREATE TABLE IF NOT EXISTS buyers (
1945
+ buyer_id TEXT PRIMARY KEY,
1946
+ display_name TEXT,
1947
+ balance_sat INTEGER NOT NULL DEFAULT 0,
1948
+ total_topup_sat INTEGER NOT NULL DEFAULT 0,
1949
+ total_spent_sat INTEGER NOT NULL DEFAULT 0,
1950
+ daily_limit_sat INTEGER NOT NULL,
1951
+ single_limit_sat INTEGER NOT NULL,
1952
+ status TEXT NOT NULL DEFAULT 'active',
1953
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1954
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1955
+ );
1956
+ CREATE TABLE IF NOT EXISTS ledger_transactions (
1957
+ id TEXT PRIMARY KEY,
1958
+ buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
1959
+ type TEXT NOT NULL,
1960
+ amount_sat INTEGER NOT NULL,
1961
+ service TEXT,
1962
+ description TEXT,
1963
+ request_id TEXT,
1964
+ external_ref TEXT,
1965
+ refunds_tx_id TEXT,
1966
+ status TEXT NOT NULL DEFAULT 'completed',
1967
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
1968
+ );
1969
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
1970
+ ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
1971
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
1972
+ ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
1973
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
1974
+ ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
1975
+ CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
1976
+ ON ledger_transactions(buyer_id, created_at);
1977
+ CREATE TABLE IF NOT EXISTS ledger_meta (
1978
+ key TEXT PRIMARY KEY,
1979
+ value TEXT NOT NULL
1980
+ );
1981
+ `);
1982
+ const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
1983
+ if (!cols.includes("signer_address")) {
1984
+ this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
1985
+ }
1986
+ if (!cols.includes("wechat_openid")) {
1987
+ this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
1988
+ }
1989
+ const currency = config.currency ?? "USD";
1990
+ const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
1991
+ if (existingCurrency) {
1992
+ if (existingCurrency.value !== currency) {
1993
+ throw new Error(
1994
+ `Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
1995
+ );
1996
+ }
1997
+ } else {
1998
+ this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
1999
+ }
2000
+ }
2001
+ /** Fetch a buyer, or null. */
2002
+ getBuyer(buyerId) {
2003
+ const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
2004
+ return row ?? null;
2005
+ }
2006
+ /** Fetch a buyer, creating an empty active account on first sight. */
2007
+ getOrCreateBuyer(buyerId, displayName) {
2008
+ const existing = this.getBuyer(buyerId);
2009
+ if (existing) return existing;
2010
+ this.db.prepare(
2011
+ `INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
2012
+ VALUES (?, ?, ?, ?)`
2013
+ ).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
2014
+ return this.getBuyer(buyerId);
2015
+ }
2016
+ /**
2017
+ * Record the gateway-attested WeChat payer openid that funded a buyer, on
2018
+ * top-up confirm. Idempotent and observational (stage 1a): it never
2019
+ * overwrites an existing binding with a *different* openid — a conflict is
2020
+ * reported to the caller (possible account sharing / spoof) but not
2021
+ * enforced here. Creates the buyer if absent.
2022
+ */
2023
+ bindOpenid(buyerId, openid) {
2024
+ const buyer = this.getOrCreateBuyer(buyerId);
2025
+ if (buyer.wechat_openid === openid) {
2026
+ return { bound: true, conflict: false, existing: openid };
2027
+ }
2028
+ if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
2029
+ return { bound: false, conflict: true, existing: buyer.wechat_openid };
2030
+ }
2031
+ this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
2032
+ return { bound: true, conflict: false, existing: null };
2033
+ }
2034
+ /**
2035
+ * TOFU-bind the account's spending signer address (EVM, lowercase). First
2036
+ * signed request records it; later requests must match. A mismatch is
2037
+ * reported (`conflict`) so the caller can reject under `enforce` — it is
2038
+ * never silently overwritten. Creates the buyer if absent.
2039
+ */
2040
+ bindSigner(buyerId, address) {
2041
+ const a = address.toLowerCase();
2042
+ const buyer = this.getOrCreateBuyer(buyerId);
2043
+ if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
2044
+ if (buyer.signer_address && buyer.signer_address !== a) {
2045
+ return { bound: false, conflict: true, existing: buyer.signer_address };
2046
+ }
2047
+ this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
2048
+ return { bound: true, conflict: false, existing: null };
2049
+ }
2050
+ /** Sum of today's (UTC) completed deducts minus refunds issued against them. */
2051
+ spentTodaySat(buyerId) {
2052
+ const row = this.db.prepare(
2053
+ `SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
2054
+ FROM ledger_transactions
2055
+ WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
2056
+ ).get(buyerId);
2057
+ return Math.max(0, row.spent);
2058
+ }
2059
+ /**
2060
+ * Read-only deduction precheck (the rail's `verify`). Never mutates.
2061
+ * Returns the same error codes `deduct` would.
2062
+ */
2063
+ checkDeduct(buyerId, amountSat) {
2064
+ const buyer = this.getBuyer(buyerId);
2065
+ if (!buyer) return { success: false, error: "buyer_not_found" };
2066
+ if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
2067
+ if (amountSat > buyer.single_limit_sat) {
2068
+ return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
2069
+ }
2070
+ const spent = this.spentTodaySat(buyerId);
2071
+ if (spent + amountSat > buyer.daily_limit_sat) {
2072
+ return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
2073
+ }
2074
+ if (buyer.balance_sat < amountSat) {
2075
+ return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
2076
+ }
2077
+ return { success: true, balanceSat: buyer.balance_sat };
2078
+ }
2079
+ /**
2080
+ * Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
2081
+ * SQLite transaction; a `request_id` replay returns the original tx
2082
+ * without charging again.
2083
+ */
2084
+ deduct(opts) {
2085
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2086
+ throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
2087
+ }
2088
+ if (opts.requestId) {
2089
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
2090
+ if (prior) {
2091
+ const buyer = this.getBuyer(prior.buyer_id);
2092
+ return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
2093
+ }
2094
+ }
2095
+ this.db.exec("BEGIN IMMEDIATE");
2096
+ try {
2097
+ const check = this.checkDeduct(opts.buyerId, opts.amountSat);
2098
+ if (!check.success) {
2099
+ this.db.exec("ROLLBACK");
2100
+ return check;
2101
+ }
2102
+ const updated = this.db.prepare(
2103
+ `UPDATE buyers SET
2104
+ balance_sat = balance_sat - ?,
2105
+ total_spent_sat = total_spent_sat + ?,
2106
+ updated_at = datetime('now')
2107
+ WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
2108
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
2109
+ if (Number(updated.changes) !== 1) {
2110
+ this.db.exec("ROLLBACK");
2111
+ return { success: false, error: "insufficient_balance" };
2112
+ }
2113
+ const txId = `btx_${randomUUID()}`;
2114
+ this.db.prepare(
2115
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
2116
+ VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
2117
+ ).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
2118
+ this.db.exec("COMMIT");
2119
+ const buyer = this.getBuyer(opts.buyerId);
2120
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2121
+ } catch (err) {
2122
+ try {
2123
+ this.db.exec("ROLLBACK");
2124
+ } catch {
2125
+ }
2126
+ throw err;
2127
+ }
2128
+ }
2129
+ /**
2130
+ * Credit a top-up. `externalRef` is the settlement proof reference
2131
+ * (on-chain tx hash / fiat trade number) and is unique: replaying the same
2132
+ * reference returns the original row without crediting twice.
2133
+ */
2134
+ topup(opts) {
2135
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2136
+ throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
2137
+ }
2138
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
2139
+ if (prior) {
2140
+ const buyer2 = this.getBuyer(prior.buyer_id);
2141
+ return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
2142
+ }
2143
+ this.getOrCreateBuyer(opts.buyerId);
2144
+ const txId = `btx_${randomUUID()}`;
2145
+ this.db.exec("BEGIN IMMEDIATE");
2146
+ try {
2147
+ this.db.prepare(
2148
+ `UPDATE buyers SET
2149
+ balance_sat = balance_sat + ?,
2150
+ total_topup_sat = total_topup_sat + ?,
2151
+ updated_at = datetime('now')
2152
+ WHERE buyer_id = ?`
2153
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId);
2154
+ this.db.prepare(
2155
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
2156
+ VALUES (?, ?, 'topup', ?, ?, ?)`
2157
+ ).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
2158
+ this.db.exec("COMMIT");
2159
+ } catch (err) {
2160
+ try {
2161
+ this.db.exec("ROLLBACK");
2162
+ } catch {
2163
+ }
2164
+ throw err;
2165
+ }
2166
+ const buyer = this.getBuyer(opts.buyerId);
2167
+ return { txId, balanceSat: buyer.balance_sat };
2168
+ }
2169
+ /**
2170
+ * Reverse a deduct (service failed after the charge). Idempotent: the
2171
+ * unique index on `refunds_tx_id` means a second refund of the same deduct
2172
+ * returns the original refund row.
2173
+ */
2174
+ refund(deductTxId, reason) {
2175
+ const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
2176
+ if (!deductRow) return { success: false, error: "tx_not_found" };
2177
+ if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
2178
+ const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
2179
+ if (priorRefund) {
2180
+ const buyer = this.getBuyer(deductRow.buyer_id);
2181
+ return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
2182
+ }
2183
+ this.db.exec("BEGIN IMMEDIATE");
2184
+ try {
2185
+ this.db.prepare(
2186
+ `UPDATE buyers SET
2187
+ balance_sat = balance_sat + ?,
2188
+ total_spent_sat = total_spent_sat - ?,
2189
+ updated_at = datetime('now')
2190
+ WHERE buyer_id = ?`
2191
+ ).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
2192
+ this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
2193
+ const txId = `btx_${randomUUID()}`;
2194
+ this.db.prepare(
2195
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
2196
+ VALUES (?, ?, 'refund', ?, ?, ?)`
2197
+ ).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
2198
+ this.db.exec("COMMIT");
2199
+ const buyer = this.getBuyer(deductRow.buyer_id);
2200
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2201
+ } catch (err) {
2202
+ try {
2203
+ this.db.exec("ROLLBACK");
2204
+ } catch {
2205
+ }
2206
+ throw err;
2207
+ }
2208
+ }
2209
+ /** Paged transaction history, newest first (rowid breaks same-second ties). */
2210
+ listTransactions(buyerId, limit = 20, offset = 0) {
2211
+ return this.db.prepare(
2212
+ `SELECT * FROM ledger_transactions WHERE buyer_id = ?
2213
+ ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
2214
+ ).all(buyerId, limit, offset);
2215
+ }
2216
+ /** Quick integrity probe for healthCheck(). */
2217
+ integrityOk() {
2218
+ const row = this.db.prepare(`PRAGMA quick_check`).get();
2219
+ return row.quick_check === "ok";
2220
+ }
2221
+ close() {
2222
+ this.db.close();
2223
+ }
2224
+ };
2225
+
2226
+ // src/facilitators/balance/auth.ts
2227
+ import { ethers as ethers2 } from "ethers";
2228
+ var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
2229
+ var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
2230
+ function extractBalanceAuth(raw) {
2231
+ if (!raw || typeof raw !== "object") return null;
2232
+ const a = raw;
2233
+ if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
2234
+ return { timestamp: a.timestamp, signature: a.signature };
2235
+ }
2236
+ return null;
2237
+ }
2238
+ function buildDeductMessage(f) {
2239
+ return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
2240
+ }
2241
+ function verifyDeductAuth(opts) {
2242
+ const { auth } = opts;
2243
+ if (!auth) return { ok: false, reason: "no_signature" };
2244
+ if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
2245
+ if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
2246
+ return { ok: false, reason: "timestamp_skew" };
2247
+ }
2248
+ const message = buildDeductMessage({
2249
+ buyerId: opts.buyerId,
2250
+ requestId: opts.requestId,
2251
+ service: opts.service,
2252
+ timestamp: auth.timestamp
2253
+ });
2254
+ try {
2255
+ const recovered = ethers2.verifyMessage(message, auth.signature).toLowerCase();
2256
+ return { ok: true, recovered };
2257
+ } catch {
2258
+ return { ok: false, reason: "bad_signature" };
2259
+ }
2260
+ }
2261
+
2262
+ // src/facilitators/balance.ts
2263
+ var BALANCE_NETWORK = "balance";
2264
+ var BALANCE_SCHEME = "balance";
2265
+ function extractBalancePayload(payment) {
2266
+ const p = payment.payload;
2267
+ if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
2268
+ return {
2269
+ buyer_id: p.buyer_id,
2270
+ request_id: typeof p.request_id === "string" ? p.request_id : void 0,
2271
+ auth: extractBalanceAuth(p.auth) ?? void 0
2272
+ };
2273
+ }
2274
+ return null;
2275
+ }
2276
+ var BalanceFacilitator = class extends BaseFacilitator {
2277
+ name = "balance";
2278
+ displayName = "Custodial Balance";
2279
+ supportedNetworks = [BALANCE_NETWORK];
2280
+ currency;
2281
+ /** User-auth rollout gate for deductions (off | shadow | enforce). */
2282
+ authMode;
2283
+ ledger;
2284
+ constructor(config) {
2285
+ super();
2286
+ this.currency = config.currency ?? "USD";
2287
+ this.authMode = config.auth_mode ?? "off";
2288
+ const ledgerConfig = {
2289
+ dbPath: config.db_path,
2290
+ defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
2291
+ defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
2292
+ currency: this.currency
2293
+ };
2294
+ this.ledger = new BalanceLedger(ledgerConfig);
2295
+ }
2296
+ /** Direct ledger access for the server's balance-management endpoints. */
2297
+ getLedger() {
2298
+ return this.ledger;
2299
+ }
2300
+ /**
2301
+ * Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
2302
+ */
2303
+ createPaymentRequirements(opts) {
2304
+ toSat(opts.price);
2305
+ return {
2306
+ scheme: BALANCE_SCHEME,
2307
+ network: BALANCE_NETWORK,
2308
+ asset: this.currency,
2309
+ amount: opts.price,
2310
+ payTo: "custodial",
2311
+ maxTimeoutSeconds: 30,
2312
+ extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
2313
+ };
2314
+ }
2315
+ /** Read-only funds/limits precheck. Never mutates the ledger. */
2316
+ async verify(paymentPayload, requirements) {
2317
+ const payload = extractBalancePayload(paymentPayload);
2318
+ if (!payload) {
2319
+ return { valid: false, error: "Missing buyer_id in balance payment payload" };
2320
+ }
2321
+ let amountSat;
2322
+ try {
2323
+ amountSat = toSat(requirements.amount);
2324
+ } catch (err) {
2325
+ return { valid: false, error: err.message };
2326
+ }
2327
+ const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
2328
+ if (!check.success) {
2329
+ return {
2330
+ valid: false,
2331
+ error: this.describeDeductError(check),
2332
+ details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
2333
+ };
2334
+ }
2335
+ return { valid: true, details: { balance: fromSat(check.balanceSat) } };
2336
+ }
2337
+ /**
2338
+ * The atomic deduction. Idempotent on `request_id`; the returned
2339
+ * `transaction` is the ledger tx id (usable for `refund`).
2340
+ */
2341
+ async settle(paymentPayload, requirements) {
2342
+ const payload = extractBalancePayload(paymentPayload);
2343
+ if (!payload) {
2344
+ return { success: false, error: "Missing buyer_id in balance payment payload" };
2345
+ }
2346
+ let amountSat;
2347
+ try {
2348
+ amountSat = toSat(requirements.amount);
2349
+ } catch (err) {
2350
+ return { success: false, error: err.message };
2351
+ }
2352
+ const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
2353
+ let result;
2354
+ try {
2355
+ result = this.ledger.deduct({
2356
+ buyerId: payload.buyer_id,
2357
+ amountSat,
2358
+ requestId: payload.request_id,
2359
+ service: serviceId
2360
+ });
2361
+ } catch (err) {
2362
+ return { success: false, error: `Ledger deduct failed: ${err.message}` };
2363
+ }
2364
+ if (!result.success) {
2365
+ return { success: false, error: this.describeDeductError(result), status: result.error };
2366
+ }
2367
+ return {
2368
+ success: true,
2369
+ transaction: result.txId,
2370
+ status: result.replayed ? "replayed" : "deducted"
2371
+ };
2372
+ }
2373
+ /** Reverse a deduct after a downstream failure. Idempotent per deduct. */
2374
+ refund(deductTxId, reason) {
2375
+ return this.ledger.refund(deductTxId, reason);
2376
+ }
2377
+ /**
2378
+ * Credit a gateway-verified external settlement to a buyer's balance. The
2379
+ * single entry point for every funding path -- WeChat callback, WeChat
2380
+ * polling, and the operator `/balance/topup` endpoint -- so they share one
2381
+ * idempotency boundary: `externalRef` is unique in the ledger, so a replay
2382
+ * credits nothing and returns the original transaction (`replayed: true`).
2383
+ * Callers must pass a gateway-verified `amountSat`, never a client-declared
2384
+ * amount.
2385
+ */
2386
+ credit(opts) {
2387
+ const result = this.ledger.topup({
2388
+ buyerId: opts.buyerId,
2389
+ amountSat: opts.amountSat,
2390
+ externalRef: opts.externalRef,
2391
+ description: opts.description
2392
+ });
2393
+ return {
2394
+ txId: result.txId,
2395
+ balance: fromSat(result.balanceSat),
2396
+ balanceSat: result.balanceSat,
2397
+ replayed: result.replayed ?? false
2398
+ };
2399
+ }
2400
+ async healthCheck() {
2401
+ const start = Date.now();
2402
+ try {
2403
+ const ok = this.ledger.integrityOk();
2404
+ return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
2405
+ } catch (err) {
2406
+ return { healthy: false, error: err.message };
2407
+ }
2408
+ }
2409
+ describeDeductError(result) {
2410
+ switch (result.error) {
2411
+ case "buyer_not_found":
2412
+ return "Unknown buyer: top up first to create an account";
2413
+ case "buyer_not_active":
2414
+ return "Buyer account is frozen or banned";
2415
+ case "insufficient_balance":
2416
+ return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
2417
+ case "exceeds_single_limit":
2418
+ return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2419
+ case "exceeds_daily_limit":
2420
+ return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2421
+ default:
2422
+ return "Deduction failed";
2423
+ }
2424
+ }
2425
+ };
2426
+
2427
+ // src/facilitators/registry.ts
2428
+ import { Keypair as Keypair2 } from "@solana/web3.js";
2429
+ import bs58 from "bs58";
2430
+ var FacilitatorRegistry = class {
2431
+ factories = /* @__PURE__ */ new Map();
2432
+ instances = /* @__PURE__ */ new Map();
2433
+ selection;
2434
+ roundRobinIndex = 0;
2435
+ constructor(selection) {
2436
+ this.registerFactory("cdp", (config) => new CDPFacilitator(config));
2437
+ this.registerFactory("tempo", () => new TempoFacilitator());
2438
+ this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
2439
+ this.registerFactory("solana", (config) => {
2440
+ let feePayerKeypair;
2441
+ const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
2442
+ if (feePayerKey) {
2443
+ try {
2444
+ feePayerKeypair = Keypair2.fromSecretKey(bs58.decode(feePayerKey));
2445
+ } catch (e) {
2446
+ console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
2447
+ }
2448
+ }
2449
+ return new SolanaFacilitator({ feePayerKeypair });
2450
+ });
2451
+ this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
2452
+ this.registerFactory("wechat", (config) => new WechatFacilitator(config));
2453
+ this.registerFactory("balance", (config) => new BalanceFacilitator(config));
2454
+ this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
2455
+ }
2456
+ /**
2457
+ * Register a new facilitator factory
2458
+ */
2459
+ registerFactory(name, factory) {
2460
+ this.factories.set(name, factory);
2461
+ }
2462
+ /**
2463
+ * Get or create a facilitator instance
2464
+ */
2465
+ get(name, config) {
2466
+ if (this.instances.has(name)) {
2467
+ return this.instances.get(name);
2468
+ }
2469
+ const factory = this.factories.get(name);
2470
+ if (!factory) {
2471
+ throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
2472
+ }
2473
+ const mergedConfig = {
2474
+ ...this.selection.config?.[name],
2475
+ ...config
2476
+ };
2477
+ const instance = factory(mergedConfig);
2478
+ this.instances.set(name, instance);
2479
+ return instance;
2480
+ }
2481
+ /**
2482
+ * Get all configured facilitator names
2483
+ */
2484
+ getConfiguredNames() {
2485
+ const names = [this.selection.primary];
2486
+ if (this.selection.fallback) {
2487
+ names.push(...this.selection.fallback);
2488
+ }
2489
+ return names;
2490
+ }
2491
+ /**
2492
+ * Get list of facilitators based on selection strategy
2493
+ */
2494
+ async getOrderedFacilitators(network) {
2495
+ const names = this.getConfiguredNames();
2496
+ const facilitators = [];
2497
+ for (const name of names) {
2498
+ try {
2499
+ const f = this.get(name);
2500
+ if (f.supportsNetwork(network)) {
2501
+ facilitators.push(f);
2502
+ }
2503
+ } catch (err) {
2504
+ console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
2505
+ }
2506
+ }
2507
+ if (facilitators.length === 0) {
2508
+ throw new Error(`No facilitators available for network: ${network}`);
2509
+ }
2510
+ switch (this.selection.strategy) {
2511
+ case "random":
2512
+ return this.shuffle(facilitators);
2513
+ case "roundrobin":
2514
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
2515
+ return [
2516
+ ...facilitators.slice(this.roundRobinIndex),
2517
+ ...facilitators.slice(0, this.roundRobinIndex)
2518
+ ];
2519
+ case "cheapest":
2520
+ return this.sortByCheapest(facilitators);
2521
+ case "fastest":
2522
+ return this.sortByFastest(facilitators);
2523
+ case "failover":
2524
+ default:
2525
+ return facilitators;
2526
+ }
2527
+ }
2528
+ shuffle(array) {
2529
+ const result = [...array];
2530
+ for (let i = result.length - 1; i > 0; i--) {
2531
+ const j = Math.floor(Math.random() * (i + 1));
2532
+ [result[i], result[j]] = [result[j], result[i]];
2533
+ }
2534
+ return result;
2535
+ }
2536
+ async sortByCheapest(facilitators) {
2537
+ const withFees = await Promise.all(
2538
+ facilitators.map(async (f) => {
2539
+ try {
2540
+ const fee = await f.getFee?.();
2541
+ return { facilitator: f, perTx: fee?.perTx ?? Infinity };
2542
+ } catch {
2543
+ return { facilitator: f, perTx: Infinity };
2544
+ }
2545
+ })
2546
+ );
2547
+ withFees.sort((a, b) => a.perTx - b.perTx);
2548
+ return withFees.map((w) => w.facilitator);
2549
+ }
2550
+ async sortByFastest(facilitators) {
2551
+ const withLatency = await Promise.all(
2552
+ facilitators.map(async (f) => {
2553
+ try {
2554
+ const health = await f.healthCheck();
2555
+ return { facilitator: f, latency: health.latencyMs ?? Infinity };
2556
+ } catch {
2557
+ return { facilitator: f, latency: Infinity };
2558
+ }
2559
+ })
2560
+ );
2561
+ withLatency.sort((a, b) => a.latency - b.latency);
2562
+ return withLatency.map((w) => w.facilitator);
2563
+ }
2564
+ /**
2565
+ * Verify payment using configured facilitators
2566
+ */
2567
+ async verify(paymentPayload, requirements) {
2568
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2569
+ const facilitators = await this.getOrderedFacilitators(network);
2570
+ let lastError;
2571
+ for (const f of facilitators) {
2572
+ try {
2573
+ console.log(`[Registry] Trying ${f.name} for verify...`);
2574
+ const result = await f.verify(paymentPayload, requirements);
2575
+ if (result.valid) {
2576
+ console.log(`[Registry] ${f.name} verify succeeded`);
2577
+ return { ...result, facilitator: f.name };
2578
+ }
2579
+ lastError = result.error;
2580
+ console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
2581
+ if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
2582
+ break;
2583
+ }
2584
+ } catch (err) {
2585
+ lastError = err.message;
2586
+ console.error(`[Registry] ${f.name} error:`, err.message);
2587
+ }
2588
+ }
2589
+ return {
2590
+ valid: false,
2591
+ error: lastError || "All facilitators failed",
2592
+ facilitator: "none"
2593
+ };
2594
+ }
2595
+ /**
2596
+ * Settle payment using configured facilitators
2597
+ */
2598
+ async settle(paymentPayload, requirements) {
2599
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2600
+ const facilitators = await this.getOrderedFacilitators(network);
2601
+ let lastError;
2602
+ for (const f of facilitators) {
2603
+ try {
2604
+ console.log(`[Registry] Trying ${f.name} for settle...`);
2605
+ const result = await f.settle(paymentPayload, requirements);
2606
+ if (result.success) {
2607
+ console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
2608
+ return { ...result, facilitator: f.name };
2609
+ }
2610
+ lastError = result.error;
2611
+ console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
2612
+ } catch (err) {
2613
+ lastError = err.message;
2614
+ console.error(`[Registry] ${f.name} error:`, err.message);
2615
+ }
2616
+ }
2617
+ return {
2618
+ success: false,
2619
+ error: lastError || "All facilitators failed",
2620
+ facilitator: "none"
2621
+ };
2622
+ }
2623
+ /**
2624
+ * Check health of all configured facilitators
2625
+ */
2626
+ async healthCheckAll() {
2627
+ const results = {};
2628
+ for (const name of this.getConfiguredNames()) {
2629
+ try {
2630
+ const f = this.get(name);
2631
+ results[name] = await f.healthCheck();
2632
+ } catch (err) {
1738
2633
  results[name] = { healthy: false, error: err.message };
1739
2634
  }
1740
2635
  }
@@ -1772,7 +2667,95 @@ var FacilitatorRegistry = class {
1772
2667
  }
1773
2668
  };
1774
2669
 
1775
- // src/server/index.ts
2670
+ // src/server/balance-endpoints.ts
2671
+ import crypto5 from "crypto";
2672
+
2673
+ // src/verify/index.ts
2674
+ import { ethers as ethers3 } from "ethers";
2675
+ var TRANSFER_EVENT_TOPIC3 = ethers3.id("Transfer(address,address,uint256)");
2676
+ async function verifyPayment(params) {
2677
+ const { txHash, expectedAmount, expectedTo, expectedToken } = params;
2678
+ let chain;
2679
+ try {
2680
+ if (typeof params.chain === "number") {
2681
+ chain = getChainById(params.chain);
2682
+ } else {
2683
+ chain = getChain(params.chain || "base");
2684
+ }
2685
+ if (!chain) {
2686
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2687
+ }
2688
+ } catch (e) {
2689
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2690
+ }
2691
+ try {
2692
+ const provider = new ethers3.JsonRpcProvider(chain.rpc);
2693
+ const receipt = await provider.getTransactionReceipt(txHash);
2694
+ if (!receipt) {
2695
+ return { verified: false, error: "Transaction not found or not confirmed" };
2696
+ }
2697
+ if (receipt.status !== 1) {
2698
+ return { verified: false, error: "Transaction failed" };
2699
+ }
2700
+ const tokenAddresses = {};
2701
+ if (!expectedToken || expectedToken === "USDC") {
2702
+ tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
2703
+ }
2704
+ if (!expectedToken || expectedToken === "USDT") {
2705
+ tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
2706
+ }
2707
+ if (Object.keys(tokenAddresses).length === 0) {
2708
+ return { verified: false, error: `No token addresses configured for ${chain.name}` };
2709
+ }
2710
+ for (const log of receipt.logs) {
2711
+ const logAddress = log.address.toLowerCase();
2712
+ const detectedToken = tokenAddresses[logAddress];
2713
+ if (!detectedToken) {
2714
+ continue;
2715
+ }
2716
+ if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
2717
+ continue;
2718
+ }
2719
+ const from = "0x" + log.topics[1].slice(-40);
2720
+ const to = "0x" + log.topics[2].slice(-40);
2721
+ const amountRaw = BigInt(log.data);
2722
+ const tokenConfig = chain.tokens[detectedToken];
2723
+ const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
2724
+ if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
2725
+ continue;
2726
+ }
2727
+ if (amount < expectedAmount) {
2728
+ return {
2729
+ verified: false,
2730
+ error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
2731
+ amount,
2732
+ token: detectedToken,
2733
+ from,
2734
+ to,
2735
+ txHash,
2736
+ blockNumber: receipt.blockNumber
2737
+ };
2738
+ }
2739
+ return {
2740
+ verified: true,
2741
+ amount,
2742
+ token: detectedToken,
2743
+ from,
2744
+ to,
2745
+ txHash,
2746
+ blockNumber: receipt.blockNumber
2747
+ };
2748
+ }
2749
+ const tokenList = expectedToken ? expectedToken : "USDC/USDT";
2750
+ return { verified: false, error: `No ${tokenList} transfer found` };
2751
+ } catch (e) {
2752
+ return { verified: false, error: e.message || String(e) };
2753
+ }
2754
+ }
2755
+
2756
+ // src/server/internal.ts
2757
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2758
+ import * as path2 from "path";
1776
2759
  var X402_VERSION2 = 2;
1777
2760
  var PAYMENT_REQUIRED_HEADER = "x-payment-required";
1778
2761
  var PAYMENT_HEADER = "x-payment";
@@ -1785,6 +2768,15 @@ var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
1785
2768
  function headerSafe(value) {
1786
2769
  return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
1787
2770
  }
2771
+ function canonicalJson(value) {
2772
+ if (value === null || typeof value !== "object") {
2773
+ return JSON.stringify(value) ?? "null";
2774
+ }
2775
+ if (Array.isArray(value)) {
2776
+ return "[" + value.map(canonicalJson).join(",") + "]";
2777
+ }
2778
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
2779
+ }
1788
2780
  var TOKEN_ADDRESSES = {
1789
2781
  "eip155:8453": {
1790
2782
  USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
@@ -1875,44 +2867,377 @@ var TOKEN_DOMAINS = {
1875
2867
  USDC: { name: "USD Coin", version: "1" },
1876
2868
  USDT: { name: "Tether USD", version: "1" }
1877
2869
  }
1878
- };
1879
- function getTokenDomain(network, token) {
1880
- const networkDomains = TOKEN_DOMAINS[network] || TOKEN_DOMAINS["eip155:8453"];
1881
- return networkDomains[token] || { name: "USD Coin", version: "2" };
1882
- }
1883
- function getAcceptedCurrencies(config) {
1884
- return config.acceptedCurrencies ?? [config.currency];
1885
- }
1886
- function loadEnvFile2() {
1887
- const envPaths = [
1888
- path2.join(process.cwd(), ".env"),
1889
- path2.join(process.env.HOME || "", ".moltspay", ".env")
1890
- ];
1891
- for (const envPath of envPaths) {
1892
- if (existsSync2(envPath)) {
1893
- try {
1894
- const content = readFileSync2(envPath, "utf-8");
1895
- for (const line of content.split("\n")) {
1896
- const trimmed = line.trim();
1897
- if (!trimmed || trimmed.startsWith("#")) continue;
1898
- const eqIndex = trimmed.indexOf("=");
1899
- if (eqIndex === -1) continue;
1900
- const key = trimmed.slice(0, eqIndex).trim();
1901
- let value = trimmed.slice(eqIndex + 1).trim();
1902
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
1903
- value = value.slice(1, -1);
1904
- }
1905
- if (!process.env[key]) {
1906
- process.env[key] = value;
1907
- }
1908
- }
1909
- console.log(`[MoltsPay] Loaded config from ${envPath}`);
1910
- break;
1911
- } catch {
2870
+ };
2871
+ function getTokenDomain(network, token) {
2872
+ const networkDomains = TOKEN_DOMAINS[network] || TOKEN_DOMAINS["eip155:8453"];
2873
+ return networkDomains[token] || { name: "USD Coin", version: "2" };
2874
+ }
2875
+ function getAcceptedCurrencies(config) {
2876
+ return config.acceptedCurrencies ?? [config.currency];
2877
+ }
2878
+ function loadEnvFile2() {
2879
+ const envPaths = [
2880
+ path2.join(process.cwd(), ".env"),
2881
+ path2.join(process.env.HOME || "", ".moltspay", ".env")
2882
+ ];
2883
+ for (const envPath of envPaths) {
2884
+ if (existsSync2(envPath)) {
2885
+ try {
2886
+ const content = readFileSync2(envPath, "utf-8");
2887
+ for (const line of content.split("\n")) {
2888
+ const trimmed = line.trim();
2889
+ if (!trimmed || trimmed.startsWith("#")) continue;
2890
+ const eqIndex = trimmed.indexOf("=");
2891
+ if (eqIndex === -1) continue;
2892
+ const key = trimmed.slice(0, eqIndex).trim();
2893
+ let value = trimmed.slice(eqIndex + 1).trim();
2894
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2895
+ value = value.slice(1, -1);
2896
+ }
2897
+ if (!process.env[key]) {
2898
+ process.env[key] = value;
2899
+ }
2900
+ }
2901
+ console.log(`[MoltsPay] Loaded config from ${envPath}`);
2902
+ break;
2903
+ } catch {
2904
+ }
2905
+ }
2906
+ }
2907
+ }
2908
+
2909
+ // src/server/balance-endpoints.ts
2910
+ var BalanceEndpoints = class {
2911
+ constructor(deps) {
2912
+ this.deps = deps;
2913
+ }
2914
+ /** GET /balance?buyer_id= -- balance, limits, and today's spend. */
2915
+ handleQuery(url, res) {
2916
+ const { balance, sendJson } = this.deps;
2917
+ const buyerId = url.searchParams.get("buyer_id");
2918
+ if (!buyerId) {
2919
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
2920
+ }
2921
+ const ledger = balance.getLedger();
2922
+ const buyer = ledger.getBuyer(buyerId);
2923
+ if (!buyer) {
2924
+ return sendJson(res, 200, {
2925
+ buyer_id: buyerId,
2926
+ balance: "0.00",
2927
+ currency: balance.currency,
2928
+ exists: false
2929
+ });
2930
+ }
2931
+ sendJson(res, 200, {
2932
+ buyer_id: buyerId,
2933
+ balance: fromSat(buyer.balance_sat),
2934
+ currency: balance.currency,
2935
+ single_limit: fromSat(buyer.single_limit_sat),
2936
+ daily_limit: fromSat(buyer.daily_limit_sat),
2937
+ today_spent: fromSat(ledger.spentTodaySat(buyerId)),
2938
+ status: buyer.status,
2939
+ wechat_openid: buyer.wechat_openid ?? null,
2940
+ signer_address: buyer.signer_address ?? null,
2941
+ exists: true
2942
+ });
2943
+ }
2944
+ /**
2945
+ * Extract the gateway-confirmed paid amount (fen) from a WeChat verify
2946
+ * result. `payer_total` is what the buyer actually paid; falls back to
2947
+ * `total`. Returns null if no usable positive integer is present, so the
2948
+ * client-declared amount can never be trusted for crediting.
2949
+ */
2950
+ wechatPaidFen(check) {
2951
+ const amount = check.details?.amount;
2952
+ const paid = amount?.payer_total ?? amount?.total;
2953
+ return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
2954
+ }
2955
+ /**
2956
+ * POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
2957
+ * configured top-up pack. The buyer_id rides in the WeChat `attach` so the
2958
+ * later confirm/callback credits the correct balance. Reuses the pending
2959
+ * order cache (keyed by buyer_id + pack) so concurrent requests share one
2960
+ * order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
2961
+ * pack, max_timeout_seconds }`.
2962
+ */
2963
+ async handleTopupOrder(body, res) {
2964
+ const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
2965
+ const { buyer_id, pack } = body || {};
2966
+ if (typeof buyer_id !== "string" || !buyer_id) {
2967
+ return sendJson(res, 400, { error: "buyer_id is required" });
2968
+ }
2969
+ const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
2970
+ if (!wechat) {
2971
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
2972
+ }
2973
+ const balCfg = manifest.provider.balance;
2974
+ const packs = balCfg?.topup_packs ?? [];
2975
+ const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
2976
+ if (!chosen) {
2977
+ return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
2978
+ }
2979
+ let chosenSat;
2980
+ try {
2981
+ chosenSat = toSat(chosen);
2982
+ if (chosenSat <= 0) throw new Error("pack must be positive");
2983
+ } catch (err) {
2984
+ return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
2985
+ }
2986
+ const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
2987
+ if (!packs.includes(chosen) && !withinMax) {
2988
+ return sendJson(res, 400, {
2989
+ error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
2990
+ });
2991
+ }
2992
+ const nonce = crypto5.randomBytes(8).toString("hex");
2993
+ const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
2994
+ const cacheKey = crypto5.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
2995
+ const result = await getOrCreatePendingWechatOrder(
2996
+ cacheKey,
2997
+ `topup:${buyer_id}`,
2998
+ () => wechat.createPaymentRequirements({
2999
+ priceCny: chosen,
3000
+ description: `Balance top-up ${chosen}`,
3001
+ attach
3002
+ })
3003
+ );
3004
+ if (!result) {
3005
+ return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
3006
+ }
3007
+ return sendJson(res, 200, {
3008
+ code_url: result.codeUrl,
3009
+ out_trade_no: result.outTradeNo,
3010
+ pack: chosen,
3011
+ max_timeout_seconds: result.accepts.maxTimeoutSeconds
3012
+ });
3013
+ }
3014
+ /**
3015
+ * POST /balance/topup/confirm -- the polling-fallback credit path. The client
3016
+ * polls this after a scan; the server verifies the order with the WeChat
3017
+ * gateway and, on SUCCESS, credits the buyer bound in `attach` with the
3018
+ * gateway-confirmed `payer_total` (never a client-declared amount).
3019
+ * Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
3020
+ */
3021
+ async handleTopupConfirm(body, res) {
3022
+ const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
3023
+ const outTradeNo = body?.out_trade_no;
3024
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3025
+ return sendJson(res, 400, { error: "out_trade_no is required" });
3026
+ }
3027
+ if (!wechat) {
3028
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3029
+ }
3030
+ const wxPayload = {
3031
+ x402Version: X402_VERSION2,
3032
+ scheme: WECHAT_SCHEME,
3033
+ network: WECHAT_NETWORK,
3034
+ payload: { out_trade_no: outTradeNo }
3035
+ };
3036
+ const wxReqs = {
3037
+ scheme: WECHAT_SCHEME,
3038
+ network: WECHAT_NETWORK,
3039
+ asset: "CNY",
3040
+ amount: "0",
3041
+ payTo: manifest.provider.wechat?.mchid || "",
3042
+ maxTimeoutSeconds: 30,
3043
+ extra: { out_trade_no: outTradeNo }
3044
+ };
3045
+ const check = await wechat.verify(wxPayload, wxReqs);
3046
+ if (!check.valid) {
3047
+ return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
3048
+ }
3049
+ const paidFen = this.wechatPaidFen(check);
3050
+ if (paidFen === null) {
3051
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3052
+ }
3053
+ const attach = parseWechatAttach(check.details?.attach);
3054
+ const buyerId = attach?.buyer_id;
3055
+ if (!buyerId) {
3056
+ return sendJson(res, 422, {
3057
+ error: "top-up order has no buyer binding (attach.buyer_id missing)"
3058
+ });
3059
+ }
3060
+ const credited = balance.credit({
3061
+ buyerId,
3062
+ amountSat: paidFen,
3063
+ externalRef: `wechat:${outTradeNo}`,
3064
+ description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
3065
+ });
3066
+ invalidateWechatChallenge(outTradeNo);
3067
+ const openid = check.details?.openid;
3068
+ let openidBinding;
3069
+ if (typeof openid === "string" && openid) {
3070
+ openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
3071
+ if (openidBinding.conflict) {
3072
+ console.warn(
3073
+ `[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
3074
+ );
3075
+ }
3076
+ }
3077
+ const signer = attach?.signer;
3078
+ if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
3079
+ const sb = balance.getLedger().bindSigner(buyerId, signer);
3080
+ if (sb.conflict) {
3081
+ console.warn(
3082
+ `[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
3083
+ );
3084
+ }
3085
+ }
3086
+ console.log(
3087
+ `[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
3088
+ );
3089
+ return sendJson(res, 200, {
3090
+ credited: true,
3091
+ buyer_id: buyerId,
3092
+ tx_id: credited.txId,
3093
+ balance: credited.balance,
3094
+ replayed: credited.replayed,
3095
+ openid_bound: openidBinding?.bound ?? false,
3096
+ openid_conflict: openidBinding?.conflict ?? false
3097
+ });
3098
+ }
3099
+ /**
3100
+ * POST /balance/topup -- verify an externally settled payment and credit the
3101
+ * buyer's ledger balance (operator/recovery path). Per rail: `crypto`
3102
+ * verifies the tx on-chain; `wechat` queries the order and credits the
3103
+ * gateway-confirmed payer_total (never the client-declared amount);
3104
+ * `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
3105
+ */
3106
+ async handleTopup(body, res) {
3107
+ const { manifest, balance, wechat, sendJson } = this.deps;
3108
+ const { buyer_id, rail, amount } = body || {};
3109
+ if (typeof buyer_id !== "string" || !buyer_id) {
3110
+ return sendJson(res, 400, { error: "buyer_id is required" });
3111
+ }
3112
+ let amountSat;
3113
+ try {
3114
+ amountSat = toSat(String(amount));
3115
+ if (amountSat <= 0) throw new Error("amount must be positive");
3116
+ } catch (err) {
3117
+ return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
3118
+ }
3119
+ let externalRef;
3120
+ let description;
3121
+ if (rail === "crypto") {
3122
+ const txHash = body.tx_hash;
3123
+ if (typeof txHash !== "string" || !txHash) {
3124
+ return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
3125
+ }
3126
+ const check = await verifyPayment({
3127
+ txHash,
3128
+ expectedAmount: amountSat / 100,
3129
+ expectedTo: manifest.provider.wallet,
3130
+ chain: body.chain || "base"
3131
+ });
3132
+ if (!check.verified) {
3133
+ return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
3134
+ }
3135
+ externalRef = txHash.toLowerCase();
3136
+ description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
3137
+ } else if (rail === "wechat") {
3138
+ const outTradeNo = body.out_trade_no;
3139
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3140
+ return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
3141
+ }
3142
+ if (!wechat) {
3143
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3144
+ }
3145
+ const wxPayload = {
3146
+ x402Version: X402_VERSION2,
3147
+ scheme: WECHAT_SCHEME,
3148
+ network: WECHAT_NETWORK,
3149
+ payload: { out_trade_no: outTradeNo }
3150
+ };
3151
+ const wxReqs = {
3152
+ scheme: WECHAT_SCHEME,
3153
+ network: WECHAT_NETWORK,
3154
+ asset: "CNY",
3155
+ amount: "0",
3156
+ payTo: manifest.provider.wechat?.mchid || "",
3157
+ maxTimeoutSeconds: 30,
3158
+ extra: { out_trade_no: outTradeNo }
3159
+ };
3160
+ const check = await wechat.verify(wxPayload, wxReqs);
3161
+ if (!check.valid) {
3162
+ return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
3163
+ }
3164
+ const paidFen = this.wechatPaidFen(check);
3165
+ if (paidFen === null) {
3166
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
1912
3167
  }
3168
+ if (paidFen !== amountSat) {
3169
+ console.warn(
3170
+ `[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
3171
+ );
3172
+ }
3173
+ amountSat = paidFen;
3174
+ externalRef = `wechat:${outTradeNo}`;
3175
+ description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
3176
+ console.log(`[MoltsPay] WeChat topup verified: ${description}`);
3177
+ } else if (rail === "alipay") {
3178
+ const tradeNo = body.trade_no;
3179
+ if (typeof tradeNo !== "string" || !tradeNo) {
3180
+ return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
3181
+ }
3182
+ externalRef = `alipay:${tradeNo}`;
3183
+ description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
3184
+ console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
3185
+ } else {
3186
+ return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
1913
3187
  }
3188
+ const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
3189
+ sendJson(res, 200, {
3190
+ success: true,
3191
+ tx_id: result.txId,
3192
+ balance: fromSat(result.balanceSat),
3193
+ replayed: result.replayed ?? false
3194
+ });
1914
3195
  }
1915
- }
3196
+ /** POST /balance/refund -- reverse a deduct (operator/agent use). */
3197
+ handleRefund(body, res) {
3198
+ const { balance, sendJson } = this.deps;
3199
+ const { tx_id, reason } = body || {};
3200
+ if (typeof tx_id !== "string" || !tx_id) {
3201
+ return sendJson(res, 400, { error: "tx_id is required" });
3202
+ }
3203
+ const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
3204
+ if (!result.success) {
3205
+ return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
3206
+ }
3207
+ sendJson(res, 200, {
3208
+ success: true,
3209
+ tx_id: result.txId,
3210
+ balance: fromSat(result.balanceSat),
3211
+ replayed: result.replayed ?? false
3212
+ });
3213
+ }
3214
+ /** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
3215
+ handleTransactions(url, res) {
3216
+ const { balance, sendJson } = this.deps;
3217
+ const buyerId = url.searchParams.get("buyer_id");
3218
+ if (!buyerId) {
3219
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3220
+ }
3221
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
3222
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
3223
+ const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
3224
+ sendJson(res, 200, {
3225
+ buyer_id: buyerId,
3226
+ transactions: rows.map((r) => ({
3227
+ tx_id: r.id,
3228
+ type: r.type,
3229
+ amount: fromSat(r.amount_sat),
3230
+ service: r.service,
3231
+ description: r.description,
3232
+ external_ref: r.external_ref,
3233
+ status: r.status,
3234
+ created_at: r.created_at
3235
+ }))
3236
+ });
3237
+ }
3238
+ };
3239
+
3240
+ // src/server/index.ts
1916
3241
  var MoltsPayServer = class {
1917
3242
  manifest;
1918
3243
  skills = /* @__PURE__ */ new Map();
@@ -1920,11 +3245,32 @@ var MoltsPayServer = class {
1920
3245
  registry;
1921
3246
  networkId;
1922
3247
  useMainnet;
1923
- /** Alipay AI facilitator instance, set when `provider.alipay` is configured (2.0.0). */
3248
+ /** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
1924
3249
  alipayFacilitator = null;
3250
+ /** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
3251
+ wechatFacilitator = null;
3252
+ /** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
3253
+ balanceFacilitator = null;
3254
+ balanceEndpoints = null;
3255
+ /**
3256
+ * Pending WeChat Native order cache — the double-charge fix.
3257
+ *
3258
+ * Every `buildWechatChallenge` used to place a NEW Native order, so a client
3259
+ * that received two 402s (e.g. initial challenge + one poll re-request that
3260
+ * raced ahead of payment) could surface two live QRs and a buyer could pay
3261
+ * both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
3262
+ * under a content-derived key — sha256(service id | canonical params |
3263
+ * price_cny) — and reused until it is paid or its `time_expire` window
3264
+ * nears expiry, so any number of 402 emits for the same purchase intent
3265
+ * share ONE order, even across separate client processes.
3266
+ * Storing the in-flight promise also dedupes concurrent 402 builds.
3267
+ * In-memory by design: on restart the worst case is one extra unpaid order,
3268
+ * which expires server-side per `time_expire` — never a double charge.
3269
+ */
3270
+ wechatPendingChallenges = /* @__PURE__ */ new Map();
1925
3271
  constructor(servicesPath, options = {}) {
1926
3272
  loadEnvFile2();
1927
- const content = readFileSync2(servicesPath, "utf-8");
3273
+ const content = readFileSync3(servicesPath, "utf-8");
1928
3274
  this.manifest = JSON.parse(content);
1929
3275
  this.options = {
1930
3276
  port: options.port || 3e3,
@@ -1946,8 +3292,8 @@ var MoltsPayServer = class {
1946
3292
  const providerAlipay = this.manifest.provider.alipay;
1947
3293
  if (providerAlipay) {
1948
3294
  try {
1949
- const baseDir = path2.dirname(servicesPath);
1950
- const resolvePem = (p, kind) => toPem(readFileSync2(path2.isAbsolute(p) ? p : path2.resolve(baseDir, p), "utf-8"), kind);
3295
+ const baseDir = path3.dirname(servicesPath);
3296
+ const resolvePem = (p, kind) => toPem(readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
1951
3297
  const alipayFacilitatorConfig = {
1952
3298
  seller_id: providerAlipay.seller_id,
1953
3299
  app_id: providerAlipay.app_id,
@@ -1970,10 +3316,73 @@ var MoltsPayServer = class {
1970
3316
  throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
1971
3317
  }
1972
3318
  }
3319
+ const providerWechat = this.manifest.provider.wechat;
3320
+ if (providerWechat) {
3321
+ try {
3322
+ const baseDir = path3.dirname(servicesPath);
3323
+ const readPem = (p) => readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
3324
+ const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new crypto6.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
3325
+ const wechatFacilitatorConfig = {
3326
+ mchid: providerWechat.mchid,
3327
+ appid: providerWechat.appid,
3328
+ serial_no: providerWechat.serial_no,
3329
+ private_key_pem: readPem(providerWechat.private_key_path),
3330
+ platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
3331
+ apiv3_key: providerWechat.apiv3_key,
3332
+ notify_url: providerWechat.notify_url,
3333
+ api_base: providerWechat.api_base
3334
+ };
3335
+ facilitatorConfig.config = {
3336
+ ...facilitatorConfig.config,
3337
+ wechat: wechatFacilitatorConfig
3338
+ };
3339
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3340
+ if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
3341
+ facilitatorConfig.fallback.push("wechat");
3342
+ }
3343
+ } catch (err) {
3344
+ throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
3345
+ }
3346
+ }
3347
+ const providerBalance = this.manifest.provider.balance;
3348
+ if (providerBalance) {
3349
+ const baseDir = path3.dirname(servicesPath);
3350
+ const balanceFacilitatorConfig = {
3351
+ db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
3352
+ currency: providerBalance.currency,
3353
+ single_limit: providerBalance.single_limit,
3354
+ daily_limit: providerBalance.daily_limit,
3355
+ auth_mode: providerBalance.auth_mode
3356
+ };
3357
+ facilitatorConfig.config = {
3358
+ ...facilitatorConfig.config,
3359
+ balance: balanceFacilitatorConfig
3360
+ };
3361
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3362
+ if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
3363
+ facilitatorConfig.fallback.push("balance");
3364
+ }
3365
+ }
1973
3366
  this.registry = new FacilitatorRegistry(facilitatorConfig);
1974
3367
  if (providerAlipay) {
1975
3368
  this.alipayFacilitator = this.registry.get("alipay");
1976
- console.log(`[MoltsPay] Alipay AI \u6536 rail enabled (seller ${providerAlipay.seller_id})`);
3369
+ console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
3370
+ }
3371
+ if (providerWechat) {
3372
+ this.wechatFacilitator = this.registry.get("wechat");
3373
+ console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
3374
+ }
3375
+ if (providerBalance) {
3376
+ this.balanceFacilitator = this.registry.get("balance");
3377
+ this.balanceEndpoints = new BalanceEndpoints({
3378
+ manifest: this.manifest,
3379
+ balance: this.balanceFacilitator,
3380
+ wechat: this.wechatFacilitator,
3381
+ sendJson: (res, status, data) => this.sendJson(res, status, data),
3382
+ getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
3383
+ invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
3384
+ });
3385
+ console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
1977
3386
  }
1978
3387
  const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
1979
3388
  console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
@@ -2015,7 +3424,10 @@ var MoltsPayServer = class {
2015
3424
  return provider.wallet;
2016
3425
  };
2017
3426
  if (provider.chains && provider.chains.length > 0) {
2018
- return provider.chains.map((c) => {
3427
+ return provider.chains.filter((c) => {
3428
+ const chainName = typeof c === "string" ? c : c.chain;
3429
+ return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
3430
+ }).map((c) => {
2019
3431
  const chainName = typeof c === "string" ? c : c.chain;
2020
3432
  const explicitWallet = typeof c === "object" ? c.wallet : null;
2021
3433
  return {
@@ -2134,9 +3546,36 @@ var MoltsPayServer = class {
2134
3546
  if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
2135
3547
  return this.handleAgentServicesDiscovery(res);
2136
3548
  }
3549
+ if (url.pathname === "/" && req.method === "GET") {
3550
+ return this.handleAgentServicesDiscovery(res);
3551
+ }
2137
3552
  if (url.pathname === "/health" && req.method === "GET") {
2138
3553
  return await this.handleHealthCheck(res);
2139
3554
  }
3555
+ if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
3556
+ if (url.pathname === "/balance" && req.method === "GET") {
3557
+ return this.balanceEndpoints.handleQuery(url, res);
3558
+ }
3559
+ if (url.pathname === "/balance/topup/order" && req.method === "POST") {
3560
+ const body = await this.readBody(req);
3561
+ return await this.balanceEndpoints.handleTopupOrder(body, res);
3562
+ }
3563
+ if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
3564
+ const body = await this.readBody(req);
3565
+ return await this.balanceEndpoints.handleTopupConfirm(body, res);
3566
+ }
3567
+ if (url.pathname === "/balance/topup" && req.method === "POST") {
3568
+ const body = await this.readBody(req);
3569
+ return await this.balanceEndpoints.handleTopup(body, res);
3570
+ }
3571
+ if (url.pathname === "/balance/refund" && req.method === "POST") {
3572
+ const body = await this.readBody(req);
3573
+ return this.balanceEndpoints.handleRefund(body, res);
3574
+ }
3575
+ if (url.pathname === "/balance/transactions" && req.method === "GET") {
3576
+ return this.balanceEndpoints.handleTransactions(url, res);
3577
+ }
3578
+ }
2140
3579
  if (url.pathname === "/execute" && req.method === "POST") {
2141
3580
  const body = await this.readBody(req);
2142
3581
  const paymentHeader = req.headers[PAYMENT_HEADER];
@@ -2162,27 +3601,78 @@ var MoltsPayServer = class {
2162
3601
  const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
2163
3602
  return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
2164
3603
  }
2165
- this.sendJson(res, 404, { error: "Not found" });
3604
+ this.sendJson(res, 404, {
3605
+ error: "Not found",
3606
+ discovery: `${this.publicBase}/.well-known/agent-services.json`,
3607
+ endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
3608
+ });
2166
3609
  } catch (err) {
2167
3610
  console.error("[MoltsPay] Error:", err);
2168
3611
  this.sendJson(res, 500, { error: err.message || "Internal error" });
2169
3612
  }
2170
3613
  }
2171
3614
  /**
2172
- * GET /.well-known/agent-services.json - Standard discovery endpoint
3615
+ * Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
3616
+ * (trailing slash stripped). Empty when unset, so emitted paths stay
3617
+ * root-relative — behavior is unchanged for local / no-prefix deploys.
3618
+ *
3619
+ * Needed because nginx rewrites the deployment prefix (e.g.
3620
+ * `/t/moltspay-server`) away before proxying, so the process cannot infer
3621
+ * its own public prefix; a root-relative `/services` would otherwise
3622
+ * resolve against the domain root and hit the wrong backend.
2173
3623
  */
2174
- handleAgentServicesDiscovery(res) {
2175
- const services = this.manifest.services.map((s) => ({
3624
+ get publicBase() {
3625
+ return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
3626
+ }
3627
+ /**
3628
+ * Per-service pricing across every configured rail, for the discovery
3629
+ * payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
3630
+ * for back-compat; this surfaces the fiat + balance rails (CNY) that were
3631
+ * previously invisible in discovery even though the manifest defines them
3632
+ * and the 402 challenge already quotes them. `acceptedCurrencies` becomes
3633
+ * the union across rails so a client can see CNY is accepted without
3634
+ * first triggering a 402.
3635
+ */
3636
+ describeServicePricing(s) {
3637
+ const pricing = [];
3638
+ if (this.getProviderChains().length > 0) {
3639
+ for (const currency of getAcceptedCurrencies(s)) {
3640
+ pricing.push({ rail: "crypto", currency, amount: String(s.price) });
3641
+ }
3642
+ }
3643
+ if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
3644
+ if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
3645
+ if (s.balance) {
3646
+ pricing.push({
3647
+ rail: "balance",
3648
+ currency: this.manifest.provider.balance?.currency ?? "CNY",
3649
+ amount: s.balance.price ?? s.price.toFixed(2)
3650
+ });
3651
+ }
3652
+ return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
3653
+ }
3654
+ /** Shared service-list entry for the discovery and /services endpoints. */
3655
+ buildDiscoveryService(s) {
3656
+ const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
3657
+ const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
3658
+ return {
2176
3659
  id: s.id,
2177
3660
  name: s.name,
2178
3661
  description: s.description,
2179
- price: s.price,
2180
- currency: s.currency,
2181
- acceptedCurrencies: getAcceptedCurrencies(s),
3662
+ price: headline ? Number(headline.amount) : s.price,
3663
+ currency: headline ? headline.currency : s.currency,
3664
+ acceptedCurrencies,
3665
+ pricing,
2182
3666
  input: s.input,
2183
3667
  output: s.output,
2184
3668
  available: this.skills.has(s.id)
2185
- }));
3669
+ };
3670
+ }
3671
+ /**
3672
+ * GET /.well-known/agent-services.json - Standard discovery endpoint
3673
+ */
3674
+ handleAgentServicesDiscovery(res) {
3675
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2186
3676
  this.sendJson(res, 200, {
2187
3677
  version: "1.0",
2188
3678
  provider: {
@@ -2195,9 +3685,9 @@ var MoltsPayServer = class {
2195
3685
  },
2196
3686
  services,
2197
3687
  endpoints: {
2198
- services: "/services",
2199
- execute: "/execute",
2200
- health: "/health"
3688
+ services: `${this.publicBase}/services`,
3689
+ execute: `${this.publicBase}/execute`,
3690
+ health: `${this.publicBase}/health`
2201
3691
  },
2202
3692
  payment: {
2203
3693
  protocol: "x402",
@@ -2212,17 +3702,7 @@ var MoltsPayServer = class {
2212
3702
  * GET /services - List available services
2213
3703
  */
2214
3704
  handleGetServices(res) {
2215
- const services = this.manifest.services.map((s) => ({
2216
- id: s.id,
2217
- name: s.name,
2218
- description: s.description,
2219
- price: s.price,
2220
- currency: s.currency,
2221
- acceptedCurrencies: getAcceptedCurrencies(s),
2222
- input: s.input,
2223
- output: s.output,
2224
- available: this.skills.has(s.id)
2225
- }));
3705
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2226
3706
  const selection = this.registry.getSelection();
2227
3707
  this.sendJson(res, 200, {
2228
3708
  provider: this.manifest.provider,
@@ -2281,7 +3761,7 @@ var MoltsPayServer = class {
2281
3761
  return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
2282
3762
  }
2283
3763
  if (!paymentHeader) {
2284
- return this.sendPaymentRequired(skill.config, res);
3764
+ return this.sendPaymentRequired(skill.config, res, params || {});
2285
3765
  }
2286
3766
  let payment;
2287
3767
  try {
@@ -2295,6 +3775,12 @@ var MoltsPayServer = class {
2295
3775
  if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
2296
3776
  return this.handleAlipayExecute(skill, params || {}, payment, res);
2297
3777
  }
3778
+ if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
3779
+ return this.handleWechatExecute(skill, params || {}, payment, res);
3780
+ }
3781
+ if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
3782
+ return this.handleBalanceExecute(skill, params || {}, payment, res);
3783
+ }
2298
3784
  const validation = this.validatePayment(payment, skill.config);
2299
3785
  if (!validation.valid) {
2300
3786
  return this.sendJson(res, 402, { error: validation.error });
@@ -2388,7 +3874,7 @@ var MoltsPayServer = class {
2388
3874
  }, responseHeaders);
2389
3875
  }
2390
3876
  /**
2391
- * Execute a service paid via the Alipay AI fiat rail (2.0.0).
3877
+ * Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
2392
3878
  *
2393
3879
  * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
2394
3880
  * validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
@@ -2482,6 +3968,290 @@ var MoltsPayServer = class {
2482
3968
  return null;
2483
3969
  }
2484
3970
  }
3971
+ /**
3972
+ * Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
3973
+ *
3974
+ * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
3975
+ * validation. The buyer (a human) scanned the Native QR and paid; the
3976
+ * client re-requests carrying `out_trade_no` in the X-Payment payload.
3977
+ * Verify queries the order (`trade_state === SUCCESS`). Settlement is an
3978
+ * idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
3979
+ * a confirm failure is logged but does NOT fail the delivered response —
3980
+ * the order was already verified SUCCESS.
3981
+ */
3982
+ async handleWechatExecute(skill, params, payment, res) {
3983
+ if (!this.wechatFacilitator) {
3984
+ return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
3985
+ }
3986
+ const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
3987
+ const requirements = {
3988
+ scheme: WECHAT_SCHEME,
3989
+ network: WECHAT_NETWORK,
3990
+ asset: "CNY",
3991
+ amount: skill.config.wechat?.price_cny || "0",
3992
+ payTo: this.manifest.provider.wechat?.mchid || "",
3993
+ maxTimeoutSeconds: 300,
3994
+ extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
3995
+ };
3996
+ console.log(`[MoltsPay] Verifying WeChat payment...`);
3997
+ const verifyResult = await this.registry.verify(payment, requirements);
3998
+ if (!verifyResult.valid) {
3999
+ return this.sendJson(res, 402, {
4000
+ error: `Payment verification failed: ${verifyResult.error}`,
4001
+ facilitator: verifyResult.facilitator
4002
+ });
4003
+ }
4004
+ console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
4005
+ if (outTradeNo) {
4006
+ this.invalidateWechatChallenge(outTradeNo);
4007
+ }
4008
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4009
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4010
+ let result;
4011
+ try {
4012
+ result = await Promise.race([
4013
+ skill.handler(params),
4014
+ new Promise(
4015
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4016
+ )
4017
+ ]);
4018
+ } catch (err) {
4019
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4020
+ return this.sendJson(res, 500, {
4021
+ error: "Service execution failed",
4022
+ message: err.message
4023
+ });
4024
+ }
4025
+ let settlement;
4026
+ try {
4027
+ settlement = await this.registry.settle(payment, requirements);
4028
+ if (settlement.success) {
4029
+ console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
4030
+ } else {
4031
+ console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
4032
+ }
4033
+ } catch (err) {
4034
+ console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
4035
+ settlement = { success: false, error: err.message, facilitator: "wechat" };
4036
+ }
4037
+ const responseHeaders = {};
4038
+ if (settlement.success) {
4039
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4040
+ success: true,
4041
+ transaction: settlement.transaction,
4042
+ network: WECHAT_NETWORK,
4043
+ facilitator: settlement.facilitator
4044
+ })).toString("base64");
4045
+ }
4046
+ this.sendJson(res, 200, {
4047
+ success: true,
4048
+ result,
4049
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4050
+ }, responseHeaders);
4051
+ }
4052
+ /**
4053
+ * Build the WeChat 402 challenge for a service, or null when the wechat rail
4054
+ * isn't configured for this server or this service. Placing a Native order
4055
+ * is a network call that returns a fresh `code_url` + `out_trade_no`; the
4056
+ * x402 `accepts[]` entry carries both in `extra` so the client can render
4057
+ * the QR and later echo `out_trade_no` back for verification.
4058
+ *
4059
+ * DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
4060
+ * `wechatPendingChallenges`), so repeated 402 emits within the order's
4061
+ * `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
4062
+ * minting a new payable order each time. The entry is dropped once the
4063
+ * order is paid (`invalidateWechatChallenge`) or shortly before it expires
4064
+ * (refresh margin, so clients never receive a nearly-dead QR). A build
4065
+ * failure is not cached and degrades gracefully (the other rails'
4066
+ * accepts[] still ship).
4067
+ */
4068
+ async buildWechatChallenge(config, params) {
4069
+ if (!this.wechatFacilitator || !config.wechat) return null;
4070
+ const cacheKey = crypto6.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
4071
+ const result = await this.getOrCreatePendingWechatOrder(
4072
+ cacheKey,
4073
+ config.id,
4074
+ () => this.wechatFacilitator.createPaymentRequirements({
4075
+ priceCny: config.wechat.price_cny,
4076
+ description: config.wechat.description
4077
+ })
4078
+ );
4079
+ return result ? { accepts: result.accepts } : null;
4080
+ }
4081
+ /**
4082
+ * Get-or-create a pending WeChat Native order under `cacheKey`, deduping
4083
+ * concurrent builds and reusing an unpaid order until it nears expiry.
4084
+ * Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
4085
+ * balance top-up order path ({@link handleBalanceTopupOrder}). See the
4086
+ * `wechatPendingChallenges` doc for the double-charge rationale.
4087
+ */
4088
+ async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
4089
+ const now = Date.now();
4090
+ const cached = this.wechatPendingChallenges.get(cacheKey);
4091
+ if (cached) {
4092
+ if (now < cached.expiresAtMs) {
4093
+ const hit = await cached.promise;
4094
+ if (hit) {
4095
+ console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
4096
+ return hit;
4097
+ }
4098
+ }
4099
+ this.wechatPendingChallenges.delete(cacheKey);
4100
+ }
4101
+ const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
4102
+ const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
4103
+ const entry = {
4104
+ expiresAtMs: now + cacheTtlMs,
4105
+ promise: Promise.resolve(null)
4106
+ };
4107
+ entry.promise = (async () => {
4108
+ try {
4109
+ const req = await create();
4110
+ entry.outTradeNo = req.outTradeNo;
4111
+ return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
4112
+ } catch (err) {
4113
+ console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
4114
+ this.wechatPendingChallenges.delete(cacheKey);
4115
+ return null;
4116
+ }
4117
+ })();
4118
+ this.wechatPendingChallenges.set(cacheKey, entry);
4119
+ return entry.promise;
4120
+ }
4121
+ /**
4122
+ * Drop the cached pending WeChat order that matches a paid `out_trade_no`,
4123
+ * so the next 402 mints a fresh order instead of re-serving a consumed one
4124
+ * (Native is one-code-one-payment).
4125
+ */
4126
+ invalidateWechatChallenge(outTradeNo) {
4127
+ for (const [cacheKey, entry] of this.wechatPendingChallenges) {
4128
+ if (entry.outTradeNo === outTradeNo) {
4129
+ this.wechatPendingChallenges.delete(cacheKey);
4130
+ }
4131
+ }
4132
+ }
4133
+ /**
4134
+ * Handle /execute for the custodial balance rail (2.2.0).
4135
+ *
4136
+ * Execution order is INVERTED relative to the QR rails: the deduction IS
4137
+ * the settlement, so it must land before the skill runs, and a skill
4138
+ * failure refunds it. `settle()` is idempotent on the client's
4139
+ * `request_id`, so a retried request never double-charges.
4140
+ *
4141
+ * QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
4142
+ * balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
4143
+ */
4144
+ async handleBalanceExecute(skill, params, payment, res) {
4145
+ if (!this.balanceFacilitator) {
4146
+ return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
4147
+ }
4148
+ const requirements = this.balanceRequirementsFor(skill.config);
4149
+ const authMode = this.balanceFacilitator.authMode;
4150
+ if (authMode !== "off") {
4151
+ const bp = extractBalancePayload(payment);
4152
+ const buyerId = bp?.buyer_id ?? "";
4153
+ const requestId = bp?.request_id ?? "";
4154
+ const av = verifyDeductAuth({
4155
+ auth: bp?.auth ?? null,
4156
+ buyerId,
4157
+ requestId,
4158
+ service: skill.id,
4159
+ nowMs: Date.now()
4160
+ });
4161
+ const ledger = this.balanceFacilitator.getLedger();
4162
+ let denyReason;
4163
+ if (av.ok && av.recovered) {
4164
+ const bind = ledger.bindSigner(buyerId, av.recovered);
4165
+ if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
4166
+ } else {
4167
+ denyReason = `signature ${av.reason}`;
4168
+ }
4169
+ if (denyReason) {
4170
+ if (authMode === "enforce") {
4171
+ console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4172
+ return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
4173
+ }
4174
+ console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4175
+ } else {
4176
+ console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
4177
+ }
4178
+ }
4179
+ console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
4180
+ const settlement = await this.balanceFacilitator.settle(payment, requirements);
4181
+ if (!settlement.success) {
4182
+ return this.sendJson(res, 402, {
4183
+ error: `Balance deduction failed: ${settlement.error}`,
4184
+ code: settlement.status,
4185
+ facilitator: "balance"
4186
+ });
4187
+ }
4188
+ console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
4189
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4190
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4191
+ let result;
4192
+ try {
4193
+ result = await Promise.race([
4194
+ skill.handler(params),
4195
+ new Promise(
4196
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4197
+ )
4198
+ ]);
4199
+ } catch (err) {
4200
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4201
+ const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
4202
+ if (!refund.success) {
4203
+ console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
4204
+ } else {
4205
+ console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
4206
+ }
4207
+ return this.sendJson(res, 500, {
4208
+ error: "Service execution failed",
4209
+ message: err.message,
4210
+ refunded: refund.success
4211
+ });
4212
+ }
4213
+ const responseHeaders = {
4214
+ [PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
4215
+ success: true,
4216
+ transaction: settlement.transaction,
4217
+ network: "balance",
4218
+ facilitator: "balance"
4219
+ })).toString("base64")
4220
+ };
4221
+ this.sendJson(res, 200, {
4222
+ success: true,
4223
+ result,
4224
+ payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
4225
+ }, responseHeaders);
4226
+ }
4227
+ /** The balance rail's requirements for a service (price defaults to `config.price`). */
4228
+ balanceRequirementsFor(config) {
4229
+ const price = config.balance?.price ?? config.price.toFixed(2);
4230
+ return {
4231
+ scheme: BALANCE_SCHEME,
4232
+ network: "balance",
4233
+ asset: this.balanceFacilitator?.currency ?? "USD",
4234
+ amount: price,
4235
+ payTo: "custodial",
4236
+ maxTimeoutSeconds: 30,
4237
+ extra: { service_id: config.id }
4238
+ };
4239
+ }
4240
+ /**
4241
+ * Build the balance 402 challenge for a service, or null when the rail
4242
+ * isn't configured for this server or this service. Pure — nothing is
4243
+ * minted, so unlike the QR rails a 402 emit has no side effects.
4244
+ */
4245
+ buildBalanceChallenge(config) {
4246
+ if (!this.balanceFacilitator || !config.balance) return null;
4247
+ try {
4248
+ return { accepts: this.balanceRequirementsFor(config) };
4249
+ } catch (err) {
4250
+ console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
4251
+ return null;
4252
+ }
4253
+ }
4254
+ /** GET /balance?buyer_id= — balance, limits, and today's spend. */
2485
4255
  /**
2486
4256
  * Handle MPP (Machine Payments Protocol) request
2487
4257
  * Supports both x402 and MPP protocols on service endpoints
@@ -2498,7 +4268,7 @@ var MoltsPayServer = class {
2498
4268
  if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
2499
4269
  return await this.handleMPPPayment(skill, params, authHeader, res);
2500
4270
  }
2501
- return this.sendMPPPaymentRequired(config, res);
4271
+ return this.sendMPPPaymentRequired(config, res, params);
2502
4272
  }
2503
4273
  /**
2504
4274
  * Handle MPP payment verification and service execution
@@ -2597,7 +4367,7 @@ var MoltsPayServer = class {
2597
4367
  /**
2598
4368
  * Return 402 with both x402 and MPP payment requirements
2599
4369
  */
2600
- async sendMPPPaymentRequired(config, res) {
4370
+ async sendMPPPaymentRequired(config, res, params) {
2601
4371
  const acceptedTokens = getAcceptedCurrencies(config);
2602
4372
  const providerChains = this.getProviderChains();
2603
4373
  const accepts = [];
@@ -2612,6 +4382,14 @@ var MoltsPayServer = class {
2612
4382
  if (alipayChallenge) {
2613
4383
  accepts.push(alipayChallenge.accepts);
2614
4384
  }
4385
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4386
+ if (wechatChallenge) {
4387
+ accepts.push(wechatChallenge.accepts);
4388
+ }
4389
+ const balanceChallenge = this.buildBalanceChallenge(config);
4390
+ if (balanceChallenge) {
4391
+ accepts.push(balanceChallenge.accepts);
4392
+ }
2615
4393
  const x402PaymentRequired = {
2616
4394
  x402Version: X402_VERSION2,
2617
4395
  accepts,
@@ -2677,7 +4455,7 @@ var MoltsPayServer = class {
2677
4455
  * Return 402 with x402 payment requirements (v2 format)
2678
4456
  * Includes requirements for all chains and all accepted currencies
2679
4457
  */
2680
- async sendPaymentRequired(config, res) {
4458
+ async sendPaymentRequired(config, res, params) {
2681
4459
  const acceptedTokens = getAcceptedCurrencies(config);
2682
4460
  const providerChains = this.getProviderChains();
2683
4461
  const accepts = [];
@@ -2692,6 +4470,14 @@ var MoltsPayServer = class {
2692
4470
  if (alipayChallenge) {
2693
4471
  accepts.push(alipayChallenge.accepts);
2694
4472
  }
4473
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4474
+ if (wechatChallenge) {
4475
+ accepts.push(wechatChallenge.accepts);
4476
+ }
4477
+ const balanceChallenge = this.buildBalanceChallenge(config);
4478
+ if (balanceChallenge) {
4479
+ accepts.push(balanceChallenge.accepts);
4480
+ }
2695
4481
  const acceptedChains = providerChains.map((c) => {
2696
4482
  if (c.network === "eip155:8453") return "base";
2697
4483
  if (c.network === "eip155:137") return "polygon";
@@ -2703,7 +4489,7 @@ var MoltsPayServer = class {
2703
4489
  acceptedCurrencies: acceptedTokens,
2704
4490
  acceptedChains,
2705
4491
  resource: {
2706
- url: `/execute?service=${config.id}`,
4492
+ url: `${this.publicBase}/execute?service=${config.id}`,
2707
4493
  description: `${config.name} - $${config.price} ${config.currency}`,
2708
4494
  mimeType: "application/json"
2709
4495
  }
@@ -2716,11 +4502,13 @@ var MoltsPayServer = class {
2716
4502
  if (alipayChallenge) {
2717
4503
  headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
2718
4504
  }
4505
+ const offered = this.describeServicePricing(config);
4506
+ 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}`;
2719
4507
  res.writeHead(402, headers);
2720
4508
  res.end(JSON.stringify({
2721
4509
  error: "Payment required",
2722
- message: `Service requires $${config.price} ${config.currency}`,
2723
- acceptedCurrencies: acceptedTokens,
4510
+ message,
4511
+ acceptedCurrencies: offered.acceptedCurrencies,
2724
4512
  acceptedChains,
2725
4513
  x402: paymentRequired
2726
4514
  }, null, 2));