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.mjs CHANGED
@@ -6,9 +6,10 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/server/index.ts
9
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
9
+ import { readFileSync as readFileSync3 } from "fs";
10
10
  import { createServer } from "http";
11
- import * as path2 from "path";
11
+ import * as path3 from "path";
12
+ import crypto6 from "crypto";
12
13
 
13
14
  // src/facilitators/interface.ts
14
15
  var BaseFacilitator = class {
@@ -422,6 +423,14 @@ var ALIPAY_CHAIN_ID = "alipay";
422
423
  function isAlipayChainId(id) {
423
424
  return id === ALIPAY_CHAIN_ID;
424
425
  }
426
+ var WECHAT_CHAIN_ID = "wechat";
427
+ function isWechatChainId(id) {
428
+ return id === WECHAT_CHAIN_ID;
429
+ }
430
+ var BALANCE_CHAIN_ID = "balance";
431
+ function isBalanceChainId(id) {
432
+ return id === BALANCE_CHAIN_ID;
433
+ }
425
434
  var ERC20_ABI = [
426
435
  "function balanceOf(address owner) view returns (uint256)",
427
436
  "function transfer(address to, uint256 amount) returns (bool)",
@@ -893,12 +902,12 @@ var BNBFacilitator = class extends BaseFacilitator {
893
902
  return this.spenderAddress;
894
903
  }
895
904
  async getServerAddress() {
896
- const { ethers: ethers7 } = await import("ethers");
897
- const wallet = new ethers7.Wallet(this.serverPrivateKey);
905
+ const { ethers: ethers8 } = await import("ethers");
906
+ const wallet = new ethers8.Wallet(this.serverPrivateKey);
898
907
  return wallet.address;
899
908
  }
900
909
  async recoverIntentSigner(intent, chainId) {
901
- const { ethers: ethers7 } = await import("ethers");
910
+ const { ethers: ethers8 } = await import("ethers");
902
911
  const domain = {
903
912
  ...EIP712_DOMAIN,
904
913
  chainId
@@ -912,7 +921,7 @@ var BNBFacilitator = class extends BaseFacilitator {
912
921
  nonce: intent.nonce,
913
922
  deadline: intent.deadline
914
923
  };
915
- const recoveredAddress = ethers7.verifyTypedData(
924
+ const recoveredAddress = ethers8.verifyTypedData(
916
925
  domain,
917
926
  INTENT_TYPES,
918
927
  message,
@@ -956,10 +965,10 @@ var BNBFacilitator = class extends BaseFacilitator {
956
965
  return result.result || "0x0";
957
966
  }
958
967
  async executeTransferFrom(from, to, amount, token, rpcUrl) {
959
- const { ethers: ethers7 } = await import("ethers");
960
- const provider = new ethers7.JsonRpcProvider(rpcUrl);
961
- const wallet = new ethers7.Wallet(this.serverPrivateKey, provider);
962
- const tokenContract = new ethers7.Contract(token, [
968
+ const { ethers: ethers8 } = await import("ethers");
969
+ const provider = new ethers8.JsonRpcProvider(rpcUrl);
970
+ const wallet = new ethers8.Wallet(this.serverPrivateKey, provider);
971
+ const tokenContract = new ethers8.Contract(token, [
963
972
  "function transferFrom(address from, address to, uint256 amount) returns (bool)"
964
973
  ], wallet);
965
974
  const tx = await tokenContract.transferFrom(from, to, amount);
@@ -1340,7 +1349,7 @@ var ALIPAY_SIGNING_FIELDS = [
1340
1349
  ];
1341
1350
  var AlipayFacilitator = class extends BaseFacilitator {
1342
1351
  name = "alipay";
1343
- displayName = "Alipay AI \u6536";
1352
+ displayName = "Alipay AI Pay";
1344
1353
  supportedNetworks = [ALIPAY_NETWORK];
1345
1354
  config;
1346
1355
  constructor(config) {
@@ -1359,7 +1368,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1359
1368
  async createPaymentRequirements(opts) {
1360
1369
  if (!ALIPAY_AMOUNT_REGEX.test(opts.priceCny)) {
1361
1370
  throw new Error(
1362
- `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is \u5143, not \u5206; e.g. "1.00" not "100")`
1371
+ `AlipayFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan, not fen; e.g. "1.00" not "100")`
1363
1372
  );
1364
1373
  }
1365
1374
  const now = /* @__PURE__ */ new Date();
@@ -1472,7 +1481,7 @@ var AlipayFacilitator = class extends BaseFacilitator {
1472
1481
  * service resource has been returned to the buyer.
1473
1482
  *
1474
1483
  * Per the design (see ALIPAY-INTEGRATION-DESIGN.md §5.1, risk row
1475
- * "履约确认失败"), this is **fire-and-forget**: the caller (registry /
1484
+ * (a fulfillment-confirm failure), this is **fire-and-forget**: the caller (registry /
1476
1485
  * server) is expected to log fulfillment failures but NOT roll back
1477
1486
  * the already-delivered resource.
1478
1487
  *
@@ -1609,206 +1618,1082 @@ function decodeProof(proofHeader) {
1609
1618
  return parsed;
1610
1619
  }
1611
1620
 
1612
- // src/facilitators/registry.ts
1613
- import { Keypair as Keypair2 } from "@solana/web3.js";
1614
- import bs58 from "bs58";
1615
- var FacilitatorRegistry = class {
1616
- factories = /* @__PURE__ */ new Map();
1617
- instances = /* @__PURE__ */ new Map();
1618
- selection;
1619
- roundRobinIndex = 0;
1620
- constructor(selection) {
1621
- this.registerFactory("cdp", (config) => new CDPFacilitator(config));
1622
- this.registerFactory("tempo", () => new TempoFacilitator());
1623
- this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
1624
- this.registerFactory("solana", (config) => {
1625
- let feePayerKeypair;
1626
- const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
1627
- if (feePayerKey) {
1628
- try {
1629
- feePayerKeypair = Keypair2.fromSecretKey(bs58.decode(feePayerKey));
1630
- } catch (e) {
1631
- console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
1632
- }
1633
- }
1634
- return new SolanaFacilitator({ feePayerKeypair });
1635
- });
1636
- this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
1637
- this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
1621
+ // src/facilitators/wechat.ts
1622
+ import crypto4 from "crypto";
1623
+
1624
+ // src/facilitators/wechat/sign.ts
1625
+ import crypto3 from "crypto";
1626
+ var WECHAT_AUTH_SCHEMA = "WECHATPAY2-SHA256-RSA2048";
1627
+ function buildRequestMessage(method, urlPath, timestamp, nonce, body) {
1628
+ return `${method.toUpperCase()}
1629
+ ${urlPath}
1630
+ ${timestamp}
1631
+ ${nonce}
1632
+ ${body}
1633
+ `;
1634
+ }
1635
+ function wechatV3Sign(method, urlPath, timestamp, nonce, body, privateKeyPem) {
1636
+ const message = buildRequestMessage(method, urlPath, timestamp, nonce, body);
1637
+ const signer = crypto3.createSign("RSA-SHA256");
1638
+ signer.update(message, "utf-8");
1639
+ signer.end();
1640
+ return signer.sign(privateKeyPem, "base64");
1641
+ }
1642
+ function buildAuthorizationToken(args) {
1643
+ const fields = [
1644
+ `mchid="${args.mchid}"`,
1645
+ `nonce_str="${args.nonce}"`,
1646
+ `signature="${args.signature}"`,
1647
+ `timestamp="${args.timestamp}"`,
1648
+ `serial_no="${args.serialNo}"`
1649
+ ].join(",");
1650
+ return `${WECHAT_AUTH_SCHEMA} ${fields}`;
1651
+ }
1652
+ function wechatV3VerifyResponse(timestamp, nonce, body, signature, platformPublicKeyPem) {
1653
+ try {
1654
+ const message = `${timestamp}
1655
+ ${nonce}
1656
+ ${body}
1657
+ `;
1658
+ const verifier = crypto3.createVerify("RSA-SHA256");
1659
+ verifier.update(message, "utf-8");
1660
+ verifier.end();
1661
+ return verifier.verify(platformPublicKeyPem, signature, "base64");
1662
+ } catch {
1663
+ return false;
1638
1664
  }
1639
- /**
1640
- * Register a new facilitator factory
1641
- */
1642
- registerFactory(name, factory) {
1643
- this.factories.set(name, factory);
1665
+ }
1666
+ function generateNonce() {
1667
+ return crypto3.randomBytes(16).toString("hex");
1668
+ }
1669
+
1670
+ // src/facilitators/wechat/api.ts
1671
+ var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
1672
+ var WechatApiError = class extends Error {
1673
+ status;
1674
+ code;
1675
+ constructor(message, status, code) {
1676
+ super(message);
1677
+ this.name = "WechatApiError";
1678
+ this.status = status;
1679
+ this.code = code;
1644
1680
  }
1645
- /**
1646
- * Get or create a facilitator instance
1647
- */
1648
- get(name, config) {
1649
- if (this.instances.has(name)) {
1650
- return this.instances.get(name);
1681
+ };
1682
+ async function wechatV3Call(method, urlPath, body, config) {
1683
+ const base = config.api_base ?? WECHAT_API_BASE;
1684
+ const bodyStr = body === null ? "" : JSON.stringify(body);
1685
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1686
+ const nonce = generateNonce();
1687
+ const signature = wechatV3Sign(
1688
+ method,
1689
+ urlPath,
1690
+ timestamp,
1691
+ nonce,
1692
+ bodyStr,
1693
+ config.private_key_pem
1694
+ );
1695
+ const authorization = buildAuthorizationToken({
1696
+ mchid: config.mchid,
1697
+ serialNo: config.serial_no,
1698
+ nonce,
1699
+ timestamp,
1700
+ signature
1701
+ });
1702
+ const response = await fetch(`${base}${urlPath}`, {
1703
+ method,
1704
+ headers: {
1705
+ Authorization: authorization,
1706
+ Accept: "application/json",
1707
+ "Content-Type": "application/json",
1708
+ // WeChat requires a non-empty UA; some edge nodes 403 a blank one.
1709
+ "User-Agent": "moltspay-wechat/1.0"
1710
+ },
1711
+ body: method === "GET" ? void 0 : bodyStr
1712
+ });
1713
+ const text = await response.text();
1714
+ if (config.platform_public_key_pem && text.length > 0) {
1715
+ const ts = response.headers.get("Wechatpay-Timestamp");
1716
+ const nc = response.headers.get("Wechatpay-Nonce");
1717
+ const sig = response.headers.get("Wechatpay-Signature");
1718
+ if (!ts || !nc || !sig) {
1719
+ throw new Error(
1720
+ `WeChat v3 ${method} ${urlPath}: response missing Wechatpay-Signature headers`
1721
+ );
1651
1722
  }
1652
- const factory = this.factories.get(name);
1653
- if (!factory) {
1654
- throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
1723
+ if (!wechatV3VerifyResponse(ts, nc, text, sig, config.platform_public_key_pem)) {
1724
+ throw new Error(
1725
+ `WeChat v3 ${method} ${urlPath}: response signature verification failed`
1726
+ );
1655
1727
  }
1656
- const mergedConfig = {
1657
- ...this.selection.config?.[name],
1658
- ...config
1659
- };
1660
- const instance = factory(mergedConfig);
1661
- this.instances.set(name, instance);
1662
- return instance;
1663
1728
  }
1664
- /**
1665
- * Get all configured facilitator names
1666
- */
1667
- getConfiguredNames() {
1668
- const names = [this.selection.primary];
1669
- if (this.selection.fallback) {
1670
- names.push(...this.selection.fallback);
1729
+ let json = {};
1730
+ if (text.length > 0) {
1731
+ try {
1732
+ json = JSON.parse(text);
1733
+ } catch {
1734
+ throw new Error(
1735
+ `WeChat v3 ${method} ${urlPath}: non-JSON response (HTTP ${response.status}): ${text.slice(0, 300)}`
1736
+ );
1671
1737
  }
1672
- return names;
1738
+ }
1739
+ if (!response.ok) {
1740
+ const code = typeof json.code === "string" ? json.code : void 0;
1741
+ const message = typeof json.message === "string" ? json.message : text.slice(0, 300);
1742
+ throw new WechatApiError(
1743
+ `WeChat v3 ${method} ${urlPath} failed: HTTP ${response.status}${code ? ` ${code}` : ""}: ${message}`,
1744
+ response.status,
1745
+ code
1746
+ );
1747
+ }
1748
+ return { status: response.status, body: json };
1749
+ }
1750
+
1751
+ // src/facilitators/wechat.ts
1752
+ var WECHAT_NETWORK = "wechat";
1753
+ var WECHAT_SCHEME = "wechatpay-native";
1754
+ var WECHAT_AMOUNT_REGEX = /^\d+(\.\d{1,2})?$/;
1755
+ var WECHAT_TIME_EXPIRE_MS = 5 * 60 * 1e3;
1756
+ var WechatFacilitator = class extends BaseFacilitator {
1757
+ name = "wechat";
1758
+ displayName = "WeChat Pay";
1759
+ supportedNetworks = [WECHAT_NETWORK];
1760
+ config;
1761
+ constructor(config) {
1762
+ super();
1763
+ this.config = { api_base: WECHAT_API_BASE, ...config };
1673
1764
  }
1674
1765
  /**
1675
- * Get list of facilitators based on selection strategy
1766
+ * Place a Native order and build the 402 challenge. The returned
1767
+ * `code_url` is payer-agnostic — any WeChat user may scan it.
1676
1768
  */
1677
- async getOrderedFacilitators(network) {
1678
- const names = this.getConfiguredNames();
1679
- const facilitators = [];
1680
- for (const name of names) {
1681
- try {
1682
- const f = this.get(name);
1683
- if (f.supportsNetwork(network)) {
1684
- facilitators.push(f);
1685
- }
1686
- } catch (err) {
1687
- console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
1688
- }
1689
- }
1690
- if (facilitators.length === 0) {
1691
- throw new Error(`No facilitators available for network: ${network}`);
1769
+ async createPaymentRequirements(opts) {
1770
+ if (!WECHAT_AMOUNT_REGEX.test(opts.priceCny)) {
1771
+ throw new Error(
1772
+ `WechatFacilitator.createPaymentRequirements: priceCny "${opts.priceCny}" does not match /^\\d+(\\.\\d{1,2})?$/ (unit is yuan; e.g. "10.00")`
1773
+ );
1692
1774
  }
1693
- switch (this.selection.strategy) {
1694
- case "random":
1695
- return this.shuffle(facilitators);
1696
- case "roundrobin":
1697
- this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
1698
- return [
1699
- ...facilitators.slice(this.roundRobinIndex),
1700
- ...facilitators.slice(0, this.roundRobinIndex)
1701
- ];
1702
- case "cheapest":
1703
- return this.sortByCheapest(facilitators);
1704
- case "fastest":
1705
- return this.sortByFastest(facilitators);
1706
- case "failover":
1707
- default:
1708
- return facilitators;
1775
+ const total = cnyToFen(opts.priceCny);
1776
+ if (total < 1) {
1777
+ throw new Error(
1778
+ `WechatFacilitator.createPaymentRequirements: amount ${total} fen is below the 1 fen minimum`
1779
+ );
1709
1780
  }
1710
- }
1711
- shuffle(array) {
1712
- const result = [...array];
1713
- for (let i = result.length - 1; i > 0; i--) {
1714
- const j = Math.floor(Math.random() * (i + 1));
1715
- [result[i], result[j]] = [result[j], result[i]];
1781
+ const outTradeNo = opts.outTradeNo ?? generateOutTradeNo2();
1782
+ const expiresInMs = opts.expiresInMs ?? WECHAT_TIME_EXPIRE_MS;
1783
+ const body = {
1784
+ appid: this.config.appid,
1785
+ mchid: this.config.mchid,
1786
+ description: opts.description,
1787
+ out_trade_no: outTradeNo,
1788
+ notify_url: this.config.notify_url,
1789
+ time_expire: formatTimeExpire(new Date(Date.now() + expiresInMs)),
1790
+ amount: { total, currency: "CNY" }
1791
+ };
1792
+ if (opts.attach) {
1793
+ const attachStr = JSON.stringify(opts.attach);
1794
+ if (Buffer.byteLength(attachStr, "utf8") > 128) {
1795
+ throw new Error(
1796
+ `WechatFacilitator.createPaymentRequirements: attach exceeds WeChat's 128-byte limit (${Buffer.byteLength(attachStr, "utf8")} bytes)`
1797
+ );
1798
+ }
1799
+ body.attach = attachStr;
1716
1800
  }
1717
- return result;
1718
- }
1719
- async sortByCheapest(facilitators) {
1720
- const withFees = await Promise.all(
1721
- facilitators.map(async (f) => {
1722
- try {
1723
- const fee = await f.getFee?.();
1724
- return { facilitator: f, perTx: fee?.perTx ?? Infinity };
1725
- } catch {
1726
- return { facilitator: f, perTx: Infinity };
1727
- }
1728
- })
1729
- );
1730
- withFees.sort((a, b) => a.perTx - b.perTx);
1731
- return withFees.map((w) => w.facilitator);
1732
- }
1733
- async sortByFastest(facilitators) {
1734
- const withLatency = await Promise.all(
1735
- facilitators.map(async (f) => {
1736
- try {
1737
- const health = await f.healthCheck();
1738
- return { facilitator: f, latency: health.latencyMs ?? Infinity };
1739
- } catch {
1740
- return { facilitator: f, latency: Infinity };
1741
- }
1742
- })
1801
+ const { body: resp } = await wechatV3Call(
1802
+ "POST",
1803
+ "/v3/pay/transactions/native",
1804
+ body,
1805
+ this.getApiConfig()
1743
1806
  );
1744
- withLatency.sort((a, b) => a.latency - b.latency);
1745
- return withLatency.map((w) => w.facilitator);
1807
+ const codeUrl = resp.code_url;
1808
+ if (typeof codeUrl !== "string" || codeUrl.length === 0) {
1809
+ throw new Error(
1810
+ `WeChat Native order returned no code_url: ${JSON.stringify(resp).slice(0, 300)}`
1811
+ );
1812
+ }
1813
+ const x402Accepts = {
1814
+ scheme: WECHAT_SCHEME,
1815
+ network: WECHAT_NETWORK,
1816
+ asset: "CNY",
1817
+ amount: opts.priceCny,
1818
+ payTo: this.config.mchid,
1819
+ maxTimeoutSeconds: Math.floor(expiresInMs / 1e3),
1820
+ extra: {
1821
+ code_url: codeUrl,
1822
+ out_trade_no: outTradeNo
1823
+ }
1824
+ };
1825
+ return { x402Accepts, codeUrl, outTradeNo };
1746
1826
  }
1747
1827
  /**
1748
- * Verify payment using configured facilitators
1828
+ * Poll an order: `trade_state === 'SUCCESS'` ⇒ paid. All failure modes
1829
+ * (missing out_trade_no, gateway error, not-yet-paid) return
1830
+ * `{ valid: false, error }`; no exception escapes.
1749
1831
  */
1750
1832
  async verify(paymentPayload, requirements) {
1751
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1752
- const facilitators = await this.getOrderedFacilitators(network);
1753
- let lastError;
1754
- for (const f of facilitators) {
1755
- try {
1756
- console.log(`[Registry] Trying ${f.name} for verify...`);
1757
- const result = await f.verify(paymentPayload, requirements);
1758
- if (result.valid) {
1759
- console.log(`[Registry] ${f.name} verify succeeded`);
1760
- return { ...result, facilitator: f.name };
1761
- }
1762
- lastError = result.error;
1763
- console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
1764
- if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
1765
- break;
1766
- }
1767
- } catch (err) {
1768
- lastError = err.message;
1769
- console.error(`[Registry] ${f.name} error:`, err.message);
1833
+ try {
1834
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1835
+ const resp = await this.queryOrder(outTradeNo);
1836
+ const tradeState = resp.trade_state;
1837
+ if (tradeState !== "SUCCESS") {
1838
+ return {
1839
+ valid: false,
1840
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"}`,
1841
+ details: { trade_state: tradeState, out_trade_no: outTradeNo }
1842
+ };
1770
1843
  }
1844
+ return {
1845
+ valid: true,
1846
+ details: {
1847
+ trade_state: tradeState,
1848
+ transaction_id: resp.transaction_id,
1849
+ out_trade_no: resp.out_trade_no ?? outTradeNo,
1850
+ amount: resp.amount,
1851
+ attach: resp.attach,
1852
+ // Payer identity, gateway-attested. Present on a SUCCESS Native
1853
+ // order even though order creation was payer-agnostic; anchors the
1854
+ // custodial balance to a real WeChat user. @see WECHAT fiat auth design.
1855
+ openid: resp.payer?.openid
1856
+ }
1857
+ };
1858
+ } catch (e) {
1859
+ return { valid: false, error: e instanceof Error ? e.message : String(e) };
1771
1860
  }
1772
- return {
1773
- valid: false,
1774
- error: lastError || "All facilitators failed",
1775
- facilitator: "none"
1776
- };
1777
1861
  }
1778
1862
  /**
1779
- * Settle payment using configured facilitators
1863
+ * Confirm settlement. Native captures funds at SUCCESS, so this is an
1864
+ * idempotent re-confirm that returns the `transaction_id`. Like Alipay's
1865
+ * fulfillment confirm, failures are surfaced but non-fatal to an
1866
+ * already-delivered resource (caller logs, does not roll back).
1780
1867
  */
1781
1868
  async settle(paymentPayload, requirements) {
1782
- const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
1783
- const facilitators = await this.getOrderedFacilitators(network);
1784
- let lastError;
1785
- for (const f of facilitators) {
1786
- try {
1787
- console.log(`[Registry] Trying ${f.name} for settle...`);
1788
- const result = await f.settle(paymentPayload, requirements);
1789
- if (result.success) {
1790
- console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
1791
- return { ...result, facilitator: f.name };
1792
- }
1793
- lastError = result.error;
1794
- console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
1795
- } catch (err) {
1796
- lastError = err.message;
1797
- console.error(`[Registry] ${f.name} error:`, err.message);
1869
+ try {
1870
+ const outTradeNo = extractOutTradeNo(paymentPayload, requirements);
1871
+ const resp = await this.queryOrder(outTradeNo);
1872
+ const tradeState = resp.trade_state;
1873
+ const transactionId = resp.transaction_id;
1874
+ if (tradeState !== "SUCCESS") {
1875
+ return {
1876
+ success: false,
1877
+ transaction: transactionId,
1878
+ error: `wechat trade_state ${tradeState ?? "UNKNOWN"} (expected SUCCESS)`,
1879
+ status: tradeState
1880
+ };
1798
1881
  }
1882
+ return { success: true, transaction: transactionId, status: "fulfilled" };
1883
+ } catch (e) {
1884
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1799
1885
  }
1800
- return {
1801
- success: false,
1802
- error: lastError || "All facilitators failed",
1803
- facilitator: "none"
1804
- };
1805
1886
  }
1806
1887
  /**
1807
- * Check health of all configured facilitators
1888
+ * Validate keys parse, apiv3 key length, and gateway reachability. Does
1889
+ * NOT make a business API call.
1808
1890
  */
1809
- async healthCheckAll() {
1810
- const results = {};
1811
- for (const name of this.getConfiguredNames()) {
1891
+ async healthCheck() {
1892
+ const start = Date.now();
1893
+ try {
1894
+ crypto4.createPrivateKey(this.config.private_key_pem);
1895
+ } catch (e) {
1896
+ return {
1897
+ healthy: false,
1898
+ error: `merchant private_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1899
+ };
1900
+ }
1901
+ if (this.config.platform_public_key_pem) {
1902
+ try {
1903
+ crypto4.createPublicKey(this.config.platform_public_key_pem);
1904
+ } catch (e) {
1905
+ return {
1906
+ healthy: false,
1907
+ error: `platform_public_key_pem parse failed: ${e instanceof Error ? e.message : String(e)}`
1908
+ };
1909
+ }
1910
+ }
1911
+ if (this.config.apiv3_key !== void 0 && Buffer.byteLength(this.config.apiv3_key, "utf-8") !== 32) {
1912
+ return { healthy: false, error: "apiv3_key must be exactly 32 bytes" };
1913
+ }
1914
+ const base = this.config.api_base ?? WECHAT_API_BASE;
1915
+ const controller = new AbortController();
1916
+ const timeout = setTimeout(() => controller.abort(), 5e3);
1917
+ const response = await fetch(base, { method: "HEAD", signal: controller.signal }).catch(() => null);
1918
+ clearTimeout(timeout);
1919
+ const latencyMs = Date.now() - start;
1920
+ if (!response) {
1921
+ return { healthy: false, error: `gateway unreachable: ${base}`, latencyMs };
1922
+ }
1923
+ return { healthy: true, latencyMs };
1924
+ }
1925
+ /** Query a Native order by out_trade_no. The query string is part of the signed path. */
1926
+ async queryOrder(outTradeNo) {
1927
+ const path5 = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(this.config.mchid)}`;
1928
+ const { body } = await wechatV3Call("GET", path5, null, this.getApiConfig());
1929
+ return body;
1930
+ }
1931
+ /** Project the facilitator config down to what api.ts needs. */
1932
+ getApiConfig() {
1933
+ return {
1934
+ mchid: this.config.mchid,
1935
+ serial_no: this.config.serial_no,
1936
+ private_key_pem: this.config.private_key_pem,
1937
+ platform_public_key_pem: this.config.platform_public_key_pem,
1938
+ api_base: this.config.api_base
1939
+ };
1940
+ }
1941
+ };
1942
+ function cnyToFen(cny) {
1943
+ return Math.round(parseFloat(cny) * 100);
1944
+ }
1945
+ function generateOutTradeNo2() {
1946
+ return "WX" + crypto4.randomBytes(15).toString("hex");
1947
+ }
1948
+ function parseWechatAttach(attach) {
1949
+ if (typeof attach !== "string" || attach.length === 0) return null;
1950
+ try {
1951
+ const parsed = JSON.parse(attach);
1952
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1953
+ } catch {
1954
+ return null;
1955
+ }
1956
+ }
1957
+ function formatTimeExpire(d) {
1958
+ const pad = (n) => String(n).padStart(2, "0");
1959
+ const offMin = -d.getTimezoneOffset();
1960
+ const sign = offMin >= 0 ? "+" : "-";
1961
+ const abs = Math.abs(offMin);
1962
+ 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)}`;
1963
+ }
1964
+ function extractOutTradeNo(paymentPayload, requirements) {
1965
+ const p = paymentPayload?.payload;
1966
+ if (typeof p === "string" && p.length > 0) return p;
1967
+ if (p !== null && typeof p === "object") {
1968
+ const obj = p;
1969
+ const cand = obj.out_trade_no ?? obj.outTradeNo;
1970
+ if (typeof cand === "string" && cand.length > 0) return cand;
1971
+ }
1972
+ const fromReq = requirements?.extra?.out_trade_no;
1973
+ if (typeof fromReq === "string" && fromReq.length > 0) return fromReq;
1974
+ throw new Error(
1975
+ "wechat payment payload must carry out_trade_no (string, {out_trade_no}/{outTradeNo}, or requirements.extra.out_trade_no)"
1976
+ );
1977
+ }
1978
+
1979
+ // src/facilitators/balance/ledger.ts
1980
+ import { randomUUID } from "crypto";
1981
+ var DEFAULT_SINGLE_LIMIT_SAT = 500;
1982
+ var DEFAULT_DAILY_LIMIT_SAT = 1e3;
1983
+ function toSat(amount) {
1984
+ const s = typeof amount === "number" ? amount.toFixed(2) : amount.trim();
1985
+ if (!/^\d+(\.\d{1,2})?$/.test(s)) {
1986
+ throw new Error(`Invalid amount "${amount}": expected a non-negative decimal with <= 2 places`);
1987
+ }
1988
+ const [whole, frac = ""] = s.split(".");
1989
+ return parseInt(whole, 10) * 100 + parseInt(frac.padEnd(2, "0") || "0", 10);
1990
+ }
1991
+ function fromSat(sat) {
1992
+ return (sat / 100).toFixed(2);
1993
+ }
1994
+ var BalanceLedger = class {
1995
+ db;
1996
+ defaultSingleLimitSat;
1997
+ defaultDailyLimitSat;
1998
+ constructor(config) {
1999
+ const getBuiltin = process.getBuiltinModule;
2000
+ const sqlite = getBuiltin?.("node:sqlite");
2001
+ if (!sqlite?.DatabaseSync) {
2002
+ throw new Error(
2003
+ `The balance rail requires the node:sqlite module (Node.js >= 22.5). Current version: ${process.version}. Upgrade Node or disable provider.balance.`
2004
+ );
2005
+ }
2006
+ const { DatabaseSync } = sqlite;
2007
+ this.db = new DatabaseSync(config.dbPath);
2008
+ this.defaultSingleLimitSat = config.defaultSingleLimitSat ?? DEFAULT_SINGLE_LIMIT_SAT;
2009
+ this.defaultDailyLimitSat = config.defaultDailyLimitSat ?? DEFAULT_DAILY_LIMIT_SAT;
2010
+ this.db.exec("PRAGMA journal_mode = WAL");
2011
+ this.db.exec(`
2012
+ CREATE TABLE IF NOT EXISTS buyers (
2013
+ buyer_id TEXT PRIMARY KEY,
2014
+ display_name TEXT,
2015
+ balance_sat INTEGER NOT NULL DEFAULT 0,
2016
+ total_topup_sat INTEGER NOT NULL DEFAULT 0,
2017
+ total_spent_sat INTEGER NOT NULL DEFAULT 0,
2018
+ daily_limit_sat INTEGER NOT NULL,
2019
+ single_limit_sat INTEGER NOT NULL,
2020
+ status TEXT NOT NULL DEFAULT 'active',
2021
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2022
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
2023
+ );
2024
+ CREATE TABLE IF NOT EXISTS ledger_transactions (
2025
+ id TEXT PRIMARY KEY,
2026
+ buyer_id TEXT NOT NULL REFERENCES buyers(buyer_id),
2027
+ type TEXT NOT NULL,
2028
+ amount_sat INTEGER NOT NULL,
2029
+ service TEXT,
2030
+ description TEXT,
2031
+ request_id TEXT,
2032
+ external_ref TEXT,
2033
+ refunds_tx_id TEXT,
2034
+ status TEXT NOT NULL DEFAULT 'completed',
2035
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2036
+ );
2037
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_request_id
2038
+ ON ledger_transactions(request_id) WHERE request_id IS NOT NULL;
2039
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_external_ref
2040
+ ON ledger_transactions(external_ref) WHERE external_ref IS NOT NULL;
2041
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refunds_tx
2042
+ ON ledger_transactions(refunds_tx_id) WHERE refunds_tx_id IS NOT NULL;
2043
+ CREATE INDEX IF NOT EXISTS idx_ledger_buyer_time
2044
+ ON ledger_transactions(buyer_id, created_at);
2045
+ CREATE TABLE IF NOT EXISTS ledger_meta (
2046
+ key TEXT PRIMARY KEY,
2047
+ value TEXT NOT NULL
2048
+ );
2049
+ `);
2050
+ const cols = this.db.prepare("PRAGMA table_info(buyers)").all().map((c) => c.name);
2051
+ if (!cols.includes("signer_address")) {
2052
+ this.db.exec("ALTER TABLE buyers ADD COLUMN signer_address TEXT");
2053
+ }
2054
+ if (!cols.includes("wechat_openid")) {
2055
+ this.db.exec("ALTER TABLE buyers ADD COLUMN wechat_openid TEXT");
2056
+ }
2057
+ const currency = config.currency ?? "USD";
2058
+ const existingCurrency = this.db.prepare(`SELECT value FROM ledger_meta WHERE key = 'currency'`).get();
2059
+ if (existingCurrency) {
2060
+ if (existingCurrency.value !== currency) {
2061
+ throw new Error(
2062
+ `Balance ledger currency mismatch: db=${existingCurrency.value} config=${currency}. Use a separate db_path for a different currency; do not reinterpret an existing ledger.`
2063
+ );
2064
+ }
2065
+ } else {
2066
+ this.db.prepare(`INSERT INTO ledger_meta (key, value) VALUES ('currency', ?)`).run(currency);
2067
+ }
2068
+ }
2069
+ /** Fetch a buyer, or null. */
2070
+ getBuyer(buyerId) {
2071
+ const row = this.db.prepare("SELECT * FROM buyers WHERE buyer_id = ?").get(buyerId);
2072
+ return row ?? null;
2073
+ }
2074
+ /** Fetch a buyer, creating an empty active account on first sight. */
2075
+ getOrCreateBuyer(buyerId, displayName) {
2076
+ const existing = this.getBuyer(buyerId);
2077
+ if (existing) return existing;
2078
+ this.db.prepare(
2079
+ `INSERT INTO buyers (buyer_id, display_name, daily_limit_sat, single_limit_sat)
2080
+ VALUES (?, ?, ?, ?)`
2081
+ ).run(buyerId, displayName ?? null, this.defaultDailyLimitSat, this.defaultSingleLimitSat);
2082
+ return this.getBuyer(buyerId);
2083
+ }
2084
+ /**
2085
+ * Record the gateway-attested WeChat payer openid that funded a buyer, on
2086
+ * top-up confirm. Idempotent and observational (stage 1a): it never
2087
+ * overwrites an existing binding with a *different* openid — a conflict is
2088
+ * reported to the caller (possible account sharing / spoof) but not
2089
+ * enforced here. Creates the buyer if absent.
2090
+ */
2091
+ bindOpenid(buyerId, openid) {
2092
+ const buyer = this.getOrCreateBuyer(buyerId);
2093
+ if (buyer.wechat_openid === openid) {
2094
+ return { bound: true, conflict: false, existing: openid };
2095
+ }
2096
+ if (buyer.wechat_openid && buyer.wechat_openid !== openid) {
2097
+ return { bound: false, conflict: true, existing: buyer.wechat_openid };
2098
+ }
2099
+ this.db.prepare(`UPDATE buyers SET wechat_openid = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(openid, buyerId);
2100
+ return { bound: true, conflict: false, existing: null };
2101
+ }
2102
+ /**
2103
+ * TOFU-bind the account's spending signer address (EVM, lowercase). First
2104
+ * signed request records it; later requests must match. A mismatch is
2105
+ * reported (`conflict`) so the caller can reject under `enforce` — it is
2106
+ * never silently overwritten. Creates the buyer if absent.
2107
+ */
2108
+ bindSigner(buyerId, address) {
2109
+ const a = address.toLowerCase();
2110
+ const buyer = this.getOrCreateBuyer(buyerId);
2111
+ if (buyer.signer_address === a) return { bound: true, conflict: false, existing: a };
2112
+ if (buyer.signer_address && buyer.signer_address !== a) {
2113
+ return { bound: false, conflict: true, existing: buyer.signer_address };
2114
+ }
2115
+ this.db.prepare(`UPDATE buyers SET signer_address = ?, updated_at = datetime('now') WHERE buyer_id = ?`).run(a, buyerId);
2116
+ return { bound: true, conflict: false, existing: null };
2117
+ }
2118
+ /** Sum of today's (UTC) completed deducts minus refunds issued against them. */
2119
+ spentTodaySat(buyerId) {
2120
+ const row = this.db.prepare(
2121
+ `SELECT COALESCE(SUM(CASE type WHEN 'deduct' THEN amount_sat ELSE -amount_sat END), 0) AS spent
2122
+ FROM ledger_transactions
2123
+ WHERE buyer_id = ? AND type IN ('deduct','refund') AND date(created_at) = date('now')`
2124
+ ).get(buyerId);
2125
+ return Math.max(0, row.spent);
2126
+ }
2127
+ /**
2128
+ * Read-only deduction precheck (the rail's `verify`). Never mutates.
2129
+ * Returns the same error codes `deduct` would.
2130
+ */
2131
+ checkDeduct(buyerId, amountSat) {
2132
+ const buyer = this.getBuyer(buyerId);
2133
+ if (!buyer) return { success: false, error: "buyer_not_found" };
2134
+ if (buyer.status !== "active") return { success: false, error: "buyer_not_active" };
2135
+ if (amountSat > buyer.single_limit_sat) {
2136
+ return { success: false, error: "exceeds_single_limit", limitSat: buyer.single_limit_sat };
2137
+ }
2138
+ const spent = this.spentTodaySat(buyerId);
2139
+ if (spent + amountSat > buyer.daily_limit_sat) {
2140
+ return { success: false, error: "exceeds_daily_limit", limitSat: buyer.daily_limit_sat };
2141
+ }
2142
+ if (buyer.balance_sat < amountSat) {
2143
+ return { success: false, error: "insufficient_balance", balanceSat: buyer.balance_sat };
2144
+ }
2145
+ return { success: true, balanceSat: buyer.balance_sat };
2146
+ }
2147
+ /**
2148
+ * Atomic deduction (the rail's `settle`). Check + UPDATE run inside one
2149
+ * SQLite transaction; a `request_id` replay returns the original tx
2150
+ * without charging again.
2151
+ */
2152
+ deduct(opts) {
2153
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2154
+ throw new Error(`deduct amountSat must be a positive integer, got ${opts.amountSat}`);
2155
+ }
2156
+ if (opts.requestId) {
2157
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE request_id = ?`).get(opts.requestId);
2158
+ if (prior) {
2159
+ const buyer = this.getBuyer(prior.buyer_id);
2160
+ return { success: true, txId: prior.id, replayed: true, balanceSat: buyer.balance_sat };
2161
+ }
2162
+ }
2163
+ this.db.exec("BEGIN IMMEDIATE");
2164
+ try {
2165
+ const check = this.checkDeduct(opts.buyerId, opts.amountSat);
2166
+ if (!check.success) {
2167
+ this.db.exec("ROLLBACK");
2168
+ return check;
2169
+ }
2170
+ const updated = this.db.prepare(
2171
+ `UPDATE buyers SET
2172
+ balance_sat = balance_sat - ?,
2173
+ total_spent_sat = total_spent_sat + ?,
2174
+ updated_at = datetime('now')
2175
+ WHERE buyer_id = ? AND balance_sat >= ? AND status = 'active'`
2176
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId, opts.amountSat);
2177
+ if (Number(updated.changes) !== 1) {
2178
+ this.db.exec("ROLLBACK");
2179
+ return { success: false, error: "insufficient_balance" };
2180
+ }
2181
+ const txId = `btx_${randomUUID()}`;
2182
+ this.db.prepare(
2183
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, service, description, request_id)
2184
+ VALUES (?, ?, 'deduct', ?, ?, ?, ?)`
2185
+ ).run(txId, opts.buyerId, opts.amountSat, opts.service ?? null, opts.description ?? null, opts.requestId ?? null);
2186
+ this.db.exec("COMMIT");
2187
+ const buyer = this.getBuyer(opts.buyerId);
2188
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2189
+ } catch (err) {
2190
+ try {
2191
+ this.db.exec("ROLLBACK");
2192
+ } catch {
2193
+ }
2194
+ throw err;
2195
+ }
2196
+ }
2197
+ /**
2198
+ * Credit a top-up. `externalRef` is the settlement proof reference
2199
+ * (on-chain tx hash / fiat trade number) and is unique: replaying the same
2200
+ * reference returns the original row without crediting twice.
2201
+ */
2202
+ topup(opts) {
2203
+ if (!Number.isInteger(opts.amountSat) || opts.amountSat <= 0) {
2204
+ throw new Error(`topup amountSat must be a positive integer, got ${opts.amountSat}`);
2205
+ }
2206
+ const prior = this.db.prepare(`SELECT * FROM ledger_transactions WHERE external_ref = ?`).get(opts.externalRef);
2207
+ if (prior) {
2208
+ const buyer2 = this.getBuyer(prior.buyer_id);
2209
+ return { txId: prior.id, balanceSat: buyer2.balance_sat, replayed: true };
2210
+ }
2211
+ this.getOrCreateBuyer(opts.buyerId);
2212
+ const txId = `btx_${randomUUID()}`;
2213
+ this.db.exec("BEGIN IMMEDIATE");
2214
+ try {
2215
+ this.db.prepare(
2216
+ `UPDATE buyers SET
2217
+ balance_sat = balance_sat + ?,
2218
+ total_topup_sat = total_topup_sat + ?,
2219
+ updated_at = datetime('now')
2220
+ WHERE buyer_id = ?`
2221
+ ).run(opts.amountSat, opts.amountSat, opts.buyerId);
2222
+ this.db.prepare(
2223
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, external_ref)
2224
+ VALUES (?, ?, 'topup', ?, ?, ?)`
2225
+ ).run(txId, opts.buyerId, opts.amountSat, opts.description ?? null, opts.externalRef);
2226
+ this.db.exec("COMMIT");
2227
+ } catch (err) {
2228
+ try {
2229
+ this.db.exec("ROLLBACK");
2230
+ } catch {
2231
+ }
2232
+ throw err;
2233
+ }
2234
+ const buyer = this.getBuyer(opts.buyerId);
2235
+ return { txId, balanceSat: buyer.balance_sat };
2236
+ }
2237
+ /**
2238
+ * Reverse a deduct (service failed after the charge). Idempotent: the
2239
+ * unique index on `refunds_tx_id` means a second refund of the same deduct
2240
+ * returns the original refund row.
2241
+ */
2242
+ refund(deductTxId, reason) {
2243
+ const deductRow = this.db.prepare(`SELECT * FROM ledger_transactions WHERE id = ?`).get(deductTxId);
2244
+ if (!deductRow) return { success: false, error: "tx_not_found" };
2245
+ if (deductRow.type !== "deduct") return { success: false, error: "not_a_deduct" };
2246
+ const priorRefund = this.db.prepare(`SELECT * FROM ledger_transactions WHERE refunds_tx_id = ?`).get(deductTxId);
2247
+ if (priorRefund) {
2248
+ const buyer = this.getBuyer(deductRow.buyer_id);
2249
+ return { success: true, txId: priorRefund.id, balanceSat: buyer.balance_sat, replayed: true };
2250
+ }
2251
+ this.db.exec("BEGIN IMMEDIATE");
2252
+ try {
2253
+ this.db.prepare(
2254
+ `UPDATE buyers SET
2255
+ balance_sat = balance_sat + ?,
2256
+ total_spent_sat = total_spent_sat - ?,
2257
+ updated_at = datetime('now')
2258
+ WHERE buyer_id = ?`
2259
+ ).run(deductRow.amount_sat, deductRow.amount_sat, deductRow.buyer_id);
2260
+ this.db.prepare(`UPDATE ledger_transactions SET status = 'refunded' WHERE id = ?`).run(deductTxId);
2261
+ const txId = `btx_${randomUUID()}`;
2262
+ this.db.prepare(
2263
+ `INSERT INTO ledger_transactions (id, buyer_id, type, amount_sat, description, refunds_tx_id)
2264
+ VALUES (?, ?, 'refund', ?, ?, ?)`
2265
+ ).run(txId, deductRow.buyer_id, deductRow.amount_sat, reason ?? null, deductTxId);
2266
+ this.db.exec("COMMIT");
2267
+ const buyer = this.getBuyer(deductRow.buyer_id);
2268
+ return { success: true, txId, balanceSat: buyer.balance_sat };
2269
+ } catch (err) {
2270
+ try {
2271
+ this.db.exec("ROLLBACK");
2272
+ } catch {
2273
+ }
2274
+ throw err;
2275
+ }
2276
+ }
2277
+ /** Paged transaction history, newest first (rowid breaks same-second ties). */
2278
+ listTransactions(buyerId, limit = 20, offset = 0) {
2279
+ return this.db.prepare(
2280
+ `SELECT * FROM ledger_transactions WHERE buyer_id = ?
2281
+ ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`
2282
+ ).all(buyerId, limit, offset);
2283
+ }
2284
+ /** Quick integrity probe for healthCheck(). */
2285
+ integrityOk() {
2286
+ const row = this.db.prepare(`PRAGMA quick_check`).get();
2287
+ return row.quick_check === "ok";
2288
+ }
2289
+ close() {
2290
+ this.db.close();
2291
+ }
2292
+ };
2293
+
2294
+ // src/facilitators/balance/auth.ts
2295
+ import { ethers as ethers2 } from "ethers";
2296
+ var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
2297
+ var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
2298
+ function extractBalanceAuth(raw) {
2299
+ if (!raw || typeof raw !== "object") return null;
2300
+ const a = raw;
2301
+ if (typeof a.signature === "string" && a.signature && typeof a.timestamp === "number" && Number.isFinite(a.timestamp)) {
2302
+ return { timestamp: a.timestamp, signature: a.signature };
2303
+ }
2304
+ return null;
2305
+ }
2306
+ function buildDeductMessage(f) {
2307
+ return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
2308
+ }
2309
+ function verifyDeductAuth(opts) {
2310
+ const { auth } = opts;
2311
+ if (!auth) return { ok: false, reason: "no_signature" };
2312
+ if (!auth.signature || !Number.isFinite(auth.timestamp)) return { ok: false, reason: "malformed" };
2313
+ if (Math.abs(opts.nowMs - auth.timestamp * 1e3) > BALANCE_AUTH_MAX_SKEW_MS) {
2314
+ return { ok: false, reason: "timestamp_skew" };
2315
+ }
2316
+ const message = buildDeductMessage({
2317
+ buyerId: opts.buyerId,
2318
+ requestId: opts.requestId,
2319
+ service: opts.service,
2320
+ timestamp: auth.timestamp
2321
+ });
2322
+ try {
2323
+ const recovered = ethers2.verifyMessage(message, auth.signature).toLowerCase();
2324
+ return { ok: true, recovered };
2325
+ } catch {
2326
+ return { ok: false, reason: "bad_signature" };
2327
+ }
2328
+ }
2329
+
2330
+ // src/facilitators/balance.ts
2331
+ var BALANCE_NETWORK = "balance";
2332
+ var BALANCE_SCHEME = "balance";
2333
+ function extractBalancePayload(payment) {
2334
+ const p = payment.payload;
2335
+ if (p && typeof p.buyer_id === "string" && p.buyer_id.length > 0) {
2336
+ return {
2337
+ buyer_id: p.buyer_id,
2338
+ request_id: typeof p.request_id === "string" ? p.request_id : void 0,
2339
+ auth: extractBalanceAuth(p.auth) ?? void 0
2340
+ };
2341
+ }
2342
+ return null;
2343
+ }
2344
+ var BalanceFacilitator = class extends BaseFacilitator {
2345
+ name = "balance";
2346
+ displayName = "Custodial Balance";
2347
+ supportedNetworks = [BALANCE_NETWORK];
2348
+ currency;
2349
+ /** User-auth rollout gate for deductions (off | shadow | enforce). */
2350
+ authMode;
2351
+ ledger;
2352
+ constructor(config) {
2353
+ super();
2354
+ this.currency = config.currency ?? "USD";
2355
+ this.authMode = config.auth_mode ?? "off";
2356
+ const ledgerConfig = {
2357
+ dbPath: config.db_path,
2358
+ defaultSingleLimitSat: config.single_limit ? toSat(config.single_limit) : DEFAULT_SINGLE_LIMIT_SAT,
2359
+ defaultDailyLimitSat: config.daily_limit ? toSat(config.daily_limit) : DEFAULT_DAILY_LIMIT_SAT,
2360
+ currency: this.currency
2361
+ };
2362
+ this.ledger = new BalanceLedger(ledgerConfig);
2363
+ }
2364
+ /** Direct ledger access for the server's balance-management endpoints. */
2365
+ getLedger() {
2366
+ return this.ledger;
2367
+ }
2368
+ /**
2369
+ * Build the `accepts[]` entry for a service. Pure — no I/O, nothing minted.
2370
+ */
2371
+ createPaymentRequirements(opts) {
2372
+ toSat(opts.price);
2373
+ return {
2374
+ scheme: BALANCE_SCHEME,
2375
+ network: BALANCE_NETWORK,
2376
+ asset: this.currency,
2377
+ amount: opts.price,
2378
+ payTo: "custodial",
2379
+ maxTimeoutSeconds: 30,
2380
+ extra: opts.serviceId ? { service_id: opts.serviceId } : void 0
2381
+ };
2382
+ }
2383
+ /** Read-only funds/limits precheck. Never mutates the ledger. */
2384
+ async verify(paymentPayload, requirements) {
2385
+ const payload = extractBalancePayload(paymentPayload);
2386
+ if (!payload) {
2387
+ return { valid: false, error: "Missing buyer_id in balance payment payload" };
2388
+ }
2389
+ let amountSat;
2390
+ try {
2391
+ amountSat = toSat(requirements.amount);
2392
+ } catch (err) {
2393
+ return { valid: false, error: err.message };
2394
+ }
2395
+ const check = this.ledger.checkDeduct(payload.buyer_id, amountSat);
2396
+ if (!check.success) {
2397
+ return {
2398
+ valid: false,
2399
+ error: this.describeDeductError(check),
2400
+ details: { code: check.error, balance: check.balanceSat !== void 0 ? fromSat(check.balanceSat) : void 0 }
2401
+ };
2402
+ }
2403
+ return { valid: true, details: { balance: fromSat(check.balanceSat) } };
2404
+ }
2405
+ /**
2406
+ * The atomic deduction. Idempotent on `request_id`; the returned
2407
+ * `transaction` is the ledger tx id (usable for `refund`).
2408
+ */
2409
+ async settle(paymentPayload, requirements) {
2410
+ const payload = extractBalancePayload(paymentPayload);
2411
+ if (!payload) {
2412
+ return { success: false, error: "Missing buyer_id in balance payment payload" };
2413
+ }
2414
+ let amountSat;
2415
+ try {
2416
+ amountSat = toSat(requirements.amount);
2417
+ } catch (err) {
2418
+ return { success: false, error: err.message };
2419
+ }
2420
+ const serviceId = typeof requirements.extra?.service_id === "string" ? requirements.extra.service_id : void 0;
2421
+ let result;
2422
+ try {
2423
+ result = this.ledger.deduct({
2424
+ buyerId: payload.buyer_id,
2425
+ amountSat,
2426
+ requestId: payload.request_id,
2427
+ service: serviceId
2428
+ });
2429
+ } catch (err) {
2430
+ return { success: false, error: `Ledger deduct failed: ${err.message}` };
2431
+ }
2432
+ if (!result.success) {
2433
+ return { success: false, error: this.describeDeductError(result), status: result.error };
2434
+ }
2435
+ return {
2436
+ success: true,
2437
+ transaction: result.txId,
2438
+ status: result.replayed ? "replayed" : "deducted"
2439
+ };
2440
+ }
2441
+ /** Reverse a deduct after a downstream failure. Idempotent per deduct. */
2442
+ refund(deductTxId, reason) {
2443
+ return this.ledger.refund(deductTxId, reason);
2444
+ }
2445
+ /**
2446
+ * Credit a gateway-verified external settlement to a buyer's balance. The
2447
+ * single entry point for every funding path -- WeChat callback, WeChat
2448
+ * polling, and the operator `/balance/topup` endpoint -- so they share one
2449
+ * idempotency boundary: `externalRef` is unique in the ledger, so a replay
2450
+ * credits nothing and returns the original transaction (`replayed: true`).
2451
+ * Callers must pass a gateway-verified `amountSat`, never a client-declared
2452
+ * amount.
2453
+ */
2454
+ credit(opts) {
2455
+ const result = this.ledger.topup({
2456
+ buyerId: opts.buyerId,
2457
+ amountSat: opts.amountSat,
2458
+ externalRef: opts.externalRef,
2459
+ description: opts.description
2460
+ });
2461
+ return {
2462
+ txId: result.txId,
2463
+ balance: fromSat(result.balanceSat),
2464
+ balanceSat: result.balanceSat,
2465
+ replayed: result.replayed ?? false
2466
+ };
2467
+ }
2468
+ async healthCheck() {
2469
+ const start = Date.now();
2470
+ try {
2471
+ const ok = this.ledger.integrityOk();
2472
+ return ok ? { healthy: true, latencyMs: Date.now() - start } : { healthy: false, error: "SQLite quick_check failed" };
2473
+ } catch (err) {
2474
+ return { healthy: false, error: err.message };
2475
+ }
2476
+ }
2477
+ describeDeductError(result) {
2478
+ switch (result.error) {
2479
+ case "buyer_not_found":
2480
+ return "Unknown buyer: top up first to create an account";
2481
+ case "buyer_not_active":
2482
+ return "Buyer account is frozen or banned";
2483
+ case "insufficient_balance":
2484
+ return `Insufficient balance${result.balanceSat !== void 0 ? ` (have ${fromSat(result.balanceSat)})` : ""}`;
2485
+ case "exceeds_single_limit":
2486
+ return `Amount exceeds the per-transaction limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2487
+ case "exceeds_daily_limit":
2488
+ return `Amount exceeds the daily spending limit${result.limitSat !== void 0 ? ` of ${fromSat(result.limitSat)}` : ""}`;
2489
+ default:
2490
+ return "Deduction failed";
2491
+ }
2492
+ }
2493
+ };
2494
+
2495
+ // src/facilitators/registry.ts
2496
+ import { Keypair as Keypair2 } from "@solana/web3.js";
2497
+ import bs58 from "bs58";
2498
+ var FacilitatorRegistry = class {
2499
+ factories = /* @__PURE__ */ new Map();
2500
+ instances = /* @__PURE__ */ new Map();
2501
+ selection;
2502
+ roundRobinIndex = 0;
2503
+ constructor(selection) {
2504
+ this.registerFactory("cdp", (config) => new CDPFacilitator(config));
2505
+ this.registerFactory("tempo", () => new TempoFacilitator());
2506
+ this.registerFactory("bnb", (config) => new BNBFacilitator(config?.serverPrivateKey));
2507
+ this.registerFactory("solana", (config) => {
2508
+ let feePayerKeypair;
2509
+ const feePayerKey = config?.feePayerPrivateKey || process.env.SOLANA_FEE_PAYER_KEY;
2510
+ if (feePayerKey) {
2511
+ try {
2512
+ feePayerKeypair = Keypair2.fromSecretKey(bs58.decode(feePayerKey));
2513
+ } catch (e) {
2514
+ console.warn(`[SolanaFacilitator] Invalid fee payer key: ${e.message}`);
2515
+ }
2516
+ }
2517
+ return new SolanaFacilitator({ feePayerKeypair });
2518
+ });
2519
+ this.registerFactory("alipay", (config) => new AlipayFacilitator(config));
2520
+ this.registerFactory("wechat", (config) => new WechatFacilitator(config));
2521
+ this.registerFactory("balance", (config) => new BalanceFacilitator(config));
2522
+ this.selection = selection || { primary: "cdp", fallback: ["tempo", "bnb", "solana"], strategy: "failover" };
2523
+ }
2524
+ /**
2525
+ * Register a new facilitator factory
2526
+ */
2527
+ registerFactory(name, factory) {
2528
+ this.factories.set(name, factory);
2529
+ }
2530
+ /**
2531
+ * Get or create a facilitator instance
2532
+ */
2533
+ get(name, config) {
2534
+ if (this.instances.has(name)) {
2535
+ return this.instances.get(name);
2536
+ }
2537
+ const factory = this.factories.get(name);
2538
+ if (!factory) {
2539
+ throw new Error(`Unknown facilitator: ${name}. Available: ${Array.from(this.factories.keys()).join(", ")}`);
2540
+ }
2541
+ const mergedConfig = {
2542
+ ...this.selection.config?.[name],
2543
+ ...config
2544
+ };
2545
+ const instance = factory(mergedConfig);
2546
+ this.instances.set(name, instance);
2547
+ return instance;
2548
+ }
2549
+ /**
2550
+ * Get all configured facilitator names
2551
+ */
2552
+ getConfiguredNames() {
2553
+ const names = [this.selection.primary];
2554
+ if (this.selection.fallback) {
2555
+ names.push(...this.selection.fallback);
2556
+ }
2557
+ return names;
2558
+ }
2559
+ /**
2560
+ * Get list of facilitators based on selection strategy
2561
+ */
2562
+ async getOrderedFacilitators(network) {
2563
+ const names = this.getConfiguredNames();
2564
+ const facilitators = [];
2565
+ for (const name of names) {
2566
+ try {
2567
+ const f = this.get(name);
2568
+ if (f.supportsNetwork(network)) {
2569
+ facilitators.push(f);
2570
+ }
2571
+ } catch (err) {
2572
+ console.warn(`[Registry] Failed to get facilitator ${name}:`, err);
2573
+ }
2574
+ }
2575
+ if (facilitators.length === 0) {
2576
+ throw new Error(`No facilitators available for network: ${network}`);
2577
+ }
2578
+ switch (this.selection.strategy) {
2579
+ case "random":
2580
+ return this.shuffle(facilitators);
2581
+ case "roundrobin":
2582
+ this.roundRobinIndex = (this.roundRobinIndex + 1) % facilitators.length;
2583
+ return [
2584
+ ...facilitators.slice(this.roundRobinIndex),
2585
+ ...facilitators.slice(0, this.roundRobinIndex)
2586
+ ];
2587
+ case "cheapest":
2588
+ return this.sortByCheapest(facilitators);
2589
+ case "fastest":
2590
+ return this.sortByFastest(facilitators);
2591
+ case "failover":
2592
+ default:
2593
+ return facilitators;
2594
+ }
2595
+ }
2596
+ shuffle(array) {
2597
+ const result = [...array];
2598
+ for (let i = result.length - 1; i > 0; i--) {
2599
+ const j = Math.floor(Math.random() * (i + 1));
2600
+ [result[i], result[j]] = [result[j], result[i]];
2601
+ }
2602
+ return result;
2603
+ }
2604
+ async sortByCheapest(facilitators) {
2605
+ const withFees = await Promise.all(
2606
+ facilitators.map(async (f) => {
2607
+ try {
2608
+ const fee = await f.getFee?.();
2609
+ return { facilitator: f, perTx: fee?.perTx ?? Infinity };
2610
+ } catch {
2611
+ return { facilitator: f, perTx: Infinity };
2612
+ }
2613
+ })
2614
+ );
2615
+ withFees.sort((a, b) => a.perTx - b.perTx);
2616
+ return withFees.map((w) => w.facilitator);
2617
+ }
2618
+ async sortByFastest(facilitators) {
2619
+ const withLatency = await Promise.all(
2620
+ facilitators.map(async (f) => {
2621
+ try {
2622
+ const health = await f.healthCheck();
2623
+ return { facilitator: f, latency: health.latencyMs ?? Infinity };
2624
+ } catch {
2625
+ return { facilitator: f, latency: Infinity };
2626
+ }
2627
+ })
2628
+ );
2629
+ withLatency.sort((a, b) => a.latency - b.latency);
2630
+ return withLatency.map((w) => w.facilitator);
2631
+ }
2632
+ /**
2633
+ * Verify payment using configured facilitators
2634
+ */
2635
+ async verify(paymentPayload, requirements) {
2636
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2637
+ const facilitators = await this.getOrderedFacilitators(network);
2638
+ let lastError;
2639
+ for (const f of facilitators) {
2640
+ try {
2641
+ console.log(`[Registry] Trying ${f.name} for verify...`);
2642
+ const result = await f.verify(paymentPayload, requirements);
2643
+ if (result.valid) {
2644
+ console.log(`[Registry] ${f.name} verify succeeded`);
2645
+ return { ...result, facilitator: f.name };
2646
+ }
2647
+ lastError = result.error;
2648
+ console.log(`[Registry] ${f.name} verify failed: ${result.error}`);
2649
+ if (this.selection.strategy === "failover" && !this.isTransientError(result.error)) {
2650
+ break;
2651
+ }
2652
+ } catch (err) {
2653
+ lastError = err.message;
2654
+ console.error(`[Registry] ${f.name} error:`, err.message);
2655
+ }
2656
+ }
2657
+ return {
2658
+ valid: false,
2659
+ error: lastError || "All facilitators failed",
2660
+ facilitator: "none"
2661
+ };
2662
+ }
2663
+ /**
2664
+ * Settle payment using configured facilitators
2665
+ */
2666
+ async settle(paymentPayload, requirements) {
2667
+ const network = paymentPayload.accepted?.network || paymentPayload.network || requirements.network;
2668
+ const facilitators = await this.getOrderedFacilitators(network);
2669
+ let lastError;
2670
+ for (const f of facilitators) {
2671
+ try {
2672
+ console.log(`[Registry] Trying ${f.name} for settle...`);
2673
+ const result = await f.settle(paymentPayload, requirements);
2674
+ if (result.success) {
2675
+ console.log(`[Registry] ${f.name} settle succeeded: ${result.transaction}`);
2676
+ return { ...result, facilitator: f.name };
2677
+ }
2678
+ lastError = result.error;
2679
+ console.log(`[Registry] ${f.name} settle failed: ${result.error}`);
2680
+ } catch (err) {
2681
+ lastError = err.message;
2682
+ console.error(`[Registry] ${f.name} error:`, err.message);
2683
+ }
2684
+ }
2685
+ return {
2686
+ success: false,
2687
+ error: lastError || "All facilitators failed",
2688
+ facilitator: "none"
2689
+ };
2690
+ }
2691
+ /**
2692
+ * Check health of all configured facilitators
2693
+ */
2694
+ async healthCheckAll() {
2695
+ const results = {};
2696
+ for (const name of this.getConfiguredNames()) {
1812
2697
  try {
1813
2698
  const f = this.get(name);
1814
2699
  results[name] = await f.healthCheck();
@@ -1860,7 +2745,160 @@ function createRegistry(selection) {
1860
2745
  return new FacilitatorRegistry(selection);
1861
2746
  }
1862
2747
 
1863
- // src/server/index.ts
2748
+ // src/server/balance-endpoints.ts
2749
+ import crypto5 from "crypto";
2750
+
2751
+ // src/verify/index.ts
2752
+ import { ethers as ethers3 } from "ethers";
2753
+ var TRANSFER_EVENT_TOPIC3 = ethers3.id("Transfer(address,address,uint256)");
2754
+ async function verifyPayment(params) {
2755
+ const { txHash, expectedAmount, expectedTo, expectedToken } = params;
2756
+ let chain;
2757
+ try {
2758
+ if (typeof params.chain === "number") {
2759
+ chain = getChainById(params.chain);
2760
+ } else {
2761
+ chain = getChain(params.chain || "base");
2762
+ }
2763
+ if (!chain) {
2764
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2765
+ }
2766
+ } catch (e) {
2767
+ return { verified: false, error: `Unsupported chain: ${params.chain}` };
2768
+ }
2769
+ try {
2770
+ const provider = new ethers3.JsonRpcProvider(chain.rpc);
2771
+ const receipt = await provider.getTransactionReceipt(txHash);
2772
+ if (!receipt) {
2773
+ return { verified: false, error: "Transaction not found or not confirmed" };
2774
+ }
2775
+ if (receipt.status !== 1) {
2776
+ return { verified: false, error: "Transaction failed" };
2777
+ }
2778
+ const tokenAddresses = {};
2779
+ if (!expectedToken || expectedToken === "USDC") {
2780
+ tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
2781
+ }
2782
+ if (!expectedToken || expectedToken === "USDT") {
2783
+ tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
2784
+ }
2785
+ if (Object.keys(tokenAddresses).length === 0) {
2786
+ return { verified: false, error: `No token addresses configured for ${chain.name}` };
2787
+ }
2788
+ for (const log of receipt.logs) {
2789
+ const logAddress = log.address.toLowerCase();
2790
+ const detectedToken = tokenAddresses[logAddress];
2791
+ if (!detectedToken) {
2792
+ continue;
2793
+ }
2794
+ if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
2795
+ continue;
2796
+ }
2797
+ const from = "0x" + log.topics[1].slice(-40);
2798
+ const to = "0x" + log.topics[2].slice(-40);
2799
+ const amountRaw = BigInt(log.data);
2800
+ const tokenConfig = chain.tokens[detectedToken];
2801
+ const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
2802
+ if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
2803
+ continue;
2804
+ }
2805
+ if (amount < expectedAmount) {
2806
+ return {
2807
+ verified: false,
2808
+ error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
2809
+ amount,
2810
+ token: detectedToken,
2811
+ from,
2812
+ to,
2813
+ txHash,
2814
+ blockNumber: receipt.blockNumber
2815
+ };
2816
+ }
2817
+ return {
2818
+ verified: true,
2819
+ amount,
2820
+ token: detectedToken,
2821
+ from,
2822
+ to,
2823
+ txHash,
2824
+ blockNumber: receipt.blockNumber
2825
+ };
2826
+ }
2827
+ const tokenList = expectedToken ? expectedToken : "USDC/USDT";
2828
+ return { verified: false, error: `No ${tokenList} transfer found` };
2829
+ } catch (e) {
2830
+ return { verified: false, error: e.message || String(e) };
2831
+ }
2832
+ }
2833
+ async function getTransactionStatus(txHash, chain = "base") {
2834
+ let chainConfig;
2835
+ try {
2836
+ chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
2837
+ if (!chainConfig) return { status: "not_found" };
2838
+ } catch {
2839
+ return { status: "not_found" };
2840
+ }
2841
+ try {
2842
+ const provider = new ethers3.JsonRpcProvider(chainConfig.rpc);
2843
+ const receipt = await provider.getTransactionReceipt(txHash);
2844
+ if (!receipt) {
2845
+ const tx = await provider.getTransaction(txHash);
2846
+ if (tx) {
2847
+ return { status: "pending" };
2848
+ }
2849
+ return { status: "not_found" };
2850
+ }
2851
+ const currentBlock = await provider.getBlockNumber();
2852
+ const confirmations = currentBlock - receipt.blockNumber;
2853
+ if (receipt.status === 1) {
2854
+ return {
2855
+ status: "confirmed",
2856
+ blockNumber: receipt.blockNumber,
2857
+ confirmations
2858
+ };
2859
+ } else {
2860
+ return {
2861
+ status: "failed",
2862
+ blockNumber: receipt.blockNumber
2863
+ };
2864
+ }
2865
+ } catch {
2866
+ return { status: "not_found" };
2867
+ }
2868
+ }
2869
+ async function waitForTransaction(txHash, chain = "base", confirmations = 1, timeoutMs = 6e4) {
2870
+ let chainConfig;
2871
+ try {
2872
+ chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
2873
+ if (!chainConfig) {
2874
+ return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
2875
+ }
2876
+ } catch (e) {
2877
+ return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
2878
+ }
2879
+ const provider = new ethers3.JsonRpcProvider(chainConfig.rpc);
2880
+ try {
2881
+ const receipt = await provider.waitForTransaction(txHash, confirmations, timeoutMs);
2882
+ if (!receipt) {
2883
+ return { verified: false, confirmed: false, error: "Timeout waiting" };
2884
+ }
2885
+ if (receipt.status !== 1) {
2886
+ return { verified: false, confirmed: true, error: "Transaction failed" };
2887
+ }
2888
+ return {
2889
+ verified: true,
2890
+ confirmed: true,
2891
+ txHash,
2892
+ blockNumber: receipt.blockNumber
2893
+ };
2894
+ } catch (e) {
2895
+ return { verified: false, confirmed: false, error: e.message || String(e) };
2896
+ }
2897
+ }
2898
+
2899
+ // src/server/internal.ts
2900
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2901
+ import * as path2 from "path";
1864
2902
  var X402_VERSION2 = 2;
1865
2903
  var PAYMENT_REQUIRED_HEADER = "x-payment-required";
1866
2904
  var PAYMENT_HEADER = "x-payment";
@@ -1873,6 +2911,15 @@ var ALIPAY_PAYMENT_PROOF_HEADER = "payment-proof";
1873
2911
  function headerSafe(value) {
1874
2912
  return String(value ?? "").replace(/[^\x20-\x7E]/g, (ch) => encodeURIComponent(ch)).replace(/"/g, "%22");
1875
2913
  }
2914
+ function canonicalJson(value) {
2915
+ if (value === null || typeof value !== "object") {
2916
+ return JSON.stringify(value) ?? "null";
2917
+ }
2918
+ if (Array.isArray(value)) {
2919
+ return "[" + value.map(canonicalJson).join(",") + "]";
2920
+ }
2921
+ return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonicalJson(value[k])).join(",") + "}";
2922
+ }
1876
2923
  var TOKEN_ADDRESSES = {
1877
2924
  "eip155:8453": {
1878
2925
  USDC: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
@@ -2000,7 +3047,340 @@ function loadEnvFile2() {
2000
3047
  }
2001
3048
  }
2002
3049
  }
2003
- }
3050
+ }
3051
+
3052
+ // src/server/balance-endpoints.ts
3053
+ var BalanceEndpoints = class {
3054
+ constructor(deps) {
3055
+ this.deps = deps;
3056
+ }
3057
+ /** GET /balance?buyer_id= -- balance, limits, and today's spend. */
3058
+ handleQuery(url, res) {
3059
+ const { balance, sendJson } = this.deps;
3060
+ const buyerId = url.searchParams.get("buyer_id");
3061
+ if (!buyerId) {
3062
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3063
+ }
3064
+ const ledger = balance.getLedger();
3065
+ const buyer = ledger.getBuyer(buyerId);
3066
+ if (!buyer) {
3067
+ return sendJson(res, 200, {
3068
+ buyer_id: buyerId,
3069
+ balance: "0.00",
3070
+ currency: balance.currency,
3071
+ exists: false
3072
+ });
3073
+ }
3074
+ sendJson(res, 200, {
3075
+ buyer_id: buyerId,
3076
+ balance: fromSat(buyer.balance_sat),
3077
+ currency: balance.currency,
3078
+ single_limit: fromSat(buyer.single_limit_sat),
3079
+ daily_limit: fromSat(buyer.daily_limit_sat),
3080
+ today_spent: fromSat(ledger.spentTodaySat(buyerId)),
3081
+ status: buyer.status,
3082
+ wechat_openid: buyer.wechat_openid ?? null,
3083
+ signer_address: buyer.signer_address ?? null,
3084
+ exists: true
3085
+ });
3086
+ }
3087
+ /**
3088
+ * Extract the gateway-confirmed paid amount (fen) from a WeChat verify
3089
+ * result. `payer_total` is what the buyer actually paid; falls back to
3090
+ * `total`. Returns null if no usable positive integer is present, so the
3091
+ * client-declared amount can never be trusted for crediting.
3092
+ */
3093
+ wechatPaidFen(check) {
3094
+ const amount = check.details?.amount;
3095
+ const paid = amount?.payer_total ?? amount?.total;
3096
+ return typeof paid === "number" && Number.isFinite(paid) && paid > 0 ? paid : null;
3097
+ }
3098
+ /**
3099
+ * POST /balance/topup/order -- mint a buyer-bound WeChat Native order for a
3100
+ * configured top-up pack. The buyer_id rides in the WeChat `attach` so the
3101
+ * later confirm/callback credits the correct balance. Reuses the pending
3102
+ * order cache (keyed by buyer_id + pack) so concurrent requests share one
3103
+ * order. Body: `{ buyer_id, pack? }`. Returns `{ code_url, out_trade_no,
3104
+ * pack, max_timeout_seconds }`.
3105
+ */
3106
+ async handleTopupOrder(body, res) {
3107
+ const { manifest, wechat, sendJson, getOrCreatePendingWechatOrder } = this.deps;
3108
+ const { buyer_id, pack } = body || {};
3109
+ if (typeof buyer_id !== "string" || !buyer_id) {
3110
+ return sendJson(res, 400, { error: "buyer_id is required" });
3111
+ }
3112
+ const signerAddress = typeof body?.signer_address === "string" && /^0x[0-9a-fA-F]{40}$/.test(body.signer_address) ? body.signer_address.toLowerCase() : void 0;
3113
+ if (!wechat) {
3114
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3115
+ }
3116
+ const balCfg = manifest.provider.balance;
3117
+ const packs = balCfg?.topup_packs ?? [];
3118
+ const chosen = typeof pack === "string" && pack ? pack : balCfg?.default_pack;
3119
+ if (!chosen) {
3120
+ return sendJson(res, 400, { error: "no pack specified and no default_pack configured" });
3121
+ }
3122
+ let chosenSat;
3123
+ try {
3124
+ chosenSat = toSat(chosen);
3125
+ if (chosenSat <= 0) throw new Error("pack must be positive");
3126
+ } catch (err) {
3127
+ return sendJson(res, 400, { error: `Invalid pack: ${err.message}` });
3128
+ }
3129
+ const withinMax = balCfg?.auto_topup_max ? chosenSat <= toSat(balCfg.auto_topup_max) : false;
3130
+ if (!packs.includes(chosen) && !withinMax) {
3131
+ return sendJson(res, 400, {
3132
+ error: `pack "${chosen}" is not an offered top-up pack and exceeds auto_topup_max`
3133
+ });
3134
+ }
3135
+ const nonce = crypto5.randomBytes(8).toString("hex");
3136
+ const attach = signerAddress ? { buyer_id, signer: signerAddress } : { buyer_id, nonce };
3137
+ const cacheKey = crypto5.createHash("sha256").update(`topup|${buyer_id}|${chosen}|${signerAddress ?? ""}`).digest("hex");
3138
+ const result = await getOrCreatePendingWechatOrder(
3139
+ cacheKey,
3140
+ `topup:${buyer_id}`,
3141
+ () => wechat.createPaymentRequirements({
3142
+ priceCny: chosen,
3143
+ description: `Balance top-up ${chosen}`,
3144
+ attach
3145
+ })
3146
+ );
3147
+ if (!result) {
3148
+ return sendJson(res, 502, { error: "failed to create WeChat top-up order" });
3149
+ }
3150
+ return sendJson(res, 200, {
3151
+ code_url: result.codeUrl,
3152
+ out_trade_no: result.outTradeNo,
3153
+ pack: chosen,
3154
+ max_timeout_seconds: result.accepts.maxTimeoutSeconds
3155
+ });
3156
+ }
3157
+ /**
3158
+ * POST /balance/topup/confirm -- the polling-fallback credit path. The client
3159
+ * polls this after a scan; the server verifies the order with the WeChat
3160
+ * gateway and, on SUCCESS, credits the buyer bound in `attach` with the
3161
+ * gateway-confirmed `payer_total` (never a client-declared amount).
3162
+ * Idempotent on `wechat:<out_trade_no>`. Body: `{ out_trade_no }`.
3163
+ */
3164
+ async handleTopupConfirm(body, res) {
3165
+ const { manifest, balance, wechat, sendJson, invalidateWechatChallenge } = this.deps;
3166
+ const outTradeNo = body?.out_trade_no;
3167
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3168
+ return sendJson(res, 400, { error: "out_trade_no is required" });
3169
+ }
3170
+ if (!wechat) {
3171
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3172
+ }
3173
+ const wxPayload = {
3174
+ x402Version: X402_VERSION2,
3175
+ scheme: WECHAT_SCHEME,
3176
+ network: WECHAT_NETWORK,
3177
+ payload: { out_trade_no: outTradeNo }
3178
+ };
3179
+ const wxReqs = {
3180
+ scheme: WECHAT_SCHEME,
3181
+ network: WECHAT_NETWORK,
3182
+ asset: "CNY",
3183
+ amount: "0",
3184
+ payTo: manifest.provider.wechat?.mchid || "",
3185
+ maxTimeoutSeconds: 30,
3186
+ extra: { out_trade_no: outTradeNo }
3187
+ };
3188
+ const check = await wechat.verify(wxPayload, wxReqs);
3189
+ if (!check.valid) {
3190
+ return sendJson(res, 200, { credited: false, pending: true, reason: check.error });
3191
+ }
3192
+ const paidFen = this.wechatPaidFen(check);
3193
+ if (paidFen === null) {
3194
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3195
+ }
3196
+ const attach = parseWechatAttach(check.details?.attach);
3197
+ const buyerId = attach?.buyer_id;
3198
+ if (!buyerId) {
3199
+ return sendJson(res, 422, {
3200
+ error: "top-up order has no buyer binding (attach.buyer_id missing)"
3201
+ });
3202
+ }
3203
+ const credited = balance.credit({
3204
+ buyerId,
3205
+ amountSat: paidFen,
3206
+ externalRef: `wechat:${outTradeNo}`,
3207
+ description: `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`
3208
+ });
3209
+ invalidateWechatChallenge(outTradeNo);
3210
+ const openid = check.details?.openid;
3211
+ let openidBinding;
3212
+ if (typeof openid === "string" && openid) {
3213
+ openidBinding = balance.getLedger().bindOpenid(buyerId, openid);
3214
+ if (openidBinding.conflict) {
3215
+ console.warn(
3216
+ `[MoltsPay] Balance top-up openid conflict for buyer=${buyerId}: already bound to ${openidBinding.existing}, this payment from ${openid} \u2014 recorded, not enforced`
3217
+ );
3218
+ }
3219
+ }
3220
+ const signer = attach?.signer;
3221
+ if (typeof signer === "string" && /^0x[0-9a-fA-F]{40}$/.test(signer)) {
3222
+ const sb = balance.getLedger().bindSigner(buyerId, signer);
3223
+ if (sb.conflict) {
3224
+ console.warn(
3225
+ `[MoltsPay] Balance top-up signer conflict for buyer=${buyerId}: already bound to ${sb.existing}, this order signed for ${signer.toLowerCase()} \u2014 recorded, not enforced`
3226
+ );
3227
+ }
3228
+ }
3229
+ console.log(
3230
+ `[MoltsPay] Balance top-up credited buyer=${buyerId} +${fromSat(paidFen)} (${outTradeNo})${credited.replayed ? " [replayed]" : ""}${typeof openid === "string" && openid ? ` openid=${openid}${openidBinding?.conflict ? " [CONFLICT]" : ""}` : ""}`
3231
+ );
3232
+ return sendJson(res, 200, {
3233
+ credited: true,
3234
+ buyer_id: buyerId,
3235
+ tx_id: credited.txId,
3236
+ balance: credited.balance,
3237
+ replayed: credited.replayed,
3238
+ openid_bound: openidBinding?.bound ?? false,
3239
+ openid_conflict: openidBinding?.conflict ?? false
3240
+ });
3241
+ }
3242
+ /**
3243
+ * POST /balance/topup -- verify an externally settled payment and credit the
3244
+ * buyer's ledger balance (operator/recovery path). Per rail: `crypto`
3245
+ * verifies the tx on-chain; `wechat` queries the order and credits the
3246
+ * gateway-confirmed payer_total (never the client-declared amount);
3247
+ * `alipay` is operator-trusted in this MVP. Idempotent on the external ref.
3248
+ */
3249
+ async handleTopup(body, res) {
3250
+ const { manifest, balance, wechat, sendJson } = this.deps;
3251
+ const { buyer_id, rail, amount } = body || {};
3252
+ if (typeof buyer_id !== "string" || !buyer_id) {
3253
+ return sendJson(res, 400, { error: "buyer_id is required" });
3254
+ }
3255
+ let amountSat;
3256
+ try {
3257
+ amountSat = toSat(String(amount));
3258
+ if (amountSat <= 0) throw new Error("amount must be positive");
3259
+ } catch (err) {
3260
+ return sendJson(res, 400, { error: `Invalid amount: ${err.message}` });
3261
+ }
3262
+ let externalRef;
3263
+ let description;
3264
+ if (rail === "crypto") {
3265
+ const txHash = body.tx_hash;
3266
+ if (typeof txHash !== "string" || !txHash) {
3267
+ return sendJson(res, 400, { error: 'tx_hash is required for rail "crypto"' });
3268
+ }
3269
+ const check = await verifyPayment({
3270
+ txHash,
3271
+ expectedAmount: amountSat / 100,
3272
+ expectedTo: manifest.provider.wallet,
3273
+ chain: body.chain || "base"
3274
+ });
3275
+ if (!check.verified) {
3276
+ return sendJson(res, 402, { error: `On-chain verification failed: ${check.error}` });
3277
+ }
3278
+ externalRef = txHash.toLowerCase();
3279
+ description = `crypto topup ${check.amount} ${check.token} on ${body.chain || "base"}`;
3280
+ } else if (rail === "wechat") {
3281
+ const outTradeNo = body.out_trade_no;
3282
+ if (typeof outTradeNo !== "string" || !outTradeNo) {
3283
+ return sendJson(res, 400, { error: 'out_trade_no is required for rail "wechat"' });
3284
+ }
3285
+ if (!wechat) {
3286
+ return sendJson(res, 400, { error: "WeChat rail not configured on this server" });
3287
+ }
3288
+ const wxPayload = {
3289
+ x402Version: X402_VERSION2,
3290
+ scheme: WECHAT_SCHEME,
3291
+ network: WECHAT_NETWORK,
3292
+ payload: { out_trade_no: outTradeNo }
3293
+ };
3294
+ const wxReqs = {
3295
+ scheme: WECHAT_SCHEME,
3296
+ network: WECHAT_NETWORK,
3297
+ asset: "CNY",
3298
+ amount: "0",
3299
+ payTo: manifest.provider.wechat?.mchid || "",
3300
+ maxTimeoutSeconds: 30,
3301
+ extra: { out_trade_no: outTradeNo }
3302
+ };
3303
+ const check = await wechat.verify(wxPayload, wxReqs);
3304
+ if (!check.valid) {
3305
+ return sendJson(res, 402, { error: `WeChat order verification failed: ${check.error}` });
3306
+ }
3307
+ const paidFen = this.wechatPaidFen(check);
3308
+ if (paidFen === null) {
3309
+ return sendJson(res, 502, { error: "WeChat order verification did not return a usable paid amount" });
3310
+ }
3311
+ if (paidFen !== amountSat) {
3312
+ console.warn(
3313
+ `[MoltsPay] WeChat topup amount mismatch for ${outTradeNo}: client declared ${fromSat(amountSat)}, gateway confirms ${fromSat(paidFen)} paid -- crediting the verified amount`
3314
+ );
3315
+ }
3316
+ amountSat = paidFen;
3317
+ externalRef = `wechat:${outTradeNo}`;
3318
+ description = `wechat topup out_trade_no=${outTradeNo} fiat=${JSON.stringify(check.details?.amount ?? null)}`;
3319
+ console.log(`[MoltsPay] WeChat topup verified: ${description}`);
3320
+ } else if (rail === "alipay") {
3321
+ const tradeNo = body.trade_no;
3322
+ if (typeof tradeNo !== "string" || !tradeNo) {
3323
+ return sendJson(res, 400, { error: 'trade_no is required for rail "alipay"' });
3324
+ }
3325
+ externalRef = `alipay:${tradeNo}`;
3326
+ description = `alipay topup trade_no=${tradeNo} (operator-confirmed, unverified)`;
3327
+ console.warn(`[MoltsPay] Alipay topup credited without gateway verification: ${tradeNo}`);
3328
+ } else {
3329
+ return sendJson(res, 400, { error: 'rail must be one of "crypto" | "alipay" | "wechat"' });
3330
+ }
3331
+ const result = balance.getLedger().topup({ buyerId: buyer_id, amountSat, externalRef, description });
3332
+ sendJson(res, 200, {
3333
+ success: true,
3334
+ tx_id: result.txId,
3335
+ balance: fromSat(result.balanceSat),
3336
+ replayed: result.replayed ?? false
3337
+ });
3338
+ }
3339
+ /** POST /balance/refund -- reverse a deduct (operator/agent use). */
3340
+ handleRefund(body, res) {
3341
+ const { balance, sendJson } = this.deps;
3342
+ const { tx_id, reason } = body || {};
3343
+ if (typeof tx_id !== "string" || !tx_id) {
3344
+ return sendJson(res, 400, { error: "tx_id is required" });
3345
+ }
3346
+ const result = balance.refund(tx_id, typeof reason === "string" ? reason : void 0);
3347
+ if (!result.success) {
3348
+ return sendJson(res, result.error === "tx_not_found" ? 404 : 400, { error: result.error });
3349
+ }
3350
+ sendJson(res, 200, {
3351
+ success: true,
3352
+ tx_id: result.txId,
3353
+ balance: fromSat(result.balanceSat),
3354
+ replayed: result.replayed ?? false
3355
+ });
3356
+ }
3357
+ /** GET /balance/transactions?buyer_id=&limit=&offset= -- history, newest first. */
3358
+ handleTransactions(url, res) {
3359
+ const { balance, sendJson } = this.deps;
3360
+ const buyerId = url.searchParams.get("buyer_id");
3361
+ if (!buyerId) {
3362
+ return sendJson(res, 400, { error: "buyer_id query parameter is required" });
3363
+ }
3364
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10) || 20, 100);
3365
+ const offset = parseInt(url.searchParams.get("offset") || "0", 10) || 0;
3366
+ const rows = balance.getLedger().listTransactions(buyerId, limit, offset);
3367
+ sendJson(res, 200, {
3368
+ buyer_id: buyerId,
3369
+ transactions: rows.map((r) => ({
3370
+ tx_id: r.id,
3371
+ type: r.type,
3372
+ amount: fromSat(r.amount_sat),
3373
+ service: r.service,
3374
+ description: r.description,
3375
+ external_ref: r.external_ref,
3376
+ status: r.status,
3377
+ created_at: r.created_at
3378
+ }))
3379
+ });
3380
+ }
3381
+ };
3382
+
3383
+ // src/server/index.ts
2004
3384
  var MoltsPayServer = class {
2005
3385
  manifest;
2006
3386
  skills = /* @__PURE__ */ new Map();
@@ -2008,11 +3388,32 @@ var MoltsPayServer = class {
2008
3388
  registry;
2009
3389
  networkId;
2010
3390
  useMainnet;
2011
- /** Alipay AI facilitator instance, set when `provider.alipay` is configured (2.0.0). */
3391
+ /** Alipay AI Pay facilitator instance, set when `provider.alipay` is configured (2.0.0). */
2012
3392
  alipayFacilitator = null;
3393
+ /** WeChat Pay Native facilitator instance, set when `provider.wechat` is configured (2.1.0). */
3394
+ wechatFacilitator = null;
3395
+ /** Custodial balance facilitator instance, set when `provider.balance` is configured (2.2.0). */
3396
+ balanceFacilitator = null;
3397
+ balanceEndpoints = null;
3398
+ /**
3399
+ * Pending WeChat Native order cache — the double-charge fix.
3400
+ *
3401
+ * Every `buildWechatChallenge` used to place a NEW Native order, so a client
3402
+ * that received two 402s (e.g. initial challenge + one poll re-request that
3403
+ * raced ahead of payment) could surface two live QRs and a buyer could pay
3404
+ * both (confirmed ¥0.07×2 on 2026-07-02). Now the unpaid order is cached
3405
+ * under a content-derived key — sha256(service id | canonical params |
3406
+ * price_cny) — and reused until it is paid or its `time_expire` window
3407
+ * nears expiry, so any number of 402 emits for the same purchase intent
3408
+ * share ONE order, even across separate client processes.
3409
+ * Storing the in-flight promise also dedupes concurrent 402 builds.
3410
+ * In-memory by design: on restart the worst case is one extra unpaid order,
3411
+ * which expires server-side per `time_expire` — never a double charge.
3412
+ */
3413
+ wechatPendingChallenges = /* @__PURE__ */ new Map();
2013
3414
  constructor(servicesPath, options = {}) {
2014
3415
  loadEnvFile2();
2015
- const content = readFileSync2(servicesPath, "utf-8");
3416
+ const content = readFileSync3(servicesPath, "utf-8");
2016
3417
  this.manifest = JSON.parse(content);
2017
3418
  this.options = {
2018
3419
  port: options.port || 3e3,
@@ -2034,8 +3435,8 @@ var MoltsPayServer = class {
2034
3435
  const providerAlipay = this.manifest.provider.alipay;
2035
3436
  if (providerAlipay) {
2036
3437
  try {
2037
- const baseDir = path2.dirname(servicesPath);
2038
- const resolvePem = (p, kind) => toPem(readFileSync2(path2.isAbsolute(p) ? p : path2.resolve(baseDir, p), "utf-8"), kind);
3438
+ const baseDir = path3.dirname(servicesPath);
3439
+ const resolvePem = (p, kind) => toPem(readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8"), kind);
2039
3440
  const alipayFacilitatorConfig = {
2040
3441
  seller_id: providerAlipay.seller_id,
2041
3442
  app_id: providerAlipay.app_id,
@@ -2058,10 +3459,73 @@ var MoltsPayServer = class {
2058
3459
  throw new Error(`[MoltsPay] Alipay rail configured but key load failed: ${err.message}`);
2059
3460
  }
2060
3461
  }
3462
+ const providerWechat = this.manifest.provider.wechat;
3463
+ if (providerWechat) {
3464
+ try {
3465
+ const baseDir = path3.dirname(servicesPath);
3466
+ const readPem = (p) => readFileSync3(path3.isAbsolute(p) ? p : path3.resolve(baseDir, p), "utf-8");
3467
+ const toPublicKeyPem = (pem) => pem.includes("BEGIN CERTIFICATE") ? new crypto6.X509Certificate(pem).publicKey.export({ type: "spki", format: "pem" }).toString() : pem;
3468
+ const wechatFacilitatorConfig = {
3469
+ mchid: providerWechat.mchid,
3470
+ appid: providerWechat.appid,
3471
+ serial_no: providerWechat.serial_no,
3472
+ private_key_pem: readPem(providerWechat.private_key_path),
3473
+ platform_public_key_pem: providerWechat.platform_public_key_path ? toPublicKeyPem(readPem(providerWechat.platform_public_key_path)) : void 0,
3474
+ apiv3_key: providerWechat.apiv3_key,
3475
+ notify_url: providerWechat.notify_url,
3476
+ api_base: providerWechat.api_base
3477
+ };
3478
+ facilitatorConfig.config = {
3479
+ ...facilitatorConfig.config,
3480
+ wechat: wechatFacilitatorConfig
3481
+ };
3482
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3483
+ if (facilitatorConfig.primary !== "wechat" && !facilitatorConfig.fallback.includes("wechat")) {
3484
+ facilitatorConfig.fallback.push("wechat");
3485
+ }
3486
+ } catch (err) {
3487
+ throw new Error(`[MoltsPay] WeChat rail configured but key load failed: ${err.message}`);
3488
+ }
3489
+ }
3490
+ const providerBalance = this.manifest.provider.balance;
3491
+ if (providerBalance) {
3492
+ const baseDir = path3.dirname(servicesPath);
3493
+ const balanceFacilitatorConfig = {
3494
+ db_path: providerBalance.db_path === ":memory:" ? providerBalance.db_path : path3.isAbsolute(providerBalance.db_path) ? providerBalance.db_path : path3.resolve(baseDir, providerBalance.db_path),
3495
+ currency: providerBalance.currency,
3496
+ single_limit: providerBalance.single_limit,
3497
+ daily_limit: providerBalance.daily_limit,
3498
+ auth_mode: providerBalance.auth_mode
3499
+ };
3500
+ facilitatorConfig.config = {
3501
+ ...facilitatorConfig.config,
3502
+ balance: balanceFacilitatorConfig
3503
+ };
3504
+ facilitatorConfig.fallback = facilitatorConfig.fallback || [];
3505
+ if (facilitatorConfig.primary !== "balance" && !facilitatorConfig.fallback.includes("balance")) {
3506
+ facilitatorConfig.fallback.push("balance");
3507
+ }
3508
+ }
2061
3509
  this.registry = new FacilitatorRegistry(facilitatorConfig);
2062
3510
  if (providerAlipay) {
2063
3511
  this.alipayFacilitator = this.registry.get("alipay");
2064
- console.log(`[MoltsPay] Alipay AI \u6536 rail enabled (seller ${providerAlipay.seller_id})`);
3512
+ console.log(`[MoltsPay] Alipay AI Pay rail enabled (seller ${providerAlipay.seller_id})`);
3513
+ }
3514
+ if (providerWechat) {
3515
+ this.wechatFacilitator = this.registry.get("wechat");
3516
+ console.log(`[MoltsPay] WeChat Pay rail enabled (mchid ${providerWechat.mchid})`);
3517
+ }
3518
+ if (providerBalance) {
3519
+ this.balanceFacilitator = this.registry.get("balance");
3520
+ this.balanceEndpoints = new BalanceEndpoints({
3521
+ manifest: this.manifest,
3522
+ balance: this.balanceFacilitator,
3523
+ wechat: this.wechatFacilitator,
3524
+ sendJson: (res, status, data) => this.sendJson(res, status, data),
3525
+ getOrCreatePendingWechatOrder: (cacheKey, logLabel, create) => this.getOrCreatePendingWechatOrder(cacheKey, logLabel, create),
3526
+ invalidateWechatChallenge: (outTradeNo) => this.invalidateWechatChallenge(outTradeNo)
3527
+ });
3528
+ console.log(`[MoltsPay] Custodial balance rail enabled (ledger ${providerBalance.db_path})`);
2065
3529
  }
2066
3530
  const primaryFacilitator = this.registry.get(facilitatorConfig.primary);
2067
3531
  console.log(`[MoltsPay] Loaded ${this.manifest.services.length} services from ${servicesPath}`);
@@ -2103,7 +3567,10 @@ var MoltsPayServer = class {
2103
3567
  return provider.wallet;
2104
3568
  };
2105
3569
  if (provider.chains && provider.chains.length > 0) {
2106
- return provider.chains.map((c) => {
3570
+ return provider.chains.filter((c) => {
3571
+ const chainName = typeof c === "string" ? c : c.chain;
3572
+ return !isAlipayChainId(chainName) && !isWechatChainId(chainName) && !isBalanceChainId(chainName);
3573
+ }).map((c) => {
2107
3574
  const chainName = typeof c === "string" ? c : c.chain;
2108
3575
  const explicitWallet = typeof c === "object" ? c.wallet : null;
2109
3576
  return {
@@ -2222,9 +3689,36 @@ var MoltsPayServer = class {
2222
3689
  if (url.pathname === "/.well-known/agent-services.json" && req.method === "GET") {
2223
3690
  return this.handleAgentServicesDiscovery(res);
2224
3691
  }
3692
+ if (url.pathname === "/" && req.method === "GET") {
3693
+ return this.handleAgentServicesDiscovery(res);
3694
+ }
2225
3695
  if (url.pathname === "/health" && req.method === "GET") {
2226
3696
  return await this.handleHealthCheck(res);
2227
3697
  }
3698
+ if (url.pathname.startsWith("/balance") && this.balanceEndpoints) {
3699
+ if (url.pathname === "/balance" && req.method === "GET") {
3700
+ return this.balanceEndpoints.handleQuery(url, res);
3701
+ }
3702
+ if (url.pathname === "/balance/topup/order" && req.method === "POST") {
3703
+ const body = await this.readBody(req);
3704
+ return await this.balanceEndpoints.handleTopupOrder(body, res);
3705
+ }
3706
+ if (url.pathname === "/balance/topup/confirm" && req.method === "POST") {
3707
+ const body = await this.readBody(req);
3708
+ return await this.balanceEndpoints.handleTopupConfirm(body, res);
3709
+ }
3710
+ if (url.pathname === "/balance/topup" && req.method === "POST") {
3711
+ const body = await this.readBody(req);
3712
+ return await this.balanceEndpoints.handleTopup(body, res);
3713
+ }
3714
+ if (url.pathname === "/balance/refund" && req.method === "POST") {
3715
+ const body = await this.readBody(req);
3716
+ return this.balanceEndpoints.handleRefund(body, res);
3717
+ }
3718
+ if (url.pathname === "/balance/transactions" && req.method === "GET") {
3719
+ return this.balanceEndpoints.handleTransactions(url, res);
3720
+ }
3721
+ }
2228
3722
  if (url.pathname === "/execute" && req.method === "POST") {
2229
3723
  const body = await this.readBody(req);
2230
3724
  const paymentHeader = req.headers[PAYMENT_HEADER];
@@ -2250,27 +3744,78 @@ var MoltsPayServer = class {
2250
3744
  const proofHeader = req.headers[ALIPAY_PAYMENT_PROOF_HEADER];
2251
3745
  return await this.handleMPPRequest(skill, body, authHeader, x402Header, res, proofHeader);
2252
3746
  }
2253
- this.sendJson(res, 404, { error: "Not found" });
3747
+ this.sendJson(res, 404, {
3748
+ error: "Not found",
3749
+ discovery: `${this.publicBase}/.well-known/agent-services.json`,
3750
+ endpoints: [`${this.publicBase}/health`, `${this.publicBase}/services`, `${this.publicBase}/execute`]
3751
+ });
2254
3752
  } catch (err) {
2255
3753
  console.error("[MoltsPay] Error:", err);
2256
3754
  this.sendJson(res, 500, { error: err.message || "Internal error" });
2257
3755
  }
2258
3756
  }
2259
3757
  /**
2260
- * GET /.well-known/agent-services.json - Standard discovery endpoint
3758
+ * Public base URL prefix for self-describing links, from PUBLIC_BASE_URL
3759
+ * (trailing slash stripped). Empty when unset, so emitted paths stay
3760
+ * root-relative — behavior is unchanged for local / no-prefix deploys.
3761
+ *
3762
+ * Needed because nginx rewrites the deployment prefix (e.g.
3763
+ * `/t/moltspay-server`) away before proxying, so the process cannot infer
3764
+ * its own public prefix; a root-relative `/services` would otherwise
3765
+ * resolve against the domain root and hit the wrong backend.
2261
3766
  */
2262
- handleAgentServicesDiscovery(res) {
2263
- const services = this.manifest.services.map((s) => ({
3767
+ get publicBase() {
3768
+ return (process.env.PUBLIC_BASE_URL || "").replace(/\/+$/, "");
3769
+ }
3770
+ /**
3771
+ * Per-service pricing across every configured rail, for the discovery
3772
+ * payloads. The top-level `price`/`currency` (crypto/USDC) stay unchanged
3773
+ * for back-compat; this surfaces the fiat + balance rails (CNY) that were
3774
+ * previously invisible in discovery even though the manifest defines them
3775
+ * and the 402 challenge already quotes them. `acceptedCurrencies` becomes
3776
+ * the union across rails so a client can see CNY is accepted without
3777
+ * first triggering a 402.
3778
+ */
3779
+ describeServicePricing(s) {
3780
+ const pricing = [];
3781
+ if (this.getProviderChains().length > 0) {
3782
+ for (const currency of getAcceptedCurrencies(s)) {
3783
+ pricing.push({ rail: "crypto", currency, amount: String(s.price) });
3784
+ }
3785
+ }
3786
+ if (s.alipay) pricing.push({ rail: "alipay", currency: "CNY", amount: s.alipay.price_cny });
3787
+ if (s.wechat) pricing.push({ rail: "wechat", currency: "CNY", amount: s.wechat.price_cny });
3788
+ if (s.balance) {
3789
+ pricing.push({
3790
+ rail: "balance",
3791
+ currency: this.manifest.provider.balance?.currency ?? "CNY",
3792
+ amount: s.balance.price ?? s.price.toFixed(2)
3793
+ });
3794
+ }
3795
+ return { acceptedCurrencies: [...new Set(pricing.map((p) => p.currency))], pricing };
3796
+ }
3797
+ /** Shared service-list entry for the discovery and /services endpoints. */
3798
+ buildDiscoveryService(s) {
3799
+ const { acceptedCurrencies, pricing } = this.describeServicePricing(s);
3800
+ const headline = pricing.find((p) => p.rail === "crypto") ?? pricing[0];
3801
+ return {
2264
3802
  id: s.id,
2265
3803
  name: s.name,
2266
3804
  description: s.description,
2267
- price: s.price,
2268
- currency: s.currency,
2269
- acceptedCurrencies: getAcceptedCurrencies(s),
3805
+ price: headline ? Number(headline.amount) : s.price,
3806
+ currency: headline ? headline.currency : s.currency,
3807
+ acceptedCurrencies,
3808
+ pricing,
2270
3809
  input: s.input,
2271
3810
  output: s.output,
2272
3811
  available: this.skills.has(s.id)
2273
- }));
3812
+ };
3813
+ }
3814
+ /**
3815
+ * GET /.well-known/agent-services.json - Standard discovery endpoint
3816
+ */
3817
+ handleAgentServicesDiscovery(res) {
3818
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2274
3819
  this.sendJson(res, 200, {
2275
3820
  version: "1.0",
2276
3821
  provider: {
@@ -2283,9 +3828,9 @@ var MoltsPayServer = class {
2283
3828
  },
2284
3829
  services,
2285
3830
  endpoints: {
2286
- services: "/services",
2287
- execute: "/execute",
2288
- health: "/health"
3831
+ services: `${this.publicBase}/services`,
3832
+ execute: `${this.publicBase}/execute`,
3833
+ health: `${this.publicBase}/health`
2289
3834
  },
2290
3835
  payment: {
2291
3836
  protocol: "x402",
@@ -2300,17 +3845,7 @@ var MoltsPayServer = class {
2300
3845
  * GET /services - List available services
2301
3846
  */
2302
3847
  handleGetServices(res) {
2303
- const services = this.manifest.services.map((s) => ({
2304
- id: s.id,
2305
- name: s.name,
2306
- description: s.description,
2307
- price: s.price,
2308
- currency: s.currency,
2309
- acceptedCurrencies: getAcceptedCurrencies(s),
2310
- input: s.input,
2311
- output: s.output,
2312
- available: this.skills.has(s.id)
2313
- }));
3848
+ const services = this.manifest.services.map((s) => this.buildDiscoveryService(s));
2314
3849
  const selection = this.registry.getSelection();
2315
3850
  this.sendJson(res, 200, {
2316
3851
  provider: this.manifest.provider,
@@ -2369,7 +3904,7 @@ var MoltsPayServer = class {
2369
3904
  return this.handleAlipayExecute(skill, params || {}, alipayPayment, res);
2370
3905
  }
2371
3906
  if (!paymentHeader) {
2372
- return this.sendPaymentRequired(skill.config, res);
3907
+ return this.sendPaymentRequired(skill.config, res, params || {});
2373
3908
  }
2374
3909
  let payment;
2375
3910
  try {
@@ -2383,6 +3918,12 @@ var MoltsPayServer = class {
2383
3918
  if (payScheme === ALIPAY_SCHEME || (payNetwork ? isAlipayChainId(payNetwork) : false)) {
2384
3919
  return this.handleAlipayExecute(skill, params || {}, payment, res);
2385
3920
  }
3921
+ if (payScheme === WECHAT_SCHEME || (payNetwork ? isWechatChainId(payNetwork) : false)) {
3922
+ return this.handleWechatExecute(skill, params || {}, payment, res);
3923
+ }
3924
+ if (payScheme === BALANCE_SCHEME || (payNetwork ? isBalanceChainId(payNetwork) : false)) {
3925
+ return this.handleBalanceExecute(skill, params || {}, payment, res);
3926
+ }
2386
3927
  const validation = this.validatePayment(payment, skill.config);
2387
3928
  if (!validation.valid) {
2388
3929
  return this.sendJson(res, 402, { error: validation.error });
@@ -2476,7 +4017,7 @@ var MoltsPayServer = class {
2476
4017
  }, responseHeaders);
2477
4018
  }
2478
4019
  /**
2479
- * Execute a service paid via the Alipay AI fiat rail (2.0.0).
4020
+ * Execute a service paid via the Alipay AI Pay fiat rail (2.0.0).
2480
4021
  *
2481
4022
  * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
2482
4023
  * validation. Verify hits the Alipay Open API (`payment.verify`). Settlement
@@ -2484,27 +4025,310 @@ var MoltsPayServer = class {
2484
4025
  * §5.1: a confirm failure is logged but does NOT fail the already-delivered
2485
4026
  * response (the buyer's payment proof was already verified).
2486
4027
  */
2487
- async handleAlipayExecute(skill, params, payment, res) {
2488
- if (!this.alipayFacilitator) {
2489
- return this.sendJson(res, 402, { error: "Alipay rail not configured on this server" });
4028
+ async handleAlipayExecute(skill, params, payment, res) {
4029
+ if (!this.alipayFacilitator) {
4030
+ return this.sendJson(res, 402, { error: "Alipay rail not configured on this server" });
4031
+ }
4032
+ const requirements = {
4033
+ scheme: ALIPAY_SCHEME,
4034
+ network: ALIPAY_NETWORK,
4035
+ asset: "CNY",
4036
+ amount: skill.config.alipay?.price_cny || "0",
4037
+ payTo: this.manifest.provider.alipay?.seller_id || "",
4038
+ maxTimeoutSeconds: 1800
4039
+ };
4040
+ console.log(`[MoltsPay] Verifying Alipay payment...`);
4041
+ const verifyResult = await this.registry.verify(payment, requirements);
4042
+ if (!verifyResult.valid) {
4043
+ return this.sendJson(res, 402, {
4044
+ error: `Payment verification failed: ${verifyResult.error}`,
4045
+ facilitator: verifyResult.facilitator
4046
+ });
4047
+ }
4048
+ console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
4049
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4050
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4051
+ let result;
4052
+ try {
4053
+ result = await Promise.race([
4054
+ skill.handler(params),
4055
+ new Promise(
4056
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4057
+ )
4058
+ ]);
4059
+ } catch (err) {
4060
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4061
+ return this.sendJson(res, 500, {
4062
+ error: "Service execution failed",
4063
+ message: err.message
4064
+ });
4065
+ }
4066
+ let settlement;
4067
+ try {
4068
+ settlement = await this.registry.settle(payment, requirements);
4069
+ if (settlement.success) {
4070
+ console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
4071
+ } else {
4072
+ console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
4073
+ }
4074
+ } catch (err) {
4075
+ console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
4076
+ settlement = { success: false, error: err.message, facilitator: "alipay" };
4077
+ }
4078
+ const responseHeaders = {};
4079
+ if (settlement.success) {
4080
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4081
+ success: true,
4082
+ transaction: settlement.transaction,
4083
+ network: ALIPAY_NETWORK,
4084
+ facilitator: settlement.facilitator
4085
+ })).toString("base64");
4086
+ }
4087
+ this.sendJson(res, 200, {
4088
+ success: true,
4089
+ result,
4090
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4091
+ }, responseHeaders);
4092
+ }
4093
+ /**
4094
+ * Build the Alipay 402 challenge for a service, or null when the alipay rail
4095
+ * isn't configured for this server or this service. Returns the x402
4096
+ * `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
4097
+ * 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
4098
+ */
4099
+ async buildAlipayChallenge(config) {
4100
+ if (!this.alipayFacilitator || !config.alipay) return null;
4101
+ try {
4102
+ const req = await this.alipayFacilitator.createPaymentRequirements({
4103
+ serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
4104
+ priceCny: config.alipay.price_cny,
4105
+ goodsName: config.alipay.goods_name,
4106
+ resourceId: `/execute?service=${config.id}`
4107
+ });
4108
+ return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
4109
+ } catch (err) {
4110
+ console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
4111
+ return null;
4112
+ }
4113
+ }
4114
+ /**
4115
+ * Execute a service paid via the WeChat Pay v3 Native fiat rail (2.1.0).
4116
+ *
4117
+ * Differs from the EVM/SVM path: no token detection, no EIP-3009/permit
4118
+ * validation. The buyer (a human) scanned the Native QR and paid; the
4119
+ * client re-requests carrying `out_trade_no` in the X-Payment payload.
4120
+ * Verify queries the order (`trade_state === SUCCESS`). Settlement is an
4121
+ * idempotent re-confirm and is FIRE-AND-FORGET (mirrors the Alipay path):
4122
+ * a confirm failure is logged but does NOT fail the delivered response —
4123
+ * the order was already verified SUCCESS.
4124
+ */
4125
+ async handleWechatExecute(skill, params, payment, res) {
4126
+ if (!this.wechatFacilitator) {
4127
+ return this.sendJson(res, 402, { error: "WeChat rail not configured on this server" });
4128
+ }
4129
+ const outTradeNo = typeof payment.accepted?.extra?.out_trade_no === "string" ? payment.accepted.extra.out_trade_no : void 0;
4130
+ const requirements = {
4131
+ scheme: WECHAT_SCHEME,
4132
+ network: WECHAT_NETWORK,
4133
+ asset: "CNY",
4134
+ amount: skill.config.wechat?.price_cny || "0",
4135
+ payTo: this.manifest.provider.wechat?.mchid || "",
4136
+ maxTimeoutSeconds: 300,
4137
+ extra: outTradeNo ? { out_trade_no: outTradeNo } : void 0
4138
+ };
4139
+ console.log(`[MoltsPay] Verifying WeChat payment...`);
4140
+ const verifyResult = await this.registry.verify(payment, requirements);
4141
+ if (!verifyResult.valid) {
4142
+ return this.sendJson(res, 402, {
4143
+ error: `Payment verification failed: ${verifyResult.error}`,
4144
+ facilitator: verifyResult.facilitator
4145
+ });
4146
+ }
4147
+ console.log(`[MoltsPay] WeChat payment verified by ${verifyResult.facilitator}`);
4148
+ if (outTradeNo) {
4149
+ this.invalidateWechatChallenge(outTradeNo);
4150
+ }
4151
+ const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
4152
+ console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
4153
+ let result;
4154
+ try {
4155
+ result = await Promise.race([
4156
+ skill.handler(params),
4157
+ new Promise(
4158
+ (_, reject) => setTimeout(() => reject(new Error(`Skill timeout after ${timeoutSeconds}s`)), timeoutSeconds * 1e3)
4159
+ )
4160
+ ]);
4161
+ } catch (err) {
4162
+ console.error("[MoltsPay] Skill execution failed:", err.message);
4163
+ return this.sendJson(res, 500, {
4164
+ error: "Service execution failed",
4165
+ message: err.message
4166
+ });
4167
+ }
4168
+ let settlement;
4169
+ try {
4170
+ settlement = await this.registry.settle(payment, requirements);
4171
+ if (settlement.success) {
4172
+ console.log(`[MoltsPay] WeChat settlement confirmed: ${settlement.transaction}`);
4173
+ } else {
4174
+ console.error(`[MoltsPay] WeChat settlement confirm failed (non-fatal): ${settlement.error}`);
4175
+ }
4176
+ } catch (err) {
4177
+ console.error(`[MoltsPay] WeChat settlement confirm threw (non-fatal): ${err.message}`);
4178
+ settlement = { success: false, error: err.message, facilitator: "wechat" };
4179
+ }
4180
+ const responseHeaders = {};
4181
+ if (settlement.success) {
4182
+ responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4183
+ success: true,
4184
+ transaction: settlement.transaction,
4185
+ network: WECHAT_NETWORK,
4186
+ facilitator: settlement.facilitator
4187
+ })).toString("base64");
4188
+ }
4189
+ this.sendJson(res, 200, {
4190
+ success: true,
4191
+ result,
4192
+ payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4193
+ }, responseHeaders);
4194
+ }
4195
+ /**
4196
+ * Build the WeChat 402 challenge for a service, or null when the wechat rail
4197
+ * isn't configured for this server or this service. Placing a Native order
4198
+ * is a network call that returns a fresh `code_url` + `out_trade_no`; the
4199
+ * x402 `accepts[]` entry carries both in `extra` so the client can render
4200
+ * the QR and later echo `out_trade_no` back for verification.
4201
+ *
4202
+ * DOUBLE-CHARGE FIX: the unpaid order is cached per service id (see
4203
+ * `wechatPendingChallenges`), so repeated 402 emits within the order's
4204
+ * `time_expire` window return the SAME `code_url`/`out_trade_no` instead of
4205
+ * minting a new payable order each time. The entry is dropped once the
4206
+ * order is paid (`invalidateWechatChallenge`) or shortly before it expires
4207
+ * (refresh margin, so clients never receive a nearly-dead QR). A build
4208
+ * failure is not cached and degrades gracefully (the other rails'
4209
+ * accepts[] still ship).
4210
+ */
4211
+ async buildWechatChallenge(config, params) {
4212
+ if (!this.wechatFacilitator || !config.wechat) return null;
4213
+ const cacheKey = crypto6.createHash("sha256").update(`${config.id}|${canonicalJson(params ?? {})}|${config.wechat.price_cny}`).digest("hex");
4214
+ const result = await this.getOrCreatePendingWechatOrder(
4215
+ cacheKey,
4216
+ config.id,
4217
+ () => this.wechatFacilitator.createPaymentRequirements({
4218
+ priceCny: config.wechat.price_cny,
4219
+ description: config.wechat.description
4220
+ })
4221
+ );
4222
+ return result ? { accepts: result.accepts } : null;
4223
+ }
4224
+ /**
4225
+ * Get-or-create a pending WeChat Native order under `cacheKey`, deduping
4226
+ * concurrent builds and reusing an unpaid order until it nears expiry.
4227
+ * Shared by the 402 challenge path ({@link buildWechatChallenge}) and the
4228
+ * balance top-up order path ({@link handleBalanceTopupOrder}). See the
4229
+ * `wechatPendingChallenges` doc for the double-charge rationale.
4230
+ */
4231
+ async getOrCreatePendingWechatOrder(cacheKey, logLabel, create) {
4232
+ const now = Date.now();
4233
+ const cached = this.wechatPendingChallenges.get(cacheKey);
4234
+ if (cached) {
4235
+ if (now < cached.expiresAtMs) {
4236
+ const hit = await cached.promise;
4237
+ if (hit) {
4238
+ console.log(`[MoltsPay] Reusing pending WeChat order ${hit.outTradeNo} for ${logLabel}`);
4239
+ return hit;
4240
+ }
4241
+ }
4242
+ this.wechatPendingChallenges.delete(cacheKey);
4243
+ }
4244
+ const orderTtlMs = WECHAT_TIME_EXPIRE_MS;
4245
+ const cacheTtlMs = Math.max(orderTtlMs - 3e4, Math.floor(orderTtlMs / 2));
4246
+ const entry = {
4247
+ expiresAtMs: now + cacheTtlMs,
4248
+ promise: Promise.resolve(null)
4249
+ };
4250
+ entry.promise = (async () => {
4251
+ try {
4252
+ const req = await create();
4253
+ entry.outTradeNo = req.outTradeNo;
4254
+ return { accepts: req.x402Accepts, codeUrl: req.codeUrl, outTradeNo: req.outTradeNo };
4255
+ } catch (err) {
4256
+ console.error(`[MoltsPay] WeChat order build failed for ${logLabel}: ${err.message}`);
4257
+ this.wechatPendingChallenges.delete(cacheKey);
4258
+ return null;
4259
+ }
4260
+ })();
4261
+ this.wechatPendingChallenges.set(cacheKey, entry);
4262
+ return entry.promise;
4263
+ }
4264
+ /**
4265
+ * Drop the cached pending WeChat order that matches a paid `out_trade_no`,
4266
+ * so the next 402 mints a fresh order instead of re-serving a consumed one
4267
+ * (Native is one-code-one-payment).
4268
+ */
4269
+ invalidateWechatChallenge(outTradeNo) {
4270
+ for (const [cacheKey, entry] of this.wechatPendingChallenges) {
4271
+ if (entry.outTradeNo === outTradeNo) {
4272
+ this.wechatPendingChallenges.delete(cacheKey);
4273
+ }
4274
+ }
4275
+ }
4276
+ /**
4277
+ * Handle /execute for the custodial balance rail (2.2.0).
4278
+ *
4279
+ * Execution order is INVERTED relative to the QR rails: the deduction IS
4280
+ * the settlement, so it must land before the skill runs, and a skill
4281
+ * failure refunds it. `settle()` is idempotent on the client's
4282
+ * `request_id`, so a retried request never double-charges.
4283
+ *
4284
+ * QR rails: verify(paid?) → run skill → settle (confirm, fire-and-forget)
4285
+ * balance: verify implicit in settle (atomic deduct) → run skill → [fail → refund]
4286
+ */
4287
+ async handleBalanceExecute(skill, params, payment, res) {
4288
+ if (!this.balanceFacilitator) {
4289
+ return this.sendJson(res, 402, { error: "Balance rail not configured on this server" });
4290
+ }
4291
+ const requirements = this.balanceRequirementsFor(skill.config);
4292
+ const authMode = this.balanceFacilitator.authMode;
4293
+ if (authMode !== "off") {
4294
+ const bp = extractBalancePayload(payment);
4295
+ const buyerId = bp?.buyer_id ?? "";
4296
+ const requestId = bp?.request_id ?? "";
4297
+ const av = verifyDeductAuth({
4298
+ auth: bp?.auth ?? null,
4299
+ buyerId,
4300
+ requestId,
4301
+ service: skill.id,
4302
+ nowMs: Date.now()
4303
+ });
4304
+ const ledger = this.balanceFacilitator.getLedger();
4305
+ let denyReason;
4306
+ if (av.ok && av.recovered) {
4307
+ const bind = ledger.bindSigner(buyerId, av.recovered);
4308
+ if (bind.conflict) denyReason = `wrong signer (account bound to ${bind.existing}, got ${av.recovered})`;
4309
+ } else {
4310
+ denyReason = `signature ${av.reason}`;
4311
+ }
4312
+ if (denyReason) {
4313
+ if (authMode === "enforce") {
4314
+ console.warn(`[MoltsPay] Balance auth DENY (enforce) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4315
+ return this.sendJson(res, 401, { error: `Balance auth failed: ${denyReason}`, facilitator: "balance" });
4316
+ }
4317
+ console.warn(`[MoltsPay] Balance auth would-deny (shadow) buyer=${buyerId} svc=${skill.id}: ${denyReason}`);
4318
+ } else {
4319
+ console.log(`[MoltsPay] Balance auth ok (${authMode}) buyer=${buyerId} signer=${av.recovered}`);
4320
+ }
2490
4321
  }
2491
- const requirements = {
2492
- scheme: ALIPAY_SCHEME,
2493
- network: ALIPAY_NETWORK,
2494
- asset: "CNY",
2495
- amount: skill.config.alipay?.price_cny || "0",
2496
- payTo: this.manifest.provider.alipay?.seller_id || "",
2497
- maxTimeoutSeconds: 1800
2498
- };
2499
- console.log(`[MoltsPay] Verifying Alipay payment...`);
2500
- const verifyResult = await this.registry.verify(payment, requirements);
2501
- if (!verifyResult.valid) {
4322
+ console.log(`[MoltsPay] Deducting balance for ${skill.id}...`);
4323
+ const settlement = await this.balanceFacilitator.settle(payment, requirements);
4324
+ if (!settlement.success) {
2502
4325
  return this.sendJson(res, 402, {
2503
- error: `Payment verification failed: ${verifyResult.error}`,
2504
- facilitator: verifyResult.facilitator
4326
+ error: `Balance deduction failed: ${settlement.error}`,
4327
+ code: settlement.status,
4328
+ facilitator: "balance"
2505
4329
  });
2506
4330
  }
2507
- console.log(`[MoltsPay] Alipay payment verified by ${verifyResult.facilitator}`);
4331
+ console.log(`[MoltsPay] Balance deducted (tx ${settlement.transaction}${settlement.status === "replayed" ? ", replayed" : ""})`);
2508
4332
  const timeoutSeconds = parseInt(process.env.SKILL_TIMEOUT_SECONDS || "1200");
2509
4333
  console.log(`[MoltsPay] Executing skill: ${skill.id} (timeout: ${timeoutSeconds}s)`);
2510
4334
  let result;
@@ -2517,59 +4341,60 @@ var MoltsPayServer = class {
2517
4341
  ]);
2518
4342
  } catch (err) {
2519
4343
  console.error("[MoltsPay] Skill execution failed:", err.message);
4344
+ const refund = this.balanceFacilitator.refund(settlement.transaction, `skill_failed: ${err.message}`.slice(0, 200));
4345
+ if (!refund.success) {
4346
+ console.error(`[MoltsPay] Balance refund FAILED for ${settlement.transaction}: ${refund.error} \u2014 manual reconciliation needed`);
4347
+ } else {
4348
+ console.log(`[MoltsPay] Balance refunded (tx ${refund.txId})`);
4349
+ }
2520
4350
  return this.sendJson(res, 500, {
2521
4351
  error: "Service execution failed",
2522
- message: err.message
4352
+ message: err.message,
4353
+ refunded: refund.success
2523
4354
  });
2524
4355
  }
2525
- let settlement;
2526
- try {
2527
- settlement = await this.registry.settle(payment, requirements);
2528
- if (settlement.success) {
2529
- console.log(`[MoltsPay] Alipay fulfillment confirmed: ${settlement.transaction}`);
2530
- } else {
2531
- console.error(`[MoltsPay] Alipay fulfillment confirm failed (non-fatal): ${settlement.error}`);
2532
- }
2533
- } catch (err) {
2534
- console.error(`[MoltsPay] Alipay fulfillment confirm threw (non-fatal): ${err.message}`);
2535
- settlement = { success: false, error: err.message, facilitator: "alipay" };
2536
- }
2537
- const responseHeaders = {};
2538
- if (settlement.success) {
2539
- responseHeaders[PAYMENT_RESPONSE_HEADER] = Buffer.from(JSON.stringify({
4356
+ const responseHeaders = {
4357
+ [PAYMENT_RESPONSE_HEADER]: Buffer.from(JSON.stringify({
2540
4358
  success: true,
2541
4359
  transaction: settlement.transaction,
2542
- network: ALIPAY_NETWORK,
2543
- facilitator: settlement.facilitator
2544
- })).toString("base64");
2545
- }
4360
+ network: "balance",
4361
+ facilitator: "balance"
4362
+ })).toString("base64")
4363
+ };
2546
4364
  this.sendJson(res, 200, {
2547
4365
  success: true,
2548
4366
  result,
2549
- payment: settlement.success ? { transaction: settlement.transaction, status: "fulfilled", facilitator: settlement.facilitator } : { status: "delivered_unconfirmed", error: settlement.error }
4367
+ payment: { transaction: settlement.transaction, status: "fulfilled", facilitator: "balance" }
2550
4368
  }, responseHeaders);
2551
4369
  }
4370
+ /** The balance rail's requirements for a service (price defaults to `config.price`). */
4371
+ balanceRequirementsFor(config) {
4372
+ const price = config.balance?.price ?? config.price.toFixed(2);
4373
+ return {
4374
+ scheme: BALANCE_SCHEME,
4375
+ network: "balance",
4376
+ asset: this.balanceFacilitator?.currency ?? "USD",
4377
+ amount: price,
4378
+ payTo: "custodial",
4379
+ maxTimeoutSeconds: 30,
4380
+ extra: { service_id: config.id }
4381
+ };
4382
+ }
2552
4383
  /**
2553
- * Build the Alipay 402 challenge for a service, or null when the alipay rail
2554
- * isn't configured for this server or this service. Returns the x402
2555
- * `accepts[]` entry plus the Base64URL `Payment-Needed` header value so the
2556
- * 402 responders can dual-emit both the x402 and legacy alipay-bot formats.
4384
+ * Build the balance 402 challenge for a service, or null when the rail
4385
+ * isn't configured for this server or this service. Pure nothing is
4386
+ * minted, so unlike the QR rails a 402 emit has no side effects.
2557
4387
  */
2558
- async buildAlipayChallenge(config) {
2559
- if (!this.alipayFacilitator || !config.alipay) return null;
4388
+ buildBalanceChallenge(config) {
4389
+ if (!this.balanceFacilitator || !config.balance) return null;
2560
4390
  try {
2561
- const req = await this.alipayFacilitator.createPaymentRequirements({
2562
- serviceId: config.alipay.service_id || this.manifest.provider.alipay.service_id_default,
2563
- priceCny: config.alipay.price_cny,
2564
- goodsName: config.alipay.goods_name,
2565
- resourceId: `/execute?service=${config.id}`
2566
- });
2567
- return { accepts: req.x402Accepts, paymentNeededHeader: req.paymentNeededHeader };
4391
+ return { accepts: this.balanceRequirementsFor(config) };
2568
4392
  } catch (err) {
2569
- console.error(`[MoltsPay] Alipay challenge build failed for ${config.id}: ${err.message}`);
4393
+ console.error(`[MoltsPay] Balance challenge build failed for ${config.id}: ${err.message}`);
2570
4394
  return null;
2571
4395
  }
2572
4396
  }
4397
+ /** GET /balance?buyer_id= — balance, limits, and today's spend. */
2573
4398
  /**
2574
4399
  * Handle MPP (Machine Payments Protocol) request
2575
4400
  * Supports both x402 and MPP protocols on service endpoints
@@ -2586,7 +4411,7 @@ var MoltsPayServer = class {
2586
4411
  if (authHeader && authHeader.toLowerCase().startsWith("payment ")) {
2587
4412
  return await this.handleMPPPayment(skill, params, authHeader, res);
2588
4413
  }
2589
- return this.sendMPPPaymentRequired(config, res);
4414
+ return this.sendMPPPaymentRequired(config, res, params);
2590
4415
  }
2591
4416
  /**
2592
4417
  * Handle MPP payment verification and service execution
@@ -2685,7 +4510,7 @@ var MoltsPayServer = class {
2685
4510
  /**
2686
4511
  * Return 402 with both x402 and MPP payment requirements
2687
4512
  */
2688
- async sendMPPPaymentRequired(config, res) {
4513
+ async sendMPPPaymentRequired(config, res, params) {
2689
4514
  const acceptedTokens = getAcceptedCurrencies(config);
2690
4515
  const providerChains = this.getProviderChains();
2691
4516
  const accepts = [];
@@ -2700,6 +4525,14 @@ var MoltsPayServer = class {
2700
4525
  if (alipayChallenge) {
2701
4526
  accepts.push(alipayChallenge.accepts);
2702
4527
  }
4528
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4529
+ if (wechatChallenge) {
4530
+ accepts.push(wechatChallenge.accepts);
4531
+ }
4532
+ const balanceChallenge = this.buildBalanceChallenge(config);
4533
+ if (balanceChallenge) {
4534
+ accepts.push(balanceChallenge.accepts);
4535
+ }
2703
4536
  const x402PaymentRequired = {
2704
4537
  x402Version: X402_VERSION2,
2705
4538
  accepts,
@@ -2765,7 +4598,7 @@ var MoltsPayServer = class {
2765
4598
  * Return 402 with x402 payment requirements (v2 format)
2766
4599
  * Includes requirements for all chains and all accepted currencies
2767
4600
  */
2768
- async sendPaymentRequired(config, res) {
4601
+ async sendPaymentRequired(config, res, params) {
2769
4602
  const acceptedTokens = getAcceptedCurrencies(config);
2770
4603
  const providerChains = this.getProviderChains();
2771
4604
  const accepts = [];
@@ -2780,6 +4613,14 @@ var MoltsPayServer = class {
2780
4613
  if (alipayChallenge) {
2781
4614
  accepts.push(alipayChallenge.accepts);
2782
4615
  }
4616
+ const wechatChallenge = await this.buildWechatChallenge(config, params);
4617
+ if (wechatChallenge) {
4618
+ accepts.push(wechatChallenge.accepts);
4619
+ }
4620
+ const balanceChallenge = this.buildBalanceChallenge(config);
4621
+ if (balanceChallenge) {
4622
+ accepts.push(balanceChallenge.accepts);
4623
+ }
2783
4624
  const acceptedChains = providerChains.map((c) => {
2784
4625
  if (c.network === "eip155:8453") return "base";
2785
4626
  if (c.network === "eip155:137") return "polygon";
@@ -2791,7 +4632,7 @@ var MoltsPayServer = class {
2791
4632
  acceptedCurrencies: acceptedTokens,
2792
4633
  acceptedChains,
2793
4634
  resource: {
2794
- url: `/execute?service=${config.id}`,
4635
+ url: `${this.publicBase}/execute?service=${config.id}`,
2795
4636
  description: `${config.name} - $${config.price} ${config.currency}`,
2796
4637
  mimeType: "application/json"
2797
4638
  }
@@ -2804,11 +4645,13 @@ var MoltsPayServer = class {
2804
4645
  if (alipayChallenge) {
2805
4646
  headers[ALIPAY_PAYMENT_NEEDED_HEADER] = alipayChallenge.paymentNeededHeader;
2806
4647
  }
4648
+ const offered = this.describeServicePricing(config);
4649
+ 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}`;
2807
4650
  res.writeHead(402, headers);
2808
4651
  res.end(JSON.stringify({
2809
4652
  error: "Payment required",
2810
- message: `Service requires $${config.price} ${config.currency}`,
2811
- acceptedCurrencies: acceptedTokens,
4653
+ message,
4654
+ acceptedCurrencies: offered.acceptedCurrencies,
2812
4655
  acceptedChains,
2813
4656
  x402: paymentRequired
2814
4657
  }, null, 2));
@@ -3340,15 +5183,16 @@ var MoltsPayServer = class {
3340
5183
  };
3341
5184
 
3342
5185
  // src/client/node/index.ts
3343
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, statSync, chmodSync } from "fs";
3344
- import { homedir as homedir3 } from "os";
3345
- import { join as join5 } from "path";
3346
- import { Wallet as Wallet2, ethers as ethers3 } from "ethers";
5186
+ import { existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3, mkdirSync as mkdirSync3, statSync, chmodSync, readdirSync as readdirSync2 } from "fs";
5187
+ import { homedir as homedir4 } from "os";
5188
+ import { join as join6 } from "path";
5189
+ import { randomUUID as randomUUID4 } from "crypto";
5190
+ import { Wallet as Wallet2, ethers as ethers5 } from "ethers";
3347
5191
 
3348
5192
  // src/wallet/solana.ts
3349
5193
  import { Keypair as Keypair3, PublicKey as PublicKey3, LAMPORTS_PER_SOL } from "@solana/web3.js";
3350
5194
  import { getAssociatedTokenAddress as getAssociatedTokenAddress2, getAccount as getAccount2 } from "@solana/spl-token";
3351
- import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync } from "fs";
5195
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync3, mkdirSync } from "fs";
3352
5196
  import { join as join3 } from "path";
3353
5197
  import { homedir } from "os";
3354
5198
  import bs582 from "bs58";
@@ -3363,7 +5207,7 @@ function loadSolanaWallet(configDir = DEFAULT_CONFIG_DIR) {
3363
5207
  return null;
3364
5208
  }
3365
5209
  try {
3366
- const data = JSON.parse(readFileSync3(walletPath, "utf-8"));
5210
+ const data = JSON.parse(readFileSync4(walletPath, "utf-8"));
3367
5211
  const secretKey = bs582.decode(data.secretKey);
3368
5212
  return Keypair3.fromSecretKey(secretKey);
3369
5213
  } catch (error) {
@@ -3483,7 +5327,7 @@ function buildBnbIntentTypedData(args) {
3483
5327
  }
3484
5328
 
3485
5329
  // src/client/node/signer.ts
3486
- import { ethers as ethers2 } from "ethers";
5330
+ import { ethers as ethers4 } from "ethers";
3487
5331
  import { Transaction as Transaction2 } from "@solana/web3.js";
3488
5332
  var NodeSigner = class {
3489
5333
  evmWallet;
@@ -3515,7 +5359,7 @@ var NodeSigner = class {
3515
5359
  if (!chain) {
3516
5360
  throw new Error(`sendEvmTransaction: unknown chainId ${args.chainId}`);
3517
5361
  }
3518
- const provider = new ethers2.JsonRpcProvider(chain.rpc);
5362
+ const provider = new ethers4.JsonRpcProvider(chain.rpc);
3519
5363
  const connected = this.evmWallet.connect(provider);
3520
5364
  const tx = await connected.sendTransaction({
3521
5365
  to: args.to,
@@ -3548,7 +5392,7 @@ function findChainByChainId(chainId) {
3548
5392
  }
3549
5393
 
3550
5394
  // src/client/alipay/index.ts
3551
- import { randomUUID } from "crypto";
5395
+ import { randomUUID as randomUUID2 } from "crypto";
3552
5396
  import { mkdir, writeFile } from "fs/promises";
3553
5397
  import { homedir as homedir2 } from "os";
3554
5398
  import { join as join4 } from "path";
@@ -3975,7 +5819,7 @@ async function pollUntil(tradeNo, resourceUrl, opts) {
3975
5819
  // src/client/alipay/index.ts
3976
5820
  var TRADE_NO_RE = /^\d{32}$/;
3977
5821
  function resolveSessionId(explicit, envSession) {
3978
- return explicit ?? envSession ?? randomUUID();
5822
+ return explicit ?? envSession ?? randomUUID2();
3979
5823
  }
3980
5824
  function assertTradeNo(t) {
3981
5825
  if (!TRADE_NO_RE.test(t)) {
@@ -4161,7 +6005,7 @@ var AlipayClient = class {
4161
6005
  if (m) media.push(m);
4162
6006
  else opts.onLine?.(line);
4163
6007
  };
4164
- const intentSummary = opts.intentSummary?.trim() || `\u652F\u4ED8 ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
6008
+ const intentSummary = opts.intentSummary?.trim() || `Pay ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
4165
6009
  await timeStep("ensure-cli", flow, () => ensureCli(this.getVersion));
4166
6010
  const intentTtlMs = resolveIntentTtlMs();
4167
6011
  const intentKey = `${this.configDir}::${this.framework}`;
@@ -4188,7 +6032,7 @@ var AlipayClient = class {
4188
6032
  }
4189
6033
  }
4190
6034
  await this.checkWallet(signal, flow);
4191
- const reqId = String(extra.out_trade_no ?? randomUUID());
6035
+ const reqId = String(extra.out_trade_no ?? randomUUID2());
4192
6036
  const dir = join4(this.configDir, "alipay");
4193
6037
  await mkdir(dir, { recursive: true });
4194
6038
  const challengeFile = join4(dir, `402_${reqId}.txt`);
@@ -4246,10 +6090,205 @@ var AlipayClient = class {
4246
6090
  }
4247
6091
  };
4248
6092
 
6093
+ // src/client/wechat/index.ts
6094
+ import { randomUUID as randomUUID3 } from "crypto";
6095
+ import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
6096
+ import { homedir as homedir3 } from "os";
6097
+ import { join as join5 } from "path";
6098
+ var WECHAT_SCHEME2 = "wechatpay-native";
6099
+ var WECHAT_NETWORK2 = "wechat";
6100
+ var POLL_INTERVAL_MS2 = 3e3;
6101
+ var TIMEOUT_MS = 5 * 60 * 1e3;
6102
+ var WechatClient = class {
6103
+ configDir;
6104
+ sessionDir;
6105
+ now;
6106
+ constructor(options = {}) {
6107
+ this.configDir = options.configDir || join5(homedir3(), ".moltspay");
6108
+ this.sessionDir = join5(this.configDir, "wechat-sessions");
6109
+ this.now = options.now ?? Date.now;
6110
+ }
6111
+ async pay402(opts) {
6112
+ const session = this.start402({ ...opts });
6113
+ const result = await this.pollSession(session.paymentSessionId, {
6114
+ pollIntervalMs: opts.pollIntervalMs,
6115
+ timeoutMs: opts.timeoutMs,
6116
+ signal: opts.signal
6117
+ });
6118
+ if (result.status === "paid" || result.status === "completed") {
6119
+ return {
6120
+ body: result.resultBody ?? "",
6121
+ status: result.lastHttpStatus ?? 200
6122
+ };
6123
+ }
6124
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
6125
+ }
6126
+ /**
6127
+ * Start a recoverable WeChat payment session. This returns immediately after
6128
+ * persisting `out_trade_no`, QR payload, original request body, and context.
6129
+ */
6130
+ start402(opts) {
6131
+ const extra = opts.requirement.extra ?? {};
6132
+ const codeUrl = typeof extra.code_url === "string" ? extra.code_url : "";
6133
+ const outTradeNo = typeof extra.out_trade_no === "string" ? extra.out_trade_no : "";
6134
+ if (!codeUrl || !outTradeNo) {
6135
+ throw new Error(
6136
+ "WechatClient.pay402: wechatpay-native requirement is missing extra.code_url / extra.out_trade_no"
6137
+ );
6138
+ }
6139
+ const now = new Date(this.now());
6140
+ const timeoutMs = opts.timeoutMs ?? (opts.requirement.maxTimeoutSeconds ?? TIMEOUT_MS / 1e3) * 1e3;
6141
+ const session = {
6142
+ paymentSessionId: opts.paymentSessionId ?? `mpay_sess_${randomUUID3()}`,
6143
+ status: "pending",
6144
+ resourceUrl: opts.resourceUrl,
6145
+ method: opts.method ?? "POST",
6146
+ data: opts.data,
6147
+ requirement: opts.requirement,
6148
+ codeUrl,
6149
+ outTradeNo,
6150
+ createdAt: now.toISOString(),
6151
+ updatedAt: now.toISOString(),
6152
+ expiresAt: new Date(this.now() + timeoutMs).toISOString(),
6153
+ context: opts.context
6154
+ };
6155
+ this.saveSession(session);
6156
+ opts.onPaymentPending?.({ codeUrl, outTradeNo });
6157
+ return session;
6158
+ }
6159
+ /**
6160
+ * Query a persisted session once by replaying the original request with the
6161
+ * stored `out_trade_no` proof. 200 means paid + fulfilled; 402 means pending.
6162
+ */
6163
+ async status(identifier) {
6164
+ const session = this.loadSession(identifier);
6165
+ if (session.status === "cancelled" || session.status === "completed") return session;
6166
+ if (this.now() >= new Date(session.expiresAt).getTime()) {
6167
+ return this.updateSession(session, {
6168
+ status: "expired",
6169
+ lastError: `WeChat payment expired at ${session.expiresAt}`
6170
+ });
6171
+ }
6172
+ const xPayment = this.buildPaymentHeader(session);
6173
+ const res = await fetch(session.resourceUrl, {
6174
+ method: session.method,
6175
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
6176
+ body: session.method === "POST" ? session.data : void 0
6177
+ });
6178
+ const text = await res.text().catch(() => "");
6179
+ if (res.status === 200) {
6180
+ return this.updateSession(session, {
6181
+ status: "completed",
6182
+ lastHttpStatus: res.status,
6183
+ lastError: void 0,
6184
+ resultBody: text
6185
+ });
6186
+ }
6187
+ if (res.status === 402) {
6188
+ return this.updateSession(session, {
6189
+ status: "pending",
6190
+ lastHttpStatus: res.status,
6191
+ lastError: text.slice(0, 500) || "wechat payment pending"
6192
+ });
6193
+ }
6194
+ return this.updateSession(session, {
6195
+ status: "failed",
6196
+ lastHttpStatus: res.status,
6197
+ lastError: `WeChat /execute returned HTTP ${res.status}: ${text.slice(0, 500)}`
6198
+ });
6199
+ }
6200
+ /** Poll a session until it completes, expires, fails, or is aborted. */
6201
+ async pollSession(identifier, opts = {}) {
6202
+ const first = this.loadSession(identifier);
6203
+ const deadline = Math.min(
6204
+ new Date(first.expiresAt).getTime(),
6205
+ this.now() + (opts.timeoutMs ?? TIMEOUT_MS)
6206
+ );
6207
+ const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS2;
6208
+ let session = first;
6209
+ while (this.now() < deadline) {
6210
+ if (opts.signal?.aborted) throw new Error("WeChat payment aborted");
6211
+ session = await this.status(session.paymentSessionId);
6212
+ if (session.status !== "pending") return session;
6213
+ await new Promise((r) => setTimeout(r, interval));
6214
+ }
6215
+ return this.updateSession(session, {
6216
+ status: "expired",
6217
+ lastError: `WeChat payment timed out after ${Math.round((opts.timeoutMs ?? TIMEOUT_MS) / 1e3)}s (out_trade_no=${session.outTradeNo}, last status ${session.lastHttpStatus ?? 0})`
6218
+ });
6219
+ }
6220
+ /** `fulfill` is an idempotent status check: paid sessions return stored body. */
6221
+ async fulfill(identifier) {
6222
+ const session = this.loadSession(identifier);
6223
+ if (session.status === "completed") return session;
6224
+ return this.status(identifier);
6225
+ }
6226
+ /** Mark a local session cancelled. Closing the merchant order is server-side. */
6227
+ cancel(identifier) {
6228
+ const session = this.loadSession(identifier);
6229
+ return this.updateSession(session, {
6230
+ status: "cancelled",
6231
+ lastError: "cancelled locally"
6232
+ });
6233
+ }
6234
+ listSessions() {
6235
+ this.ensureSessionDir();
6236
+ return readdirSync(this.sessionDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse(readFileSync5(join5(this.sessionDir, name), "utf-8"))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
6237
+ }
6238
+ buildPaymentHeader(session) {
6239
+ const proof = {
6240
+ x402Version: 2,
6241
+ scheme: WECHAT_SCHEME2,
6242
+ network: WECHAT_NETWORK2,
6243
+ accepted: {
6244
+ scheme: WECHAT_SCHEME2,
6245
+ network: WECHAT_NETWORK2,
6246
+ extra: { out_trade_no: session.outTradeNo }
6247
+ },
6248
+ payload: { out_trade_no: session.outTradeNo }
6249
+ };
6250
+ return Buffer.from(JSON.stringify(proof)).toString("base64");
6251
+ }
6252
+ loadSession(identifier) {
6253
+ this.ensureSessionDir();
6254
+ const direct = join5(this.sessionDir, `${identifier}.json`);
6255
+ try {
6256
+ return JSON.parse(readFileSync5(direct, "utf-8"));
6257
+ } catch {
6258
+ const byTradeNo = this.listSessions().find((s) => s.outTradeNo === identifier);
6259
+ if (byTradeNo) return byTradeNo;
6260
+ throw new Error(`WeChat payment session not found: ${identifier}`);
6261
+ }
6262
+ }
6263
+ saveSession(session) {
6264
+ this.ensureSessionDir();
6265
+ writeFileSync2(
6266
+ join5(this.sessionDir, `${session.paymentSessionId}.json`),
6267
+ JSON.stringify(session, null, 2)
6268
+ );
6269
+ }
6270
+ updateSession(session, updates) {
6271
+ const next = {
6272
+ ...session,
6273
+ ...updates,
6274
+ updatedAt: new Date(this.now()).toISOString()
6275
+ };
6276
+ this.saveSession(next);
6277
+ return next;
6278
+ }
6279
+ ensureSessionDir() {
6280
+ mkdirSync2(this.sessionDir, { recursive: true });
6281
+ }
6282
+ };
6283
+
4249
6284
  // src/client/alipay/router.ts
4250
6285
  var ALIPAY_RAIL = "alipay";
6286
+ var WECHAT_RAIL = "wechat";
6287
+ var BALANCE_RAIL = "balance";
4251
6288
  function railOf(req) {
4252
6289
  if (req.scheme === "alipay-aipay" || req.network === ALIPAY_RAIL) return ALIPAY_RAIL;
6290
+ if (req.scheme === "wechatpay-native" || req.network === WECHAT_RAIL) return WECHAT_RAIL;
6291
+ if (req.scheme === BALANCE_RAIL || req.network === BALANCE_RAIL) return BALANCE_RAIL;
4253
6292
  return networkToChainName(req.network) ?? req.network;
4254
6293
  }
4255
6294
  function findRail(accepts, rail) {
@@ -4308,7 +6347,7 @@ var MoltsPayClient = class {
4308
6347
  railPreference;
4309
6348
  alipaySessionId;
4310
6349
  constructor(options = {}) {
4311
- this.configDir = options.configDir || join5(homedir3(), ".moltspay");
6350
+ this.configDir = options.configDir || join6(homedir4(), ".moltspay");
4312
6351
  this.config = this.loadConfig();
4313
6352
  this.railPreference = options.railPreference ?? this.config.railPreference;
4314
6353
  this.alipaySessionId = options.alipaySessionId;
@@ -4392,6 +6431,12 @@ var MoltsPayClient = class {
4392
6431
  if (options.rail === ALIPAY_RAIL) {
4393
6432
  return this.payViaAlipay(serverUrl, service, params, options);
4394
6433
  }
6434
+ if (options.rail === WECHAT_RAIL) {
6435
+ return this.payViaWechat(serverUrl, service, params, options);
6436
+ }
6437
+ if (options.rail === BALANCE_RAIL) {
6438
+ return this.payViaBalance(serverUrl, service, params, options);
6439
+ }
4395
6440
  if (!this.wallet || !this.walletData) {
4396
6441
  throw new Error("Client not initialized. Run: npx moltspay init");
4397
6442
  }
@@ -4567,55 +6612,416 @@ Please specify: --chain <chain_name>`
4567
6612
  if (options.chain) {
4568
6613
  paidRequestBody.chain = options.chain;
4569
6614
  }
4570
- const paidRes = await fetch(executeUrl, {
6615
+ const paidRes = await fetch(executeUrl, {
6616
+ method: "POST",
6617
+ headers: {
6618
+ "Content-Type": "application/json",
6619
+ [PAYMENT_HEADER2]: paymentHeader
6620
+ },
6621
+ body: JSON.stringify(paidRequestBody)
6622
+ });
6623
+ const result = await paidRes.json();
6624
+ if (!paidRes.ok) {
6625
+ throw new Error(result.error || "Service execution failed");
6626
+ }
6627
+ this.recordSpending(amount);
6628
+ console.log(`[MoltsPay] Success! Payment: ${result.payment?.status || "claimed"}`);
6629
+ return result.result || result;
6630
+ }
6631
+ /**
6632
+ * Pay for a service over the Alipay fiat rail (2.0.0).
6633
+ *
6634
+ * Unlike the crypto path this needs no EVM wallet — it shells out to
6635
+ * alipay-bot via {@link AlipayClient}. Flow: hit the resource with no
6636
+ * payment to get the 402 challenge, confirm the server actually offers the
6637
+ * alipay rail (selectRail), then run the 8-step state machine and return the
6638
+ * resource body.
6639
+ */
6640
+ async payViaAlipay(serverUrl, service, params, options) {
6641
+ const flow = this.alipaySessionId;
6642
+ let executeUrl = `${serverUrl}/execute`;
6643
+ try {
6644
+ const services = await timeStep(
6645
+ "discover-services",
6646
+ flow,
6647
+ () => this.getServices(serverUrl)
6648
+ );
6649
+ const svc = services.services?.find((s) => s.id === service);
6650
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
6651
+ } catch {
6652
+ }
6653
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
6654
+ const bodyJson = JSON.stringify(requestBody);
6655
+ const res = await timeStep(
6656
+ "challenge-402",
6657
+ flow,
6658
+ () => fetch(executeUrl, {
6659
+ method: "POST",
6660
+ headers: { "Content-Type": "application/json" },
6661
+ body: bodyJson
6662
+ })
6663
+ );
6664
+ if (res.status !== 402) {
6665
+ const data = await res.json().catch(() => ({}));
6666
+ if (res.ok && data.result) return data.result;
6667
+ throw new Error(data.error || `Expected 402, got ${res.status}`);
6668
+ }
6669
+ const header = res.headers.get(PAYMENT_REQUIRED_HEADER2);
6670
+ if (!header) throw new Error("Missing x-payment-required header on 402");
6671
+ const parsed = JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
6672
+ const accepts = Array.isArray(parsed) ? parsed : parsed.accepts ?? [parsed];
6673
+ const { requirement } = selectRail({
6674
+ serverAccepts: accepts,
6675
+ explicitRail: ALIPAY_RAIL,
6676
+ preference: this.railPreference,
6677
+ availability: { evmReady: this.isInitialized }
6678
+ });
6679
+ const onLine = options.onLine ?? ((line) => process.stdout.write(line + "\n"));
6680
+ const alipay = new AlipayClient({
6681
+ sessionId: this.alipaySessionId,
6682
+ configDir: this.configDir
6683
+ });
6684
+ const result = await alipay.pay402({
6685
+ resourceUrl: executeUrl,
6686
+ requirement,
6687
+ method: "POST",
6688
+ data: bodyJson,
6689
+ onLine,
6690
+ onPaymentPending: options.onPaymentPending,
6691
+ timeoutMs: options.timeoutMs,
6692
+ signal: options.signal
6693
+ });
6694
+ try {
6695
+ const json = JSON.parse(result.body);
6696
+ return json.result ?? json;
6697
+ } catch {
6698
+ return { body: result.body, payment: result.payment, media: result.media };
6699
+ }
6700
+ }
6701
+ /**
6702
+ * Pay for a service over the WeChat Pay Native fiat rail (2.1.0).
6703
+ *
6704
+ * Mirrors {@link payViaAlipay} and needs no EVM wallet. The server placed the
6705
+ * Native order when it built the 402, so its `wechatpay-native` accepts[]
6706
+ * entry already carries `extra.code_url` + `extra.out_trade_no`. Flow: hit the
6707
+ * resource to get the 402, confirm the server offers the wechat rail, surface
6708
+ * the code_url (caller renders a QR), then poll the resource with the
6709
+ * out_trade_no proof until the server verifies the order paid and delivers.
6710
+ */
6711
+ async payViaWechat(serverUrl, service, params, options) {
6712
+ const session = await this.startWechatPayment(serverUrl, service, params, options);
6713
+ const wechat = new WechatClient({ configDir: this.configDir });
6714
+ const result = await wechat.pollSession(session.paymentSessionId, {
6715
+ timeoutMs: options.timeoutMs,
6716
+ signal: options.signal
6717
+ });
6718
+ if (result.status !== "completed") {
6719
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
6720
+ }
6721
+ try {
6722
+ const json = JSON.parse(result.resultBody ?? "");
6723
+ return json.result ?? json;
6724
+ } catch {
6725
+ return { body: result.resultBody ?? "" };
6726
+ }
6727
+ }
6728
+ /**
6729
+ * The ethers wallet used to sign balance-rail deductions: the client's EVM
6730
+ * wallet if it has one, else a per-configDir identity key persisted at
6731
+ * `<configDir>/balance-identity.key` (0600) so a balance-only client works
6732
+ * without a full crypto wallet. Under `agent 统一代付`, one key spends every
6733
+ * account the agent tops up.
6734
+ */
6735
+ balanceSigner() {
6736
+ if (this.wallet) return this.wallet;
6737
+ const p = join6(this.configDir, "balance-identity.key");
6738
+ let pk;
6739
+ if (existsSync4(p)) {
6740
+ pk = readFileSync6(p, "utf-8").trim();
6741
+ } else {
6742
+ mkdirSync3(this.configDir, { recursive: true });
6743
+ pk = Wallet2.createRandom().privateKey;
6744
+ writeFileSync3(p, pk, { mode: 384 });
6745
+ }
6746
+ return new Wallet2(pk);
6747
+ }
6748
+ /** The balance-rail spending signer address (lowercase 0x…). Stable per
6749
+ * configDir; this is the identity the server TOFU-binds and later verifies. */
6750
+ getBalanceSignerAddress() {
6751
+ return this.balanceSigner().address.toLowerCase();
6752
+ }
6753
+ /** The buyer id for the balance rail: explicit option > persisted config. */
6754
+ resolveBuyerId(explicit) {
6755
+ const buyerId = explicit ?? this.config.buyerId;
6756
+ if (!buyerId) {
6757
+ throw new Error(
6758
+ "Balance rail needs a buyer id. Pass { buyerId } or persist one with setBuyerId()."
6759
+ );
6760
+ }
6761
+ return buyerId;
6762
+ }
6763
+ /**
6764
+ * Pay via the custodial balance rail (2.2.0, password-free).
6765
+ *
6766
+ * No wallet, no QR: the request carries `{buyer_id, request_id}` in the
6767
+ * X-Payment payload and the server deducts the prepaid balance atomically
6768
+ * before running the skill. The client-generated `request_id` makes the
6769
+ * charge idempotent — a network retry can never double-deduct.
6770
+ */
6771
+ async payViaBalance(serverUrl, service, params, options) {
6772
+ const buyerId = this.resolveBuyerId(options.buyerId);
6773
+ let attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
6774
+ if (attempt.ok) return attempt.result;
6775
+ 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 || ""));
6776
+ if (!fundable || options.autoTopup === false) {
6777
+ throw new Error(attempt.error || `Balance payment failed with HTTP ${attempt.status}`);
6778
+ }
6779
+ if (options.topupMode === "manual") {
6780
+ const order = await this.createBalanceTopupOrder(serverUrl, {
6781
+ pack: options.topupPack,
6782
+ buyerId,
6783
+ context: { service }
6784
+ });
6785
+ options.onTopupRequired?.(order.pack, order.codeUrl);
6786
+ return {
6787
+ status: "topup_required",
6788
+ out_trade_no: order.outTradeNo,
6789
+ code_url: order.codeUrl,
6790
+ pack: order.pack,
6791
+ server_url: serverUrl
6792
+ };
6793
+ }
6794
+ const credited = await this.topupBalancePack(serverUrl, {
6795
+ pack: options.topupPack,
6796
+ buyerId,
6797
+ pollIntervalMs: options.topupPollIntervalMs,
6798
+ signal: options.signal,
6799
+ onCodeUrl: (pack, codeUrl) => options.onTopupRequired?.(pack, codeUrl)
6800
+ });
6801
+ options.onTopupCredited?.(credited.balance);
6802
+ attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
6803
+ if (attempt.ok) return attempt.result;
6804
+ throw new Error(attempt.error || "Balance payment failed after top-up");
6805
+ }
6806
+ /** One password-free deduct attempt. Never throws on an HTTP error; the
6807
+ * caller inspects `{ ok, status, error }` to decide whether to auto-fund. */
6808
+ async balanceDeduct(serverUrl, service, params, options, buyerId) {
6809
+ let executeUrl = `${serverUrl}/execute`;
6810
+ try {
6811
+ const services = await this.getServices(serverUrl);
6812
+ const svc = services.services?.find((s) => s.id === service);
6813
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
6814
+ } catch {
6815
+ }
6816
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
6817
+ const requestId = randomUUID4();
6818
+ const timestamp = Math.floor(Date.now() / 1e3);
6819
+ const signature = await this.balanceSigner().signMessage(
6820
+ buildDeductMessage({ buyerId, requestId, service, timestamp })
6821
+ );
6822
+ const xPayment = Buffer.from(JSON.stringify({
6823
+ x402Version: 2,
6824
+ scheme: BALANCE_RAIL,
6825
+ network: BALANCE_RAIL,
6826
+ payload: { buyer_id: buyerId, request_id: requestId, auth: { timestamp, signature } }
6827
+ })).toString("base64");
6828
+ const res = await fetch(executeUrl, {
6829
+ method: "POST",
6830
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
6831
+ body: JSON.stringify(requestBody),
6832
+ signal: options.signal
6833
+ });
6834
+ const data = await res.json().catch(() => ({}));
6835
+ if (!res.ok) return { ok: false, status: res.status, error: data.error, code: data.code };
6836
+ return { ok: true, status: res.status, result: data.result ?? data };
6837
+ }
6838
+ /**
6839
+ * Non-blocking: POST /balance/topup/order, persist a recoverable session, and
6840
+ * return at once (no polling). Use with {@link confirmBalanceTopup} for
6841
+ * turn-based agents; the blocking {@link topupBalancePack} is built on this.
6842
+ */
6843
+ async createBalanceTopupOrder(serverUrl, opts = {}) {
6844
+ const id = this.resolveBuyerId(opts.buyerId);
6845
+ const orderRes = await fetch(`${serverUrl}/balance/topup/order`, {
6846
+ method: "POST",
6847
+ headers: { "Content-Type": "application/json" },
6848
+ // Carry the spending signer so the server binds it to the account on
6849
+ // confirm (topup-time binding). The same key signs deductions later.
6850
+ body: JSON.stringify({ buyer_id: id, pack: opts.pack, signer_address: this.getBalanceSignerAddress() })
6851
+ });
6852
+ const order = await orderRes.json().catch(() => ({}));
6853
+ if (!orderRes.ok) throw new Error(order.error || `Top-up order failed with HTTP ${orderRes.status}`);
6854
+ const maxTimeoutSeconds = order.max_timeout_seconds ?? 300;
6855
+ const now = Date.now();
6856
+ this.saveBalanceTopupSession({
6857
+ out_trade_no: order.out_trade_no,
6858
+ buyer_id: id,
6859
+ pack: order.pack,
6860
+ server_url: serverUrl,
6861
+ code_url: order.code_url,
6862
+ status: "pending",
6863
+ created_at: new Date(now).toISOString(),
6864
+ expires_at: new Date(now + maxTimeoutSeconds * 1e3).toISOString(),
6865
+ context: opts.context
6866
+ });
6867
+ return { outTradeNo: order.out_trade_no, codeUrl: order.code_url, pack: order.pack, maxTimeoutSeconds };
6868
+ }
6869
+ /**
6870
+ * One-shot: POST /balance/topup/confirm for a single order. No polling.
6871
+ * Updates the persisted session on credit. `serverUrl` defaults to the one
6872
+ * recorded in the session (recover by out_trade_no alone).
6873
+ */
6874
+ async confirmBalanceTopup(outTradeNo, opts = {}) {
6875
+ const session = this.getBalanceTopupSession(outTradeNo);
6876
+ const serverUrl = opts.serverUrl || session?.server_url;
6877
+ if (!serverUrl) {
6878
+ return { credited: false, reason: `No server URL for ${outTradeNo}: pass serverUrl or run topup-order first` };
6879
+ }
6880
+ const confRes = await fetch(`${serverUrl}/balance/topup/confirm`, {
4571
6881
  method: "POST",
4572
- headers: {
4573
- "Content-Type": "application/json",
4574
- [PAYMENT_HEADER2]: paymentHeader
4575
- },
4576
- body: JSON.stringify(paidRequestBody)
6882
+ headers: { "Content-Type": "application/json" },
6883
+ body: JSON.stringify({ out_trade_no: outTradeNo })
4577
6884
  });
4578
- const result = await paidRes.json();
4579
- if (!paidRes.ok) {
4580
- throw new Error(result.error || "Service execution failed");
6885
+ const conf = await confRes.json().catch(() => ({}));
6886
+ if (!confRes.ok) return { credited: false, reason: conf.error || `Confirm failed with HTTP ${confRes.status}` };
6887
+ if (conf.credited) {
6888
+ if (session) {
6889
+ session.status = "credited";
6890
+ session.tx_id = conf.tx_id;
6891
+ session.balance = conf.balance;
6892
+ this.saveBalanceTopupSession(session);
6893
+ }
6894
+ return { credited: true, balance: conf.balance, txId: conf.tx_id };
4581
6895
  }
4582
- this.recordSpending(amount);
4583
- console.log(`[MoltsPay] Success! Payment: ${result.payment?.status || "claimed"}`);
4584
- return result.result || result;
6896
+ return { credited: false, pending: !!conf.pending, reason: conf.reason };
4585
6897
  }
4586
6898
  /**
4587
- * Pay for a service over the Alipay fiat rail (2.0.0).
4588
- *
4589
- * Unlike the crypto path this needs no EVM wallet — it shells out to
4590
- * alipay-bot via {@link AlipayClient}. Flow: hit the resource with no
4591
- * payment to get the 402 challenge, confirm the server actually offers the
4592
- * alipay rail (selectRail), then run the 8-step state machine and return the
4593
- * resource body.
6899
+ * Blocking terminal wrapper: create the order then poll confirm until the
6900
+ * scan is credited (or the order expires). Built on
6901
+ * {@link createBalanceTopupOrder} + {@link confirmBalanceTopup}.
4594
6902
  */
4595
- async payViaAlipay(serverUrl, service, params, options) {
4596
- const flow = this.alipaySessionId;
6903
+ async topupBalancePack(serverUrl, opts = {}) {
6904
+ const order = await this.createBalanceTopupOrder(serverUrl, { pack: opts.pack, buyerId: opts.buyerId });
6905
+ opts.onCodeUrl?.(order.pack, order.codeUrl);
6906
+ const interval = opts.pollIntervalMs ?? 2e3;
6907
+ const deadline = Date.now() + order.maxTimeoutSeconds * 1e3;
6908
+ while (Date.now() < deadline) {
6909
+ if (opts.signal?.aborted) throw new Error("Top-up aborted");
6910
+ const conf = await this.confirmBalanceTopup(order.outTradeNo, { serverUrl });
6911
+ if (conf.credited) return { balance: conf.balance, outTradeNo: order.outTradeNo, txId: conf.txId };
6912
+ await this.sleep(interval, opts.signal);
6913
+ }
6914
+ throw new Error("Top-up timed out before the payment was confirmed");
6915
+ }
6916
+ // --- Recoverable balance top-up sessions (<configDir>/balance-topup-sessions) ---
6917
+ balanceTopupSessionDir() {
6918
+ return join6(this.configDir, "balance-topup-sessions");
6919
+ }
6920
+ saveBalanceTopupSession(session) {
6921
+ const dir = this.balanceTopupSessionDir();
6922
+ mkdirSync3(dir, { recursive: true });
6923
+ writeFileSync3(join6(dir, `${session.out_trade_no}.json`), JSON.stringify(session, null, 2));
6924
+ }
6925
+ /** Read a persisted top-up session by out_trade_no, or null. */
6926
+ getBalanceTopupSession(outTradeNo) {
6927
+ const p = join6(this.balanceTopupSessionDir(), `${outTradeNo}.json`);
6928
+ if (!existsSync4(p)) return null;
6929
+ try {
6930
+ return JSON.parse(readFileSync6(p, "utf-8"));
6931
+ } catch {
6932
+ return null;
6933
+ }
6934
+ }
6935
+ /** List persisted top-up sessions, newest first. */
6936
+ listBalanceTopupSessions() {
6937
+ const dir = this.balanceTopupSessionDir();
6938
+ if (!existsSync4(dir)) return [];
6939
+ return readdirSync2(dir).filter((f) => f.endsWith(".json")).map((f) => {
6940
+ try {
6941
+ return JSON.parse(readFileSync6(join6(dir, f), "utf-8"));
6942
+ } catch {
6943
+ return null;
6944
+ }
6945
+ }).filter((s) => s !== null).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
6946
+ }
6947
+ /** Abortable sleep used by the top-up poll loop. */
6948
+ sleep(ms, signal) {
6949
+ return new Promise((resolve2, reject) => {
6950
+ if (signal?.aborted) return reject(new Error("aborted"));
6951
+ const t = setTimeout(resolve2, ms);
6952
+ signal?.addEventListener("abort", () => {
6953
+ clearTimeout(t);
6954
+ reject(new Error("aborted"));
6955
+ }, { once: true });
6956
+ });
6957
+ }
6958
+ /** Persist the buyer id used by the balance rail (bearer semantics). */
6959
+ setBuyerId(buyerId) {
6960
+ this.config.buyerId = buyerId;
6961
+ this.saveConfig();
6962
+ }
6963
+ /** GET /balance — custodial balance, limits, and today's spend for a buyer. */
6964
+ async getBuyerBalance(serverUrl, buyerId) {
6965
+ const id = this.resolveBuyerId(buyerId);
6966
+ const res = await fetch(`${serverUrl}/balance?buyer_id=${encodeURIComponent(id)}`);
6967
+ const data = await res.json().catch(() => ({}));
6968
+ if (!res.ok) throw new Error(data.error || `Balance query failed with HTTP ${res.status}`);
6969
+ return data;
6970
+ }
6971
+ /**
6972
+ * POST /balance/topup — report an externally settled payment (on-chain
6973
+ * tx hash / Alipay trade_no / WeChat out_trade_no) so the server verifies
6974
+ * and credits the ledger. Idempotent per reference.
6975
+ */
6976
+ async topupBalance(serverUrl, opts) {
6977
+ const id = this.resolveBuyerId(opts.buyerId);
6978
+ const res = await fetch(`${serverUrl}/balance/topup`, {
6979
+ method: "POST",
6980
+ headers: { "Content-Type": "application/json" },
6981
+ body: JSON.stringify({
6982
+ buyer_id: id,
6983
+ rail: opts.rail,
6984
+ amount: opts.amount,
6985
+ tx_hash: opts.txHash,
6986
+ chain: opts.chain,
6987
+ trade_no: opts.tradeNo,
6988
+ out_trade_no: opts.outTradeNo
6989
+ })
6990
+ });
6991
+ const data = await res.json().catch(() => ({}));
6992
+ if (!res.ok) throw new Error(data.error || `Top-up failed with HTTP ${res.status}`);
6993
+ return data;
6994
+ }
6995
+ /** GET /balance/transactions — ledger history for a buyer, newest first. */
6996
+ async listBalanceTransactions(serverUrl, opts = {}) {
6997
+ const id = this.resolveBuyerId(opts.buyerId);
6998
+ const qs = new URLSearchParams({ buyer_id: id });
6999
+ if (opts.limit) qs.set("limit", String(opts.limit));
7000
+ if (opts.offset) qs.set("offset", String(opts.offset));
7001
+ const res = await fetch(`${serverUrl}/balance/transactions?${qs}`);
7002
+ const data = await res.json().catch(() => ({}));
7003
+ if (!res.ok) throw new Error(data.error || `Transaction query failed with HTTP ${res.status}`);
7004
+ return data;
7005
+ }
7006
+ /**
7007
+ * Start a recoverable WeChat Pay Native session and return immediately with
7008
+ * QR metadata. The SDK client persists enough context to poll/fulfill later.
7009
+ */
7010
+ async startWechatPayment(serverUrl, service, params, options = {}) {
4597
7011
  let executeUrl = `${serverUrl}/execute`;
4598
7012
  try {
4599
- const services = await timeStep(
4600
- "discover-services",
4601
- flow,
4602
- () => this.getServices(serverUrl)
4603
- );
7013
+ const services = await this.getServices(serverUrl);
4604
7014
  const svc = services.services?.find((s) => s.id === service);
4605
7015
  if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
4606
7016
  } catch {
4607
7017
  }
4608
7018
  const requestBody = options.rawData ? { service, ...params } : { service, params };
4609
7019
  const bodyJson = JSON.stringify(requestBody);
4610
- const res = await timeStep(
4611
- "challenge-402",
4612
- flow,
4613
- () => fetch(executeUrl, {
4614
- method: "POST",
4615
- headers: { "Content-Type": "application/json" },
4616
- body: bodyJson
4617
- })
4618
- );
7020
+ const res = await fetch(executeUrl, {
7021
+ method: "POST",
7022
+ headers: { "Content-Type": "application/json" },
7023
+ body: bodyJson
7024
+ });
4619
7025
  if (res.status !== 402) {
4620
7026
  const data = await res.json().catch(() => ({}));
4621
7027
  if (res.ok && data.result) return data.result;
@@ -4627,31 +7033,63 @@ Please specify: --chain <chain_name>`
4627
7033
  const accepts = Array.isArray(parsed) ? parsed : parsed.accepts ?? [parsed];
4628
7034
  const { requirement } = selectRail({
4629
7035
  serverAccepts: accepts,
4630
- explicitRail: ALIPAY_RAIL,
7036
+ explicitRail: WECHAT_RAIL,
4631
7037
  preference: this.railPreference,
4632
7038
  availability: { evmReady: this.isInitialized }
4633
7039
  });
4634
- const onLine = options.onLine ?? ((line) => process.stdout.write(line + "\n"));
4635
- const alipay = new AlipayClient({
4636
- sessionId: this.alipaySessionId,
4637
- configDir: this.configDir
4638
- });
4639
- const result = await alipay.pay402({
7040
+ const wechat = new WechatClient({ configDir: this.configDir });
7041
+ const session = wechat.start402({
4640
7042
  resourceUrl: executeUrl,
4641
7043
  requirement,
4642
7044
  method: "POST",
4643
7045
  data: bodyJson,
4644
- onLine,
4645
- onPaymentPending: options.onPaymentPending,
7046
+ onPaymentPending: options.onPaymentPending ? (info) => options.onPaymentPending({ paymentUrl: info.codeUrl, tradeNo: info.outTradeNo }) : void 0,
4646
7047
  timeoutMs: options.timeoutMs,
4647
- signal: options.signal
7048
+ signal: options.signal,
7049
+ context: {
7050
+ serverUrl,
7051
+ service,
7052
+ params,
7053
+ rawData: options.rawData ?? false,
7054
+ rail: WECHAT_RAIL
7055
+ }
4648
7056
  });
4649
- try {
4650
- const json = JSON.parse(result.body);
4651
- return json.result ?? json;
4652
- } catch {
4653
- return { body: result.body, payment: result.payment, media: result.media };
7057
+ if (options.autoPoll || options.onWechatPaymentCompleted || options.onWechatPaymentFailed) {
7058
+ void wechat.pollSession(session.paymentSessionId, {
7059
+ timeoutMs: options.timeoutMs,
7060
+ signal: options.signal
7061
+ }).then(async (finalSession) => {
7062
+ if (finalSession.status === "completed") {
7063
+ await options.onWechatPaymentCompleted?.(finalSession);
7064
+ } else {
7065
+ await options.onWechatPaymentFailed?.(finalSession);
7066
+ }
7067
+ }).catch(async (error) => {
7068
+ const failed = await wechat.fulfill(session.paymentSessionId).catch(() => ({
7069
+ ...session,
7070
+ status: "failed",
7071
+ lastError: error instanceof Error ? error.message : String(error)
7072
+ }));
7073
+ await options.onWechatPaymentFailed?.(failed);
7074
+ });
4654
7075
  }
7076
+ return session;
7077
+ }
7078
+ /** Query a persisted WeChat session once. */
7079
+ async getWechatPaymentStatus(identifier) {
7080
+ return new WechatClient({ configDir: this.configDir }).status(identifier);
7081
+ }
7082
+ /** Idempotently fulfill a paid WeChat session, returning stored or fetched result. */
7083
+ async fulfillWechatPayment(identifier) {
7084
+ return new WechatClient({ configDir: this.configDir }).fulfill(identifier);
7085
+ }
7086
+ /** Mark a local WeChat session as cancelled. */
7087
+ cancelWechatPayment(identifier) {
7088
+ return new WechatClient({ configDir: this.configDir }).cancel(identifier);
7089
+ }
7090
+ /** List persisted WeChat sessions, newest first. */
7091
+ listWechatPaymentSessions() {
7092
+ return new WechatClient({ configDir: this.configDir }).listSessions();
4655
7093
  }
4656
7094
  /**
4657
7095
  * Handle MPP (Machine Payments Protocol) payment flow
@@ -4748,14 +7186,14 @@ Please specify: --chain <chain_name>`
4748
7186
  async handleBNBPayment(executeUrl, service, params, paymentDetails, options = {}) {
4749
7187
  const { to, amount, token, chainName, chain, spender } = paymentDetails;
4750
7188
  const tokenConfig = chain.tokens[token];
4751
- const provider = new ethers3.JsonRpcProvider(chain.rpc);
7189
+ const provider = new ethers5.JsonRpcProvider(chain.rpc);
4752
7190
  const allowance = await this.checkAllowance(tokenConfig.address, spender, provider);
4753
7191
  const amountWeiCheck = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals));
4754
7192
  if (allowance < amountWeiCheck) {
4755
7193
  const nativeBalance = await provider.getBalance(this.wallet.address);
4756
- const minGasBalance = ethers3.parseEther("0.0005");
7194
+ const minGasBalance = ethers5.parseEther("0.0005");
4757
7195
  if (nativeBalance < minGasBalance) {
4758
- const nativeBNB = parseFloat(ethers3.formatEther(nativeBalance)).toFixed(4);
7196
+ const nativeBNB = parseFloat(ethers5.formatEther(nativeBalance)).toFixed(4);
4759
7197
  const isTestnet = chainName === "bnb_testnet";
4760
7198
  if (isTestnet) {
4761
7199
  throw new Error(
@@ -4928,7 +7366,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
4928
7366
  * Check ERC20 allowance for a spender
4929
7367
  */
4930
7368
  async checkAllowance(tokenAddress, spender, provider) {
4931
- const contract = new ethers3.Contract(
7369
+ const contract = new ethers5.Contract(
4932
7370
  tokenAddress,
4933
7371
  ["function allowance(address owner, address spender) view returns (uint256)"],
4934
7372
  provider
@@ -4947,7 +7385,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
4947
7385
  async signEIP3009(to, amount, chain, token = "USDC", domainOverride) {
4948
7386
  const tokenConfig = chain.tokens[token];
4949
7387
  const value = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals)).toString();
4950
- const nonce = ethers3.hexlify(ethers3.randomBytes(32));
7388
+ const nonce = ethers5.hexlify(ethers5.randomBytes(32));
4951
7389
  const tokenName = domainOverride?.name || tokenConfig.eip712Name || (token === "USDC" ? "USD Coin" : "Tether USD");
4952
7390
  const tokenVersion = domainOverride?.version || "2";
4953
7391
  const envelope = buildEIP3009TypedData({
@@ -4993,26 +7431,26 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
4993
7431
  }
4994
7432
  // --- Config & Wallet Management ---
4995
7433
  loadConfig() {
4996
- const configPath = join5(this.configDir, "config.json");
7434
+ const configPath = join6(this.configDir, "config.json");
4997
7435
  if (existsSync4(configPath)) {
4998
- const content = readFileSync4(configPath, "utf-8");
7436
+ const content = readFileSync6(configPath, "utf-8");
4999
7437
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
5000
7438
  }
5001
7439
  return { ...DEFAULT_CONFIG };
5002
7440
  }
5003
7441
  saveConfig() {
5004
- mkdirSync2(this.configDir, { recursive: true });
5005
- const configPath = join5(this.configDir, "config.json");
5006
- writeFileSync2(configPath, JSON.stringify(this.config, null, 2));
7442
+ mkdirSync3(this.configDir, { recursive: true });
7443
+ const configPath = join6(this.configDir, "config.json");
7444
+ writeFileSync3(configPath, JSON.stringify(this.config, null, 2));
5007
7445
  }
5008
7446
  /**
5009
7447
  * Load spending data from disk
5010
7448
  */
5011
7449
  loadSpending() {
5012
- const spendingPath = join5(this.configDir, "spending.json");
7450
+ const spendingPath = join6(this.configDir, "spending.json");
5013
7451
  if (existsSync4(spendingPath)) {
5014
7452
  try {
5015
- const data = JSON.parse(readFileSync4(spendingPath, "utf-8"));
7453
+ const data = JSON.parse(readFileSync6(spendingPath, "utf-8"));
5016
7454
  const today = (/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0);
5017
7455
  if (data.date && data.date === today) {
5018
7456
  this.todaySpending = data.amount || 0;
@@ -5031,17 +7469,17 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5031
7469
  * Save spending data to disk
5032
7470
  */
5033
7471
  saveSpending() {
5034
- mkdirSync2(this.configDir, { recursive: true });
5035
- const spendingPath = join5(this.configDir, "spending.json");
7472
+ mkdirSync3(this.configDir, { recursive: true });
7473
+ const spendingPath = join6(this.configDir, "spending.json");
5036
7474
  const data = {
5037
7475
  date: this.lastSpendingReset || (/* @__PURE__ */ new Date()).setHours(0, 0, 0, 0),
5038
7476
  amount: this.todaySpending,
5039
7477
  updatedAt: Date.now()
5040
7478
  };
5041
- writeFileSync2(spendingPath, JSON.stringify(data, null, 2));
7479
+ writeFileSync3(spendingPath, JSON.stringify(data, null, 2));
5042
7480
  }
5043
7481
  loadWallet() {
5044
- const walletPath = join5(this.configDir, "wallet.json");
7482
+ const walletPath = join6(this.configDir, "wallet.json");
5045
7483
  if (existsSync4(walletPath)) {
5046
7484
  if (process.platform !== "win32") {
5047
7485
  try {
@@ -5055,7 +7493,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5055
7493
  } catch {
5056
7494
  }
5057
7495
  }
5058
- const content = readFileSync4(walletPath, "utf-8");
7496
+ const content = readFileSync6(walletPath, "utf-8");
5059
7497
  return JSON.parse(content);
5060
7498
  }
5061
7499
  return null;
@@ -5064,15 +7502,15 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5064
7502
  * Initialize a new wallet (called by CLI)
5065
7503
  */
5066
7504
  static init(configDir, options) {
5067
- mkdirSync2(configDir, { recursive: true });
7505
+ mkdirSync3(configDir, { recursive: true });
5068
7506
  const wallet = Wallet2.createRandom();
5069
7507
  const walletData = {
5070
7508
  address: wallet.address,
5071
7509
  privateKey: wallet.privateKey,
5072
7510
  createdAt: Date.now()
5073
7511
  };
5074
- const walletPath = join5(configDir, "wallet.json");
5075
- writeFileSync2(walletPath, JSON.stringify(walletData, null, 2), { mode: 384 });
7512
+ const walletPath = join6(configDir, "wallet.json");
7513
+ writeFileSync3(walletPath, JSON.stringify(walletData, null, 2), { mode: 384 });
5076
7514
  const config = {
5077
7515
  chain: options.chain,
5078
7516
  limits: {
@@ -5080,8 +7518,8 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5080
7518
  maxPerDay: options.maxPerDay
5081
7519
  }
5082
7520
  };
5083
- const configPath = join5(configDir, "config.json");
5084
- writeFileSync2(configPath, JSON.stringify(config, null, 2));
7521
+ const configPath = join6(configDir, "config.json");
7522
+ writeFileSync3(configPath, JSON.stringify(config, null, 2));
5085
7523
  return { address: wallet.address, configDir };
5086
7524
  }
5087
7525
  /**
@@ -5097,17 +7535,17 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5097
7535
  } catch {
5098
7536
  throw new Error(`Unknown chain: ${this.config.chain}`);
5099
7537
  }
5100
- const provider = new ethers3.JsonRpcProvider(chain.rpc);
7538
+ const provider = new ethers5.JsonRpcProvider(chain.rpc);
5101
7539
  const tokenAbi = ["function balanceOf(address) view returns (uint256)"];
5102
7540
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
5103
7541
  provider.getBalance(this.wallet.address),
5104
- new ethers3.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
5105
- new ethers3.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
7542
+ new ethers5.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
7543
+ new ethers5.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
5106
7544
  ]);
5107
7545
  return {
5108
- usdc: parseFloat(ethers3.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
5109
- usdt: parseFloat(ethers3.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
5110
- native: parseFloat(ethers3.formatEther(nativeBalance))
7546
+ usdc: parseFloat(ethers5.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
7547
+ usdt: parseFloat(ethers5.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
7548
+ native: parseFloat(ethers5.formatEther(nativeBalance))
5111
7549
  };
5112
7550
  }
5113
7551
  /**
@@ -5130,38 +7568,38 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5130
7568
  supportedChains.map(async (chainName) => {
5131
7569
  try {
5132
7570
  const chain = getChain(chainName);
5133
- const provider = new ethers3.JsonRpcProvider(chain.rpc);
7571
+ const provider = new ethers5.JsonRpcProvider(chain.rpc);
5134
7572
  if (chainName === "tempo_moderato") {
5135
7573
  const [nativeBalance, pathUSD, alphaUSD, betaUSD, thetaUSD] = await Promise.all([
5136
7574
  provider.getBalance(this.wallet.address),
5137
- new ethers3.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5138
- new ethers3.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5139
- new ethers3.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
5140
- new ethers3.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
7575
+ new ethers5.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7576
+ new ethers5.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7577
+ new ethers5.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
7578
+ new ethers5.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
5141
7579
  ]);
5142
7580
  results[chainName] = {
5143
- usdc: parseFloat(ethers3.formatUnits(pathUSD, 6)),
7581
+ usdc: parseFloat(ethers5.formatUnits(pathUSD, 6)),
5144
7582
  // pathUSD as default USDC
5145
- usdt: parseFloat(ethers3.formatUnits(alphaUSD, 6)),
7583
+ usdt: parseFloat(ethers5.formatUnits(alphaUSD, 6)),
5146
7584
  // alphaUSD as default USDT
5147
- native: parseFloat(ethers3.formatEther(nativeBalance)),
7585
+ native: parseFloat(ethers5.formatEther(nativeBalance)),
5148
7586
  tempo: {
5149
- pathUSD: parseFloat(ethers3.formatUnits(pathUSD, 6)),
5150
- alphaUSD: parseFloat(ethers3.formatUnits(alphaUSD, 6)),
5151
- betaUSD: parseFloat(ethers3.formatUnits(betaUSD, 6)),
5152
- thetaUSD: parseFloat(ethers3.formatUnits(thetaUSD, 6))
7587
+ pathUSD: parseFloat(ethers5.formatUnits(pathUSD, 6)),
7588
+ alphaUSD: parseFloat(ethers5.formatUnits(alphaUSD, 6)),
7589
+ betaUSD: parseFloat(ethers5.formatUnits(betaUSD, 6)),
7590
+ thetaUSD: parseFloat(ethers5.formatUnits(thetaUSD, 6))
5153
7591
  }
5154
7592
  };
5155
7593
  } else {
5156
7594
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
5157
7595
  provider.getBalance(this.wallet.address),
5158
- new ethers3.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
5159
- new ethers3.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
7596
+ new ethers5.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
7597
+ new ethers5.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
5160
7598
  ]);
5161
7599
  results[chainName] = {
5162
- usdc: parseFloat(ethers3.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
5163
- usdt: parseFloat(ethers3.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
5164
- native: parseFloat(ethers3.formatEther(nativeBalance))
7600
+ usdc: parseFloat(ethers5.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
7601
+ usdt: parseFloat(ethers5.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
7602
+ native: parseFloat(ethers5.formatEther(nativeBalance))
5165
7603
  };
5166
7604
  }
5167
7605
  } catch (err) {
@@ -5289,14 +7727,14 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
5289
7727
  };
5290
7728
 
5291
7729
  // src/wallet/Wallet.ts
5292
- import { ethers as ethers4 } from "ethers";
7730
+ import { ethers as ethers6 } from "ethers";
5293
7731
 
5294
7732
  // src/wallet/createWallet.ts
5295
- import { ethers as ethers5 } from "ethers";
5296
- import { writeFileSync as writeFileSync3, readFileSync as readFileSync5, existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
5297
- import { join as join6, dirname as dirname2 } from "path";
7733
+ import { ethers as ethers7 } from "ethers";
7734
+ import { writeFileSync as writeFileSync4, readFileSync as readFileSync7, existsSync as existsSync5, mkdirSync as mkdirSync4 } from "fs";
7735
+ import { join as join7, dirname as dirname2 } from "path";
5298
7736
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
5299
- var DEFAULT_STORAGE_DIR = join6(process.env.HOME || "~", ".moltspay");
7737
+ var DEFAULT_STORAGE_DIR = join7(process.env.HOME || "~", ".moltspay");
5300
7738
  var DEFAULT_STORAGE_FILE = "wallet.json";
5301
7739
  function encryptPrivateKey(privateKey, password) {
5302
7740
  const salt = randomBytes(16);
@@ -5319,10 +7757,10 @@ function decryptPrivateKey(encrypted, password, iv, salt) {
5319
7757
  return decrypted;
5320
7758
  }
5321
7759
  function createWallet(options = {}) {
5322
- const storagePath = options.storagePath || join6(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7760
+ const storagePath = options.storagePath || join7(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5323
7761
  if (existsSync5(storagePath) && !options.overwrite) {
5324
7762
  try {
5325
- const existing = JSON.parse(readFileSync5(storagePath, "utf8"));
7763
+ const existing = JSON.parse(readFileSync7(storagePath, "utf8"));
5326
7764
  return {
5327
7765
  success: true,
5328
7766
  address: existing.address,
@@ -5337,7 +7775,7 @@ function createWallet(options = {}) {
5337
7775
  }
5338
7776
  }
5339
7777
  try {
5340
- const wallet = ethers5.Wallet.createRandom();
7778
+ const wallet = ethers7.Wallet.createRandom();
5341
7779
  const walletData = {
5342
7780
  address: wallet.address,
5343
7781
  label: options.label,
@@ -5355,9 +7793,9 @@ function createWallet(options = {}) {
5355
7793
  }
5356
7794
  const dir = dirname2(storagePath);
5357
7795
  if (!existsSync5(dir)) {
5358
- mkdirSync3(dir, { recursive: true });
7796
+ mkdirSync4(dir, { recursive: true });
5359
7797
  }
5360
- writeFileSync3(storagePath, JSON.stringify(walletData, null, 2), { mode: 384 });
7798
+ writeFileSync4(storagePath, JSON.stringify(walletData, null, 2), { mode: 384 });
5361
7799
  return {
5362
7800
  success: true,
5363
7801
  address: wallet.address,
@@ -5372,12 +7810,12 @@ function createWallet(options = {}) {
5372
7810
  }
5373
7811
  }
5374
7812
  function loadWallet(options = {}) {
5375
- const storagePath = options.storagePath || join6(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7813
+ const storagePath = options.storagePath || join7(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5376
7814
  if (!existsSync5(storagePath)) {
5377
7815
  return { success: false, error: "Wallet not found. Run createWallet() first." };
5378
7816
  }
5379
7817
  try {
5380
- const data = JSON.parse(readFileSync5(storagePath, "utf8"));
7818
+ const data = JSON.parse(readFileSync7(storagePath, "utf8"));
5381
7819
  if (data.encrypted) {
5382
7820
  if (!options.password) {
5383
7821
  return { success: false, error: "Wallet is encrypted. Password required." };
@@ -5392,174 +7830,26 @@ function loadWallet(options = {}) {
5392
7830
  }
5393
7831
  }
5394
7832
  function getWalletAddress(storagePath) {
5395
- const path4 = storagePath || join6(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5396
- if (!existsSync5(path4)) {
7833
+ const path5 = storagePath || join7(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7834
+ if (!existsSync5(path5)) {
5397
7835
  return null;
5398
7836
  }
5399
7837
  try {
5400
- const data = JSON.parse(readFileSync5(path4, "utf8"));
7838
+ const data = JSON.parse(readFileSync7(path5, "utf8"));
5401
7839
  return data.address;
5402
7840
  } catch {
5403
7841
  return null;
5404
7842
  }
5405
7843
  }
5406
7844
  function walletExists(storagePath) {
5407
- const path4 = storagePath || join6(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
5408
- return existsSync5(path4);
5409
- }
5410
-
5411
- // src/verify/index.ts
5412
- import { ethers as ethers6 } from "ethers";
5413
- var TRANSFER_EVENT_TOPIC3 = ethers6.id("Transfer(address,address,uint256)");
5414
- async function verifyPayment(params) {
5415
- const { txHash, expectedAmount, expectedTo, expectedToken } = params;
5416
- let chain;
5417
- try {
5418
- if (typeof params.chain === "number") {
5419
- chain = getChainById(params.chain);
5420
- } else {
5421
- chain = getChain(params.chain || "base");
5422
- }
5423
- if (!chain) {
5424
- return { verified: false, error: `Unsupported chain: ${params.chain}` };
5425
- }
5426
- } catch (e) {
5427
- return { verified: false, error: `Unsupported chain: ${params.chain}` };
5428
- }
5429
- try {
5430
- const provider = new ethers6.JsonRpcProvider(chain.rpc);
5431
- const receipt = await provider.getTransactionReceipt(txHash);
5432
- if (!receipt) {
5433
- return { verified: false, error: "Transaction not found or not confirmed" };
5434
- }
5435
- if (receipt.status !== 1) {
5436
- return { verified: false, error: "Transaction failed" };
5437
- }
5438
- const tokenAddresses = {};
5439
- if (!expectedToken || expectedToken === "USDC") {
5440
- tokenAddresses[chain.tokens.USDC.address.toLowerCase()] = "USDC";
5441
- }
5442
- if (!expectedToken || expectedToken === "USDT") {
5443
- tokenAddresses[chain.tokens.USDT.address.toLowerCase()] = "USDT";
5444
- }
5445
- if (Object.keys(tokenAddresses).length === 0) {
5446
- return { verified: false, error: `No token addresses configured for ${chain.name}` };
5447
- }
5448
- for (const log of receipt.logs) {
5449
- const logAddress = log.address.toLowerCase();
5450
- const detectedToken = tokenAddresses[logAddress];
5451
- if (!detectedToken) {
5452
- continue;
5453
- }
5454
- if (log.topics.length < 3 || log.topics[0] !== TRANSFER_EVENT_TOPIC3) {
5455
- continue;
5456
- }
5457
- const from = "0x" + log.topics[1].slice(-40);
5458
- const to = "0x" + log.topics[2].slice(-40);
5459
- const amountRaw = BigInt(log.data);
5460
- const tokenConfig = chain.tokens[detectedToken];
5461
- const amount = Number(amountRaw) / 10 ** tokenConfig.decimals;
5462
- if (expectedTo && to.toLowerCase() !== expectedTo.toLowerCase()) {
5463
- continue;
5464
- }
5465
- if (amount < expectedAmount) {
5466
- return {
5467
- verified: false,
5468
- error: `Insufficient amount: received ${amount} ${detectedToken}, expected ${expectedAmount}`,
5469
- amount,
5470
- token: detectedToken,
5471
- from,
5472
- to,
5473
- txHash,
5474
- blockNumber: receipt.blockNumber
5475
- };
5476
- }
5477
- return {
5478
- verified: true,
5479
- amount,
5480
- token: detectedToken,
5481
- from,
5482
- to,
5483
- txHash,
5484
- blockNumber: receipt.blockNumber
5485
- };
5486
- }
5487
- const tokenList = expectedToken ? expectedToken : "USDC/USDT";
5488
- return { verified: false, error: `No ${tokenList} transfer found` };
5489
- } catch (e) {
5490
- return { verified: false, error: e.message || String(e) };
5491
- }
5492
- }
5493
- async function getTransactionStatus(txHash, chain = "base") {
5494
- let chainConfig;
5495
- try {
5496
- chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
5497
- if (!chainConfig) return { status: "not_found" };
5498
- } catch {
5499
- return { status: "not_found" };
5500
- }
5501
- try {
5502
- const provider = new ethers6.JsonRpcProvider(chainConfig.rpc);
5503
- const receipt = await provider.getTransactionReceipt(txHash);
5504
- if (!receipt) {
5505
- const tx = await provider.getTransaction(txHash);
5506
- if (tx) {
5507
- return { status: "pending" };
5508
- }
5509
- return { status: "not_found" };
5510
- }
5511
- const currentBlock = await provider.getBlockNumber();
5512
- const confirmations = currentBlock - receipt.blockNumber;
5513
- if (receipt.status === 1) {
5514
- return {
5515
- status: "confirmed",
5516
- blockNumber: receipt.blockNumber,
5517
- confirmations
5518
- };
5519
- } else {
5520
- return {
5521
- status: "failed",
5522
- blockNumber: receipt.blockNumber
5523
- };
5524
- }
5525
- } catch {
5526
- return { status: "not_found" };
5527
- }
5528
- }
5529
- async function waitForTransaction(txHash, chain = "base", confirmations = 1, timeoutMs = 6e4) {
5530
- let chainConfig;
5531
- try {
5532
- chainConfig = typeof chain === "number" ? getChainById(chain) : getChain(chain);
5533
- if (!chainConfig) {
5534
- return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
5535
- }
5536
- } catch (e) {
5537
- return { verified: false, confirmed: false, error: `Unsupported chain: ${chain}` };
5538
- }
5539
- const provider = new ethers6.JsonRpcProvider(chainConfig.rpc);
5540
- try {
5541
- const receipt = await provider.waitForTransaction(txHash, confirmations, timeoutMs);
5542
- if (!receipt) {
5543
- return { verified: false, confirmed: false, error: "Timeout waiting" };
5544
- }
5545
- if (receipt.status !== 1) {
5546
- return { verified: false, confirmed: true, error: "Transaction failed" };
5547
- }
5548
- return {
5549
- verified: true,
5550
- confirmed: true,
5551
- txHash,
5552
- blockNumber: receipt.blockNumber
5553
- };
5554
- } catch (e) {
5555
- return { verified: false, confirmed: false, error: e.message || String(e) };
5556
- }
7845
+ const path5 = storagePath || join7(DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_FILE);
7846
+ return existsSync5(path5);
5557
7847
  }
5558
7848
 
5559
7849
  // src/cdp/index.ts
5560
7850
  import * as fs from "fs";
5561
- import * as path3 from "path";
5562
- var DEFAULT_STORAGE_DIR2 = path3.join(process.env.HOME || ".", ".moltspay");
7851
+ import * as path4 from "path";
7852
+ var DEFAULT_STORAGE_DIR2 = path4.join(process.env.HOME || ".", ".moltspay");
5563
7853
  var CDP_CONFIG_FILE = "cdp-wallet.json";
5564
7854
  function isCDPAvailable() {
5565
7855
  try {
@@ -5581,7 +7871,7 @@ function getCDPCredentials(config) {
5581
7871
  async function initCDPWallet(config = {}) {
5582
7872
  const storageDir = config.storageDir || DEFAULT_STORAGE_DIR2;
5583
7873
  const chain = config.chain || "base";
5584
- const storagePath = path3.join(storageDir, CDP_CONFIG_FILE);
7874
+ const storagePath = path4.join(storageDir, CDP_CONFIG_FILE);
5585
7875
  if (fs.existsSync(storagePath)) {
5586
7876
  try {
5587
7877
  const data = JSON.parse(fs.readFileSync(storagePath, "utf-8"));
@@ -5643,7 +7933,7 @@ async function initCDPWallet(config = {}) {
5643
7933
  }
5644
7934
  function loadCDPWallet(config = {}) {
5645
7935
  const storageDir = config.storageDir || DEFAULT_STORAGE_DIR2;
5646
- const storagePath = path3.join(storageDir, CDP_CONFIG_FILE);
7936
+ const storagePath = path4.join(storageDir, CDP_CONFIG_FILE);
5647
7937
  if (!fs.existsSync(storagePath)) {
5648
7938
  return null;
5649
7939
  }
@@ -5676,9 +7966,9 @@ var CDPWallet = class {
5676
7966
  * Get USDC balance
5677
7967
  */
5678
7968
  async getBalance() {
5679
- const { ethers: ethers7 } = await import("ethers");
5680
- const provider = new ethers7.JsonRpcProvider(this.chainConfig.rpc);
5681
- const usdcContract = new ethers7.Contract(
7969
+ const { ethers: ethers8 } = await import("ethers");
7970
+ const provider = new ethers8.JsonRpcProvider(this.chainConfig.rpc);
7971
+ const usdcContract = new ethers8.Contract(
5682
7972
  this.chainConfig.usdc,
5683
7973
  ["function balanceOf(address) view returns (uint256)"],
5684
7974
  provider
@@ -5689,7 +7979,7 @@ var CDPWallet = class {
5689
7979
  ]);
5690
7980
  return {
5691
7981
  usdc: (Number(usdcBalance) / 1e6).toFixed(2),
5692
- eth: ethers7.formatEther(ethBalance)
7982
+ eth: ethers8.formatEther(ethBalance)
5693
7983
  };
5694
7984
  }
5695
7985
  /**
@@ -5707,7 +7997,7 @@ var CDPWallet = class {
5707
7997
  }
5708
7998
  try {
5709
7999
  const { CdpClient } = await import("@coinbase/cdp-sdk");
5710
- const { ethers: ethers7 } = await import("ethers");
8000
+ const { ethers: ethers8 } = await import("ethers");
5711
8001
  const cdp = new CdpClient({
5712
8002
  apiKeyId: creds.apiKeyId,
5713
8003
  apiKeySecret: creds.apiKeySecret,
@@ -5715,7 +8005,7 @@ var CDPWallet = class {
5715
8005
  });
5716
8006
  const account = await cdp.evm.getAccount({ address: this.address });
5717
8007
  const amountWei = BigInt(Math.floor(params.amount * 1e6));
5718
- const iface = new ethers7.Interface([
8008
+ const iface = new ethers8.Interface([
5719
8009
  "function transfer(address to, uint256 amount) returns (bool)"
5720
8010
  ]);
5721
8011
  const callData = iface.encodeFunctionData("transfer", [params.to, amountWei]);
@@ -5769,6 +8059,7 @@ export {
5769
8059
  FacilitatorRegistry,
5770
8060
  MoltsPayClient,
5771
8061
  MoltsPayServer,
8062
+ WechatClient,
5772
8063
  alipayLog,
5773
8064
  createRegistry,
5774
8065
  createWallet,