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
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  FacilitatorRegistry: () => FacilitatorRegistry,
39
39
  MoltsPayClient: () => MoltsPayClient,
40
40
  MoltsPayServer: () => MoltsPayServer,
41
+ WechatClient: () => WechatClient,
41
42
  alipayLog: () => alipayLog,
42
43
  createRegistry: () => createRegistry,
43
44
  createWallet: () => createWallet,
@@ -64,9 +65,10 @@ __export(index_exports, {
64
65
  module.exports = __toCommonJS(index_exports);
65
66
 
66
67
  // src/server/index.ts
67
- var import_fs2 = require("fs");
68
+ var import_fs3 = require("fs");
68
69
  var import_http = require("http");
69
- var path2 = __toESM(require("path"));
70
+ var path3 = __toESM(require("path"));
71
+ var import_node_crypto7 = __toESM(require("crypto"));
70
72
 
71
73
  // src/facilitators/interface.ts
72
74
  var BaseFacilitator = class {
@@ -480,6 +482,14 @@ var ALIPAY_CHAIN_ID = "alipay";
480
482
  function isAlipayChainId(id) {
481
483
  return id === ALIPAY_CHAIN_ID;
482
484
  }
485
+ var WECHAT_CHAIN_ID = "wechat";
486
+ function isWechatChainId(id) {
487
+ return id === WECHAT_CHAIN_ID;
488
+ }
489
+ var BALANCE_CHAIN_ID = "balance";
490
+ function isBalanceChainId(id) {
491
+ return id === BALANCE_CHAIN_ID;
492
+ }
483
493
  var ERC20_ABI = [
484
494
  "function balanceOf(address owner) view returns (uint256)",
485
495
  "function transfer(address to, uint256 amount) returns (bool)",
@@ -951,12 +961,12 @@ var BNBFacilitator = class extends BaseFacilitator {
951
961
  return this.spenderAddress;
952
962
  }
953
963
  async getServerAddress() {
954
- const { ethers: ethers7 } = await import("ethers");
955
- const wallet = new ethers7.Wallet(this.serverPrivateKey);
964
+ const { ethers: ethers8 } = await import("ethers");
965
+ const wallet = new ethers8.Wallet(this.serverPrivateKey);
956
966
  return wallet.address;
957
967
  }
958
968
  async recoverIntentSigner(intent, chainId) {
959
- const { ethers: ethers7 } = await import("ethers");
969
+ const { ethers: ethers8 } = await import("ethers");
960
970
  const domain = {
961
971
  ...EIP712_DOMAIN,
962
972
  chainId
@@ -970,7 +980,7 @@ var BNBFacilitator = class extends BaseFacilitator {
970
980
  nonce: intent.nonce,
971
981
  deadline: intent.deadline
972
982
  };
973
- const recoveredAddress = ethers7.verifyTypedData(
983
+ const recoveredAddress = ethers8.verifyTypedData(
974
984
  domain,
975
985
  INTENT_TYPES,
976
986
  message,
@@ -1014,10 +1024,10 @@ var BNBFacilitator = class extends BaseFacilitator {
1014
1024
  return result.result || "0x0";
1015
1025
  }
1016
1026
  async executeTransferFrom(from, to, amount, token, rpcUrl) {
1017
- const { ethers: ethers7 } = await import("ethers");
1018
- const provider = new ethers7.JsonRpcProvider(rpcUrl);
1019
- const wallet = new ethers7.Wallet(this.serverPrivateKey, provider);
1020
- const tokenContract = new ethers7.Contract(token, [
1027
+ const { ethers: ethers8 } = await import("ethers");
1028
+ const provider = new ethers8.JsonRpcProvider(rpcUrl);
1029
+ const wallet = new ethers8.Wallet(this.serverPrivateKey, provider);
1030
+ const tokenContract = new ethers8.Contract(token, [
1021
1031
  "function transferFrom(address from, address to, uint256 amount) returns (bool)"
1022
1032
  ], wallet);
1023
1033
  const tx = await tokenContract.transferFrom(from, to, amount);
@@ -1388,7 +1398,7 @@ var ALIPAY_SIGNING_FIELDS = [
1388
1398
  ];
1389
1399
  var AlipayFacilitator = class extends BaseFacilitator {
1390
1400
  name = "alipay";
1391
- displayName = "Alipay AI \u6536";
1401
+ displayName = "Alipay AI Pay";
1392
1402
  supportedNetworks = [ALIPAY_NETWORK];
1393
1403
  config;
1394
1404
  constructor(config) {
@@ -1407,7 +1417,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1407
1417
  async createPaymentRequirements(opts) {
1408
1418
  if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
1409
1419
  throw new Error(
1410
- `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is \u5143, not \u5206; e.g. "1.00" not "100")`
1420
+ `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
1411
1421
  );
1412
1422
  }
1413
1423
  const now = /* @__PURE__ */ new Date();
@@ -1520,7 +1530,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1520
1530
  * service resource has been returned to the buyer.
1521
1531
  *
1522
1532
  * Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
1523
- * "履约确认失败"), this is **fire-and-forget**: the caller (registry /
1533
+ * (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
1524
1534
  * server) is expected to log fulfillment failures but NOT roll back
1525
1535
  * the already-delivered resource.
1526
1536
  *
@@ -1657,205 +1667,1081 @@ function decodeProof(proofHeader) {
1657
1667
  return parsed;
1658
1668
  }
1659
1669
 
1660
- // src/facilitators/registry.ts
1661
- var import_web33 = require("@solana/web3.js");
1662
- var import_bs58 = __toESM(require("bs58"));
1663
- var FacilitatorRegistry = class {
1664
- factories = /* @__PURE__ */ new Map();
1665
- instances = /* @__PURE__ */ new Map();
1666
- selection;
1667
- roundRobinIndex = 0;
1668
- constructor(selection) {
1669
- this.registerFactory("cdp", (config) => new CDPFacilitator(config));
1670
- this.registerFactory("tempo", () => new TempoFacilitator());
1671
- this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
1672
- this.registerFactory("solana", (config) => {
1673
- let feePayerKeypair;
1674
- const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
1675
- if (feePayerKey) {
1676
- try {
1677
- feePayerKeypair = import_web33.Keypair.fromSecretKey(import_bs58.default.decode(feePayerKey));
1678
- } catch (e) {
1679
- console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
1680
- }
1681
- }
1682
- return new SolanaFacilitator({ feePayerKeypair });
1683
- });
1684
- this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
1685
- this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
1670
+ // src/facilitators/wechat.ts
1671
+ var import_node_crypto4 = __toESM(require("crypto"));
1672
+
1673
+ // src/facilitators/wechat/sign.ts
1674
+ var import_node_crypto3 = __toESM(require("crypto"));
1675
+ var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
1676
+ function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
1677
+ return `${method.toUpperCase()}
1678
+ ${urlPath}
1679
+ ${timestamp}
1680
+ ${nonce}
1681
+ ${body}
1682
+ `;
1683
+ }
1684
+ function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
1685
+ const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
1686
+ const signer = import_node_crypto3.default.createSign("RSA-SHA256");
1687
+ signer.update(message, "utf-8");
1688
+ signer.end();
1689
+ return signer.sign(privateKeyPem, "base64");
1690
+ }
1691
+ function buildAuthorizationToken(args) {
1692
+ const fields = [
1693
+ `mchid="${args.mchid}"`,
1694
+ `nonce_str="${args.nonce}"`,
1695
+ `signature="${args.signature}"`,
1696
+ `timestamp="${args.timestamp}"`,
1697
+ `serial_no="${args.serialNo}"`
1698
+ ].join(",");
1699
+ return `${WECHAT_AUTH_SCHEMA} ${fields}`;
1700
+ }
1701
+ function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
1702
+ try {
1703
+ const message = `${timestamp}
1704
+ ${nonce}
1705
+ ${body}
1706
+ `;
1707
+ const verifier = import_node_crypto3.default.createVerify("RSA-SHA256");
1708
+ verifier.update(message, "utf-8");
1709
+ verifier.end();
1710
+ return verifier.verify(platformPublicKeyPem, signature, "base64");
1711
+ } catch {
1712
+ return false;
1686
1713
  }
1687
- /**
1688
- * Register a new facilitator factory
1689
- */
1690
- registerFactory(name, factory) {
1691
- this.factories.set(name, factory);
1714
+ }
1715
+ function generateNonce() {
1716
+ return import_node_crypto3.default.randomBytes(16).toString("hex");
1717
+ }
1718
+
1719
+ // src/facilitators/wechat/api.ts
1720
+ var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
1721
+ var WechatApiError = class extends Error {
1722
+ status;
1723
+ code;
1724
+ constructor(message, status, code) {
1725
+ super(message);
1726
+ this.name = "WechatApiError";
1727
+ this.status = status;
1728
+ this.code = code;
1692
1729
  }
1693
- /**
1694
- * Get or create a facilitator instance
1695
- */
1696
- get(name, config) {
1697
- if (this.instances.has(name)) {
1698
- return this.instances.get(name);
1730
+ };
1731
+ async function wechatV3Call(method, urlPath, body, config) {
1732
+ const base = config.api_base ?? WECHAT_API_BASE;
1733
+ const bodyStr = body === null ? "" : JSON.stringify(body);
1734
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1735
+ const nonce = generateNonce();
1736
+ const signature = wechatV3Sign(
1737
+ method,
1738
+ urlPath,
1739
+ timestamp,
1740
+ nonce,
1741
+ bodyStr,
1742
+ config.private_key_pem
1743
+ );
1744
+ const authorization = buildAuthorizationToken({
1745
+ mchid: config.mchid,
1746
+ serialNo: config.serial_no,
1747
+ nonce,
1748
+ timestamp,
1749
+ signature
1750
+ });
1751
+ const response = await fetch(`${base}${urlPath}`, {
1752
+ method,
1753
+ headers: {
1754
+ Authorization: authorization,
1755
+ Accept: "application/json",
1756
+ "Content-Type": "application/json",
1757
+ // WeChat requires a non-empty UA; some edge nodes 403 a blank one.
1758
+ "User-Agent": "moltspay-wechat/1.0"
1759
+ },
1760
+ body: method === "GET" ? void 0 : bodyStr
1761
+ });
1762
+ const text = await response.text();
1763
+ if (config.platform_public_key_pem && text.length > 0) {
1764
+ const ts = response.headers.get("Wechatpay-Timestamp");
1765
+ const nc = response.headers.get("Wechatpay-Nonce");
1766
+ const sig = response.headers.get("Wechatpay-Signature");
1767
+ if (!ts || !nc || !sig) {
1768
+ throw new Error(
1769
+ `WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
1770
+ );
1699
1771
  }
1700
- const factory = this.factories.get(name);
1701
- if (!factory) {
1702
- throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
1772
+ if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
1773
+ throw new Error(
1774
+ `WeChat v3 ${method} ${urlPath}: response signature verification failed`
1775
+ );
1703
1776
  }
1704
- const mergedConfig = {
1705
- ...this.selection.config?.[name],
1706
- ...config
1707
- };
1708
- const instance = factory(mergedConfig);
1709
- this.instances.set(name, instance);
1710
- return instance;
1711
1777
  }
1712
- /**
1713
- * Get all configured facilitator names
1714
- */
1715
- getConfiguredNames() {
1716
- const names = [this.selection.primary];
1717
- if (this.selection.fallback) {
1718
- names.push(...this.selection.fallback);
1778
+ let json = {};
1779
+ if (text.length > 0) {
1780
+ try {
1781
+ json = JSON.parse(text);
1782
+ } catch {
1783
+ throw new Error(
1784
+ `WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
1785
+ );
1719
1786
  }
1720
- return names;
1787
+ }
1788
+ if (!response.ok) {
1789
+ const code = typeof json.code === "string" ? json.code : void 0;
1790
+ const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
1791
+ throw new WechatApiError(
1792
+ `WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
1793
+ response.status,
1794
+ code
1795
+ );
1796
+ }
1797
+ return { status: response.status, body: json };
1798
+ }
1799
+
1800
+ // src/facilitators/wechat.ts
1801
+ var WECHAT_NETWORK = "wechat";
1802
+ var WECHAT_SCHEME = "wechatpay-native";
1803
+ var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
1804
+ var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
1805
+ var WechatFacilitator = class extends BaseFacilitator {
1806
+ name = "wechat";
1807
+ displayName = "WeChat Pay";
1808
+ supportedNetworks = [WECHAT_NETWORK];
1809
+ config;
1810
+ constructor(config) {
1811
+ super();
1812
+ this.config = { api_base: WECHAT_API_BASE, ...config };
1721
1813
  }
1722
1814
  /**
1723
- * Get list of facilitators based on selection strategy
1815
+ * Place a Native order and build the 402 challenge. The returned
1816
+ * `code_url` is payer-agnostic — any WeChat user may scan it.
1724
1817
  */
1725
- async getOrderedFacilitators(network) {
1726
- const names = this.getConfiguredNames();
1727
- const facilitators = [];
1728
- for (const name of names) {
1729
- try {
1730
- const f = this.get(name);
1731
- if (f.supportsNetwork(network)) {
1732
- facilitators.push(f);
1733
- }
1734
- } catch (err) {
1735
- console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
1736
- }
1737
- }
1738
- if (facilitators.length === 0) {
1739
- throw new Error(`No facilitators available for network: ${network}`);
1818
+ async createPaymentRequirements(opts) {
1819
+ if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
1820
+ throw new Error(
1821
+ `WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
1822
+ );
1740
1823
  }
1741
- switch (this.selection.strategy) {
1742
- case "random":
1743
- return this.shuffle(facilitators);
1744
- case "roundrobin":
1745
- this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
1746
- return [
1747
- ...facilitators.slice(this.roundRobinIndex),
1748
- ...facilitators.slice(0, this.roundRobinIndex)
1749
- ];
1750
- case "cheapest":
1751
- return this.sortByCheapest(facilitators);
1752
- case "fastest":
1753
- return this.sortByFastest(facilitators);
1754
- case "failover":
1755
- default:
1756
- return facilitators;
1824
+ const total = cnyToFen(opts.priceCny);
1825
+ if (total < 1) {
1826
+ throw new Error(
1827
+ `WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
1828
+ );
1757
1829
  }
1758
- }
1759
- shuffle(array) {
1760
- const result = [...array];
1761
- for (let i = result.length - 1; i > 0; i--) {
1762
- const j = Math.floor(Math.random() * (i + 1));
1763
- [result[i], result[j]] = [result[j], result[i]];
1830
+ const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
1831
+ const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
1832
+ const body = {
1833
+ appid: this.config.appid,
1834
+ mchid: this.config.mchid,
1835
+ description: opts.description,
1836
+ out_trade_no: outTradeNo,
1837
+ notify_url: this.config.notify_url,
1838
+ time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
1839
+ amount: { total, currency: "CNY" }
1840
+ };
1841
+ if (opts.attach) {
1842
+ const attachStr = JSON.stringify(opts.attach);
1843
+ if (Buffer.byteLength(attachStr, "utf8") > 128) {
1844
+ throw new Error(
1845
+ `WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
1846
+ );
1847
+ }
1848
+ body.attach = attachStr;
1764
1849
  }
1765
- return result;
1766
- }
1767
- async sortByCheapest(facilitators) {
1768
- const withFees = await Promise.all(
1769
- facilitators.map(async (f) => {
1770
- try {
1771
- const fee = await f.getFee?.();
1772
- return { facilitator: f, perTx: fee?.perTx ?? Infinity };
1773
- } catch {
1774
- return { facilitator: f, perTx: Infinity };
1775
- }
1776
- })
1777
- );
1778
- withFees.sort((a, b) => a.perTx - b.perTx);
1779
- return withFees.map((w) => w.facilitator);
1780
- }
1781
- async sortByFastest(facilitators) {
1782
- const withLatency = await Promise.all(
1783
- facilitators.map(async (f) => {
1784
- try {
1785
- const health = await f.healthCheck();
1786
- return { facilitator: f, latency: health.latencyMs ?? Infinity };
1787
- } catch {
1788
- return { facilitator: f, latency: Infinity };
1789
- }
1790
- })
1850
+ const { body: resp } = await wechatV3Call(
1851
+ "POST",
1852
+ "/v3/pay/transactions/native",
1853
+ body,
1854
+ this.getApiConfig()
1791
1855
  );
1792
- withLatency.sort((a, b) => a.latency - b.latency);
1793
- return withLatency.map((w) => w.facilitator);
1856
+ const codeUrl = resp.code_url;
1857
+ if (typeof codeUrl !== "string" || codeUrl.length === 0) {
1858
+ throw new Error(
1859
+ `WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
1860
+ );
1861
+ }
1862
+ const x402Accepts = {
1863
+ scheme: WECHAT_SCHEME,
1864
+ network: WECHAT_NETWORK,
1865
+ asset: "CNY",
1866
+ amount: opts.priceCny,
1867
+ payTo: this.config.mchid,
1868
+ maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
1869
+ extra: {
1870
+ code_url: codeUrl,
1871
+ out_trade_no: outTradeNo
1872
+ }
1873
+ };
1874
+ return { x402Accepts, codeUrl, outTradeNo };
1794
1875
  }
1795
1876
  /**
1796
- * Verify payment using configured facilitators
1877
+ * Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
1878
+ * (missing out_trade_no, gateway error, not-yet-paid) return
1879
+ * `{ valid: false, error }`; no exception escapes.
1797
1880
  */
1798
1881
  async verify(paymentPayload, requirements) {
1799
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1800
- const facilitators = await this.getOrderedFacilitators(network);
1801
- let lastError;
1802
- for (const f of facilitators) {
1803
- try {
1804
- console.log(`[Registry] Trying ${f.name} for verify...`);
1805
- const result = await f.verify(paymentPayload, requirements);
1806
- if (result.valid) {
1807
- console.log(`[Registry] ${f.name} verify succeeded`);
1808
- return { ...result, facilitator: f.name };
1809
- }
1810
- lastError = result.error;
1811
- console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
1812
- if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
1813
- break;
1814
- }
1815
- } catch (err) {
1816
- lastError = err.message;
1817
- console.error(`[Registry] ${f.name} error:`, err.message);
1882
+ try {
1883
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1884
+ const resp = await this.queryOrder(outTradeNo);
1885
+ const tradeState = resp.trade_state;
1886
+ if (tradeState !== "SUCCESS") {
1887
+ return {
1888
+ valid: false,
1889
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
1890
+ details: { trade_state: tradeState, out_trade_no: outTradeNo }
1891
+ };
1818
1892
  }
1893
+ return {
1894
+ valid: true,
1895
+ details: {
1896
+ trade_state: tradeState,
1897
+ transaction_id: resp.transaction_id,
1898
+ out_trade_no: resp.out_trade_no ?? outTradeNo,
1899
+ amount: resp.amount,
1900
+ attach: resp.attach,
1901
+ // Payer identity, gateway-attested. Present on a SUCCESS Native
1902
+ // order even though order creation was payer-agnostic; anchors the
1903
+ // custodial balance to a real WeChat user. @see WECHAT fiat auth design.
1904
+ openid: resp.payer?.openid
1905
+ }
1906
+ };
1907
+ } catch (e) {
1908
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
1819
1909
  }
1820
- return {
1821
- valid: false,
1822
- error: lastError || "All facilitators failed",
1823
- facilitator: "none"
1824
- };
1825
1910
  }
1826
1911
  /**
1827
- * Settle payment using configured facilitators
1912
+ * Confirm settlement. Native captures funds at SUCCESS, so this is an
1913
+ * idempotent re-confirm that returns the `transaction_id`. Like Alipay's
1914
+ * fulfillment confirm, failures are surfaced but non-fatal to an
1915
+ * already-delivered resource (caller logs, does not roll back).
1828
1916
  */
1829
1917
  async settle(paymentPayload, requirements) {
1830
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1831
- const facilitators = await this.getOrderedFacilitators(network);
1832
- let lastError;
1833
- for (const f of facilitators) {
1834
- try {
1835
- console.log(`[Registry] Trying ${f.name} for settle...`);
1836
- const result = await f.settle(paymentPayload, requirements);
1837
- if (result.success) {
1838
- console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
1839
- return { ...result, facilitator: f.name };
1840
- }
1841
- lastError = result.error;
1842
- console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
1843
- } catch (err) {
1844
- lastError = err.message;
1845
- console.error(`[Registry] ${f.name} error:`, err.message);
1918
+ try {
1919
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1920
+ const resp = await this.queryOrder(outTradeNo);
1921
+ const tradeState = resp.trade_state;
1922
+ const transactionId = resp.transaction_id;
1923
+ if (tradeState !== "SUCCESS") {
1924
+ return {
1925
+ success: false,
1926
+ transaction: transactionId,
1927
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
1928
+ status: tradeState
1929
+ };
1846
1930
  }
1931
+ return { success: true, transaction: transactionId, status: "fulfilled" };
1932
+ } catch (e) {
1933
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1847
1934
  }
1848
- return {
1849
- success: false,
1850
- error: lastError || "All facilitators failed",
1851
- facilitator: "none"
1852
- };
1853
1935
  }
1854
1936
  /**
1855
- * Check health of all configured facilitators
1937
+ * Validate keys parse, apiv3 key length, and gateway reachability. Does
1938
+ * NOT make a business API call.
1856
1939
  */
1857
- async healthCheckAll() {
1858
- const results = {};
1940
+ async healthCheck() {
1941
+ const start = Date.now();
1942
+ try {
1943
+ import_node_crypto4.default.createPrivateKey(this.config.private_key_pem);
1944
+ } catch (e) {
1945
+ return {
1946
+ healthy: false,
1947
+ error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1948
+ };
1949
+ }
1950
+ if (this.config.platform_public_key_pem) {
1951
+ try {
1952
+ import_node_crypto4.default.createPublicKey(this.config.platform_public_key_pem);
1953
+ } catch (e) {
1954
+ return {
1955
+ healthy: false,
1956
+ error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1957
+ };
1958
+ }
1959
+ }
1960
+ if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
1961
+ return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
1962
+ }
1963
+ const base = this.config.api_base ?? WECHAT_API_BASE;
1964
+ const controller = new AbortController();
1965
+ const timeout = setTimeout(() => controller.abort(), 5e3);
1966
+ const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
1967
+ clearTimeout(timeout);
1968
+ const latencyMs = Date.now() - start;
1969
+ if (!response) {
1970
+ return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
1971
+ }
1972
+ return { healthy: true, latencyMs };
1973
+ }
1974
+ /** Query a Native order by out_trade_no. The query string is part of the signed path. */
1975
+ async queryOrder(outTradeNo) {
1976
+ const path5 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
1977
+ const { body } = await wechatV3Call("GET", path5, null, this.getApiConfig());
1978
+ return body;
1979
+ }
1980
+ /** Project the facilitator config down to what api.ts needs. */
1981
+ getApiConfig() {
1982
+ return {
1983
+ mchid: this.config.mchid,
1984
+ serial_no: this.config.serial_no,
1985
+ private_key_pem: this.config.private_key_pem,
1986
+ platform_public_key_pem: this.config.platform_public_key_pem,
1987
+ api_base: this.config.api_base
1988
+ };
1989
+ }
1990
+ };
1991
+ function cnyToFen(cny) {
1992
+ return Math.round(parseFloat(cny) * 100);
1993
+ }
1994
+ function generateOutTradeNo2() {
1995
+ return "WX" + import_node_crypto4.default.randomBytes(15).toString("hex");
1996
+ }
1997
+ function parseWechatAttach(attach) {
1998
+ if (typeof attach !== "string" || attach.length === 0) return null;
1999
+ try {
2000
+ const parsed = JSON.parse(attach);
2001
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
2002
+ } catch {
2003
+ return null;
2004
+ }
2005
+ }
2006
+ function formatTimeExpire(d) {
2007
+ const pad = (n) => String(n).padStart(2, "0");
2008
+ const offMin = -d.getTimezoneOffset();
2009
+ const sign = offMin >= 0 ? "+" : "-";
2010
+ const abs = Math.abs(offMin);
2011
+ 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)}`;
2012
+ }
2013
+ function extractOutTradeNo(paymentPayload, requirements) {
2014
+ const p = paymentPayload?.payload;
2015
+ if (typeof p === "string" && p.length > 0) return p;
2016
+ if (p !== null && typeof p === "object") {
2017
+ const obj = p;
2018
+ const cand = obj.out_trade_no ?? obj.outTradeNo;
2019
+ if (typeof cand === "string" && cand.length > 0) return cand;
2020
+ }
2021
+ const fromReq = requirements?.extra?.out_trade_no;
2022
+ if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
2023
+ throw new Error(
2024
+ "wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
2025
+ );
2026
+ }
2027
+
2028
+ // src/facilitators/balance/ledger.ts
2029
+ var import_node_crypto5 = require("crypto");
2030
+ var DEFAULT_SINGLE_LIMIT_SAT = 500;
2031
+ var DEFAULT_DAILY_LIMIT_SAT = 1e3;
2032
+ function toSat(amount) {
2033
+ const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
2034
+ if (!/^\d+(\.\d{1,2})?$/.test(s)) {
2035
+ throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
2036
+ }
2037
+ const [whole, frac = ""] = s.split(".");
2038
+ return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
2039
+ }
2040
+ function fromSat(sat) {
2041
+ return (sat / 100).toFixed(2);
2042
+ }
2043
+ var BalanceLedger = class {
2044
+ db;
2045
+ defaultSingleLimitSat;
2046
+ defaultDailyLimitSat;
2047
+ constructor(config) {
2048
+ const getBuiltin = process.getBuiltinModule;
2049
+ const sqlite = getBuiltin?.("node:sqlite");
2050
+ if (!sqlite?.DatabaseSync) {
2051
+ throw new Error(
2052
+ `The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
2053
+ );
2054
+ }
2055
+ const { DatabaseSync } = sqlite;
2056
+ this.db = new DatabaseSync(config.dbPath);
2057
+ this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
2058
+ this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
2059
+ this.db.exec("PRAGMA journal_mode = WAL");
2060
+ this.db.exec(`
2061
+ CREATE TABLE IF NOT EXISTS buyers (
2062
+ buyer_id TEXT PRIMARY KEY,
2063
+ display_name TEXT,
2064
+ balance_sat INTEGER NOT NULL DEFAULT 0,
2065
+ total_topup_sat INTEGER NOT NULL DEFAULT 0,
2066
+ total_spent_sat INTEGER NOT NULL DEFAULT 0,
2067
+ daily_limit_sat INTEGER NOT NULL,
2068
+ single_limit_sat INTEGER NOT NULL,
2069
+ status TEXT NOT NULL DEFAULT 'active',
2070
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2071
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2072
+ );
2073
+ CREATE TABLE IF NOT EXISTS ledger_transactions (
2074
+ id TEXT PRIMARY KEY,
2075
+ buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
2076
+ type TEXT NOT NULL,
2077
+ amount_sat INTEGER NOT NULL,
2078
+ service TEXT,
2079
+ description TEXT,
2080
+ request_id TEXT,
2081
+ external_ref TEXT,
2082
+ refunds_tx_id TEXT,
2083
+ status TEXT NOT NULL DEFAULT 'completed',
2084
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2085
+ );
2086
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
2087
+ ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
2088
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
2089
+ ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
2090
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
2091
+ ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
2092
+ CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
2093
+ ON ledger_transactions(buyer_id, created_at);
2094
+ CREATE TABLE IF NOT EXISTS ledger_meta (
2095
+ key TEXT PRIMARY KEY,
2096
+ value TEXT NOT NULL
2097
+ );
2098
+ `);
2099
+ const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
2100
+ if (!cols.includes("signer_address")) {
2101
+ this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
2102
+ }
2103
+ if (!cols.includes("wechat_openid")) {
2104
+ this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
2105
+ }
2106
+ const currency = config.currency ?? "USD";
2107
+ const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
2108
+ if (existingCurrency) {
2109
+ if (existingCurrency.value !== currency) {
2110
+ throw new Error(
2111
+ `Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
2112
+ );
2113
+ }
2114
+ } else {
2115
+ this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
2116
+ }
2117
+ }
2118
+ /** Fetch a buyer, or null. */
2119
+ getBuyer(buyerId) {
2120
+ const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
2121
+ return row ?? null;
2122
+ }
2123
+ /** Fetch a buyer, creating an empty active account on first sight. */
2124
+ getOrCreateBuyer(buyerId, displayName) {
2125
+ const existing = this.getBuyer(buyerId);
2126
+ if (existing) return existing;
2127
+ this.db.prepare(
2128
+ `INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
2129
+ VALUES (?, ?, ?, ?)`
2130
+ ).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
2131
+ return this.getBuyer(buyerId);
2132
+ }
2133
+ /**
2134
+ * Record the gateway-attested WeChat payer openid that funded a buyer, on
2135
+ * top-up confirm. Idempotent and observational (stage 1a): it never
2136
+ * overwrites an existing binding with a *different* openid — a conflict is
2137
+ * reported to the caller (possible account sharing / spoof) but not
2138
+ * enforced here. Creates the buyer if absent.
2139
+ */
2140
+ bindOpenid(buyerId, openid) {
2141
+ const buyer = this.getOrCreateBuyer(buyerId);
2142
+ if (buyer.wechat_openid === openid) {
2143
+ return { bound: true, conflict: false, existing: openid };
2144
+ }
2145
+ if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
2146
+ return { bound: false, conflict: true, existing: buyer.wechat_openid };
2147
+ }
2148
+ this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
2149
+ return { bound: true, conflict: false, existing: null };
2150
+ }
2151
+ /**
2152
+ * TOFU-bind the account's spending signer address (EVM, lowercase). First
2153
+ * signed request records it; later requests must match. A mismatch is
2154
+ * reported (`conflict`) so the caller can reject under `enforce` — it is
2155
+ * never silently overwritten. Creates the buyer if absent.
2156
+ */
2157
+ bindSigner(buyerId, address) {
2158
+ const a = address.toLowerCase();
2159
+ const buyer = this.getOrCreateBuyer(buyerId);
2160
+ if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
2161
+ if (buyer.signer_address && buyer.signer_address !== a) {
2162
+ return { bound: false, conflict: true, existing: buyer.signer_address };
2163
+ }
2164
+ this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
2165
+ return { bound: true, conflict: false, existing: null };
2166
+ }
2167
+ /** Sum of today's (UTC) completed deducts minus refunds issued against them. */
2168
+ spentTodaySat(buyerId) {
2169
+ const row = this.db.prepare(
2170
+ `SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
2171
+ FROM ledger_transactions
2172
+ WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
2173
+ ).get(buyerId);
2174
+ return Math.max(0, row.spent);
2175
+ }
2176
+ /**
2177
+ * Read-only deduction precheck (the rail's `verify`). Never mutates.
2178
+ * Returns the same error codes `deduct` would.
2179
+ */
2180
+ checkDeduct(buyerId, amountSat) {
2181
+ const buyer = this.getBuyer(buyerId);
2182
+ if (!buyer) return { success: false, error: "buyer_not_found" };
2183
+ if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
2184
+ if (amountSat > buyer.single_limit_sat) {
2185
+ return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
2186
+ }
2187
+ const spent = this.spentTodaySat(buyerId);
2188
+ if (spent + amountSat > buyer.daily_limit_sat) {
2189
+ return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
2190
+ }
2191
+ if (buyer.balance_sat < amountSat) {
2192
+ return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
2193
+ }
2194
+ return { success: true, balanceSat: buyer.balance_sat };
2195
+ }
2196
+ /**
2197
+ * Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
2198
+ * SQLite transaction; a `request_id` replay returns the original tx
2199
+ * without charging again.
2200
+ */
2201
+ deduct(opts) {
2202
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2203
+ throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
2204
+ }
2205
+ if (opts.requestId) {
2206
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
2207
+ if (prior) {
2208
+ const buyer = this.getBuyer(prior.buyer_id);
2209
+ return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
2210
+ }
2211
+ }
2212
+ this.db.exec("BEGIN IMMEDIATE");
2213
+ try {
2214
+ const check = this.checkDeduct(opts.buyerId, opts.amountSat);
2215
+ if (!check.success) {
2216
+ this.db.exec("ROLLBACK");
2217
+ return check;
2218
+ }
2219
+ const updated = this.db.prepare(
2220
+ `UPDATE buyers SET
2221
+ balance_sat = balance_sat - ?,
2222
+ total_spent_sat = total_spent_sat + ?,
2223
+ updated_at = datetime('now')
2224
+ WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
2225
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
2226
+ if (Number(updated.changes) !== 1) {
2227
+ this.db.exec("ROLLBACK");
2228
+ return { success: false, error: "insufficient_balance" };
2229
+ }
2230
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2231
+ this.db.prepare(
2232
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
2233
+ VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
2234
+ ).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
2235
+ this.db.exec("COMMIT");
2236
+ const buyer = this.getBuyer(opts.buyerId);
2237
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2238
+ } catch (err) {
2239
+ try {
2240
+ this.db.exec("ROLLBACK");
2241
+ } catch {
2242
+ }
2243
+ throw err;
2244
+ }
2245
+ }
2246
+ /**
2247
+ * Credit a top-up. `externalRef` is the settlement proof reference
2248
+ * (on-chain tx hash / fiat trade number) and is unique: replaying the same
2249
+ * reference returns the original row without crediting twice.
2250
+ */
2251
+ topup(opts) {
2252
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2253
+ throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
2254
+ }
2255
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
2256
+ if (prior) {
2257
+ const buyer2 = this.getBuyer(prior.buyer_id);
2258
+ return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
2259
+ }
2260
+ this.getOrCreateBuyer(opts.buyerId);
2261
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2262
+ this.db.exec("BEGIN IMMEDIATE");
2263
+ try {
2264
+ this.db.prepare(
2265
+ `UPDATE buyers SET
2266
+ balance_sat = balance_sat + ?,
2267
+ total_topup_sat = total_topup_sat + ?,
2268
+ updated_at = datetime('now')
2269
+ WHERE buyer_id = ?`
2270
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId);
2271
+ this.db.prepare(
2272
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
2273
+ VALUES (?, ?, 'topup', ?, ?, ?)`
2274
+ ).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
2275
+ this.db.exec("COMMIT");
2276
+ } catch (err) {
2277
+ try {
2278
+ this.db.exec("ROLLBACK");
2279
+ } catch {
2280
+ }
2281
+ throw err;
2282
+ }
2283
+ const buyer = this.getBuyer(opts.buyerId);
2284
+ return { txId, balanceSat: buyer.balance_sat };
2285
+ }
2286
+ /**
2287
+ * Reverse a deduct (service failed after the charge). Idempotent: the
2288
+ * unique index on `refunds_tx_id` means a second refund of the same deduct
2289
+ * returns the original refund row.
2290
+ */
2291
+ refund(deductTxId, reason) {
2292
+ const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
2293
+ if (!deductRow) return { success: false, error: "tx_not_found" };
2294
+ if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
2295
+ const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
2296
+ if (priorRefund) {
2297
+ const buyer = this.getBuyer(deductRow.buyer_id);
2298
+ return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
2299
+ }
2300
+ this.db.exec("BEGIN IMMEDIATE");
2301
+ try {
2302
+ this.db.prepare(
2303
+ `UPDATE buyers SET
2304
+ balance_sat = balance_sat + ?,
2305
+ total_spent_sat = total_spent_sat - ?,
2306
+ updated_at = datetime('now')
2307
+ WHERE buyer_id = ?`
2308
+ ).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
2309
+ this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
2310
+ const txId = `btx_${(0, import_node_crypto5.randomUUID)()}`;
2311
+ this.db.prepare(
2312
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
2313
+ VALUES (?, ?, 'refund', ?, ?, ?)`
2314
+ ).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
2315
+ this.db.exec("COMMIT");
2316
+ const buyer = this.getBuyer(deductRow.buyer_id);
2317
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2318
+ } catch (err) {
2319
+ try {
2320
+ this.db.exec("ROLLBACK");
2321
+ } catch {
2322
+ }
2323
+ throw err;
2324
+ }
2325
+ }
2326
+ /** Paged transaction history, newest first (rowid breaks same-second ties). */
2327
+ listTransactions(buyerId, limit = 20, offset = 0) {
2328
+ return this.db.prepare(
2329
+ `SELECT * FROM ledger_transactions WHERE buyer_id = ?
2330
+ ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
2331
+ ).all(buyerId, limit, offset);
2332
+ }
2333
+ /** Quick integrity probe for healthCheck(). */
2334
+ integrityOk() {
2335
+ const row = this.db.prepare(`PRAGMA quick_check`).get();
2336
+ return row.quick_check === "ok";
2337
+ }
2338
+ close() {
2339
+ this.db.close();
2340
+ }
2341
+ };
2342
+
2343
+ // src/facilitators/balance/auth.ts
2344
+ var import_ethers2 = require("ethers");
2345
+ var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
2346
+ var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
2347
+ function extractBalanceAuth(raw) {
2348
+ if (!raw || typeof raw !== "object") return null;
2349
+ const a = raw;
2350
+ if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
2351
+ return { timestamp: a.timestamp, signature: a.signature };
2352
+ }
2353
+ return null;
2354
+ }
2355
+ function buildDeductMessage(f) {
2356
+ return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
2357
+ }
2358
+ function verifyDeductAuth(opts) {
2359
+ const { auth } = opts;
2360
+ if (!auth) return { ok: false, reason: "no_signature" };
2361
+ if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
2362
+ if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
2363
+ return { ok: false, reason: "timestamp_skew" };
2364
+ }
2365
+ const message = buildDeductMessage({
2366
+ buyerId: opts.buyerId,
2367
+ requestId: opts.requestId,
2368
+ service: opts.service,
2369
+ timestamp: auth.timestamp
2370
+ });
2371
+ try {
2372
+ const recovered = import_ethers2.ethers.verifyMessage(message, auth.signature).toLowerCase();
2373
+ return { ok: true, recovered };
2374
+ } catch {
2375
+ return { ok: false, reason: "bad_signature" };
2376
+ }
2377
+ }
2378
+
2379
+ // src/facilitators/balance.ts
2380
+ var BALANCE_NETWORK = "balance";
2381
+ var BALANCE_SCHEME = "balance";
2382
+ function extractBalancePayload(payment) {
2383
+ const p = payment.payload;
2384
+ if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
2385
+ return {
2386
+ buyer_id: p.buyer_id,
2387
+ request_id: typeof p.request_id === "string" ? p.request_id : void 0,
2388
+ auth: extractBalanceAuth(p.auth) ?? void 0
2389
+ };
2390
+ }
2391
+ return null;
2392
+ }
2393
+ var BalanceFacilitator = class extends BaseFacilitator {
2394
+ name = "balance";
2395
+ displayName = "Custodial Balance";
2396
+ supportedNetworks = [BALANCE_NETWORK];
2397
+ currency;
2398
+ /** User-auth rollout gate for deductions (off | shadow | enforce). */
2399
+ authMode;
2400
+ ledger;
2401
+ constructor(config) {
2402
+ super();
2403
+ this.currency = config.currency ?? "USD";
2404
+ this.authMode = config.auth_mode ?? "off";
2405
+ const ledgerConfig = {
2406
+ dbPath: config.db_path,
2407
+ defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
2408
+ defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
2409
+ currency: this.currency
2410
+ };
2411
+ this.ledger = new BalanceLedger(ledgerConfig);
2412
+ }
2413
+ /** Direct ledger access for the server's balance-management endpoints. */
2414
+ getLedger() {
2415
+ return this.ledger;
2416
+ }
2417
+ /**
2418
+ * Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
2419
+ */
2420
+ createPaymentRequirements(opts) {
2421
+ toSat(opts.price);
2422
+ return {
2423
+ scheme: BALANCE_SCHEME,
2424
+ network: BALANCE_NETWORK,
2425
+ asset: this.currency,
2426
+ amount: opts.price,
2427
+ payTo: "custodial",
2428
+ maxTimeoutSeconds: 30,
2429
+ extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
2430
+ };
2431
+ }
2432
+ /** Read-only funds/limits precheck. Never mutates the ledger. */
2433
+ async verify(paymentPayload, requirements) {
2434
+ const payload = extractBalancePayload(paymentPayload);
2435
+ if (!payload) {
2436
+ return { valid: false, error: "Missing buyer_id in balance payment payload" };
2437
+ }
2438
+ let amountSat;
2439
+ try {
2440
+ amountSat = toSat(requirements.amount);
2441
+ } catch (err) {
2442
+ return { valid: false, error: err.message };
2443
+ }
2444
+ const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
2445
+ if (!check.success) {
2446
+ return {
2447
+ valid: false,
2448
+ error: this.describeDeductError(check),
2449
+ details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
2450
+ };
2451
+ }
2452
+ return { valid: true, details: { balance: fromSat(check.balanceSat) } };
2453
+ }
2454
+ /**
2455
+ * The atomic deduction. Idempotent on `request_id`; the returned
2456
+ * `transaction` is the ledger tx id (usable for `refund`).
2457
+ */
2458
+ async settle(paymentPayload, requirements) {
2459
+ const payload = extractBalancePayload(paymentPayload);
2460
+ if (!payload) {
2461
+ return { success: false, error: "Missing buyer_id in balance payment payload" };
2462
+ }
2463
+ let amountSat;
2464
+ try {
2465
+ amountSat = toSat(requirements.amount);
2466
+ } catch (err) {
2467
+ return { success: false, error: err.message };
2468
+ }
2469
+ const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
2470
+ let result;
2471
+ try {
2472
+ result = this.ledger.deduct({
2473
+ buyerId: payload.buyer_id,
2474
+ amountSat,
2475
+ requestId: payload.request_id,
2476
+ service: serviceId
2477
+ });
2478
+ } catch (err) {
2479
+ return { success: false, error: `Ledger deduct failed: ${err.message}` };
2480
+ }
2481
+ if (!result.success) {
2482
+ return { success: false, error: this.describeDeductError(result), status: result.error };
2483
+ }
2484
+ return {
2485
+ success: true,
2486
+ transaction: result.txId,
2487
+ status: result.replayed ? "replayed" : "deducted"
2488
+ };
2489
+ }
2490
+ /** Reverse a deduct after a downstream failure. Idempotent per deduct. */
2491
+ refund(deductTxId, reason) {
2492
+ return this.ledger.refund(deductTxId, reason);
2493
+ }
2494
+ /**
2495
+ * Credit a gateway-verified external settlement to a buyer's balance. The
2496
+ * single entry point for every funding path -- WeChat callback, WeChat
2497
+ * polling, and the operator `/balance/topup` endpoint -- so they share one
2498
+ * idempotency boundary: `externalRef` is unique in the ledger, so a replay
2499
+ * credits nothing and returns the original transaction (`replayed: true`).
2500
+ * Callers must pass a gateway-verified `amountSat`, never a client-declared
2501
+ * amount.
2502
+ */
2503
+ credit(opts) {
2504
+ const result = this.ledger.topup({
2505
+ buyerId: opts.buyerId,
2506
+ amountSat: opts.amountSat,
2507
+ externalRef: opts.externalRef,
2508
+ description: opts.description
2509
+ });
2510
+ return {
2511
+ txId: result.txId,
2512
+ balance: fromSat(result.balanceSat),
2513
+ balanceSat: result.balanceSat,
2514
+ replayed: result.replayed ?? false
2515
+ };
2516
+ }
2517
+ async healthCheck() {
2518
+ const start = Date.now();
2519
+ try {
2520
+ const ok = this.ledger.integrityOk();
2521
+ return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
2522
+ } catch (err) {
2523
+ return { healthy: false, error: err.message };
2524
+ }
2525
+ }
2526
+ describeDeductError(result) {
2527
+ switch (result.error) {
2528
+ case "buyer_not_found":
2529
+ return "Unknown buyer: top up first to create an account";
2530
+ case "buyer_not_active":
2531
+ return "Buyer account is frozen or banned";
2532
+ case "insufficient_balance":
2533
+ return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
2534
+ case "exceeds_single_limit":
2535
+ return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2536
+ case "exceeds_daily_limit":
2537
+ return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2538
+ default:
2539
+ return "Deduction failed";
2540
+ }
2541
+ }
2542
+ };
2543
+
2544
+ // src/facilitators/registry.ts
2545
+ var import_web33 = require("@solana/web3.js");
2546
+ var import_bs58 = __toESM(require("bs58"));
2547
+ var FacilitatorRegistry = class {
2548
+ factories = /* @__PURE__ */ new Map();
2549
+ instances = /* @__PURE__ */ new Map();
2550
+ selection;
2551
+ roundRobinIndex = 0;
2552
+ constructor(selection) {
2553
+ this.registerFactory("cdp", (config) => new CDPFacilitator(config));
2554
+ this.registerFactory("tempo", () => new TempoFacilitator());
2555
+ this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
2556
+ this.registerFactory("solana", (config) => {
2557
+ let feePayerKeypair;
2558
+ const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
2559
+ if (feePayerKey) {
2560
+ try {
2561
+ feePayerKeypair = import_web33.Keypair.fromSecretKey(import_bs58.default.decode(feePayerKey));
2562
+ } catch (e) {
2563
+ console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
2564
+ }
2565
+ }
2566
+ return new SolanaFacilitator({ feePayerKeypair });
2567
+ });
2568
+ this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
2569
+ this.registerFactory("wechat", (config) => new WechatFacilitator(config));
2570
+ this.registerFactory("balance", (config) => new BalanceFacilitator(config));
2571
+ this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
2572
+ }
2573
+ /**
2574
+ * Register a new facilitator factory
2575
+ */
2576
+ registerFactory(name, factory) {
2577
+ this.factories.set(name, factory);
2578
+ }
2579
+ /**
2580
+ * Get or create a facilitator instance
2581
+ */
2582
+ get(name, config) {
2583
+ if (this.instances.has(name)) {
2584
+ return this.instances.get(name);
2585
+ }
2586
+ const factory = this.factories.get(name);
2587
+ if (!factory) {
2588
+ throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
2589
+ }
2590
+ const mergedConfig = {
2591
+ ...this.selection.config?.[name],
2592
+ ...config
2593
+ };
2594
+ const instance = factory(mergedConfig);
2595
+ this.instances.set(name, instance);
2596
+ return instance;
2597
+ }
2598
+ /**
2599
+ * Get all configured facilitator names
2600
+ */
2601
+ getConfiguredNames() {
2602
+ const names = [this.selection.primary];
2603
+ if (this.selection.fallback) {
2604
+ names.push(...this.selection.fallback);
2605
+ }
2606
+ return names;
2607
+ }
2608
+ /**
2609
+ * Get list of facilitators based on selection strategy
2610
+ */
2611
+ async getOrderedFacilitators(network) {
2612
+ const names = this.getConfiguredNames();
2613
+ const facilitators = [];
2614
+ for (const name of names) {
2615
+ try {
2616
+ const f = this.get(name);
2617
+ if (f.supportsNetwork(network)) {
2618
+ facilitators.push(f);
2619
+ }
2620
+ } catch (err) {
2621
+ console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
2622
+ }
2623
+ }
2624
+ if (facilitators.length === 0) {
2625
+ throw new Error(`No facilitators available for network: ${network}`);
2626
+ }
2627
+ switch (this.selection.strategy) {
2628
+ case "random":
2629
+ return this.shuffle(facilitators);
2630
+ case "roundrobin":
2631
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
2632
+ return [
2633
+ ...facilitators.slice(this.roundRobinIndex),
2634
+ ...facilitators.slice(0, this.roundRobinIndex)
2635
+ ];
2636
+ case "cheapest":
2637
+ return this.sortByCheapest(facilitators);
2638
+ case "fastest":
2639
+ return this.sortByFastest(facilitators);
2640
+ case "failover":
2641
+ default:
2642
+ return facilitators;
2643
+ }
2644
+ }
2645
+ shuffle(array) {
2646
+ const result = [...array];
2647
+ for (let i = result.length - 1; i > 0; i--) {
2648
+ const j = Math.floor(Math.random() * (i + 1));
2649
+ [result[i], result[j]] = [result[j], result[i]];
2650
+ }
2651
+ return result;
2652
+ }
2653
+ async sortByCheapest(facilitators) {
2654
+ const withFees = await Promise.all(
2655
+ facilitators.map(async (f) => {
2656
+ try {
2657
+ const fee = await f.getFee?.();
2658
+ return { facilitator: f, perTx: fee?.perTx ?? Infinity };
2659
+ } catch {
2660
+ return { facilitator: f, perTx: Infinity };
2661
+ }
2662
+ })
2663
+ );
2664
+ withFees.sort((a, b) => a.perTx - b.perTx);
2665
+ return withFees.map((w) => w.facilitator);
2666
+ }
2667
+ async sortByFastest(facilitators) {
2668
+ const withLatency = await Promise.all(
2669
+ facilitators.map(async (f) => {
2670
+ try {
2671
+ const health = await f.healthCheck();
2672
+ return { facilitator: f, latency: health.latencyMs ?? Infinity };
2673
+ } catch {
2674
+ return { facilitator: f, latency: Infinity };
2675
+ }
2676
+ })
2677
+ );
2678
+ withLatency.sort((a, b) => a.latency - b.latency);
2679
+ return withLatency.map((w) => w.facilitator);
2680
+ }
2681
+ /**
2682
+ * Verify payment using configured facilitators
2683
+ */
2684
+ async verify(paymentPayload, requirements) {
2685
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2686
+ const facilitators = await this.getOrderedFacilitators(network);
2687
+ let lastError;
2688
+ for (const f of facilitators) {
2689
+ try {
2690
+ console.log(`[Registry] Trying ${f.name} for verify...`);
2691
+ const result = await f.verify(paymentPayload, requirements);
2692
+ if (result.valid) {
2693
+ console.log(`[Registry] ${f.name} verify succeeded`);
2694
+ return { ...result, facilitator: f.name };
2695
+ }
2696
+ lastError = result.error;
2697
+ console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
2698
+ if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
2699
+ break;
2700
+ }
2701
+ } catch (err) {
2702
+ lastError = err.message;
2703
+ console.error(`[Registry] ${f.name} error:`, err.message);
2704
+ }
2705
+ }
2706
+ return {
2707
+ valid: false,
2708
+ error: lastError || "All facilitators failed",
2709
+ facilitator: "none"
2710
+ };
2711
+ }
2712
+ /**
2713
+ * Settle payment using configured facilitators
2714
+ */
2715
+ async settle(paymentPayload, requirements) {
2716
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2717
+ const facilitators = await this.getOrderedFacilitators(network);
2718
+ let lastError;
2719
+ for (const f of facilitators) {
2720
+ try {
2721
+ console.log(`[Registry] Trying ${f.name} for settle...`);
2722
+ const result = await f.settle(paymentPayload, requirements);
2723
+ if (result.success) {
2724
+ console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
2725
+ return { ...result, facilitator: f.name };
2726
+ }
2727
+ lastError = result.error;
2728
+ console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
2729
+ } catch (err) {
2730
+ lastError = err.message;
2731
+ console.error(`[Registry] ${f.name} error:`, err.message);
2732
+ }
2733
+ }
2734
+ return {
2735
+ success: false,
2736
+ error: lastError || "All facilitators failed",
2737
+ facilitator: "none"
2738
+ };
2739
+ }
2740
+ /**
2741
+ * Check health of all configured facilitators
2742
+ */
2743
+ async healthCheckAll() {
2744
+ const results = {};
1859
2745
  for (const name of this.getConfiguredNames()) {
1860
2746
  try {
1861
2747
  const f = this.get(name);
@@ -1908,7 +2794,160 @@ function createRegistry(selection) {
1908
2794
  return new FacilitatorRegistry(selection);
1909
2795
  }
1910
2796
 
1911
- // src/server/index.ts
2797
+ // src/server/balance-endpoints.ts
2798
+ var import_node_crypto6 = __toESM(require("crypto"));
2799
+
2800
+ // src/verify/index.ts
2801
+ var import_ethers3 = require("ethers");
2802
+ var TRANSFER_EVENT_TOPIC3 = import_ethers3.ethers.id("Transfer(address,address,uint256)");
2803
+ async function verifyPayment(params) {
2804
+ const { txHash, expectedAmount, expectedTo, expectedToken } = params;
2805
+ let chain;
2806
+ try {
2807
+ if (typeof params.chain === "number") {
2808
+ chain = getChainById(params.chain);
2809
+ } else {
2810
+ chain = getChain(params.chain || "base");
2811
+ }
2812
+ if (!chain) {
2813
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2814
+ }
2815
+ } catch (e) {
2816
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2817
+ }
2818
+ try {
2819
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
2820
+ const receipt = await provider.getTransactionReceipt(txHash);
2821
+ if (!receipt) {
2822
+ return { verified: false, error: "Transaction not found or not confirmed" };
2823
+ }
2824
+ if (receipt.status !== 1) {
2825
+ return { verified: false, error: "Transaction failed" };
2826
+ }
2827
+ const tokenAddresses = {};
2828
+ if (!expectedToken || expectedToken === "USDC") {
2829
+ tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
2830
+ }
2831
+ if (!expectedToken || expectedToken === "USDT") {
2832
+ tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
2833
+ }
2834
+ if (Object.keys(tokenAddresses).length === 0) {
2835
+ return { verified: false, error: `No token addresses configured for ${chain.name}` };
2836
+ }
2837
+ for (const log of receipt.logs) {
2838
+ const logAddress = log.address.toLowerCase();
2839
+ const detectedToken = tokenAddresses[logAddress];
2840
+ if (!detectedToken) {
2841
+ continue;
2842
+ }
2843
+ if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
2844
+ continue;
2845
+ }
2846
+ const from = "0x" + log.topics[1].slice(-40);
2847
+ const to = "0x" + log.topics[2].slice(-40);
2848
+ const amountRaw = BigInt(log.data);
2849
+ const tokenConfig = chain.tokens[detectedToken];
2850
+ const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
2851
+ if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
2852
+ continue;
2853
+ }
2854
+ if (amount < expectedAmount) {
2855
+ return {
2856
+ verified: false,
2857
+ error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
2858
+ amount,
2859
+ token: detectedToken,
2860
+ from,
2861
+ to,
2862
+ txHash,
2863
+ blockNumber: receipt.blockNumber
2864
+ };
2865
+ }
2866
+ return {
2867
+ verified: true,
2868
+ amount,
2869
+ token: detectedToken,
2870
+ from,
2871
+ to,
2872
+ txHash,
2873
+ blockNumber: receipt.blockNumber
2874
+ };
2875
+ }
2876
+ const tokenList = expectedToken ? expectedToken : "USDC/USDT";
2877
+ return { verified: false, error: `No ${tokenList} transfer found` };
2878
+ } catch (e) {
2879
+ return { verified: false, error: e.message || String(e) };
2880
+ }
2881
+ }
2882
+ async function getTransactionStatus(txHash, chain = "base") {
2883
+ let chainConfig;
2884
+ try {
2885
+ chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
2886
+ if (!chainConfig) return { status: "not_found" };
2887
+ } catch {
2888
+ return { status: "not_found" };
2889
+ }
2890
+ try {
2891
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chainConfig.rpc);
2892
+ const receipt = await provider.getTransactionReceipt(txHash);
2893
+ if (!receipt) {
2894
+ const tx = await provider.getTransaction(txHash);
2895
+ if (tx) {
2896
+ return { status: "pending" };
2897
+ }
2898
+ return { status: "not_found" };
2899
+ }
2900
+ const currentBlock = await provider.getBlockNumber();
2901
+ const confirmations = currentBlock - receipt.blockNumber;
2902
+ if (receipt.status === 1) {
2903
+ return {
2904
+ status: "confirmed",
2905
+ blockNumber: receipt.blockNumber,
2906
+ confirmations
2907
+ };
2908
+ } else {
2909
+ return {
2910
+ status: "failed",
2911
+ blockNumber: receipt.blockNumber
2912
+ };
2913
+ }
2914
+ } catch {
2915
+ return { status: "not_found" };
2916
+ }
2917
+ }
2918
+ async function waitForTransaction(txHash, chain = "base", confirmations = 1, timeoutMs = 6e4) {
2919
+ let chainConfig;
2920
+ try {
2921
+ chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
2922
+ if (!chainConfig) {
2923
+ return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
2924
+ }
2925
+ } catch (e) {
2926
+ return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
2927
+ }
2928
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chainConfig.rpc);
2929
+ try {
2930
+ const receipt = await provider.waitForTransaction(txHash, confirmations, timeoutMs);
2931
+ if (!receipt) {
2932
+ return { verified: false, confirmed: false, error: "Timeout waiting" };
2933
+ }
2934
+ if (receipt.status !== 1) {
2935
+ return { verified: false, confirmed: true, error: "Transaction failed" };
2936
+ }
2937
+ return {
2938
+ verified: true,
2939
+ confirmed: true,
2940
+ txHash,
2941
+ blockNumber: receipt.blockNumber
2942
+ };
2943
+ } catch (e) {
2944
+ return { verified: false, confirmed: false, error: e.message || String(e) };
2945
+ }
2946
+ }
2947
+
2948
+ // src/server/internal.ts
2949
+ var import_fs2 = require("fs");
2950
+ var path2 = __toESM(require("path"));
1912
2951
  var X402_VERSION2 = 2;
1913
2952
  var PAYMENT_REQUIRED_HEADER = "x-payment-required";
1914
2953
  var PAYMENT_HEADER = "x-payment";
@@ -1921,6 +2960,15 @@ var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
1921
2960
  function headerSafe(value) {
1922
2961
  return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
1923
2962
  }
2963
+ function canonicalJson(value) {
2964
+ if (value === null || typeof value !== "object") {
2965
+ return JSON.stringify(value) ?? "null";
2966
+ }
2967
+ if (Array.isArray(value)) {
2968
+ return "[" + value.map(canonicalJson).join(",") + "]";
2969
+ }
2970
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
2971
+ }
1924
2972
  var TOKEN_ADDRESSES = {
1925
2973
  "eip155:8453": {
1926
2974
  USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
@@ -2048,7 +3096,340 @@ function loadEnvFile2() {
2048
3096
  }
2049
3097
  }
2050
3098
  }
2051
- }
3099
+ }
3100
+
3101
+ // src/server/balance-endpoints.ts
3102
+ var BalanceEndpoints = class {
3103
+ constructor(deps) {
3104
+ this.deps = deps;
3105
+ }
3106
+ /** GET /balance?buyer_id= -- balance, limits, and today's spend. */
3107
+ handleQuery(url, res) {
3108
+ const { balance, sendJson } = this.deps;
3109
+ const buyerId = url.searchParams.get("buyer_id");
3110
+ if (!buyerId) {
3111
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3112
+ }
3113
+ const ledger = balance.getLedger();
3114
+ const buyer = ledger.getBuyer(buyerId);
3115
+ if (!buyer) {
3116
+ return sendJson(res, 200, {
3117
+ buyer_id: buyerId,
3118
+ balance: "0.00",
3119
+ currency: balance.currency,
3120
+ exists: false
3121
+ });
3122
+ }
3123
+ sendJson(res, 200, {
3124
+ buyer_id: buyerId,
3125
+ balance: fromSat(buyer.balance_sat),
3126
+ currency: balance.currency,
3127
+ single_limit: fromSat(buyer.single_limit_sat),
3128
+ daily_limit: fromSat(buyer.daily_limit_sat),
3129
+ today_spent: fromSat(ledger.spentTodaySat(buyerId)),
3130
+ status: buyer.status,
3131
+ wechat_openid: buyer.wechat_openid ?? null,
3132
+ signer_address: buyer.signer_address ?? null,
3133
+ exists: true
3134
+ });
3135
+ }
3136
+ /**
3137
+ * Extract the gateway-confirmed paid amount (fen) from a WeChat verify
3138
+ * result. `payer_total` is what the buyer actually paid; falls back to
3139
+ * `total`. Returns null if no usable positive integer is present, so the
3140
+ * client-declared amount can never be trusted for crediting.
3141
+ */
3142
+ wechatPaidFen(check) {
3143
+ const amount = check.details?.amount;
3144
+ const paid = amount?.payer_total ?? amount?.total;
3145
+ return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
3146
+ }
3147
+ /**
3148
+ * POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
3149
+ * configured top-up pack. The buyer_id rides in the WeChat `attach` so the
3150
+ * later confirm/callback credits the correct balance. Reuses the pending
3151
+ * order cache (keyed by buyer_id + pack) so concurrent requests share one
3152
+ * order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
3153
+ * pack, max_timeout_seconds }`.
3154
+ */
3155
+ async handleTopupOrder(body, res) {
3156
+ const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
3157
+ const { buyer_id, pack } = body || {};
3158
+ if (typeof buyer_id !== "string" || !buyer_id) {
3159
+ return sendJson(res, 400, { error: "buyer_id is required" });
3160
+ }
3161
+ const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
3162
+ if (!wechat) {
3163
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3164
+ }
3165
+ const balCfg = manifest.provider.balance;
3166
+ const packs = balCfg?.topup_packs ?? [];
3167
+ const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
3168
+ if (!chosen) {
3169
+ return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
3170
+ }
3171
+ let chosenSat;
3172
+ try {
3173
+ chosenSat = toSat(chosen);
3174
+ if (chosenSat <= 0) throw new Error("pack must be positive");
3175
+ } catch (err) {
3176
+ return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
3177
+ }
3178
+ const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
3179
+ if (!packs.includes(chosen) && !withinMax) {
3180
+ return sendJson(res, 400, {
3181
+ error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
3182
+ });
3183
+ }
3184
+ const nonce = import_node_crypto6.default.randomBytes(8).toString("hex");
3185
+ const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
3186
+ const cacheKey = import_node_crypto6.default.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
3187
+ const result = await getOrCreatePendingWechatOrder(
3188
+ cacheKey,
3189
+ `topup:${buyer_id}`,
3190
+ () => wechat.createPaymentRequirements({
3191
+ priceCny: chosen,
3192
+ description: `Balance top-up ${chosen}`,
3193
+ attach
3194
+ })
3195
+ );
3196
+ if (!result) {
3197
+ return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
3198
+ }
3199
+ return sendJson(res, 200, {
3200
+ code_url: result.codeUrl,
3201
+ out_trade_no: result.outTradeNo,
3202
+ pack: chosen,
3203
+ max_timeout_seconds: result.accepts.maxTimeoutSeconds
3204
+ });
3205
+ }
3206
+ /**
3207
+ * POST /balance/topup/confirm -- the polling-fallback credit path. The client
3208
+ * polls this after a scan; the server verifies the order with the WeChat
3209
+ * gateway and, on SUCCESS, credits the buyer bound in `attach` with the
3210
+ * gateway-confirmed `payer_total` (never a client-declared amount).
3211
+ * Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
3212
+ */
3213
+ async handleTopupConfirm(body, res) {
3214
+ const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
3215
+ const outTradeNo = body?.out_trade_no;
3216
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3217
+ return sendJson(res, 400, { error: "out_trade_no is required" });
3218
+ }
3219
+ if (!wechat) {
3220
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3221
+ }
3222
+ const wxPayload = {
3223
+ x402Version: X402_VERSION2,
3224
+ scheme: WECHAT_SCHEME,
3225
+ network: WECHAT_NETWORK,
3226
+ payload: { out_trade_no: outTradeNo }
3227
+ };
3228
+ const wxReqs = {
3229
+ scheme: WECHAT_SCHEME,
3230
+ network: WECHAT_NETWORK,
3231
+ asset: "CNY",
3232
+ amount: "0",
3233
+ payTo: manifest.provider.wechat?.mchid || "",
3234
+ maxTimeoutSeconds: 30,
3235
+ extra: { out_trade_no: outTradeNo }
3236
+ };
3237
+ const check = await wechat.verify(wxPayload, wxReqs);
3238
+ if (!check.valid) {
3239
+ return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
3240
+ }
3241
+ const paidFen = this.wechatPaidFen(check);
3242
+ if (paidFen === null) {
3243
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3244
+ }
3245
+ const attach = parseWechatAttach(check.details?.attach);
3246
+ const buyerId = attach?.buyer_id;
3247
+ if (!buyerId) {
3248
+ return sendJson(res, 422, {
3249
+ error: "top-up order has no buyer binding (attach.buyer_id missing)"
3250
+ });
3251
+ }
3252
+ const credited = balance.credit({
3253
+ buyerId,
3254
+ amountSat: paidFen,
3255
+ externalRef: `wechat:${outTradeNo}`,
3256
+ description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
3257
+ });
3258
+ invalidateWechatChallenge(outTradeNo);
3259
+ const openid = check.details?.openid;
3260
+ let openidBinding;
3261
+ if (typeof openid === "string" && openid) {
3262
+ openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
3263
+ if (openidBinding.conflict) {
3264
+ console.warn(
3265
+ `[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
3266
+ );
3267
+ }
3268
+ }
3269
+ const signer = attach?.signer;
3270
+ if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
3271
+ const sb = balance.getLedger().bindSigner(buyerId, signer);
3272
+ if (sb.conflict) {
3273
+ console.warn(
3274
+ `[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
3275
+ );
3276
+ }
3277
+ }
3278
+ console.log(
3279
+ `[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
3280
+ );
3281
+ return sendJson(res, 200, {
3282
+ credited: true,
3283
+ buyer_id: buyerId,
3284
+ tx_id: credited.txId,
3285
+ balance: credited.balance,
3286
+ replayed: credited.replayed,
3287
+ openid_bound: openidBinding?.bound ?? false,
3288
+ openid_conflict: openidBinding?.conflict ?? false
3289
+ });
3290
+ }
3291
+ /**
3292
+ * POST /balance/topup -- verify an externally settled payment and credit the
3293
+ * buyer's ledger balance (operator/recovery path). Per rail: `crypto`
3294
+ * verifies the tx on-chain; `wechat` queries the order and credits the
3295
+ * gateway-confirmed payer_total (never the client-declared amount);
3296
+ * `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
3297
+ */
3298
+ async handleTopup(body, res) {
3299
+ const { manifest, balance, wechat, sendJson } = this.deps;
3300
+ const { buyer_id, rail, amount } = body || {};
3301
+ if (typeof buyer_id !== "string" || !buyer_id) {
3302
+ return sendJson(res, 400, { error: "buyer_id is required" });
3303
+ }
3304
+ let amountSat;
3305
+ try {
3306
+ amountSat = toSat(String(amount));
3307
+ if (amountSat <= 0) throw new Error("amount must be positive");
3308
+ } catch (err) {
3309
+ return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
3310
+ }
3311
+ let externalRef;
3312
+ let description;
3313
+ if (rail === "crypto") {
3314
+ const txHash = body.tx_hash;
3315
+ if (typeof txHash !== "string" || !txHash) {
3316
+ return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
3317
+ }
3318
+ const check = await verifyPayment({
3319
+ txHash,
3320
+ expectedAmount: amountSat / 100,
3321
+ expectedTo: manifest.provider.wallet,
3322
+ chain: body.chain || "base"
3323
+ });
3324
+ if (!check.verified) {
3325
+ return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
3326
+ }
3327
+ externalRef = txHash.toLowerCase();
3328
+ description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
3329
+ } else if (rail === "wechat") {
3330
+ const outTradeNo = body.out_trade_no;
3331
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3332
+ return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
3333
+ }
3334
+ if (!wechat) {
3335
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3336
+ }
3337
+ const wxPayload = {
3338
+ x402Version: X402_VERSION2,
3339
+ scheme: WECHAT_SCHEME,
3340
+ network: WECHAT_NETWORK,
3341
+ payload: { out_trade_no: outTradeNo }
3342
+ };
3343
+ const wxReqs = {
3344
+ scheme: WECHAT_SCHEME,
3345
+ network: WECHAT_NETWORK,
3346
+ asset: "CNY",
3347
+ amount: "0",
3348
+ payTo: manifest.provider.wechat?.mchid || "",
3349
+ maxTimeoutSeconds: 30,
3350
+ extra: { out_trade_no: outTradeNo }
3351
+ };
3352
+ const check = await wechat.verify(wxPayload, wxReqs);
3353
+ if (!check.valid) {
3354
+ return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
3355
+ }
3356
+ const paidFen = this.wechatPaidFen(check);
3357
+ if (paidFen === null) {
3358
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3359
+ }
3360
+ if (paidFen !== amountSat) {
3361
+ console.warn(
3362
+ `[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
3363
+ );
3364
+ }
3365
+ amountSat = paidFen;
3366
+ externalRef = `wechat:${outTradeNo}`;
3367
+ description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
3368
+ console.log(`[MoltsPay] WeChat topup verified: ${description}`);
3369
+ } else if (rail === "alipay") {
3370
+ const tradeNo = body.trade_no;
3371
+ if (typeof tradeNo !== "string" || !tradeNo) {
3372
+ return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
3373
+ }
3374
+ externalRef = `alipay:${tradeNo}`;
3375
+ description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
3376
+ console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
3377
+ } else {
3378
+ return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
3379
+ }
3380
+ const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
3381
+ sendJson(res, 200, {
3382
+ success: true,
3383
+ tx_id: result.txId,
3384
+ balance: fromSat(result.balanceSat),
3385
+ replayed: result.replayed ?? false
3386
+ });
3387
+ }
3388
+ /** POST /balance/refund -- reverse a deduct (operator/agent use). */
3389
+ handleRefund(body, res) {
3390
+ const { balance, sendJson } = this.deps;
3391
+ const { tx_id, reason } = body || {};
3392
+ if (typeof tx_id !== "string" || !tx_id) {
3393
+ return sendJson(res, 400, { error: "tx_id is required" });
3394
+ }
3395
+ const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
3396
+ if (!result.success) {
3397
+ return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
3398
+ }
3399
+ sendJson(res, 200, {
3400
+ success: true,
3401
+ tx_id: result.txId,
3402
+ balance: fromSat(result.balanceSat),
3403
+ replayed: result.replayed ?? false
3404
+ });
3405
+ }
3406
+ /** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
3407
+ handleTransactions(url, res) {
3408
+ const { balance, sendJson } = this.deps;
3409
+ const buyerId = url.searchParams.get("buyer_id");
3410
+ if (!buyerId) {
3411
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3412
+ }
3413
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
3414
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
3415
+ const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
3416
+ sendJson(res, 200, {
3417
+ buyer_id: buyerId,
3418
+ transactions: rows.map((r) => ({
3419
+ tx_id: r.id,
3420
+ type: r.type,
3421
+ amount: fromSat(r.amount_sat),
3422
+ service: r.service,
3423
+ description: r.description,
3424
+ external_ref: r.external_ref,
3425
+ status: r.status,
3426
+ created_at: r.created_at
3427
+ }))
3428
+ });
3429
+ }
3430
+ };
3431
+
3432
+ // src/server/index.ts
2052
3433
  var MoltsPayServer = class {
2053
3434
  manifest;
2054
3435
  skills = /* @__PURE__ */ new Map();
@@ -2056,11 +3437,32 @@ var MoltsPayServer = class {
2056
3437
  registry;
2057
3438
  networkId;
2058
3439
  useMainnet;
2059
- /** Alipay AI facilitator instance, set when `provider.alipay` is configured (2.0.0). */
3440
+ /** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
2060
3441
  alipayFacilitator = null;
3442
+ /** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
3443
+ wechatFacilitator = null;
3444
+ /** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
3445
+ balanceFacilitator = null;
3446
+ balanceEndpoints = null;
3447
+ /**
3448
+ * Pending WeChat Native order cache — the double-charge fix.
3449
+ *
3450
+ * Every `buildWechatChallenge` used to place a NEW Native order, so a client
3451
+ * that received two 402s (e.g. initial challenge + one poll re-request that
3452
+ * raced ahead of payment) could surface two live QRs and a buyer could pay
3453
+ * both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
3454
+ * under a content-derived key — sha256(service id | canonical params |
3455
+ * price_cny) — and reused until it is paid or its `time_expire` window
3456
+ * nears expiry, so any number of 402 emits for the same purchase intent
3457
+ * share ONE order, even across separate client processes.
3458
+ * Storing the in-flight promise also dedupes concurrent 402 builds.
3459
+ * In-memory by design: on restart the worst case is one extra unpaid order,
3460
+ * which expires server-side per `time_expire` — never a double charge.
3461
+ */
3462
+ wechatPendingChallenges = /* @__PURE__ */ new Map();
2061
3463
  constructor(servicesPath, options = {}) {
2062
3464
  loadEnvFile2();
2063
- const content = (0, import_fs2.readFileSync)(servicesPath, "utf-8");
3465
+ const content = (0, import_fs3.readFileSync)(servicesPath, "utf-8");
2064
3466
  this.manifest = JSON.parse(content);
2065
3467
  this.options = {
2066
3468
  port: options.port || 3e3,
@@ -2082,8 +3484,8 @@ var MoltsPayServer = class {
2082
3484
  const providerAlipay = this.manifest.provider.alipay;
2083
3485
  if (providerAlipay) {
2084
3486
  try {
2085
- const baseDir = path2.dirname(servicesPath);
2086
- const resolvePem = (p, kind) => toPem((0, import_fs2.readFileSync)(path2.isAbsolute(p) ? p : path2.resolve(baseDir, p), "utf-8"), kind);
3487
+ const baseDir = path3.dirname(servicesPath);
3488
+ const resolvePem = (p, kind) => toPem((0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
2087
3489
  const alipayFacilitatorConfig = {
2088
3490
  seller_id: providerAlipay.seller_id,
2089
3491
  app_id: providerAlipay.app_id,
@@ -2106,10 +3508,73 @@ var MoltsPayServer = class {
2106
3508
  throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
2107
3509
  }
2108
3510
  }
3511
+ const providerWechat = this.manifest.provider.wechat;
3512
+ if (providerWechat) {
3513
+ try {
3514
+ const baseDir = path3.dirname(servicesPath);
3515
+ const readPem = (p) => (0, import_fs3.readFileSync)(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
3516
+ const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new import_node_crypto7.default.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
3517
+ const wechatFacilitatorConfig = {
3518
+ mchid: providerWechat.mchid,
3519
+ appid: providerWechat.appid,
3520
+ serial_no: providerWechat.serial_no,
3521
+ private_key_pem: readPem(providerWechat.private_key_path),
3522
+ platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
3523
+ apiv3_key: providerWechat.apiv3_key,
3524
+ notify_url: providerWechat.notify_url,
3525
+ api_base: providerWechat.api_base
3526
+ };
3527
+ facilitatorConfig.config = {
3528
+ ...facilitatorConfig.config,
3529
+ wechat: wechatFacilitatorConfig
3530
+ };
3531
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3532
+ if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
3533
+ facilitatorConfig.fallback.push("wechat");
3534
+ }
3535
+ } catch (err) {
3536
+ throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
3537
+ }
3538
+ }
3539
+ const providerBalance = this.manifest.provider.balance;
3540
+ if (providerBalance) {
3541
+ const baseDir = path3.dirname(servicesPath);
3542
+ const balanceFacilitatorConfig = {
3543
+ db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
3544
+ currency: providerBalance.currency,
3545
+ single_limit: providerBalance.single_limit,
3546
+ daily_limit: providerBalance.daily_limit,
3547
+ auth_mode: providerBalance.auth_mode
3548
+ };
3549
+ facilitatorConfig.config = {
3550
+ ...facilitatorConfig.config,
3551
+ balance: balanceFacilitatorConfig
3552
+ };
3553
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3554
+ if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
3555
+ facilitatorConfig.fallback.push("balance");
3556
+ }
3557
+ }
2109
3558
  this.registry = new FacilitatorRegistry(facilitatorConfig);
2110
3559
  if (providerAlipay) {
2111
3560
  this.alipayFacilitator = this.registry.get("alipay");
2112
- console.log(`[MoltsPay] Alipay AI \u6536 rail enabled (seller ${providerAlipay.seller_id})`);
3561
+ console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
3562
+ }
3563
+ if (providerWechat) {
3564
+ this.wechatFacilitator = this.registry.get("wechat");
3565
+ console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
3566
+ }
3567
+ if (providerBalance) {
3568
+ this.balanceFacilitator = this.registry.get("balance");
3569
+ this.balanceEndpoints = new BalanceEndpoints({
3570
+ manifest: this.manifest,
3571
+ balance: this.balanceFacilitator,
3572
+ wechat: this.wechatFacilitator,
3573
+ sendJson: (res, status, data) => this.sendJson(res, status, data),
3574
+ getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
3575
+ invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
3576
+ });
3577
+ console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
2113
3578
  }
2114
3579
  const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
2115
3580
  console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
@@ -2151,7 +3616,10 @@ var MoltsPayServer = class {
2151
3616
  return provider.wallet;
2152
3617
  };
2153
3618
  if (provider.chains && provider.chains.length > 0) {
2154
- return provider.chains.map((c) => {
3619
+ return provider.chains.filter((c) => {
3620
+ const chainName = typeof c === "string" ? c : c.chain;
3621
+ return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
3622
+ }).map((c) => {
2155
3623
  const chainName = typeof c === "string" ? c : c.chain;
2156
3624
  const explicitWallet = typeof c === "object" ? c.wallet : null;
2157
3625
  return {
@@ -2270,9 +3738,36 @@ var MoltsPayServer = class {
2270
3738
  if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
2271
3739
  return this.handleAgentServicesDiscovery(res);
2272
3740
  }
3741
+ if (url.pathname === "/" && req.method === "GET") {
3742
+ return this.handleAgentServicesDiscovery(res);
3743
+ }
2273
3744
  if (url.pathname === "/health" && req.method === "GET") {
2274
3745
  return await this.handleHealthCheck(res);
2275
3746
  }
3747
+ if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
3748
+ if (url.pathname === "/balance" && req.method === "GET") {
3749
+ return this.balanceEndpoints.handleQuery(url, res);
3750
+ }
3751
+ if (url.pathname === "/balance/topup/order" && req.method === "POST") {
3752
+ const body = await this.readBody(req);
3753
+ return await this.balanceEndpoints.handleTopupOrder(body, res);
3754
+ }
3755
+ if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
3756
+ const body = await this.readBody(req);
3757
+ return await this.balanceEndpoints.handleTopupConfirm(body, res);
3758
+ }
3759
+ if (url.pathname === "/balance/topup" && req.method === "POST") {
3760
+ const body = await this.readBody(req);
3761
+ return await this.balanceEndpoints.handleTopup(body, res);
3762
+ }
3763
+ if (url.pathname === "/balance/refund" && req.method === "POST") {
3764
+ const body = await this.readBody(req);
3765
+ return this.balanceEndpoints.handleRefund(body, res);
3766
+ }
3767
+ if (url.pathname === "/balance/transactions" && req.method === "GET") {
3768
+ return this.balanceEndpoints.handleTransactions(url, res);
3769
+ }
3770
+ }
2276
3771
  if (url.pathname === "/execute" && req.method === "POST") {
2277
3772
  const body = await this.readBody(req);
2278
3773
  const paymentHeader = req.headers[PAYMENT_HEADER];
@@ -2298,27 +3793,78 @@ var MoltsPayServer = class {
2298
3793
  const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
2299
3794
  return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
2300
3795
  }
2301
- this.sendJson(res, 404, { error: "Not found" });
3796
+ this.sendJson(res, 404, {
3797
+ error: "Not found",
3798
+ discovery: `${this.publicBase}/.well-known/agent-services.json`,
3799
+ endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
3800
+ });
2302
3801
  } catch (err) {
2303
3802
  console.error("[MoltsPay] Error:", err);
2304
3803
  this.sendJson(res, 500, { error: err.message || "Internal error" });
2305
3804
  }
2306
3805
  }
2307
3806
  /**
2308
- * GET /.well-known/agent-services.json - Standard discovery endpoint
3807
+ * Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
3808
+ * (trailing slash stripped). Empty when unset, so emitted paths stay
3809
+ * root-relative — behavior is unchanged for local / no-prefix deploys.
3810
+ *
3811
+ * Needed because nginx rewrites the deployment prefix (e.g.
3812
+ * `/t/moltspay-server`) away before proxying, so the process cannot infer
3813
+ * its own public prefix; a root-relative `/services` would otherwise
3814
+ * resolve against the domain root and hit the wrong backend.
2309
3815
  */
2310
- handleAgentServicesDiscovery(res) {
2311
- const services = this.manifest.services.map((s) => ({
3816
+ get publicBase() {
3817
+ return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
3818
+ }
3819
+ /**
3820
+ * Per-service pricing across every configured rail, for the discovery
3821
+ * payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
3822
+ * for back-compat; this surfaces the fiat + balance rails (CNY) that were
3823
+ * previously invisible in discovery even though the manifest defines them
3824
+ * and the 402 challenge already quotes them. `acceptedCurrencies` becomes
3825
+ * the union across rails so a client can see CNY is accepted without
3826
+ * first triggering a 402.
3827
+ */
3828
+ describeServicePricing(s) {
3829
+ const pricing = [];
3830
+ if (this.getProviderChains().length > 0) {
3831
+ for (const currency of getAcceptedCurrencies(s)) {
3832
+ pricing.push({ rail: "crypto", currency, amount: String(s.price) });
3833
+ }
3834
+ }
3835
+ if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
3836
+ if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
3837
+ if (s.balance) {
3838
+ pricing.push({
3839
+ rail: "balance",
3840
+ currency: this.manifest.provider.balance?.currency ?? "CNY",
3841
+ amount: s.balance.price ?? s.price.toFixed(2)
3842
+ });
3843
+ }
3844
+ return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
3845
+ }
3846
+ /** Shared service-list entry for the discovery and /services endpoints. */
3847
+ buildDiscoveryService(s) {
3848
+ const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
3849
+ const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
3850
+ return {
2312
3851
  id: s.id,
2313
3852
  name: s.name,
2314
3853
  description: s.description,
2315
- price: s.price,
2316
- currency: s.currency,
2317
- acceptedCurrencies: getAcceptedCurrencies(s),
3854
+ price: headline ? Number(headline.amount) : s.price,
3855
+ currency: headline ? headline.currency : s.currency,
3856
+ acceptedCurrencies,
3857
+ pricing,
2318
3858
  input: s.input,
2319
3859
  output: s.output,
2320
3860
  available: this.skills.has(s.id)
2321
- }));
3861
+ };
3862
+ }
3863
+ /**
3864
+ * GET /.well-known/agent-services.json - Standard discovery endpoint
3865
+ */
3866
+ handleAgentServicesDiscovery(res) {
3867
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2322
3868
  this.sendJson(res, 200, {
2323
3869
  version: "1.0",
2324
3870
  provider: {
@@ -2331,9 +3877,9 @@ var MoltsPayServer = class {
2331
3877
  },
2332
3878
  services,
2333
3879
  endpoints: {
2334
- services: "/services",
2335
- execute: "/execute",
2336
- health: "/health"
3880
+ services: `${this.publicBase}/services`,
3881
+ execute: `${this.publicBase}/execute`,
3882
+ health: `${this.publicBase}/health`
2337
3883
  },
2338
3884
  payment: {
2339
3885
  protocol: "x402",
@@ -2348,17 +3894,7 @@ var MoltsPayServer = class {
2348
3894
  * GET /services - List available services
2349
3895
  */
2350
3896
  handleGetServices(res) {
2351
- const services = this.manifest.services.map((s) => ({
2352
- id: s.id,
2353
- name: s.name,
2354
- description: s.description,
2355
- price: s.price,
2356
- currency: s.currency,
2357
- acceptedCurrencies: getAcceptedCurrencies(s),
2358
- input: s.input,
2359
- output: s.output,
2360
- available: this.skills.has(s.id)
2361
- }));
3897
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2362
3898
  const selection = this.registry.getSelection();
2363
3899
  this.sendJson(res, 200, {
2364
3900
  provider: this.manifest.provider,
@@ -2417,7 +3953,7 @@ var MoltsPayServer = class {
2417
3953
  return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
2418
3954
  }
2419
3955
  if (!paymentHeader) {
2420
- return this.sendPaymentRequired(skill.config, res);
3956
+ return this.sendPaymentRequired(skill.config, res, params || {});
2421
3957
  }
2422
3958
  let payment;
2423
3959
  try {
@@ -2431,6 +3967,12 @@ var MoltsPayServer = class {
2431
3967
  if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
2432
3968
  return this.handleAlipayExecute(skill, params || {}, payment, res);
2433
3969
  }
3970
+ if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
3971
+ return this.handleWechatExecute(skill, params || {}, payment, res);
3972
+ }
3973
+ if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
3974
+ return this.handleBalanceExecute(skill, params || {}, payment, res);
3975
+ }
2434
3976
  const validation = this.validatePayment(payment, skill.config);
2435
3977
  if (!validation.valid) {
2436
3978
  return this.sendJson(res, 402, { error: validation.error });
@@ -2524,7 +4066,7 @@ var MoltsPayServer = class {
2524
4066
  }, responseHeaders);
2525
4067
  }
2526
4068
  /**
2527
- * Execute a service paid via the Alipay AI fiat rail (2.0.0).
4069
+ * Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
2528
4070
  *
2529
4071
  * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
2530
4072
  * validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
@@ -2536,23 +4078,306 @@ var MoltsPayServer = class {
2536
4078
  if (!this.alipayFacilitator) {
2537
4079
  return this.sendJson(res, 402, { error: "Alipay rail not configured on this server" });
2538
4080
  }
2539
- const requirements = {
2540
- scheme: ALIPAY_SCHEME,
2541
- network: ALIPAY_NETWORK,
2542
- asset: "CNY",
2543
- amount: skill.config.alipay?.price_cny || "0",
2544
- payTo: this.manifest.provider.alipay?.seller_id || "",
2545
- maxTimeoutSeconds: 1800
2546
- };
2547
- console.log(`[MoltsPay] Verifying Alipay payment...`);
2548
- const verifyResult = await this.registry.verify(payment, requirements);
2549
- if (!verifyResult.valid) {
4081
+ const requirements = {
4082
+ scheme: ALIPAY_SCHEME,
4083
+ network: ALIPAY_NETWORK,
4084
+ asset: "CNY",
4085
+ amount: skill.config.alipay?.price_cny || "0",
4086
+ payTo: this.manifest.provider.alipay?.seller_id || "",
4087
+ maxTimeoutSeconds: 1800
4088
+ };
4089
+ console.log(`[MoltsPay] Verifying Alipay payment...`);
4090
+ const verifyResult = await this.registry.verify(payment, requirements);
4091
+ if (!verifyResult.valid) {
4092
+ return this.sendJson(res, 402, {
4093
+ error: `Payment verification failed: ${verifyResult.error}`,
4094
+ facilitator: verifyResult.facilitator
4095
+ });
4096
+ }
4097
+ console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
4098
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4099
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4100
+ let result;
4101
+ try {
4102
+ result = await Promise.race([
4103
+ skill.handler(params),
4104
+ new Promise(
4105
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4106
+ )
4107
+ ]);
4108
+ } catch (err) {
4109
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4110
+ return this.sendJson(res, 500, {
4111
+ error: "Service execution failed",
4112
+ message: err.message
4113
+ });
4114
+ }
4115
+ let settlement;
4116
+ try {
4117
+ settlement = await this.registry.settle(payment, requirements);
4118
+ if (settlement.success) {
4119
+ console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
4120
+ } else {
4121
+ console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
4122
+ }
4123
+ } catch (err) {
4124
+ console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
4125
+ settlement = { success: false, error: err.message, facilitator: "alipay" };
4126
+ }
4127
+ const responseHeaders = {};
4128
+ if (settlement.success) {
4129
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4130
+ success: true,
4131
+ transaction: settlement.transaction,
4132
+ network: ALIPAY_NETWORK,
4133
+ facilitator: settlement.facilitator
4134
+ })).toString("base64");
4135
+ }
4136
+ this.sendJson(res, 200, {
4137
+ success: true,
4138
+ result,
4139
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4140
+ }, responseHeaders);
4141
+ }
4142
+ /**
4143
+ * Build the Alipay 402 challenge for a service, or null when the alipay rail
4144
+ * isn't configured for this server or this service. Returns the x402
4145
+ * `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
4146
+ * 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
4147
+ */
4148
+ async buildAlipayChallenge(config) {
4149
+ if (!this.alipayFacilitator || !config.alipay) return null;
4150
+ try {
4151
+ const req = await this.alipayFacilitator.createPaymentRequirements({
4152
+ serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
4153
+ priceCny: config.alipay.price_cny,
4154
+ goodsName: config.alipay.goods_name,
4155
+ resourceId: `/execute?service=${config.id}`
4156
+ });
4157
+ return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
4158
+ } catch (err) {
4159
+ console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
4160
+ return null;
4161
+ }
4162
+ }
4163
+ /**
4164
+ * Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
4165
+ *
4166
+ * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
4167
+ * validation. The buyer (a human) scanned the Native QR and paid; the
4168
+ * client re-requests carrying `out_trade_no` in the X-Payment payload.
4169
+ * Verify queries the order (`trade_state === SUCCESS`). Settlement is an
4170
+ * idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
4171
+ * a confirm failure is logged but does NOT fail the delivered response —
4172
+ * the order was already verified SUCCESS.
4173
+ */
4174
+ async handleWechatExecute(skill, params, payment, res) {
4175
+ if (!this.wechatFacilitator) {
4176
+ return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
4177
+ }
4178
+ const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
4179
+ const requirements = {
4180
+ scheme: WECHAT_SCHEME,
4181
+ network: WECHAT_NETWORK,
4182
+ asset: "CNY",
4183
+ amount: skill.config.wechat?.price_cny || "0",
4184
+ payTo: this.manifest.provider.wechat?.mchid || "",
4185
+ maxTimeoutSeconds: 300,
4186
+ extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
4187
+ };
4188
+ console.log(`[MoltsPay] Verifying WeChat payment...`);
4189
+ const verifyResult = await this.registry.verify(payment, requirements);
4190
+ if (!verifyResult.valid) {
4191
+ return this.sendJson(res, 402, {
4192
+ error: `Payment verification failed: ${verifyResult.error}`,
4193
+ facilitator: verifyResult.facilitator
4194
+ });
4195
+ }
4196
+ console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
4197
+ if (outTradeNo) {
4198
+ this.invalidateWechatChallenge(outTradeNo);
4199
+ }
4200
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4201
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4202
+ let result;
4203
+ try {
4204
+ result = await Promise.race([
4205
+ skill.handler(params),
4206
+ new Promise(
4207
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4208
+ )
4209
+ ]);
4210
+ } catch (err) {
4211
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4212
+ return this.sendJson(res, 500, {
4213
+ error: "Service execution failed",
4214
+ message: err.message
4215
+ });
4216
+ }
4217
+ let settlement;
4218
+ try {
4219
+ settlement = await this.registry.settle(payment, requirements);
4220
+ if (settlement.success) {
4221
+ console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
4222
+ } else {
4223
+ console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
4224
+ }
4225
+ } catch (err) {
4226
+ console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
4227
+ settlement = { success: false, error: err.message, facilitator: "wechat" };
4228
+ }
4229
+ const responseHeaders = {};
4230
+ if (settlement.success) {
4231
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4232
+ success: true,
4233
+ transaction: settlement.transaction,
4234
+ network: WECHAT_NETWORK,
4235
+ facilitator: settlement.facilitator
4236
+ })).toString("base64");
4237
+ }
4238
+ this.sendJson(res, 200, {
4239
+ success: true,
4240
+ result,
4241
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4242
+ }, responseHeaders);
4243
+ }
4244
+ /**
4245
+ * Build the WeChat 402 challenge for a service, or null when the wechat rail
4246
+ * isn't configured for this server or this service. Placing a Native order
4247
+ * is a network call that returns a fresh `code_url` + `out_trade_no`; the
4248
+ * x402 `accepts[]` entry carries both in `extra` so the client can render
4249
+ * the QR and later echo `out_trade_no` back for verification.
4250
+ *
4251
+ * DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
4252
+ * `wechatPendingChallenges`), so repeated 402 emits within the order's
4253
+ * `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
4254
+ * minting a new payable order each time. The entry is dropped once the
4255
+ * order is paid (`invalidateWechatChallenge`) or shortly before it expires
4256
+ * (refresh margin, so clients never receive a nearly-dead QR). A build
4257
+ * failure is not cached and degrades gracefully (the other rails'
4258
+ * accepts[] still ship).
4259
+ */
4260
+ async buildWechatChallenge(config, params) {
4261
+ if (!this.wechatFacilitator || !config.wechat) return null;
4262
+ const cacheKey = import_node_crypto7.default.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
4263
+ const result = await this.getOrCreatePendingWechatOrder(
4264
+ cacheKey,
4265
+ config.id,
4266
+ () => this.wechatFacilitator.createPaymentRequirements({
4267
+ priceCny: config.wechat.price_cny,
4268
+ description: config.wechat.description
4269
+ })
4270
+ );
4271
+ return result ? { accepts: result.accepts } : null;
4272
+ }
4273
+ /**
4274
+ * Get-or-create a pending WeChat Native order under `cacheKey`, deduping
4275
+ * concurrent builds and reusing an unpaid order until it nears expiry.
4276
+ * Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
4277
+ * balance top-up order path ({@link handleBalanceTopupOrder}). See the
4278
+ * `wechatPendingChallenges` doc for the double-charge rationale.
4279
+ */
4280
+ async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
4281
+ const now = Date.now();
4282
+ const cached = this.wechatPendingChallenges.get(cacheKey);
4283
+ if (cached) {
4284
+ if (now < cached.expiresAtMs) {
4285
+ const hit = await cached.promise;
4286
+ if (hit) {
4287
+ console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
4288
+ return hit;
4289
+ }
4290
+ }
4291
+ this.wechatPendingChallenges.delete(cacheKey);
4292
+ }
4293
+ const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
4294
+ const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
4295
+ const entry = {
4296
+ expiresAtMs: now + cacheTtlMs,
4297
+ promise: Promise.resolve(null)
4298
+ };
4299
+ entry.promise = (async () => {
4300
+ try {
4301
+ const req = await create();
4302
+ entry.outTradeNo = req.outTradeNo;
4303
+ return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
4304
+ } catch (err) {
4305
+ console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
4306
+ this.wechatPendingChallenges.delete(cacheKey);
4307
+ return null;
4308
+ }
4309
+ })();
4310
+ this.wechatPendingChallenges.set(cacheKey, entry);
4311
+ return entry.promise;
4312
+ }
4313
+ /**
4314
+ * Drop the cached pending WeChat order that matches a paid `out_trade_no`,
4315
+ * so the next 402 mints a fresh order instead of re-serving a consumed one
4316
+ * (Native is one-code-one-payment).
4317
+ */
4318
+ invalidateWechatChallenge(outTradeNo) {
4319
+ for (const [cacheKey, entry] of this.wechatPendingChallenges) {
4320
+ if (entry.outTradeNo === outTradeNo) {
4321
+ this.wechatPendingChallenges.delete(cacheKey);
4322
+ }
4323
+ }
4324
+ }
4325
+ /**
4326
+ * Handle /execute for the custodial balance rail (2.2.0).
4327
+ *
4328
+ * Execution order is INVERTED relative to the QR rails: the deduction IS
4329
+ * the settlement, so it must land before the skill runs, and a skill
4330
+ * failure refunds it. `settle()` is idempotent on the client's
4331
+ * `request_id`, so a retried request never double-charges.
4332
+ *
4333
+ * QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
4334
+ * balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
4335
+ */
4336
+ async handleBalanceExecute(skill, params, payment, res) {
4337
+ if (!this.balanceFacilitator) {
4338
+ return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
4339
+ }
4340
+ const requirements = this.balanceRequirementsFor(skill.config);
4341
+ const authMode = this.balanceFacilitator.authMode;
4342
+ if (authMode !== "off") {
4343
+ const bp = extractBalancePayload(payment);
4344
+ const buyerId = bp?.buyer_id ?? "";
4345
+ const requestId = bp?.request_id ?? "";
4346
+ const av = verifyDeductAuth({
4347
+ auth: bp?.auth ?? null,
4348
+ buyerId,
4349
+ requestId,
4350
+ service: skill.id,
4351
+ nowMs: Date.now()
4352
+ });
4353
+ const ledger = this.balanceFacilitator.getLedger();
4354
+ let denyReason;
4355
+ if (av.ok && av.recovered) {
4356
+ const bind = ledger.bindSigner(buyerId, av.recovered);
4357
+ if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
4358
+ } else {
4359
+ denyReason = `signature ${av.reason}`;
4360
+ }
4361
+ if (denyReason) {
4362
+ if (authMode === "enforce") {
4363
+ console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4364
+ return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
4365
+ }
4366
+ console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4367
+ } else {
4368
+ console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
4369
+ }
4370
+ }
4371
+ console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
4372
+ const settlement = await this.balanceFacilitator.settle(payment, requirements);
4373
+ if (!settlement.success) {
2550
4374
  return this.sendJson(res, 402, {
2551
- error: `Payment verification failed: ${verifyResult.error}`,
2552
- facilitator: verifyResult.facilitator
4375
+ error: `Balance deduction failed: ${settlement.error}`,
4376
+ code: settlement.status,
4377
+ facilitator: "balance"
2553
4378
  });
2554
4379
  }
2555
- console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
4380
+ console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
2556
4381
  const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
2557
4382
  console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
2558
4383
  let result;
@@ -2565,59 +4390,60 @@ var MoltsPayServer = class {
2565
4390
  ]);
2566
4391
  } catch (err) {
2567
4392
  console.error("[MoltsPay] Skill execution failed:", err.message);
4393
+ const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
4394
+ if (!refund.success) {
4395
+ console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
4396
+ } else {
4397
+ console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
4398
+ }
2568
4399
  return this.sendJson(res, 500, {
2569
4400
  error: "Service execution failed",
2570
- message: err.message
4401
+ message: err.message,
4402
+ refunded: refund.success
2571
4403
  });
2572
4404
  }
2573
- let settlement;
2574
- try {
2575
- settlement = await this.registry.settle(payment, requirements);
2576
- if (settlement.success) {
2577
- console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
2578
- } else {
2579
- console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
2580
- }
2581
- } catch (err) {
2582
- console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
2583
- settlement = { success: false, error: err.message, facilitator: "alipay" };
2584
- }
2585
- const responseHeaders = {};
2586
- if (settlement.success) {
2587
- responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4405
+ const responseHeaders = {
4406
+ [PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
2588
4407
  success: true,
2589
4408
  transaction: settlement.transaction,
2590
- network: ALIPAY_NETWORK,
2591
- facilitator: settlement.facilitator
2592
- })).toString("base64");
2593
- }
4409
+ network: "balance",
4410
+ facilitator: "balance"
4411
+ })).toString("base64")
4412
+ };
2594
4413
  this.sendJson(res, 200, {
2595
4414
  success: true,
2596
4415
  result,
2597
- payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4416
+ payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
2598
4417
  }, responseHeaders);
2599
4418
  }
4419
+ /** The balance rail's requirements for a service (price defaults to `config.price`). */
4420
+ balanceRequirementsFor(config) {
4421
+ const price = config.balance?.price ?? config.price.toFixed(2);
4422
+ return {
4423
+ scheme: BALANCE_SCHEME,
4424
+ network: "balance",
4425
+ asset: this.balanceFacilitator?.currency ?? "USD",
4426
+ amount: price,
4427
+ payTo: "custodial",
4428
+ maxTimeoutSeconds: 30,
4429
+ extra: { service_id: config.id }
4430
+ };
4431
+ }
2600
4432
  /**
2601
- * Build the Alipay 402 challenge for a service, or null when the alipay rail
2602
- * isn't configured for this server or this service. Returns the x402
2603
- * `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
2604
- * 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
4433
+ * Build the balance 402 challenge for a service, or null when the rail
4434
+ * isn't configured for this server or this service. Pure nothing is
4435
+ * minted, so unlike the QR rails a 402 emit has no side effects.
2605
4436
  */
2606
- async buildAlipayChallenge(config) {
2607
- if (!this.alipayFacilitator || !config.alipay) return null;
4437
+ buildBalanceChallenge(config) {
4438
+ if (!this.balanceFacilitator || !config.balance) return null;
2608
4439
  try {
2609
- const req = await this.alipayFacilitator.createPaymentRequirements({
2610
- serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
2611
- priceCny: config.alipay.price_cny,
2612
- goodsName: config.alipay.goods_name,
2613
- resourceId: `/execute?service=${config.id}`
2614
- });
2615
- return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
4440
+ return { accepts: this.balanceRequirementsFor(config) };
2616
4441
  } catch (err) {
2617
- console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
4442
+ console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
2618
4443
  return null;
2619
4444
  }
2620
4445
  }
4446
+ /** GET /balance?buyer_id= — balance, limits, and today's spend. */
2621
4447
  /**
2622
4448
  * Handle MPP (Machine Payments Protocol) request
2623
4449
  * Supports both x402 and MPP protocols on service endpoints
@@ -2634,7 +4460,7 @@ var MoltsPayServer = class {
2634
4460
  if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
2635
4461
  return await this.handleMPPPayment(skill, params, authHeader, res);
2636
4462
  }
2637
- return this.sendMPPPaymentRequired(config, res);
4463
+ return this.sendMPPPaymentRequired(config, res, params);
2638
4464
  }
2639
4465
  /**
2640
4466
  * Handle MPP payment verification and service execution
@@ -2733,7 +4559,7 @@ var MoltsPayServer = class {
2733
4559
  /**
2734
4560
  * Return 402 with both x402 and MPP payment requirements
2735
4561
  */
2736
- async sendMPPPaymentRequired(config, res) {
4562
+ async sendMPPPaymentRequired(config, res, params) {
2737
4563
  const acceptedTokens = getAcceptedCurrencies(config);
2738
4564
  const providerChains = this.getProviderChains();
2739
4565
  const accepts = [];
@@ -2748,6 +4574,14 @@ var MoltsPayServer = class {
2748
4574
  if (alipayChallenge) {
2749
4575
  accepts.push(alipayChallenge.accepts);
2750
4576
  }
4577
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4578
+ if (wechatChallenge) {
4579
+ accepts.push(wechatChallenge.accepts);
4580
+ }
4581
+ const balanceChallenge = this.buildBalanceChallenge(config);
4582
+ if (balanceChallenge) {
4583
+ accepts.push(balanceChallenge.accepts);
4584
+ }
2751
4585
  const x402PaymentRequired = {
2752
4586
  x402Version: X402_VERSION2,
2753
4587
  accepts,
@@ -2813,7 +4647,7 @@ var MoltsPayServer = class {
2813
4647
  * Return 402 with x402 payment requirements (v2 format)
2814
4648
  * Includes requirements for all chains and all accepted currencies
2815
4649
  */
2816
- async sendPaymentRequired(config, res) {
4650
+ async sendPaymentRequired(config, res, params) {
2817
4651
  const acceptedTokens = getAcceptedCurrencies(config);
2818
4652
  const providerChains = this.getProviderChains();
2819
4653
  const accepts = [];
@@ -2828,6 +4662,14 @@ var MoltsPayServer = class {
2828
4662
  if (alipayChallenge) {
2829
4663
  accepts.push(alipayChallenge.accepts);
2830
4664
  }
4665
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4666
+ if (wechatChallenge) {
4667
+ accepts.push(wechatChallenge.accepts);
4668
+ }
4669
+ const balanceChallenge = this.buildBalanceChallenge(config);
4670
+ if (balanceChallenge) {
4671
+ accepts.push(balanceChallenge.accepts);
4672
+ }
2831
4673
  const acceptedChains = providerChains.map((c) => {
2832
4674
  if (c.network === "eip155:8453") return "base";
2833
4675
  if (c.network === "eip155:137") return "polygon";
@@ -2839,7 +4681,7 @@ var MoltsPayServer = class {
2839
4681
  acceptedCurrencies: acceptedTokens,
2840
4682
  acceptedChains,
2841
4683
  resource: {
2842
- url: `/execute?service=${config.id}`,
4684
+ url: `${this.publicBase}/execute?service=${config.id}`,
2843
4685
  description: `${config.name} - $${config.price} ${config.currency}`,
2844
4686
  mimeType: "application/json"
2845
4687
  }
@@ -2852,11 +4694,13 @@ var MoltsPayServer = class {
2852
4694
  if (alipayChallenge) {
2853
4695
  headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
2854
4696
  }
4697
+ const offered = this.describeServicePricing(config);
4698
+ 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}`;
2855
4699
  res.writeHead(402, headers);
2856
4700
  res.end(JSON.stringify({
2857
4701
  error: "Payment required",
2858
- message: `Service requires $${config.price} ${config.currency}`,
2859
- acceptedCurrencies: acceptedTokens,
4702
+ message,
4703
+ acceptedCurrencies: offered.acceptedCurrencies,
2860
4704
  acceptedChains,
2861
4705
  x402: paymentRequired
2862
4706
  }, null, 2));
@@ -3388,15 +5232,16 @@ var MoltsPayServer = class {
3388
5232
  };
3389
5233
 
3390
5234
  // src/client/node/index.ts
3391
- var import_fs4 = require("fs");
5235
+ var import_fs5 = require("fs");
3392
5236
  var import_os3 = require("os");
3393
5237
  var import_path3 = require("path");
3394
- var import_ethers3 = require("ethers");
5238
+ var import_node_crypto10 = require("crypto");
5239
+ var import_ethers5 = require("ethers");
3395
5240
 
3396
5241
  // src/wallet/solana.ts
3397
5242
  var import_web34 = require("@solana/web3.js");
3398
5243
  var import_spl_token2 = require("@solana/spl-token");
3399
- var import_fs3 = require("fs");
5244
+ var import_fs4 = require("fs");
3400
5245
  var import_path = require("path");
3401
5246
  var import_os = require("os");
3402
5247
  var import_bs582 = __toESM(require("bs58"));
@@ -3407,11 +5252,11 @@ function getSolanaWalletPath(configDir = DEFAULT_CONFIG_DIR) {
3407
5252
  }
3408
5253
  function loadSolanaWallet(configDir = DEFAULT_CONFIG_DIR) {
3409
5254
  const walletPath = getSolanaWalletPath(configDir);
3410
- if (!(0, import_fs3.existsSync)(walletPath)) {
5255
+ if (!(0, import_fs4.existsSync)(walletPath)) {
3411
5256
  return null;
3412
5257
  }
3413
5258
  try {
3414
- const data = JSON.parse((0, import_fs3.readFileSync)(walletPath, "utf-8"));
5259
+ const data = JSON.parse((0, import_fs4.readFileSync)(walletPath, "utf-8"));
3415
5260
  const secretKey = import_bs582.default.decode(data.secretKey);
3416
5261
  return import_web34.Keypair.fromSecretKey(secretKey);
3417
5262
  } catch (error) {
@@ -3531,7 +5376,7 @@ function buildBnbIntentTypedData(args) {
3531
5376
  }
3532
5377
 
3533
5378
  // src/client/node/signer.ts
3534
- var import_ethers2 = require("ethers");
5379
+ var import_ethers4 = require("ethers");
3535
5380
  var import_web35 = require("@solana/web3.js");
3536
5381
  var NodeSigner = class {
3537
5382
  evmWallet;
@@ -3563,7 +5408,7 @@ var NodeSigner = class {
3563
5408
  if (!chain) {
3564
5409
  throw new Error(`sendEvmTransaction: unknown chainId ${args.chainId}`);
3565
5410
  }
3566
- const provider = new import_ethers2.ethers.JsonRpcProvider(chain.rpc);
5411
+ const provider = new import_ethers4.ethers.JsonRpcProvider(chain.rpc);
3567
5412
  const connected = this.evmWallet.connect(provider);
3568
5413
  const tx = await connected.sendTransaction({
3569
5414
  to: args.to,
@@ -3596,7 +5441,7 @@ function findChainByChainId(chainId) {
3596
5441
  }
3597
5442
 
3598
5443
  // src/client/alipay/index.ts
3599
- var import_node_crypto3 = require("crypto");
5444
+ var import_node_crypto8 = require("crypto");
3600
5445
  var import_promises = require("fs/promises");
3601
5446
  var import_os2 = require("os");
3602
5447
  var import_path2 = require("path");
@@ -4023,7 +5868,7 @@ async function pollUntil(tradeNo, resourceUrl, opts) {
4023
5868
  // src/client/alipay/index.ts
4024
5869
  var TRADE_NO_RE = /^\d{32}$/;
4025
5870
  function resolveSessionId(explicit, envSession) {
4026
- return explicit ?? envSession ?? (0, import_node_crypto3.randomUUID)();
5871
+ return explicit ?? envSession ?? (0, import_node_crypto8.randomUUID)();
4027
5872
  }
4028
5873
  function assertTradeNo(t) {
4029
5874
  if (!TRADE_NO_RE.test(t)) {
@@ -4209,7 +6054,7 @@ var AlipayClient = class {
4209
6054
  if (m) media.push(m);
4210
6055
  else opts.onLine?.(line);
4211
6056
  };
4212
- const intentSummary = opts.intentSummary?.trim() || `\u652F\u4ED8 ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
6057
+ const intentSummary = opts.intentSummary?.trim() || `Pay ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
4213
6058
  await timeStep("ensure-cli", flow, () => ensureCli(this.getVersion));
4214
6059
  const intentTtlMs = resolveIntentTtlMs();
4215
6060
  const intentKey = `${this.configDir}::${this.framework}`;
@@ -4236,7 +6081,7 @@ var AlipayClient = class {
4236
6081
  }
4237
6082
  }
4238
6083
  await this.checkWallet(signal, flow);
4239
- const reqId = String(extra.out_trade_no ?? (0, import_node_crypto3.randomUUID)());
6084
+ const reqId = String(extra.out_trade_no ?? (0, import_node_crypto8.randomUUID)());
4240
6085
  const dir = (0, import_path2.join)(this.configDir, "alipay");
4241
6086
  await (0, import_promises.mkdir)(dir, { recursive: true });
4242
6087
  const challengeFile = (0, import_path2.join)(dir, `402_${reqId}.txt`);
@@ -4294,10 +6139,205 @@ var AlipayClient = class {
4294
6139
  }
4295
6140
  };
4296
6141
 
6142
+ // src/client/wechat/index.ts
6143
+ var import_node_crypto9 = require("crypto");
6144
+ var import_node_fs = require("fs");
6145
+ var import_node_os = require("os");
6146
+ var import_node_path = require("path");
6147
+ var WECHAT_SCHEME2 = "wechatpay-native";
6148
+ var WECHAT_NETWORK2 = "wechat";
6149
+ var POLL_INTERVAL_MS2 = 3e3;
6150
+ var TIMEOUT_MS = 5 * 60 * 1e3;
6151
+ var WechatClient = class {
6152
+ configDir;
6153
+ sessionDir;
6154
+ now;
6155
+ constructor(options = {}) {
6156
+ this.configDir = options.configDir || (0, import_node_path.join)((0, import_node_os.homedir)(), ".moltspay");
6157
+ this.sessionDir = (0, import_node_path.join)(this.configDir, "wechat-sessions");
6158
+ this.now = options.now ?? Date.now;
6159
+ }
6160
+ async pay402(opts) {
6161
+ const session = this.start402({ ...opts });
6162
+ const result = await this.pollSession(session.paymentSessionId, {
6163
+ pollIntervalMs: opts.pollIntervalMs,
6164
+ timeoutMs: opts.timeoutMs,
6165
+ signal: opts.signal
6166
+ });
6167
+ if (result.status === "paid" || result.status === "completed") {
6168
+ return {
6169
+ body: result.resultBody ?? "",
6170
+ status: result.lastHttpStatus ?? 200
6171
+ };
6172
+ }
6173
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
6174
+ }
6175
+ /**
6176
+ * Start a recoverable WeChat payment session. This returns immediately after
6177
+ * persisting `out_trade_no`, QR payload, original request body, and context.
6178
+ */
6179
+ start402(opts) {
6180
+ const extra = opts.requirement.extra ?? {};
6181
+ const codeUrl = typeof extra.code_url === "string" ? extra.code_url : "";
6182
+ const outTradeNo = typeof extra.out_trade_no === "string" ? extra.out_trade_no : "";
6183
+ if (!codeUrl || !outTradeNo) {
6184
+ throw new Error(
6185
+ "WechatClient.pay402: wechatpay-native requirement is missing extra.code_url / extra.out_trade_no"
6186
+ );
6187
+ }
6188
+ const now = new Date(this.now());
6189
+ const timeoutMs = opts.timeoutMs ?? (opts.requirement.maxTimeoutSeconds ?? TIMEOUT_MS / 1e3) * 1e3;
6190
+ const session = {
6191
+ paymentSessionId: opts.paymentSessionId ?? `mpay_sess_${(0, import_node_crypto9.randomUUID)()}`,
6192
+ status: "pending",
6193
+ resourceUrl: opts.resourceUrl,
6194
+ method: opts.method ?? "POST",
6195
+ data: opts.data,
6196
+ requirement: opts.requirement,
6197
+ codeUrl,
6198
+ outTradeNo,
6199
+ createdAt: now.toISOString(),
6200
+ updatedAt: now.toISOString(),
6201
+ expiresAt: new Date(this.now() + timeoutMs).toISOString(),
6202
+ context: opts.context
6203
+ };
6204
+ this.saveSession(session);
6205
+ opts.onPaymentPending?.({ codeUrl, outTradeNo });
6206
+ return session;
6207
+ }
6208
+ /**
6209
+ * Query a persisted session once by replaying the original request with the
6210
+ * stored `out_trade_no` proof. 200 means paid + fulfilled; 402 means pending.
6211
+ */
6212
+ async status(identifier) {
6213
+ const session = this.loadSession(identifier);
6214
+ if (session.status === "cancelled" || session.status === "completed") return session;
6215
+ if (this.now() >= new Date(session.expiresAt).getTime()) {
6216
+ return this.updateSession(session, {
6217
+ status: "expired",
6218
+ lastError: `WeChat payment expired at ${session.expiresAt}`
6219
+ });
6220
+ }
6221
+ const xPayment = this.buildPaymentHeader(session);
6222
+ const res = await fetch(session.resourceUrl, {
6223
+ method: session.method,
6224
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
6225
+ body: session.method === "POST" ? session.data : void 0
6226
+ });
6227
+ const text = await res.text().catch(() => "");
6228
+ if (res.status === 200) {
6229
+ return this.updateSession(session, {
6230
+ status: "completed",
6231
+ lastHttpStatus: res.status,
6232
+ lastError: void 0,
6233
+ resultBody: text
6234
+ });
6235
+ }
6236
+ if (res.status === 402) {
6237
+ return this.updateSession(session, {
6238
+ status: "pending",
6239
+ lastHttpStatus: res.status,
6240
+ lastError: text.slice(0, 500) || "wechat payment pending"
6241
+ });
6242
+ }
6243
+ return this.updateSession(session, {
6244
+ status: "failed",
6245
+ lastHttpStatus: res.status,
6246
+ lastError: `WeChat /execute returned HTTP ${res.status}: ${text.slice(0, 500)}`
6247
+ });
6248
+ }
6249
+ /** Poll a session until it completes, expires, fails, or is aborted. */
6250
+ async pollSession(identifier, opts = {}) {
6251
+ const first = this.loadSession(identifier);
6252
+ const deadline = Math.min(
6253
+ new Date(first.expiresAt).getTime(),
6254
+ this.now() + (opts.timeoutMs ?? TIMEOUT_MS)
6255
+ );
6256
+ const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS2;
6257
+ let session = first;
6258
+ while (this.now() < deadline) {
6259
+ if (opts.signal?.aborted) throw new Error("WeChat payment aborted");
6260
+ session = await this.status(session.paymentSessionId);
6261
+ if (session.status !== "pending") return session;
6262
+ await new Promise((r) => setTimeout(r, interval));
6263
+ }
6264
+ return this.updateSession(session, {
6265
+ status: "expired",
6266
+ lastError: `WeChat payment timed out after ${Math.round((opts.timeoutMs ?? TIMEOUT_MS) / 1e3)}s (out_trade_no=${session.outTradeNo}, last status ${session.lastHttpStatus ?? 0})`
6267
+ });
6268
+ }
6269
+ /** `fulfill` is an idempotent status check: paid sessions return stored body. */
6270
+ async fulfill(identifier) {
6271
+ const session = this.loadSession(identifier);
6272
+ if (session.status === "completed") return session;
6273
+ return this.status(identifier);
6274
+ }
6275
+ /** Mark a local session cancelled. Closing the merchant order is server-side. */
6276
+ cancel(identifier) {
6277
+ const session = this.loadSession(identifier);
6278
+ return this.updateSession(session, {
6279
+ status: "cancelled",
6280
+ lastError: "cancelled locally"
6281
+ });
6282
+ }
6283
+ listSessions() {
6284
+ this.ensureSessionDir();
6285
+ return (0, import_node_fs.readdirSync)(this.sessionDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(this.sessionDir, name), "utf-8"))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6286
+ }
6287
+ buildPaymentHeader(session) {
6288
+ const proof = {
6289
+ x402Version: 2,
6290
+ scheme: WECHAT_SCHEME2,
6291
+ network: WECHAT_NETWORK2,
6292
+ accepted: {
6293
+ scheme: WECHAT_SCHEME2,
6294
+ network: WECHAT_NETWORK2,
6295
+ extra: { out_trade_no: session.outTradeNo }
6296
+ },
6297
+ payload: { out_trade_no: session.outTradeNo }
6298
+ };
6299
+ return Buffer.from(JSON.stringify(proof)).toString("base64");
6300
+ }
6301
+ loadSession(identifier) {
6302
+ this.ensureSessionDir();
6303
+ const direct = (0, import_node_path.join)(this.sessionDir, `${identifier}.json`);
6304
+ try {
6305
+ return JSON.parse((0, import_node_fs.readFileSync)(direct, "utf-8"));
6306
+ } catch {
6307
+ const byTradeNo = this.listSessions().find((s) => s.outTradeNo === identifier);
6308
+ if (byTradeNo) return byTradeNo;
6309
+ throw new Error(`WeChat payment session not found: ${identifier}`);
6310
+ }
6311
+ }
6312
+ saveSession(session) {
6313
+ this.ensureSessionDir();
6314
+ (0, import_node_fs.writeFileSync)(
6315
+ (0, import_node_path.join)(this.sessionDir, `${session.paymentSessionId}.json`),
6316
+ JSON.stringify(session, null, 2)
6317
+ );
6318
+ }
6319
+ updateSession(session, updates) {
6320
+ const next = {
6321
+ ...session,
6322
+ ...updates,
6323
+ updatedAt: new Date(this.now()).toISOString()
6324
+ };
6325
+ this.saveSession(next);
6326
+ return next;
6327
+ }
6328
+ ensureSessionDir() {
6329
+ (0, import_node_fs.mkdirSync)(this.sessionDir, { recursive: true });
6330
+ }
6331
+ };
6332
+
4297
6333
  // src/client/alipay/router.ts
4298
6334
  var ALIPAY_RAIL = "alipay";
6335
+ var WECHAT_RAIL = "wechat";
6336
+ var BALANCE_RAIL = "balance";
4299
6337
  function railOf(req) {
4300
6338
  if (req.scheme === "alipay-aipay" || req.network === ALIPAY_RAIL) return ALIPAY_RAIL;
6339
+ if (req.scheme === "wechatpay-native" || req.network === WECHAT_RAIL) return WECHAT_RAIL;
6340
+ if (req.scheme === BALANCE_RAIL || req.network === BALANCE_RAIL) return BALANCE_RAIL;
4301
6341
  return networkToChainName(req.network) ?? req.network;
4302
6342
  }
4303
6343
  function findRail(accepts, rail) {
@@ -4363,7 +6403,7 @@ var MoltsPayClient = class {
4363
6403
  this.walletData = this.loadWallet();
4364
6404
  this.loadSpending();
4365
6405
  if (this.walletData) {
4366
- this.wallet = new import_ethers3.Wallet(this.walletData.privateKey);
6406
+ this.wallet = new import_ethers5.Wallet(this.walletData.privateKey);
4367
6407
  const configDir = this.configDir;
4368
6408
  this.signer = new NodeSigner(this.wallet, {
4369
6409
  getSolanaKeypair: () => loadSolanaWallet(configDir)
@@ -4440,6 +6480,12 @@ var MoltsPayClient = class {
4440
6480
  if (options.rail === ALIPAY_RAIL) {
4441
6481
  return this.payViaAlipay(serverUrl, service, params, options);
4442
6482
  }
6483
+ if (options.rail === WECHAT_RAIL) {
6484
+ return this.payViaWechat(serverUrl, service, params, options);
6485
+ }
6486
+ if (options.rail === BALANCE_RAIL) {
6487
+ return this.payViaBalance(serverUrl, service, params, options);
6488
+ }
4443
6489
  if (!this.wallet || !this.walletData) {
4444
6490
  throw new Error("Client not initialized. Run: npx moltspay init");
4445
6491
  }
@@ -4615,55 +6661,416 @@ Please specify: --chain <chain_name>`
4615
6661
  if (options.chain) {
4616
6662
  paidRequestBody.chain = options.chain;
4617
6663
  }
4618
- const paidRes = await fetch(executeUrl, {
6664
+ const paidRes = await fetch(executeUrl, {
6665
+ method: "POST",
6666
+ headers: {
6667
+ "Content-Type": "application/json",
6668
+ [PAYMENT_HEADER2]: paymentHeader
6669
+ },
6670
+ body: JSON.stringify(paidRequestBody)
6671
+ });
6672
+ const result = await paidRes.json();
6673
+ if (!paidRes.ok) {
6674
+ throw new Error(result.error || "Service execution failed");
6675
+ }
6676
+ this.recordSpending(amount);
6677
+ console.log(`[MoltsPay] Success! Payment: ${result.payment?.status || "claimed"}`);
6678
+ return result.result || result;
6679
+ }
6680
+ /**
6681
+ * Pay for a service over the Alipay fiat rail (2.0.0).
6682
+ *
6683
+ * Unlike the crypto path this needs no EVM wallet — it shells out to
6684
+ * alipay-bot via {@link AlipayClient}. Flow: hit the resource with no
6685
+ * payment to get the 402 challenge, confirm the server actually offers the
6686
+ * alipay rail (selectRail), then run the 8-step state machine and return the
6687
+ * resource body.
6688
+ */
6689
+ async payViaAlipay(serverUrl, service, params, options) {
6690
+ const flow = this.alipaySessionId;
6691
+ let executeUrl = `${serverUrl}/execute`;
6692
+ try {
6693
+ const services = await timeStep(
6694
+ "discover-services",
6695
+ flow,
6696
+ () => this.getServices(serverUrl)
6697
+ );
6698
+ const svc = services.services?.find((s) => s.id === service);
6699
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
6700
+ } catch {
6701
+ }
6702
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
6703
+ const bodyJson = JSON.stringify(requestBody);
6704
+ const res = await timeStep(
6705
+ "challenge-402",
6706
+ flow,
6707
+ () => fetch(executeUrl, {
6708
+ method: "POST",
6709
+ headers: { "Content-Type": "application/json" },
6710
+ body: bodyJson
6711
+ })
6712
+ );
6713
+ if (res.status !== 402) {
6714
+ const data = await res.json().catch(() => ({}));
6715
+ if (res.ok && data.result) return data.result;
6716
+ throw new Error(data.error || `Expected 402, got ${res.status}`);
6717
+ }
6718
+ const header = res.headers.get(PAYMENT_REQUIRED_HEADER2);
6719
+ if (!header) throw new Error("Missing x-payment-required header on 402");
6720
+ const parsed = JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
6721
+ const accepts = Array.isArray(parsed) ? parsed : parsed.accepts ?? [parsed];
6722
+ const { requirement } = selectRail({
6723
+ serverAccepts: accepts,
6724
+ explicitRail: ALIPAY_RAIL,
6725
+ preference: this.railPreference,
6726
+ availability: { evmReady: this.isInitialized }
6727
+ });
6728
+ const onLine = options.onLine ?? ((line) => process.stdout.write(line + "\n"));
6729
+ const alipay = new AlipayClient({
6730
+ sessionId: this.alipaySessionId,
6731
+ configDir: this.configDir
6732
+ });
6733
+ const result = await alipay.pay402({
6734
+ resourceUrl: executeUrl,
6735
+ requirement,
6736
+ method: "POST",
6737
+ data: bodyJson,
6738
+ onLine,
6739
+ onPaymentPending: options.onPaymentPending,
6740
+ timeoutMs: options.timeoutMs,
6741
+ signal: options.signal
6742
+ });
6743
+ try {
6744
+ const json = JSON.parse(result.body);
6745
+ return json.result ?? json;
6746
+ } catch {
6747
+ return { body: result.body, payment: result.payment, media: result.media };
6748
+ }
6749
+ }
6750
+ /**
6751
+ * Pay for a service over the WeChat Pay Native fiat rail (2.1.0).
6752
+ *
6753
+ * Mirrors {@link payViaAlipay} and needs no EVM wallet. The server placed the
6754
+ * Native order when it built the 402, so its `wechatpay-native` accepts[]
6755
+ * entry already carries `extra.code_url` + `extra.out_trade_no`. Flow: hit the
6756
+ * resource to get the 402, confirm the server offers the wechat rail, surface
6757
+ * the code_url (caller renders a QR), then poll the resource with the
6758
+ * out_trade_no proof until the server verifies the order paid and delivers.
6759
+ */
6760
+ async payViaWechat(serverUrl, service, params, options) {
6761
+ const session = await this.startWechatPayment(serverUrl, service, params, options);
6762
+ const wechat = new WechatClient({ configDir: this.configDir });
6763
+ const result = await wechat.pollSession(session.paymentSessionId, {
6764
+ timeoutMs: options.timeoutMs,
6765
+ signal: options.signal
6766
+ });
6767
+ if (result.status !== "completed") {
6768
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
6769
+ }
6770
+ try {
6771
+ const json = JSON.parse(result.resultBody ?? "");
6772
+ return json.result ?? json;
6773
+ } catch {
6774
+ return { body: result.resultBody ?? "" };
6775
+ }
6776
+ }
6777
+ /**
6778
+ * The ethers wallet used to sign balance-rail deductions: the client's EVM
6779
+ * wallet if it has one, else a per-configDir identity key persisted at
6780
+ * `<configDir>/balance-identity.key` (0600) so a balance-only client works
6781
+ * without a full crypto wallet. Under `agent 统一代付`, one key spends every
6782
+ * account the agent tops up.
6783
+ */
6784
+ balanceSigner() {
6785
+ if (this.wallet) return this.wallet;
6786
+ const p = (0, import_path3.join)(this.configDir, "balance-identity.key");
6787
+ let pk;
6788
+ if ((0, import_fs5.existsSync)(p)) {
6789
+ pk = (0, import_fs5.readFileSync)(p, "utf-8").trim();
6790
+ } else {
6791
+ (0, import_fs5.mkdirSync)(this.configDir, { recursive: true });
6792
+ pk = import_ethers5.Wallet.createRandom().privateKey;
6793
+ (0, import_fs5.writeFileSync)(p, pk, { mode: 384 });
6794
+ }
6795
+ return new import_ethers5.Wallet(pk);
6796
+ }
6797
+ /** The balance-rail spending signer address (lowercase 0x…). Stable per
6798
+ * configDir; this is the identity the server TOFU-binds and later verifies. */
6799
+ getBalanceSignerAddress() {
6800
+ return this.balanceSigner().address.toLowerCase();
6801
+ }
6802
+ /** The buyer id for the balance rail: explicit option > persisted config. */
6803
+ resolveBuyerId(explicit) {
6804
+ const buyerId = explicit ?? this.config.buyerId;
6805
+ if (!buyerId) {
6806
+ throw new Error(
6807
+ "Balance rail needs a buyer id. Pass { buyerId } or persist one with setBuyerId()."
6808
+ );
6809
+ }
6810
+ return buyerId;
6811
+ }
6812
+ /**
6813
+ * Pay via the custodial balance rail (2.2.0, password-free).
6814
+ *
6815
+ * No wallet, no QR: the request carries `{buyer_id, request_id}` in the
6816
+ * X-Payment payload and the server deducts the prepaid balance atomically
6817
+ * before running the skill. The client-generated `request_id` makes the
6818
+ * charge idempotent — a network retry can never double-deduct.
6819
+ */
6820
+ async payViaBalance(serverUrl, service, params, options) {
6821
+ const buyerId = this.resolveBuyerId(options.buyerId);
6822
+ let attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
6823
+ if (attempt.ok) return attempt.result;
6824
+ const fundable = attempt.status === 402 && (attempt.code === "buyer_not_found" || attempt.code === "insufficient_balance" || /insufficient balance|unknown buyer|top up first/i.test(attempt.error || ""));
6825
+ if (!fundable || options.autoTopup === false) {
6826
+ throw new Error(attempt.error || `Balance payment failed with HTTP ${attempt.status}`);
6827
+ }
6828
+ if (options.topupMode === "manual") {
6829
+ const order = await this.createBalanceTopupOrder(serverUrl, {
6830
+ pack: options.topupPack,
6831
+ buyerId,
6832
+ context: { service }
6833
+ });
6834
+ options.onTopupRequired?.(order.pack, order.codeUrl);
6835
+ return {
6836
+ status: "topup_required",
6837
+ out_trade_no: order.outTradeNo,
6838
+ code_url: order.codeUrl,
6839
+ pack: order.pack,
6840
+ server_url: serverUrl
6841
+ };
6842
+ }
6843
+ const credited = await this.topupBalancePack(serverUrl, {
6844
+ pack: options.topupPack,
6845
+ buyerId,
6846
+ pollIntervalMs: options.topupPollIntervalMs,
6847
+ signal: options.signal,
6848
+ onCodeUrl: (pack, codeUrl) => options.onTopupRequired?.(pack, codeUrl)
6849
+ });
6850
+ options.onTopupCredited?.(credited.balance);
6851
+ attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
6852
+ if (attempt.ok) return attempt.result;
6853
+ throw new Error(attempt.error || "Balance payment failed after top-up");
6854
+ }
6855
+ /** One password-free deduct attempt. Never throws on an HTTP error; the
6856
+ * caller inspects `{ ok, status, error }` to decide whether to auto-fund. */
6857
+ async balanceDeduct(serverUrl, service, params, options, buyerId) {
6858
+ let executeUrl = `${serverUrl}/execute`;
6859
+ try {
6860
+ const services = await this.getServices(serverUrl);
6861
+ const svc = services.services?.find((s) => s.id === service);
6862
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
6863
+ } catch {
6864
+ }
6865
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
6866
+ const requestId = (0, import_node_crypto10.randomUUID)();
6867
+ const timestamp = Math.floor(Date.now() / 1e3);
6868
+ const signature = await this.balanceSigner().signMessage(
6869
+ buildDeductMessage({ buyerId, requestId, service, timestamp })
6870
+ );
6871
+ const xPayment = Buffer.from(JSON.stringify({
6872
+ x402Version: 2,
6873
+ scheme: BALANCE_RAIL,
6874
+ network: BALANCE_RAIL,
6875
+ payload: { buyer_id: buyerId, request_id: requestId, auth: { timestamp, signature } }
6876
+ })).toString("base64");
6877
+ const res = await fetch(executeUrl, {
6878
+ method: "POST",
6879
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
6880
+ body: JSON.stringify(requestBody),
6881
+ signal: options.signal
6882
+ });
6883
+ const data = await res.json().catch(() => ({}));
6884
+ if (!res.ok) return { ok: false, status: res.status, error: data.error, code: data.code };
6885
+ return { ok: true, status: res.status, result: data.result ?? data };
6886
+ }
6887
+ /**
6888
+ * Non-blocking: POST /balance/topup/order, persist a recoverable session, and
6889
+ * return at once (no polling). Use with {@link confirmBalanceTopup} for
6890
+ * turn-based agents; the blocking {@link topupBalancePack} is built on this.
6891
+ */
6892
+ async createBalanceTopupOrder(serverUrl, opts = {}) {
6893
+ const id = this.resolveBuyerId(opts.buyerId);
6894
+ const orderRes = await fetch(`${serverUrl}/balance/topup/order`, {
6895
+ method: "POST",
6896
+ headers: { "Content-Type": "application/json" },
6897
+ // Carry the spending signer so the server binds it to the account on
6898
+ // confirm (topup-time binding). The same key signs deductions later.
6899
+ body: JSON.stringify({ buyer_id: id, pack: opts.pack, signer_address: this.getBalanceSignerAddress() })
6900
+ });
6901
+ const order = await orderRes.json().catch(() => ({}));
6902
+ if (!orderRes.ok) throw new Error(order.error || `Top-up order failed with HTTP ${orderRes.status}`);
6903
+ const maxTimeoutSeconds = order.max_timeout_seconds ?? 300;
6904
+ const now = Date.now();
6905
+ this.saveBalanceTopupSession({
6906
+ out_trade_no: order.out_trade_no,
6907
+ buyer_id: id,
6908
+ pack: order.pack,
6909
+ server_url: serverUrl,
6910
+ code_url: order.code_url,
6911
+ status: "pending",
6912
+ created_at: new Date(now).toISOString(),
6913
+ expires_at: new Date(now + maxTimeoutSeconds * 1e3).toISOString(),
6914
+ context: opts.context
6915
+ });
6916
+ return { outTradeNo: order.out_trade_no, codeUrl: order.code_url, pack: order.pack, maxTimeoutSeconds };
6917
+ }
6918
+ /**
6919
+ * One-shot: POST /balance/topup/confirm for a single order. No polling.
6920
+ * Updates the persisted session on credit. `serverUrl` defaults to the one
6921
+ * recorded in the session (recover by out_trade_no alone).
6922
+ */
6923
+ async confirmBalanceTopup(outTradeNo, opts = {}) {
6924
+ const session = this.getBalanceTopupSession(outTradeNo);
6925
+ const serverUrl = opts.serverUrl || session?.server_url;
6926
+ if (!serverUrl) {
6927
+ return { credited: false, reason: `No server URL for ${outTradeNo}: pass serverUrl or run topup-order first` };
6928
+ }
6929
+ const confRes = await fetch(`${serverUrl}/balance/topup/confirm`, {
4619
6930
  method: "POST",
4620
- headers: {
4621
- "Content-Type": "application/json",
4622
- [PAYMENT_HEADER2]: paymentHeader
4623
- },
4624
- body: JSON.stringify(paidRequestBody)
6931
+ headers: { "Content-Type": "application/json" },
6932
+ body: JSON.stringify({ out_trade_no: outTradeNo })
4625
6933
  });
4626
- const result = await paidRes.json();
4627
- if (!paidRes.ok) {
4628
- throw new Error(result.error || "Service execution failed");
6934
+ const conf = await confRes.json().catch(() => ({}));
6935
+ if (!confRes.ok) return { credited: false, reason: conf.error || `Confirm failed with HTTP ${confRes.status}` };
6936
+ if (conf.credited) {
6937
+ if (session) {
6938
+ session.status = "credited";
6939
+ session.tx_id = conf.tx_id;
6940
+ session.balance = conf.balance;
6941
+ this.saveBalanceTopupSession(session);
6942
+ }
6943
+ return { credited: true, balance: conf.balance, txId: conf.tx_id };
4629
6944
  }
4630
- this.recordSpending(amount);
4631
- console.log(`[MoltsPay] Success! Payment: ${result.payment?.status || "claimed"}`);
4632
- return result.result || result;
6945
+ return { credited: false, pending: !!conf.pending, reason: conf.reason };
4633
6946
  }
4634
6947
  /**
4635
- * Pay for a service over the Alipay fiat rail (2.0.0).
4636
- *
4637
- * Unlike the crypto path this needs no EVM wallet — it shells out to
4638
- * alipay-bot via {@link AlipayClient}. Flow: hit the resource with no
4639
- * payment to get the 402 challenge, confirm the server actually offers the
4640
- * alipay rail (selectRail), then run the 8-step state machine and return the
4641
- * resource body.
6948
+ * Blocking terminal wrapper: create the order then poll confirm until the
6949
+ * scan is credited (or the order expires). Built on
6950
+ * {@link createBalanceTopupOrder} + {@link confirmBalanceTopup}.
4642
6951
  */
4643
- async payViaAlipay(serverUrl, service, params, options) {
4644
- const flow = this.alipaySessionId;
6952
+ async topupBalancePack(serverUrl, opts = {}) {
6953
+ const order = await this.createBalanceTopupOrder(serverUrl, { pack: opts.pack, buyerId: opts.buyerId });
6954
+ opts.onCodeUrl?.(order.pack, order.codeUrl);
6955
+ const interval = opts.pollIntervalMs ?? 2e3;
6956
+ const deadline = Date.now() + order.maxTimeoutSeconds * 1e3;
6957
+ while (Date.now() < deadline) {
6958
+ if (opts.signal?.aborted) throw new Error("Top-up aborted");
6959
+ const conf = await this.confirmBalanceTopup(order.outTradeNo, { serverUrl });
6960
+ if (conf.credited) return { balance: conf.balance, outTradeNo: order.outTradeNo, txId: conf.txId };
6961
+ await this.sleep(interval, opts.signal);
6962
+ }
6963
+ throw new Error("Top-up timed out before the payment was confirmed");
6964
+ }
6965
+ // --- Recoverable balance top-up sessions (<configDir>/balance-topup-sessions) ---
6966
+ balanceTopupSessionDir() {
6967
+ return (0, import_path3.join)(this.configDir, "balance-topup-sessions");
6968
+ }
6969
+ saveBalanceTopupSession(session) {
6970
+ const dir = this.balanceTopupSessionDir();
6971
+ (0, import_fs5.mkdirSync)(dir, { recursive: true });
6972
+ (0, import_fs5.writeFileSync)((0, import_path3.join)(dir, `${session.out_trade_no}.json`), JSON.stringify(session, null, 2));
6973
+ }
6974
+ /** Read a persisted top-up session by out_trade_no, or null. */
6975
+ getBalanceTopupSession(outTradeNo) {
6976
+ const p = (0, import_path3.join)(this.balanceTopupSessionDir(), `${outTradeNo}.json`);
6977
+ if (!(0, import_fs5.existsSync)(p)) return null;
6978
+ try {
6979
+ return JSON.parse((0, import_fs5.readFileSync)(p, "utf-8"));
6980
+ } catch {
6981
+ return null;
6982
+ }
6983
+ }
6984
+ /** List persisted top-up sessions, newest first. */
6985
+ listBalanceTopupSessions() {
6986
+ const dir = this.balanceTopupSessionDir();
6987
+ if (!(0, import_fs5.existsSync)(dir)) return [];
6988
+ return (0, import_fs5.readdirSync)(dir).filter((f) => f.endsWith(".json")).map((f) => {
6989
+ try {
6990
+ return JSON.parse((0, import_fs5.readFileSync)((0, import_path3.join)(dir, f), "utf-8"));
6991
+ } catch {
6992
+ return null;
6993
+ }
6994
+ }).filter((s) => s !== null).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
6995
+ }
6996
+ /** Abortable sleep used by the top-up poll loop. */
6997
+ sleep(ms, signal) {
6998
+ return new Promise((resolve2, reject) => {
6999
+ if (signal?.aborted) return reject(new Error("aborted"));
7000
+ const t = setTimeout(resolve2, ms);
7001
+ signal?.addEventListener("abort", () => {
7002
+ clearTimeout(t);
7003
+ reject(new Error("aborted"));
7004
+ }, { once: true });
7005
+ });
7006
+ }
7007
+ /** Persist the buyer id used by the balance rail (bearer semantics). */
7008
+ setBuyerId(buyerId) {
7009
+ this.config.buyerId = buyerId;
7010
+ this.saveConfig();
7011
+ }
7012
+ /** GET /balance — custodial balance, limits, and today's spend for a buyer. */
7013
+ async getBuyerBalance(serverUrl, buyerId) {
7014
+ const id = this.resolveBuyerId(buyerId);
7015
+ const res = await fetch(`${serverUrl}/balance?buyer_id=${encodeURIComponent(id)}`);
7016
+ const data = await res.json().catch(() => ({}));
7017
+ if (!res.ok) throw new Error(data.error || `Balance query failed with HTTP ${res.status}`);
7018
+ return data;
7019
+ }
7020
+ /**
7021
+ * POST /balance/topup — report an externally settled payment (on-chain
7022
+ * tx hash / Alipay trade_no / WeChat out_trade_no) so the server verifies
7023
+ * and credits the ledger. Idempotent per reference.
7024
+ */
7025
+ async topupBalance(serverUrl, opts) {
7026
+ const id = this.resolveBuyerId(opts.buyerId);
7027
+ const res = await fetch(`${serverUrl}/balance/topup`, {
7028
+ method: "POST",
7029
+ headers: { "Content-Type": "application/json" },
7030
+ body: JSON.stringify({
7031
+ buyer_id: id,
7032
+ rail: opts.rail,
7033
+ amount: opts.amount,
7034
+ tx_hash: opts.txHash,
7035
+ chain: opts.chain,
7036
+ trade_no: opts.tradeNo,
7037
+ out_trade_no: opts.outTradeNo
7038
+ })
7039
+ });
7040
+ const data = await res.json().catch(() => ({}));
7041
+ if (!res.ok) throw new Error(data.error || `Top-up failed with HTTP ${res.status}`);
7042
+ return data;
7043
+ }
7044
+ /** GET /balance/transactions — ledger history for a buyer, newest first. */
7045
+ async listBalanceTransactions(serverUrl, opts = {}) {
7046
+ const id = this.resolveBuyerId(opts.buyerId);
7047
+ const qs = new URLSearchParams({ buyer_id: id });
7048
+ if (opts.limit) qs.set("limit", String(opts.limit));
7049
+ if (opts.offset) qs.set("offset", String(opts.offset));
7050
+ const res = await fetch(`${serverUrl}/balance/transactions?${qs}`);
7051
+ const data = await res.json().catch(() => ({}));
7052
+ if (!res.ok) throw new Error(data.error || `Transaction query failed with HTTP ${res.status}`);
7053
+ return data;
7054
+ }
7055
+ /**
7056
+ * Start a recoverable WeChat Pay Native session and return immediately with
7057
+ * QR metadata. The SDK client persists enough context to poll/fulfill later.
7058
+ */
7059
+ async startWechatPayment(serverUrl, service, params, options = {}) {
4645
7060
  let executeUrl = `${serverUrl}/execute`;
4646
7061
  try {
4647
- const services = await timeStep(
4648
- "discover-services",
4649
- flow,
4650
- () => this.getServices(serverUrl)
4651
- );
7062
+ const services = await this.getServices(serverUrl);
4652
7063
  const svc = services.services?.find((s) => s.id === service);
4653
7064
  if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
4654
7065
  } catch {
4655
7066
  }
4656
7067
  const requestBody = options.rawData ? { service, ...params } : { service, params };
4657
7068
  const bodyJson = JSON.stringify(requestBody);
4658
- const res = await timeStep(
4659
- "challenge-402",
4660
- flow,
4661
- () => fetch(executeUrl, {
4662
- method: "POST",
4663
- headers: { "Content-Type": "application/json" },
4664
- body: bodyJson
4665
- })
4666
- );
7069
+ const res = await fetch(executeUrl, {
7070
+ method: "POST",
7071
+ headers: { "Content-Type": "application/json" },
7072
+ body: bodyJson
7073
+ });
4667
7074
  if (res.status !== 402) {
4668
7075
  const data = await res.json().catch(() => ({}));
4669
7076
  if (res.ok && data.result) return data.result;
@@ -4675,31 +7082,63 @@ Please specify: --chain <chain_name>`
4675
7082
  const accepts = Array.isArray(parsed) ? parsed : parsed.accepts ?? [parsed];
4676
7083
  const { requirement } = selectRail({
4677
7084
  serverAccepts: accepts,
4678
- explicitRail: ALIPAY_RAIL,
7085
+ explicitRail: WECHAT_RAIL,
4679
7086
  preference: this.railPreference,
4680
7087
  availability: { evmReady: this.isInitialized }
4681
7088
  });
4682
- const onLine = options.onLine ?? ((line) => process.stdout.write(line + "\n"));
4683
- const alipay = new AlipayClient({
4684
- sessionId: this.alipaySessionId,
4685
- configDir: this.configDir
4686
- });
4687
- const result = await alipay.pay402({
7089
+ const wechat = new WechatClient({ configDir: this.configDir });
7090
+ const session = wechat.start402({
4688
7091
  resourceUrl: executeUrl,
4689
7092
  requirement,
4690
7093
  method: "POST",
4691
7094
  data: bodyJson,
4692
- onLine,
4693
- onPaymentPending: options.onPaymentPending,
7095
+ onPaymentPending: options.onPaymentPending ? (info) => options.onPaymentPending({ paymentUrl: info.codeUrl, tradeNo: info.outTradeNo }) : void 0,
4694
7096
  timeoutMs: options.timeoutMs,
4695
- signal: options.signal
7097
+ signal: options.signal,
7098
+ context: {
7099
+ serverUrl,
7100
+ service,
7101
+ params,
7102
+ rawData: options.rawData ?? false,
7103
+ rail: WECHAT_RAIL
7104
+ }
4696
7105
  });
4697
- try {
4698
- const json = JSON.parse(result.body);
4699
- return json.result ?? json;
4700
- } catch {
4701
- return { body: result.body, payment: result.payment, media: result.media };
7106
+ if (options.autoPoll || options.onWechatPaymentCompleted || options.onWechatPaymentFailed) {
7107
+ void wechat.pollSession(session.paymentSessionId, {
7108
+ timeoutMs: options.timeoutMs,
7109
+ signal: options.signal
7110
+ }).then(async (finalSession) => {
7111
+ if (finalSession.status === "completed") {
7112
+ await options.onWechatPaymentCompleted?.(finalSession);
7113
+ } else {
7114
+ await options.onWechatPaymentFailed?.(finalSession);
7115
+ }
7116
+ }).catch(async (error) => {
7117
+ const failed = await wechat.fulfill(session.paymentSessionId).catch(() => ({
7118
+ ...session,
7119
+ status: "failed",
7120
+ lastError: error instanceof Error ? error.message : String(error)
7121
+ }));
7122
+ await options.onWechatPaymentFailed?.(failed);
7123
+ });
4702
7124
  }
7125
+ return session;
7126
+ }
7127
+ /** Query a persisted WeChat session once. */
7128
+ async getWechatPaymentStatus(identifier) {
7129
+ return new WechatClient({ configDir: this.configDir }).status(identifier);
7130
+ }
7131
+ /** Idempotently fulfill a paid WeChat session, returning stored or fetched result. */
7132
+ async fulfillWechatPayment(identifier) {
7133
+ return new WechatClient({ configDir: this.configDir }).fulfill(identifier);
7134
+ }
7135
+ /** Mark a local WeChat session as cancelled. */
7136
+ cancelWechatPayment(identifier) {
7137
+ return new WechatClient({ configDir: this.configDir }).cancel(identifier);
7138
+ }
7139
+ /** List persisted WeChat sessions, newest first. */
7140
+ listWechatPaymentSessions() {
7141
+ return new WechatClient({ configDir: this.configDir }).listSessions();
4703
7142
  }
4704
7143
  /**
4705
7144
  * Handle MPP (Machine Payments Protocol) payment flow
@@ -4796,14 +7235,14 @@ Please specify: --chain <chain_name>`
4796
7235
  async handleBNBPayment(executeUrl, service, params, paymentDetails, options = {}) {
4797
7236
  const { to, amount, token, chainName, chain, spender } = paymentDetails;
4798
7237
  const tokenConfig = chain.tokens[token];
4799
- const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
7238
+ const provider = new import_ethers5.ethers.JsonRpcProvider(chain.rpc);
4800
7239
  const allowance = await this.checkAllowance(tokenConfig.address, spender, provider);
4801
7240
  const amountWeiCheck = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals));
4802
7241
  if (allowance < amountWeiCheck) {
4803
7242
  const nativeBalance = await provider.getBalance(this.wallet.address);
4804
- const minGasBalance = import_ethers3.ethers.parseEther("0.0005");
7243
+ const minGasBalance = import_ethers5.ethers.parseEther("0.0005");
4805
7244
  if (nativeBalance < minGasBalance) {
4806
- const nativeBNB = parseFloat(import_ethers3.ethers.formatEther(nativeBalance)).toFixed(4);
7245
+ const nativeBNB = parseFloat(import_ethers5.ethers.formatEther(nativeBalance)).toFixed(4);
4807
7246
  const isTestnet = chainName === "bnb_testnet";
4808
7247
  if (isTestnet) {
4809
7248
  throw new Error(
@@ -4976,7 +7415,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
4976
7415
  * Check ERC20 allowance for a spender
4977
7416
  */
4978
7417
  async checkAllowance(tokenAddress, spender, provider) {
4979
- const contract = new import_ethers3.ethers.Contract(
7418
+ const contract = new import_ethers5.ethers.Contract(
4980
7419
  tokenAddress,
4981
7420
  ["function allowance(address owner, address spender) view returns (uint256)"],
4982
7421
  provider
@@ -4995,7 +7434,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
4995
7434
  async signEIP3009(to, amount, chain, token = "USDC", domainOverride) {
4996
7435
  const tokenConfig = chain.tokens[token];
4997
7436
  const value = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals)).toString();
4998
- const nonce = import_ethers3.ethers.hexlify(import_ethers3.ethers.randomBytes(32));
7437
+ const nonce = import_ethers5.ethers.hexlify(import_ethers5.ethers.randomBytes(32));
4999
7438
  const tokenName = domainOverride?.name || tokenConfig.eip712Name || (token === "USDC" ? "USD Coin" : "Tether USD");
5000
7439
  const tokenVersion = domainOverride?.version || "2";
5001
7440
  const envelope = buildEIP3009TypedData({
@@ -5042,25 +7481,25 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5042
7481
  // --- Config & Wallet Management ---
5043
7482
  loadConfig() {
5044
7483
  const configPath = (0, import_path3.join)(this.configDir, "config.json");
5045
- if ((0, import_fs4.existsSync)(configPath)) {
5046
- const content = (0, import_fs4.readFileSync)(configPath, "utf-8");
7484
+ if ((0, import_fs5.existsSync)(configPath)) {
7485
+ const content = (0, import_fs5.readFileSync)(configPath, "utf-8");
5047
7486
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
5048
7487
  }
5049
7488
  return { ...DEFAULT_CONFIG };
5050
7489
  }
5051
7490
  saveConfig() {
5052
- (0, import_fs4.mkdirSync)(this.configDir, { recursive: true });
7491
+ (0, import_fs5.mkdirSync)(this.configDir, { recursive: true });
5053
7492
  const configPath = (0, import_path3.join)(this.configDir, "config.json");
5054
- (0, import_fs4.writeFileSync)(configPath, JSON.stringify(this.config, null, 2));
7493
+ (0, import_fs5.writeFileSync)(configPath, JSON.stringify(this.config, null, 2));
5055
7494
  }
5056
7495
  /**
5057
7496
  * Load spending data from disk
5058
7497
  */
5059
7498
  loadSpending() {
5060
7499
  const spendingPath = (0, import_path3.join)(this.configDir, "spending.json");
5061
- if ((0, import_fs4.existsSync)(spendingPath)) {
7500
+ if ((0, import_fs5.existsSync)(spendingPath)) {
5062
7501
  try {
5063
- const data = JSON.parse((0, import_fs4.readFileSync)(spendingPath, "utf-8"));
7502
+ const data = JSON.parse((0, import_fs5.readFileSync)(spendingPath, "utf-8"));
5064
7503
  const today = (/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0);
5065
7504
  if (data.date && data.date === today) {
5066
7505
  this.todaySpending = data.amount || 0;
@@ -5079,31 +7518,31 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5079
7518
  * Save spending data to disk
5080
7519
  */
5081
7520
  saveSpending() {
5082
- (0, import_fs4.mkdirSync)(this.configDir, { recursive: true });
7521
+ (0, import_fs5.mkdirSync)(this.configDir, { recursive: true });
5083
7522
  const spendingPath = (0, import_path3.join)(this.configDir, "spending.json");
5084
7523
  const data = {
5085
7524
  date: this.lastSpendingReset || (/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0),
5086
7525
  amount: this.todaySpending,
5087
7526
  updatedAt: Date.now()
5088
7527
  };
5089
- (0, import_fs4.writeFileSync)(spendingPath, JSON.stringify(data, null, 2));
7528
+ (0, import_fs5.writeFileSync)(spendingPath, JSON.stringify(data, null, 2));
5090
7529
  }
5091
7530
  loadWallet() {
5092
7531
  const walletPath = (0, import_path3.join)(this.configDir, "wallet.json");
5093
- if ((0, import_fs4.existsSync)(walletPath)) {
7532
+ if ((0, import_fs5.existsSync)(walletPath)) {
5094
7533
  if (process.platform !== "win32") {
5095
7534
  try {
5096
- const stats = (0, import_fs4.statSync)(walletPath);
7535
+ const stats = (0, import_fs5.statSync)(walletPath);
5097
7536
  const mode = stats.mode & 511;
5098
7537
  if (mode !== 384) {
5099
7538
  console.warn(`[MoltsPay] WARNING: wallet.json has insecure permissions (${mode.toString(8)})`);
5100
7539
  console.warn(`[MoltsPay] Fixing permissions to 0600...`);
5101
- (0, import_fs4.chmodSync)(walletPath, 384);
7540
+ (0, import_fs5.chmodSync)(walletPath, 384);
5102
7541
  }
5103
7542
  } catch {
5104
7543
  }
5105
7544
  }
5106
- const content = (0, import_fs4.readFileSync)(walletPath, "utf-8");
7545
+ const content = (0, import_fs5.readFileSync)(walletPath, "utf-8");
5107
7546
  return JSON.parse(content);
5108
7547
  }
5109
7548
  return null;
@@ -5112,15 +7551,15 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5112
7551
  * Initialize a new wallet (called by CLI)
5113
7552
  */
5114
7553
  static init(configDir, options) {
5115
- (0, import_fs4.mkdirSync)(configDir, { recursive: true });
5116
- const wallet = import_ethers3.Wallet.createRandom();
7554
+ (0, import_fs5.mkdirSync)(configDir, { recursive: true });
7555
+ const wallet = import_ethers5.Wallet.createRandom();
5117
7556
  const walletData = {
5118
7557
  address: wallet.address,
5119
7558
  privateKey: wallet.privateKey,
5120
7559
  createdAt: Date.now()
5121
7560
  };
5122
7561
  const walletPath = (0, import_path3.join)(configDir, "wallet.json");
5123
- (0, import_fs4.writeFileSync)(walletPath, JSON.stringify(walletData, null, 2), { mode: 384 });
7562
+ (0, import_fs5.writeFileSync)(walletPath, JSON.stringify(walletData, null, 2), { mode: 384 });
5124
7563
  const config = {
5125
7564
  chain: options.chain,
5126
7565
  limits: {
@@ -5129,7 +7568,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5129
7568
  }
5130
7569
  };
5131
7570
  const configPath = (0, import_path3.join)(configDir, "config.json");
5132
- (0, import_fs4.writeFileSync)(configPath, JSON.stringify(config, null, 2));
7571
+ (0, import_fs5.writeFileSync)(configPath, JSON.stringify(config, null, 2));
5133
7572
  return { address: wallet.address, configDir };
5134
7573
  }
5135
7574
  /**
@@ -5145,17 +7584,17 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5145
7584
  } catch {
5146
7585
  throw new Error(`Unknown chain: ${this.config.chain}`);
5147
7586
  }
5148
- const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
7587
+ const provider = new import_ethers5.ethers.JsonRpcProvider(chain.rpc);
5149
7588
  const tokenAbi = ["function balanceOf(address) view returns (uint256)"];
5150
7589
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
5151
7590
  provider.getBalance(this.wallet.address),
5152
- new import_ethers3.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
5153
- new import_ethers3.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
7591
+ new import_ethers5.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
7592
+ new import_ethers5.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
5154
7593
  ]);
5155
7594
  return {
5156
- usdc: parseFloat(import_ethers3.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
5157
- usdt: parseFloat(import_ethers3.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
5158
- native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance))
7595
+ usdc: parseFloat(import_ethers5.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
7596
+ usdt: parseFloat(import_ethers5.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
7597
+ native: parseFloat(import_ethers5.ethers.formatEther(nativeBalance))
5159
7598
  };
5160
7599
  }
5161
7600
  /**
@@ -5178,38 +7617,38 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5178
7617
  supportedChains.map(async (chainName) => {
5179
7618
  try {
5180
7619
  const chain = getChain(chainName);
5181
- const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
7620
+ const provider = new import_ethers5.ethers.JsonRpcProvider(chain.rpc);
5182
7621
  if (chainName === "tempo_moderato") {
5183
7622
  const [nativeBalance, pathUSD, alphaUSD, betaUSD, thetaUSD] = await Promise.all([
5184
7623
  provider.getBalance(this.wallet.address),
5185
- new import_ethers3.ethers.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5186
- new import_ethers3.ethers.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5187
- new import_ethers3.ethers.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5188
- new import_ethers3.ethers.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
7624
+ new import_ethers5.ethers.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7625
+ new import_ethers5.ethers.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7626
+ new import_ethers5.ethers.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7627
+ new import_ethers5.ethers.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
5189
7628
  ]);
5190
7629
  results[chainName] = {
5191
- usdc: parseFloat(import_ethers3.ethers.formatUnits(pathUSD, 6)),
7630
+ usdc: parseFloat(import_ethers5.ethers.formatUnits(pathUSD, 6)),
5192
7631
  // pathUSD as default USDC
5193
- usdt: parseFloat(import_ethers3.ethers.formatUnits(alphaUSD, 6)),
7632
+ usdt: parseFloat(import_ethers5.ethers.formatUnits(alphaUSD, 6)),
5194
7633
  // alphaUSD as default USDT
5195
- native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance)),
7634
+ native: parseFloat(import_ethers5.ethers.formatEther(nativeBalance)),
5196
7635
  tempo: {
5197
- pathUSD: parseFloat(import_ethers3.ethers.formatUnits(pathUSD, 6)),
5198
- alphaUSD: parseFloat(import_ethers3.ethers.formatUnits(alphaUSD, 6)),
5199
- betaUSD: parseFloat(import_ethers3.ethers.formatUnits(betaUSD, 6)),
5200
- thetaUSD: parseFloat(import_ethers3.ethers.formatUnits(thetaUSD, 6))
7636
+ pathUSD: parseFloat(import_ethers5.ethers.formatUnits(pathUSD, 6)),
7637
+ alphaUSD: parseFloat(import_ethers5.ethers.formatUnits(alphaUSD, 6)),
7638
+ betaUSD: parseFloat(import_ethers5.ethers.formatUnits(betaUSD, 6)),
7639
+ thetaUSD: parseFloat(import_ethers5.ethers.formatUnits(thetaUSD, 6))
5201
7640
  }
5202
7641
  };
5203
7642
  } else {
5204
7643
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
5205
7644
  provider.getBalance(this.wallet.address),
5206
- new import_ethers3.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
5207
- new import_ethers3.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
7645
+ new import_ethers5.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
7646
+ new import_ethers5.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
5208
7647
  ]);
5209
7648
  results[chainName] = {
5210
- usdc: parseFloat(import_ethers3.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
5211
- usdt: parseFloat(import_ethers3.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
5212
- native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance))
7649
+ usdc: parseFloat(import_ethers5.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
7650
+ usdt: parseFloat(import_ethers5.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
7651
+ native: parseFloat(import_ethers5.ethers.formatEther(nativeBalance))
5213
7652
  };
5214
7653
  }
5215
7654
  } catch (err) {
@@ -5337,11 +7776,11 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5337
7776
  };
5338
7777
 
5339
7778
  // src/wallet/Wallet.ts
5340
- var import_ethers4 = require("ethers");
7779
+ var import_ethers6 = require("ethers");
5341
7780
 
5342
7781
  // src/wallet/createWallet.ts
5343
- var import_ethers5 = require("ethers");
5344
- var import_fs5 = require("fs");
7782
+ var import_ethers7 = require("ethers");
7783
+ var import_fs6 = require("fs");
5345
7784
  var import_path4 = require("path");
5346
7785
  var import_crypto = require("crypto");
5347
7786
  var DEFAULT_STORAGE_DIR = (0, import_path4.join)(process.env.HOME || "~", ".moltspay");
@@ -5368,9 +7807,9 @@ function decryptPrivateKey(encrypted, password, iv, salt) {
5368
7807
  }
5369
7808
  function createWallet(options = {}) {
5370
7809
  const storagePath = options.storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5371
- if ((0, import_fs5.existsSync)(storagePath) && !options.overwrite) {
7810
+ if ((0, import_fs6.existsSync)(storagePath) && !options.overwrite) {
5372
7811
  try {
5373
- const existing = JSON.parse((0, import_fs5.readFileSync)(storagePath, "utf8"));
7812
+ const existing = JSON.parse((0, import_fs6.readFileSync)(storagePath, "utf8"));
5374
7813
  return {
5375
7814
  success: true,
5376
7815
  address: existing.address,
@@ -5385,7 +7824,7 @@ function createWallet(options = {}) {
5385
7824
  }
5386
7825
  }
5387
7826
  try {
5388
- const wallet = import_ethers5.ethers.Wallet.createRandom();
7827
+ const wallet = import_ethers7.ethers.Wallet.createRandom();
5389
7828
  const walletData = {
5390
7829
  address: wallet.address,
5391
7830
  label: options.label,
@@ -5402,10 +7841,10 @@ function createWallet(options = {}) {
5402
7841
  walletData.privateKey = wallet.privateKey;
5403
7842
  }
5404
7843
  const dir = (0, import_path4.dirname)(storagePath);
5405
- if (!(0, import_fs5.existsSync)(dir)) {
5406
- (0, import_fs5.mkdirSync)(dir, { recursive: true });
7844
+ if (!(0, import_fs6.existsSync)(dir)) {
7845
+ (0, import_fs6.mkdirSync)(dir, { recursive: true });
5407
7846
  }
5408
- (0, import_fs5.writeFileSync)(storagePath, JSON.stringify(walletData, null, 2), { mode: 384 });
7847
+ (0, import_fs6.writeFileSync)(storagePath, JSON.stringify(walletData, null, 2), { mode: 384 });
5409
7848
  return {
5410
7849
  success: true,
5411
7850
  address: wallet.address,
@@ -5421,11 +7860,11 @@ function createWallet(options = {}) {
5421
7860
  }
5422
7861
  function loadWallet(options = {}) {
5423
7862
  const storagePath = options.storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5424
- if (!(0, import_fs5.existsSync)(storagePath)) {
7863
+ if (!(0, import_fs6.existsSync)(storagePath)) {
5425
7864
  return { success: false, error: "Wallet not found. Run createWallet() first." };
5426
7865
  }
5427
7866
  try {
5428
- const data = JSON.parse((0, import_fs5.readFileSync)(storagePath, "utf8"));
7867
+ const data = JSON.parse((0, import_fs6.readFileSync)(storagePath, "utf8"));
5429
7868
  if (data.encrypted) {
5430
7869
  if (!options.password) {
5431
7870
  return { success: false, error: "Wallet is encrypted. Password required." };
@@ -5440,174 +7879,26 @@ function loadWallet(options = {}) {
5440
7879
  }
5441
7880
  }
5442
7881
  function getWalletAddress(storagePath) {
5443
- const path4 = storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5444
- if (!(0, import_fs5.existsSync)(path4)) {
7882
+ const path5 = storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7883
+ if (!(0, import_fs6.existsSync)(path5)) {
5445
7884
  return null;
5446
7885
  }
5447
7886
  try {
5448
- const data = JSON.parse((0, import_fs5.readFileSync)(path4, "utf8"));
7887
+ const data = JSON.parse((0, import_fs6.readFileSync)(path5, "utf8"));
5449
7888
  return data.address;
5450
7889
  } catch {
5451
7890
  return null;
5452
7891
  }
5453
7892
  }
5454
7893
  function walletExists(storagePath) {
5455
- const path4 = storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5456
- return (0, import_fs5.existsSync)(path4);
5457
- }
5458
-
5459
- // src/verify/index.ts
5460
- var import_ethers6 = require("ethers");
5461
- var TRANSFER_EVENT_TOPIC3 = import_ethers6.ethers.id("Transfer(address,address,uint256)");
5462
- async function verifyPayment(params) {
5463
- const { txHash, expectedAmount, expectedTo, expectedToken } = params;
5464
- let chain;
5465
- try {
5466
- if (typeof params.chain === "number") {
5467
- chain = getChainById(params.chain);
5468
- } else {
5469
- chain = getChain(params.chain || "base");
5470
- }
5471
- if (!chain) {
5472
- return { verified: false, error: `Unsupported chain: ${params.chain}` };
5473
- }
5474
- } catch (e) {
5475
- return { verified: false, error: `Unsupported chain: ${params.chain}` };
5476
- }
5477
- try {
5478
- const provider = new import_ethers6.ethers.JsonRpcProvider(chain.rpc);
5479
- const receipt = await provider.getTransactionReceipt(txHash);
5480
- if (!receipt) {
5481
- return { verified: false, error: "Transaction not found or not confirmed" };
5482
- }
5483
- if (receipt.status !== 1) {
5484
- return { verified: false, error: "Transaction failed" };
5485
- }
5486
- const tokenAddresses = {};
5487
- if (!expectedToken || expectedToken === "USDC") {
5488
- tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
5489
- }
5490
- if (!expectedToken || expectedToken === "USDT") {
5491
- tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
5492
- }
5493
- if (Object.keys(tokenAddresses).length === 0) {
5494
- return { verified: false, error: `No token addresses configured for ${chain.name}` };
5495
- }
5496
- for (const log of receipt.logs) {
5497
- const logAddress = log.address.toLowerCase();
5498
- const detectedToken = tokenAddresses[logAddress];
5499
- if (!detectedToken) {
5500
- continue;
5501
- }
5502
- if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
5503
- continue;
5504
- }
5505
- const from = "0x" + log.topics[1].slice(-40);
5506
- const to = "0x" + log.topics[2].slice(-40);
5507
- const amountRaw = BigInt(log.data);
5508
- const tokenConfig = chain.tokens[detectedToken];
5509
- const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
5510
- if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
5511
- continue;
5512
- }
5513
- if (amount < expectedAmount) {
5514
- return {
5515
- verified: false,
5516
- error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
5517
- amount,
5518
- token: detectedToken,
5519
- from,
5520
- to,
5521
- txHash,
5522
- blockNumber: receipt.blockNumber
5523
- };
5524
- }
5525
- return {
5526
- verified: true,
5527
- amount,
5528
- token: detectedToken,
5529
- from,
5530
- to,
5531
- txHash,
5532
- blockNumber: receipt.blockNumber
5533
- };
5534
- }
5535
- const tokenList = expectedToken ? expectedToken : "USDC/USDT";
5536
- return { verified: false, error: `No ${tokenList} transfer found` };
5537
- } catch (e) {
5538
- return { verified: false, error: e.message || String(e) };
5539
- }
5540
- }
5541
- async function getTransactionStatus(txHash, chain = "base") {
5542
- let chainConfig;
5543
- try {
5544
- chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
5545
- if (!chainConfig) return { status: "not_found" };
5546
- } catch {
5547
- return { status: "not_found" };
5548
- }
5549
- try {
5550
- const provider = new import_ethers6.ethers.JsonRpcProvider(chainConfig.rpc);
5551
- const receipt = await provider.getTransactionReceipt(txHash);
5552
- if (!receipt) {
5553
- const tx = await provider.getTransaction(txHash);
5554
- if (tx) {
5555
- return { status: "pending" };
5556
- }
5557
- return { status: "not_found" };
5558
- }
5559
- const currentBlock = await provider.getBlockNumber();
5560
- const confirmations = currentBlock - receipt.blockNumber;
5561
- if (receipt.status === 1) {
5562
- return {
5563
- status: "confirmed",
5564
- blockNumber: receipt.blockNumber,
5565
- confirmations
5566
- };
5567
- } else {
5568
- return {
5569
- status: "failed",
5570
- blockNumber: receipt.blockNumber
5571
- };
5572
- }
5573
- } catch {
5574
- return { status: "not_found" };
5575
- }
5576
- }
5577
- async function waitForTransaction(txHash, chain = "base", confirmations = 1, timeoutMs = 6e4) {
5578
- let chainConfig;
5579
- try {
5580
- chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
5581
- if (!chainConfig) {
5582
- return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
5583
- }
5584
- } catch (e) {
5585
- return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
5586
- }
5587
- const provider = new import_ethers6.ethers.JsonRpcProvider(chainConfig.rpc);
5588
- try {
5589
- const receipt = await provider.waitForTransaction(txHash, confirmations, timeoutMs);
5590
- if (!receipt) {
5591
- return { verified: false, confirmed: false, error: "Timeout waiting" };
5592
- }
5593
- if (receipt.status !== 1) {
5594
- return { verified: false, confirmed: true, error: "Transaction failed" };
5595
- }
5596
- return {
5597
- verified: true,
5598
- confirmed: true,
5599
- txHash,
5600
- blockNumber: receipt.blockNumber
5601
- };
5602
- } catch (e) {
5603
- return { verified: false, confirmed: false, error: e.message || String(e) };
5604
- }
7894
+ const path5 = storagePath || (0, import_path4.join)(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7895
+ return (0, import_fs6.existsSync)(path5);
5605
7896
  }
5606
7897
 
5607
7898
  // src/cdp/index.ts
5608
7899
  var fs = __toESM(require("fs"));
5609
- var path3 = __toESM(require("path"));
5610
- var DEFAULT_STORAGE_DIR2 = path3.join(process.env.HOME || ".", ".moltspay");
7900
+ var path4 = __toESM(require("path"));
7901
+ var DEFAULT_STORAGE_DIR2 = path4.join(process.env.HOME || ".", ".moltspay");
5611
7902
  var CDP_CONFIG_FILE = "cdp-wallet.json";
5612
7903
  function isCDPAvailable() {
5613
7904
  try {
@@ -5629,7 +7920,7 @@ function getCDPCredentials(config) {
5629
7920
  async function initCDPWallet(config = {}) {
5630
7921
  const storageDir = config.storageDir || DEFAULT_STORAGE_DIR2;
5631
7922
  const chain = config.chain || "base";
5632
- const storagePath = path3.join(storageDir, CDP_CONFIG_FILE);
7923
+ const storagePath = path4.join(storageDir, CDP_CONFIG_FILE);
5633
7924
  if (fs.existsSync(storagePath)) {
5634
7925
  try {
5635
7926
  const data = JSON.parse(fs.readFileSync(storagePath, "utf-8"));
@@ -5691,7 +7982,7 @@ async function initCDPWallet(config = {}) {
5691
7982
  }
5692
7983
  function loadCDPWallet(config = {}) {
5693
7984
  const storageDir = config.storageDir || DEFAULT_STORAGE_DIR2;
5694
- const storagePath = path3.join(storageDir, CDP_CONFIG_FILE);
7985
+ const storagePath = path4.join(storageDir, CDP_CONFIG_FILE);
5695
7986
  if (!fs.existsSync(storagePath)) {
5696
7987
  return null;
5697
7988
  }
@@ -5724,9 +8015,9 @@ var CDPWallet = class {
5724
8015
  * Get USDC balance
5725
8016
  */
5726
8017
  async getBalance() {
5727
- const { ethers: ethers7 } = await import("ethers");
5728
- const provider = new ethers7.JsonRpcProvider(this.chainConfig.rpc);
5729
- const usdcContract = new ethers7.Contract(
8018
+ const { ethers: ethers8 } = await import("ethers");
8019
+ const provider = new ethers8.JsonRpcProvider(this.chainConfig.rpc);
8020
+ const usdcContract = new ethers8.Contract(
5730
8021
  this.chainConfig.usdc,
5731
8022
  ["function balanceOf(address) view returns (uint256)"],
5732
8023
  provider
@@ -5737,7 +8028,7 @@ var CDPWallet = class {
5737
8028
  ]);
5738
8029
  return {
5739
8030
  usdc: (Number(usdcBalance) / 1e6).toFixed(2),
5740
- eth: ethers7.formatEther(ethBalance)
8031
+ eth: ethers8.formatEther(ethBalance)
5741
8032
  };
5742
8033
  }
5743
8034
  /**
@@ -5755,7 +8046,7 @@ var CDPWallet = class {
5755
8046
  }
5756
8047
  try {
5757
8048
  const { CdpClient } = await import("@coinbase/cdp-sdk");
5758
- const { ethers: ethers7 } = await import("ethers");
8049
+ const { ethers: ethers8 } = await import("ethers");
5759
8050
  const cdp = new CdpClient({
5760
8051
  apiKeyId: creds.apiKeyId,
5761
8052
  apiKeySecret: creds.apiKeySecret,
@@ -5763,7 +8054,7 @@ var CDPWallet = class {
5763
8054
  });
5764
8055
  const account = await cdp.evm.getAccount({ address: this.address });
5765
8056
  const amountWei = BigInt(Math.floor(params.amount * 1e6));
5766
- const iface = new ethers7.Interface([
8057
+ const iface = new ethers8.Interface([
5767
8058
  "function transfer(address to, uint256 amount) returns (bool)"
5768
8059
  ]);
5769
8060
  const callData = iface.encodeFunctionData("transfer", [params.to, amountWei]);
@@ -5818,6 +8109,7 @@ var CDPWallet = class {
5818
8109
  FacilitatorRegistry,
5819
8110
  MoltsPayClient,
5820
8111
  MoltsPayServer,
8112
+ WechatClient,
5821
8113
  alipayLog,
5822
8114
  createRegistry,
5823
8115
  createWallet,