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
@@ -30,7 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/client/index.ts
31
31
  var client_exports = {};
32
32
  __export(client_exports, {
33
- MoltsPayClient: () => MoltsPayClient
33
+ MoltsPayClient: () => MoltsPayClient,
34
+ WechatClient: () => WechatClient
34
35
  });
35
36
  module.exports = __toCommonJS(client_exports);
36
37
 
@@ -38,7 +39,8 @@ module.exports = __toCommonJS(client_exports);
38
39
  var import_fs2 = require("fs");
39
40
  var import_os3 = require("os");
40
41
  var import_path3 = require("path");
41
- var import_ethers2 = require("ethers");
42
+ var import_node_crypto3 = require("crypto");
43
+ var import_ethers3 = require("ethers");
42
44
 
43
45
  // src/chains/index.ts
44
46
  var CHAINS = {
@@ -1099,7 +1101,7 @@ var AlipayClient = class {
1099
1101
  if (m) media.push(m);
1100
1102
  else opts.onLine?.(line);
1101
1103
  };
1102
- const intentSummary = opts.intentSummary?.trim() || `\u652F\u4ED8 ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
1104
+ const intentSummary = opts.intentSummary?.trim() || `Pay ${requirement.amount ?? ""} ${requirement.asset ?? "CNY"}`.trim();
1103
1105
  await timeStep("ensure-cli", flow, () => ensureCli(this.getVersion));
1104
1106
  const intentTtlMs = resolveIntentTtlMs();
1105
1107
  const intentKey = `${this.configDir}::${this.framework}`;
@@ -1184,10 +1186,205 @@ var AlipayClient = class {
1184
1186
  }
1185
1187
  };
1186
1188
 
1189
+ // src/client/wechat/index.ts
1190
+ var import_node_crypto2 = require("crypto");
1191
+ var import_node_fs = require("fs");
1192
+ var import_node_os = require("os");
1193
+ var import_node_path = require("path");
1194
+ var WECHAT_SCHEME = "wechatpay-native";
1195
+ var WECHAT_NETWORK = "wechat";
1196
+ var POLL_INTERVAL_MS2 = 3e3;
1197
+ var TIMEOUT_MS = 5 * 60 * 1e3;
1198
+ var WechatClient = class {
1199
+ configDir;
1200
+ sessionDir;
1201
+ now;
1202
+ constructor(options = {}) {
1203
+ this.configDir = options.configDir || (0, import_node_path.join)((0, import_node_os.homedir)(), ".moltspay");
1204
+ this.sessionDir = (0, import_node_path.join)(this.configDir, "wechat-sessions");
1205
+ this.now = options.now ?? Date.now;
1206
+ }
1207
+ async pay402(opts) {
1208
+ const session = this.start402({ ...opts });
1209
+ const result = await this.pollSession(session.paymentSessionId, {
1210
+ pollIntervalMs: opts.pollIntervalMs,
1211
+ timeoutMs: opts.timeoutMs,
1212
+ signal: opts.signal
1213
+ });
1214
+ if (result.status === "paid" || result.status === "completed") {
1215
+ return {
1216
+ body: result.resultBody ?? "",
1217
+ status: result.lastHttpStatus ?? 200
1218
+ };
1219
+ }
1220
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
1221
+ }
1222
+ /**
1223
+ * Start a recoverable WeChat payment session. This returns immediately after
1224
+ * persisting `out_trade_no`, QR payload, original request body, and context.
1225
+ */
1226
+ start402(opts) {
1227
+ const extra = opts.requirement.extra ?? {};
1228
+ const codeUrl = typeof extra.code_url === "string" ? extra.code_url : "";
1229
+ const outTradeNo = typeof extra.out_trade_no === "string" ? extra.out_trade_no : "";
1230
+ if (!codeUrl || !outTradeNo) {
1231
+ throw new Error(
1232
+ "WechatClient.pay402: wechatpay-native requirement is missing extra.code_url / extra.out_trade_no"
1233
+ );
1234
+ }
1235
+ const now = new Date(this.now());
1236
+ const timeoutMs = opts.timeoutMs ?? (opts.requirement.maxTimeoutSeconds ?? TIMEOUT_MS / 1e3) * 1e3;
1237
+ const session = {
1238
+ paymentSessionId: opts.paymentSessionId ?? `mpay_sess_${(0, import_node_crypto2.randomUUID)()}`,
1239
+ status: "pending",
1240
+ resourceUrl: opts.resourceUrl,
1241
+ method: opts.method ?? "POST",
1242
+ data: opts.data,
1243
+ requirement: opts.requirement,
1244
+ codeUrl,
1245
+ outTradeNo,
1246
+ createdAt: now.toISOString(),
1247
+ updatedAt: now.toISOString(),
1248
+ expiresAt: new Date(this.now() + timeoutMs).toISOString(),
1249
+ context: opts.context
1250
+ };
1251
+ this.saveSession(session);
1252
+ opts.onPaymentPending?.({ codeUrl, outTradeNo });
1253
+ return session;
1254
+ }
1255
+ /**
1256
+ * Query a persisted session once by replaying the original request with the
1257
+ * stored `out_trade_no` proof. 200 means paid + fulfilled; 402 means pending.
1258
+ */
1259
+ async status(identifier) {
1260
+ const session = this.loadSession(identifier);
1261
+ if (session.status === "cancelled" || session.status === "completed") return session;
1262
+ if (this.now() >= new Date(session.expiresAt).getTime()) {
1263
+ return this.updateSession(session, {
1264
+ status: "expired",
1265
+ lastError: `WeChat payment expired at ${session.expiresAt}`
1266
+ });
1267
+ }
1268
+ const xPayment = this.buildPaymentHeader(session);
1269
+ const res = await fetch(session.resourceUrl, {
1270
+ method: session.method,
1271
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
1272
+ body: session.method === "POST" ? session.data : void 0
1273
+ });
1274
+ const text = await res.text().catch(() => "");
1275
+ if (res.status === 200) {
1276
+ return this.updateSession(session, {
1277
+ status: "completed",
1278
+ lastHttpStatus: res.status,
1279
+ lastError: void 0,
1280
+ resultBody: text
1281
+ });
1282
+ }
1283
+ if (res.status === 402) {
1284
+ return this.updateSession(session, {
1285
+ status: "pending",
1286
+ lastHttpStatus: res.status,
1287
+ lastError: text.slice(0, 500) || "wechat payment pending"
1288
+ });
1289
+ }
1290
+ return this.updateSession(session, {
1291
+ status: "failed",
1292
+ lastHttpStatus: res.status,
1293
+ lastError: `WeChat /execute returned HTTP ${res.status}: ${text.slice(0, 500)}`
1294
+ });
1295
+ }
1296
+ /** Poll a session until it completes, expires, fails, or is aborted. */
1297
+ async pollSession(identifier, opts = {}) {
1298
+ const first = this.loadSession(identifier);
1299
+ const deadline = Math.min(
1300
+ new Date(first.expiresAt).getTime(),
1301
+ this.now() + (opts.timeoutMs ?? TIMEOUT_MS)
1302
+ );
1303
+ const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS2;
1304
+ let session = first;
1305
+ while (this.now() < deadline) {
1306
+ if (opts.signal?.aborted) throw new Error("WeChat payment aborted");
1307
+ session = await this.status(session.paymentSessionId);
1308
+ if (session.status !== "pending") return session;
1309
+ await new Promise((r) => setTimeout(r, interval));
1310
+ }
1311
+ return this.updateSession(session, {
1312
+ status: "expired",
1313
+ lastError: `WeChat payment timed out after ${Math.round((opts.timeoutMs ?? TIMEOUT_MS) / 1e3)}s (out_trade_no=${session.outTradeNo}, last status ${session.lastHttpStatus ?? 0})`
1314
+ });
1315
+ }
1316
+ /** `fulfill` is an idempotent status check: paid sessions return stored body. */
1317
+ async fulfill(identifier) {
1318
+ const session = this.loadSession(identifier);
1319
+ if (session.status === "completed") return session;
1320
+ return this.status(identifier);
1321
+ }
1322
+ /** Mark a local session cancelled. Closing the merchant order is server-side. */
1323
+ cancel(identifier) {
1324
+ const session = this.loadSession(identifier);
1325
+ return this.updateSession(session, {
1326
+ status: "cancelled",
1327
+ lastError: "cancelled locally"
1328
+ });
1329
+ }
1330
+ listSessions() {
1331
+ this.ensureSessionDir();
1332
+ return (0, import_node_fs.readdirSync)(this.sessionDir).filter((name) => name.endsWith(".json")).map((name) => JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(this.sessionDir, name), "utf-8"))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
1333
+ }
1334
+ buildPaymentHeader(session) {
1335
+ const proof = {
1336
+ x402Version: 2,
1337
+ scheme: WECHAT_SCHEME,
1338
+ network: WECHAT_NETWORK,
1339
+ accepted: {
1340
+ scheme: WECHAT_SCHEME,
1341
+ network: WECHAT_NETWORK,
1342
+ extra: { out_trade_no: session.outTradeNo }
1343
+ },
1344
+ payload: { out_trade_no: session.outTradeNo }
1345
+ };
1346
+ return Buffer.from(JSON.stringify(proof)).toString("base64");
1347
+ }
1348
+ loadSession(identifier) {
1349
+ this.ensureSessionDir();
1350
+ const direct = (0, import_node_path.join)(this.sessionDir, `${identifier}.json`);
1351
+ try {
1352
+ return JSON.parse((0, import_node_fs.readFileSync)(direct, "utf-8"));
1353
+ } catch {
1354
+ const byTradeNo = this.listSessions().find((s) => s.outTradeNo === identifier);
1355
+ if (byTradeNo) return byTradeNo;
1356
+ throw new Error(`WeChat payment session not found: ${identifier}`);
1357
+ }
1358
+ }
1359
+ saveSession(session) {
1360
+ this.ensureSessionDir();
1361
+ (0, import_node_fs.writeFileSync)(
1362
+ (0, import_node_path.join)(this.sessionDir, `${session.paymentSessionId}.json`),
1363
+ JSON.stringify(session, null, 2)
1364
+ );
1365
+ }
1366
+ updateSession(session, updates) {
1367
+ const next = {
1368
+ ...session,
1369
+ ...updates,
1370
+ updatedAt: new Date(this.now()).toISOString()
1371
+ };
1372
+ this.saveSession(next);
1373
+ return next;
1374
+ }
1375
+ ensureSessionDir() {
1376
+ (0, import_node_fs.mkdirSync)(this.sessionDir, { recursive: true });
1377
+ }
1378
+ };
1379
+
1187
1380
  // src/client/alipay/router.ts
1188
1381
  var ALIPAY_RAIL = "alipay";
1382
+ var WECHAT_RAIL = "wechat";
1383
+ var BALANCE_RAIL = "balance";
1189
1384
  function railOf(req) {
1190
1385
  if (req.scheme === "alipay-aipay" || req.network === ALIPAY_RAIL) return ALIPAY_RAIL;
1386
+ if (req.scheme === "wechatpay-native" || req.network === WECHAT_RAIL) return WECHAT_RAIL;
1387
+ if (req.scheme === BALANCE_RAIL || req.network === BALANCE_RAIL) return BALANCE_RAIL;
1191
1388
  return networkToChainName(req.network) ?? req.network;
1192
1389
  }
1193
1390
  function findRail(accepts, rail) {
@@ -1227,6 +1424,14 @@ function selectRail(input) {
1227
1424
  return { rail: railOf(serverAccepts[0]), requirement: serverAccepts[0] };
1228
1425
  }
1229
1426
 
1427
+ // src/facilitators/balance/auth.ts
1428
+ var import_ethers2 = require("ethers");
1429
+ var BALANCE_AUTH_DOMAIN = "moltspay-balance-auth:v1";
1430
+ var BALANCE_AUTH_MAX_SKEW_MS = 5 * 60 * 1e3;
1431
+ function buildDeductMessage(f) {
1432
+ return [BALANCE_AUTH_DOMAIN, "balance-deduct", f.buyerId, f.requestId, f.service, String(f.timestamp)].join("\n");
1433
+ }
1434
+
1230
1435
  // src/client/node/index.ts
1231
1436
  var DEFAULT_CONFIG = {
1232
1437
  chain: "base",
@@ -1253,7 +1458,7 @@ var MoltsPayClient = class {
1253
1458
  this.walletData = this.loadWallet();
1254
1459
  this.loadSpending();
1255
1460
  if (this.walletData) {
1256
- this.wallet = new import_ethers2.Wallet(this.walletData.privateKey);
1461
+ this.wallet = new import_ethers3.Wallet(this.walletData.privateKey);
1257
1462
  const configDir = this.configDir;
1258
1463
  this.signer = new NodeSigner(this.wallet, {
1259
1464
  getSolanaKeypair: () => loadSolanaWallet(configDir)
@@ -1330,6 +1535,12 @@ var MoltsPayClient = class {
1330
1535
  if (options.rail === ALIPAY_RAIL) {
1331
1536
  return this.payViaAlipay(serverUrl, service, params, options);
1332
1537
  }
1538
+ if (options.rail === WECHAT_RAIL) {
1539
+ return this.payViaWechat(serverUrl, service, params, options);
1540
+ }
1541
+ if (options.rail === BALANCE_RAIL) {
1542
+ return this.payViaBalance(serverUrl, service, params, options);
1543
+ }
1333
1544
  if (!this.wallet || !this.walletData) {
1334
1545
  throw new Error("Client not initialized. Run: npx moltspay init");
1335
1546
  }
@@ -1591,6 +1802,399 @@ Please specify: --chain <chain_name>`
1591
1802
  return { body: result.body, payment: result.payment, media: result.media };
1592
1803
  }
1593
1804
  }
1805
+ /**
1806
+ * Pay for a service over the WeChat Pay Native fiat rail (2.1.0).
1807
+ *
1808
+ * Mirrors {@link payViaAlipay} and needs no EVM wallet. The server placed the
1809
+ * Native order when it built the 402, so its `wechatpay-native` accepts[]
1810
+ * entry already carries `extra.code_url` + `extra.out_trade_no`. Flow: hit the
1811
+ * resource to get the 402, confirm the server offers the wechat rail, surface
1812
+ * the code_url (caller renders a QR), then poll the resource with the
1813
+ * out_trade_no proof until the server verifies the order paid and delivers.
1814
+ */
1815
+ async payViaWechat(serverUrl, service, params, options) {
1816
+ const session = await this.startWechatPayment(serverUrl, service, params, options);
1817
+ const wechat = new WechatClient({ configDir: this.configDir });
1818
+ const result = await wechat.pollSession(session.paymentSessionId, {
1819
+ timeoutMs: options.timeoutMs,
1820
+ signal: options.signal
1821
+ });
1822
+ if (result.status !== "completed") {
1823
+ throw new Error(result.lastError || `WeChat payment ended with status ${result.status}`);
1824
+ }
1825
+ try {
1826
+ const json = JSON.parse(result.resultBody ?? "");
1827
+ return json.result ?? json;
1828
+ } catch {
1829
+ return { body: result.resultBody ?? "" };
1830
+ }
1831
+ }
1832
+ /**
1833
+ * The ethers wallet used to sign balance-rail deductions: the client's EVM
1834
+ * wallet if it has one, else a per-configDir identity key persisted at
1835
+ * `<configDir>/balance-identity.key` (0600) so a balance-only client works
1836
+ * without a full crypto wallet. Under `agent 统一代付`, one key spends every
1837
+ * account the agent tops up.
1838
+ */
1839
+ balanceSigner() {
1840
+ if (this.wallet) return this.wallet;
1841
+ const p = (0, import_path3.join)(this.configDir, "balance-identity.key");
1842
+ let pk;
1843
+ if ((0, import_fs2.existsSync)(p)) {
1844
+ pk = (0, import_fs2.readFileSync)(p, "utf-8").trim();
1845
+ } else {
1846
+ (0, import_fs2.mkdirSync)(this.configDir, { recursive: true });
1847
+ pk = import_ethers3.Wallet.createRandom().privateKey;
1848
+ (0, import_fs2.writeFileSync)(p, pk, { mode: 384 });
1849
+ }
1850
+ return new import_ethers3.Wallet(pk);
1851
+ }
1852
+ /** The balance-rail spending signer address (lowercase 0x…). Stable per
1853
+ * configDir; this is the identity the server TOFU-binds and later verifies. */
1854
+ getBalanceSignerAddress() {
1855
+ return this.balanceSigner().address.toLowerCase();
1856
+ }
1857
+ /** The buyer id for the balance rail: explicit option > persisted config. */
1858
+ resolveBuyerId(explicit) {
1859
+ const buyerId = explicit ?? this.config.buyerId;
1860
+ if (!buyerId) {
1861
+ throw new Error(
1862
+ "Balance rail needs a buyer id. Pass { buyerId } or persist one with setBuyerId()."
1863
+ );
1864
+ }
1865
+ return buyerId;
1866
+ }
1867
+ /**
1868
+ * Pay via the custodial balance rail (2.2.0, password-free).
1869
+ *
1870
+ * No wallet, no QR: the request carries `{buyer_id, request_id}` in the
1871
+ * X-Payment payload and the server deducts the prepaid balance atomically
1872
+ * before running the skill. The client-generated `request_id` makes the
1873
+ * charge idempotent — a network retry can never double-deduct.
1874
+ */
1875
+ async payViaBalance(serverUrl, service, params, options) {
1876
+ const buyerId = this.resolveBuyerId(options.buyerId);
1877
+ let attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
1878
+ if (attempt.ok) return attempt.result;
1879
+ 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 || ""));
1880
+ if (!fundable || options.autoTopup === false) {
1881
+ throw new Error(attempt.error || `Balance payment failed with HTTP ${attempt.status}`);
1882
+ }
1883
+ if (options.topupMode === "manual") {
1884
+ const order = await this.createBalanceTopupOrder(serverUrl, {
1885
+ pack: options.topupPack,
1886
+ buyerId,
1887
+ context: { service }
1888
+ });
1889
+ options.onTopupRequired?.(order.pack, order.codeUrl);
1890
+ return {
1891
+ status: "topup_required",
1892
+ out_trade_no: order.outTradeNo,
1893
+ code_url: order.codeUrl,
1894
+ pack: order.pack,
1895
+ server_url: serverUrl
1896
+ };
1897
+ }
1898
+ const credited = await this.topupBalancePack(serverUrl, {
1899
+ pack: options.topupPack,
1900
+ buyerId,
1901
+ pollIntervalMs: options.topupPollIntervalMs,
1902
+ signal: options.signal,
1903
+ onCodeUrl: (pack, codeUrl) => options.onTopupRequired?.(pack, codeUrl)
1904
+ });
1905
+ options.onTopupCredited?.(credited.balance);
1906
+ attempt = await this.balanceDeduct(serverUrl, service, params, options, buyerId);
1907
+ if (attempt.ok) return attempt.result;
1908
+ throw new Error(attempt.error || "Balance payment failed after top-up");
1909
+ }
1910
+ /** One password-free deduct attempt. Never throws on an HTTP error; the
1911
+ * caller inspects `{ ok, status, error }` to decide whether to auto-fund. */
1912
+ async balanceDeduct(serverUrl, service, params, options, buyerId) {
1913
+ let executeUrl = `${serverUrl}/execute`;
1914
+ try {
1915
+ const services = await this.getServices(serverUrl);
1916
+ const svc = services.services?.find((s) => s.id === service);
1917
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
1918
+ } catch {
1919
+ }
1920
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
1921
+ const requestId = (0, import_node_crypto3.randomUUID)();
1922
+ const timestamp = Math.floor(Date.now() / 1e3);
1923
+ const signature = await this.balanceSigner().signMessage(
1924
+ buildDeductMessage({ buyerId, requestId, service, timestamp })
1925
+ );
1926
+ const xPayment = Buffer.from(JSON.stringify({
1927
+ x402Version: 2,
1928
+ scheme: BALANCE_RAIL,
1929
+ network: BALANCE_RAIL,
1930
+ payload: { buyer_id: buyerId, request_id: requestId, auth: { timestamp, signature } }
1931
+ })).toString("base64");
1932
+ const res = await fetch(executeUrl, {
1933
+ method: "POST",
1934
+ headers: { "Content-Type": "application/json", "X-Payment": xPayment },
1935
+ body: JSON.stringify(requestBody),
1936
+ signal: options.signal
1937
+ });
1938
+ const data = await res.json().catch(() => ({}));
1939
+ if (!res.ok) return { ok: false, status: res.status, error: data.error, code: data.code };
1940
+ return { ok: true, status: res.status, result: data.result ?? data };
1941
+ }
1942
+ /**
1943
+ * Non-blocking: POST /balance/topup/order, persist a recoverable session, and
1944
+ * return at once (no polling). Use with {@link confirmBalanceTopup} for
1945
+ * turn-based agents; the blocking {@link topupBalancePack} is built on this.
1946
+ */
1947
+ async createBalanceTopupOrder(serverUrl, opts = {}) {
1948
+ const id = this.resolveBuyerId(opts.buyerId);
1949
+ const orderRes = await fetch(`${serverUrl}/balance/topup/order`, {
1950
+ method: "POST",
1951
+ headers: { "Content-Type": "application/json" },
1952
+ // Carry the spending signer so the server binds it to the account on
1953
+ // confirm (topup-time binding). The same key signs deductions later.
1954
+ body: JSON.stringify({ buyer_id: id, pack: opts.pack, signer_address: this.getBalanceSignerAddress() })
1955
+ });
1956
+ const order = await orderRes.json().catch(() => ({}));
1957
+ if (!orderRes.ok) throw new Error(order.error || `Top-up order failed with HTTP ${orderRes.status}`);
1958
+ const maxTimeoutSeconds = order.max_timeout_seconds ?? 300;
1959
+ const now = Date.now();
1960
+ this.saveBalanceTopupSession({
1961
+ out_trade_no: order.out_trade_no,
1962
+ buyer_id: id,
1963
+ pack: order.pack,
1964
+ server_url: serverUrl,
1965
+ code_url: order.code_url,
1966
+ status: "pending",
1967
+ created_at: new Date(now).toISOString(),
1968
+ expires_at: new Date(now + maxTimeoutSeconds * 1e3).toISOString(),
1969
+ context: opts.context
1970
+ });
1971
+ return { outTradeNo: order.out_trade_no, codeUrl: order.code_url, pack: order.pack, maxTimeoutSeconds };
1972
+ }
1973
+ /**
1974
+ * One-shot: POST /balance/topup/confirm for a single order. No polling.
1975
+ * Updates the persisted session on credit. `serverUrl` defaults to the one
1976
+ * recorded in the session (recover by out_trade_no alone).
1977
+ */
1978
+ async confirmBalanceTopup(outTradeNo, opts = {}) {
1979
+ const session = this.getBalanceTopupSession(outTradeNo);
1980
+ const serverUrl = opts.serverUrl || session?.server_url;
1981
+ if (!serverUrl) {
1982
+ return { credited: false, reason: `No server URL for ${outTradeNo}: pass serverUrl or run topup-order first` };
1983
+ }
1984
+ const confRes = await fetch(`${serverUrl}/balance/topup/confirm`, {
1985
+ method: "POST",
1986
+ headers: { "Content-Type": "application/json" },
1987
+ body: JSON.stringify({ out_trade_no: outTradeNo })
1988
+ });
1989
+ const conf = await confRes.json().catch(() => ({}));
1990
+ if (!confRes.ok) return { credited: false, reason: conf.error || `Confirm failed with HTTP ${confRes.status}` };
1991
+ if (conf.credited) {
1992
+ if (session) {
1993
+ session.status = "credited";
1994
+ session.tx_id = conf.tx_id;
1995
+ session.balance = conf.balance;
1996
+ this.saveBalanceTopupSession(session);
1997
+ }
1998
+ return { credited: true, balance: conf.balance, txId: conf.tx_id };
1999
+ }
2000
+ return { credited: false, pending: !!conf.pending, reason: conf.reason };
2001
+ }
2002
+ /**
2003
+ * Blocking terminal wrapper: create the order then poll confirm until the
2004
+ * scan is credited (or the order expires). Built on
2005
+ * {@link createBalanceTopupOrder} + {@link confirmBalanceTopup}.
2006
+ */
2007
+ async topupBalancePack(serverUrl, opts = {}) {
2008
+ const order = await this.createBalanceTopupOrder(serverUrl, { pack: opts.pack, buyerId: opts.buyerId });
2009
+ opts.onCodeUrl?.(order.pack, order.codeUrl);
2010
+ const interval = opts.pollIntervalMs ?? 2e3;
2011
+ const deadline = Date.now() + order.maxTimeoutSeconds * 1e3;
2012
+ while (Date.now() < deadline) {
2013
+ if (opts.signal?.aborted) throw new Error("Top-up aborted");
2014
+ const conf = await this.confirmBalanceTopup(order.outTradeNo, { serverUrl });
2015
+ if (conf.credited) return { balance: conf.balance, outTradeNo: order.outTradeNo, txId: conf.txId };
2016
+ await this.sleep(interval, opts.signal);
2017
+ }
2018
+ throw new Error("Top-up timed out before the payment was confirmed");
2019
+ }
2020
+ // --- Recoverable balance top-up sessions (<configDir>/balance-topup-sessions) ---
2021
+ balanceTopupSessionDir() {
2022
+ return (0, import_path3.join)(this.configDir, "balance-topup-sessions");
2023
+ }
2024
+ saveBalanceTopupSession(session) {
2025
+ const dir = this.balanceTopupSessionDir();
2026
+ (0, import_fs2.mkdirSync)(dir, { recursive: true });
2027
+ (0, import_fs2.writeFileSync)((0, import_path3.join)(dir, `${session.out_trade_no}.json`), JSON.stringify(session, null, 2));
2028
+ }
2029
+ /** Read a persisted top-up session by out_trade_no, or null. */
2030
+ getBalanceTopupSession(outTradeNo) {
2031
+ const p = (0, import_path3.join)(this.balanceTopupSessionDir(), `${outTradeNo}.json`);
2032
+ if (!(0, import_fs2.existsSync)(p)) return null;
2033
+ try {
2034
+ return JSON.parse((0, import_fs2.readFileSync)(p, "utf-8"));
2035
+ } catch {
2036
+ return null;
2037
+ }
2038
+ }
2039
+ /** List persisted top-up sessions, newest first. */
2040
+ listBalanceTopupSessions() {
2041
+ const dir = this.balanceTopupSessionDir();
2042
+ if (!(0, import_fs2.existsSync)(dir)) return [];
2043
+ return (0, import_fs2.readdirSync)(dir).filter((f) => f.endsWith(".json")).map((f) => {
2044
+ try {
2045
+ return JSON.parse((0, import_fs2.readFileSync)((0, import_path3.join)(dir, f), "utf-8"));
2046
+ } catch {
2047
+ return null;
2048
+ }
2049
+ }).filter((s) => s !== null).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
2050
+ }
2051
+ /** Abortable sleep used by the top-up poll loop. */
2052
+ sleep(ms, signal) {
2053
+ return new Promise((resolve, reject) => {
2054
+ if (signal?.aborted) return reject(new Error("aborted"));
2055
+ const t = setTimeout(resolve, ms);
2056
+ signal?.addEventListener("abort", () => {
2057
+ clearTimeout(t);
2058
+ reject(new Error("aborted"));
2059
+ }, { once: true });
2060
+ });
2061
+ }
2062
+ /** Persist the buyer id used by the balance rail (bearer semantics). */
2063
+ setBuyerId(buyerId) {
2064
+ this.config.buyerId = buyerId;
2065
+ this.saveConfig();
2066
+ }
2067
+ /** GET /balance — custodial balance, limits, and today's spend for a buyer. */
2068
+ async getBuyerBalance(serverUrl, buyerId) {
2069
+ const id = this.resolveBuyerId(buyerId);
2070
+ const res = await fetch(`${serverUrl}/balance?buyer_id=${encodeURIComponent(id)}`);
2071
+ const data = await res.json().catch(() => ({}));
2072
+ if (!res.ok) throw new Error(data.error || `Balance query failed with HTTP ${res.status}`);
2073
+ return data;
2074
+ }
2075
+ /**
2076
+ * POST /balance/topup — report an externally settled payment (on-chain
2077
+ * tx hash / Alipay trade_no / WeChat out_trade_no) so the server verifies
2078
+ * and credits the ledger. Idempotent per reference.
2079
+ */
2080
+ async topupBalance(serverUrl, opts) {
2081
+ const id = this.resolveBuyerId(opts.buyerId);
2082
+ const res = await fetch(`${serverUrl}/balance/topup`, {
2083
+ method: "POST",
2084
+ headers: { "Content-Type": "application/json" },
2085
+ body: JSON.stringify({
2086
+ buyer_id: id,
2087
+ rail: opts.rail,
2088
+ amount: opts.amount,
2089
+ tx_hash: opts.txHash,
2090
+ chain: opts.chain,
2091
+ trade_no: opts.tradeNo,
2092
+ out_trade_no: opts.outTradeNo
2093
+ })
2094
+ });
2095
+ const data = await res.json().catch(() => ({}));
2096
+ if (!res.ok) throw new Error(data.error || `Top-up failed with HTTP ${res.status}`);
2097
+ return data;
2098
+ }
2099
+ /** GET /balance/transactions — ledger history for a buyer, newest first. */
2100
+ async listBalanceTransactions(serverUrl, opts = {}) {
2101
+ const id = this.resolveBuyerId(opts.buyerId);
2102
+ const qs = new URLSearchParams({ buyer_id: id });
2103
+ if (opts.limit) qs.set("limit", String(opts.limit));
2104
+ if (opts.offset) qs.set("offset", String(opts.offset));
2105
+ const res = await fetch(`${serverUrl}/balance/transactions?${qs}`);
2106
+ const data = await res.json().catch(() => ({}));
2107
+ if (!res.ok) throw new Error(data.error || `Transaction query failed with HTTP ${res.status}`);
2108
+ return data;
2109
+ }
2110
+ /**
2111
+ * Start a recoverable WeChat Pay Native session and return immediately with
2112
+ * QR metadata. The SDK client persists enough context to poll/fulfill later.
2113
+ */
2114
+ async startWechatPayment(serverUrl, service, params, options = {}) {
2115
+ let executeUrl = `${serverUrl}/execute`;
2116
+ try {
2117
+ const services = await this.getServices(serverUrl);
2118
+ const svc = services.services?.find((s) => s.id === service);
2119
+ if (svc?.endpoint) executeUrl = `${serverUrl}${svc.endpoint}`;
2120
+ } catch {
2121
+ }
2122
+ const requestBody = options.rawData ? { service, ...params } : { service, params };
2123
+ const bodyJson = JSON.stringify(requestBody);
2124
+ const res = await fetch(executeUrl, {
2125
+ method: "POST",
2126
+ headers: { "Content-Type": "application/json" },
2127
+ body: bodyJson
2128
+ });
2129
+ if (res.status !== 402) {
2130
+ const data = await res.json().catch(() => ({}));
2131
+ if (res.ok && data.result) return data.result;
2132
+ throw new Error(data.error || `Expected 402, got ${res.status}`);
2133
+ }
2134
+ const header = res.headers.get(PAYMENT_REQUIRED_HEADER);
2135
+ if (!header) throw new Error("Missing x-payment-required header on 402");
2136
+ const parsed = JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
2137
+ const accepts = Array.isArray(parsed) ? parsed : parsed.accepts ?? [parsed];
2138
+ const { requirement } = selectRail({
2139
+ serverAccepts: accepts,
2140
+ explicitRail: WECHAT_RAIL,
2141
+ preference: this.railPreference,
2142
+ availability: { evmReady: this.isInitialized }
2143
+ });
2144
+ const wechat = new WechatClient({ configDir: this.configDir });
2145
+ const session = wechat.start402({
2146
+ resourceUrl: executeUrl,
2147
+ requirement,
2148
+ method: "POST",
2149
+ data: bodyJson,
2150
+ onPaymentPending: options.onPaymentPending ? (info) => options.onPaymentPending({ paymentUrl: info.codeUrl, tradeNo: info.outTradeNo }) : void 0,
2151
+ timeoutMs: options.timeoutMs,
2152
+ signal: options.signal,
2153
+ context: {
2154
+ serverUrl,
2155
+ service,
2156
+ params,
2157
+ rawData: options.rawData ?? false,
2158
+ rail: WECHAT_RAIL
2159
+ }
2160
+ });
2161
+ if (options.autoPoll || options.onWechatPaymentCompleted || options.onWechatPaymentFailed) {
2162
+ void wechat.pollSession(session.paymentSessionId, {
2163
+ timeoutMs: options.timeoutMs,
2164
+ signal: options.signal
2165
+ }).then(async (finalSession) => {
2166
+ if (finalSession.status === "completed") {
2167
+ await options.onWechatPaymentCompleted?.(finalSession);
2168
+ } else {
2169
+ await options.onWechatPaymentFailed?.(finalSession);
2170
+ }
2171
+ }).catch(async (error) => {
2172
+ const failed = await wechat.fulfill(session.paymentSessionId).catch(() => ({
2173
+ ...session,
2174
+ status: "failed",
2175
+ lastError: error instanceof Error ? error.message : String(error)
2176
+ }));
2177
+ await options.onWechatPaymentFailed?.(failed);
2178
+ });
2179
+ }
2180
+ return session;
2181
+ }
2182
+ /** Query a persisted WeChat session once. */
2183
+ async getWechatPaymentStatus(identifier) {
2184
+ return new WechatClient({ configDir: this.configDir }).status(identifier);
2185
+ }
2186
+ /** Idempotently fulfill a paid WeChat session, returning stored or fetched result. */
2187
+ async fulfillWechatPayment(identifier) {
2188
+ return new WechatClient({ configDir: this.configDir }).fulfill(identifier);
2189
+ }
2190
+ /** Mark a local WeChat session as cancelled. */
2191
+ cancelWechatPayment(identifier) {
2192
+ return new WechatClient({ configDir: this.configDir }).cancel(identifier);
2193
+ }
2194
+ /** List persisted WeChat sessions, newest first. */
2195
+ listWechatPaymentSessions() {
2196
+ return new WechatClient({ configDir: this.configDir }).listSessions();
2197
+ }
1594
2198
  /**
1595
2199
  * Handle MPP (Machine Payments Protocol) payment flow
1596
2200
  * Called when pay() detects WWW-Authenticate header in 402 response
@@ -1686,14 +2290,14 @@ Please specify: --chain <chain_name>`
1686
2290
  async handleBNBPayment(executeUrl, service, params, paymentDetails, options = {}) {
1687
2291
  const { to, amount, token, chainName, chain, spender } = paymentDetails;
1688
2292
  const tokenConfig = chain.tokens[token];
1689
- const provider = new import_ethers2.ethers.JsonRpcProvider(chain.rpc);
2293
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
1690
2294
  const allowance = await this.checkAllowance(tokenConfig.address, spender, provider);
1691
2295
  const amountWeiCheck = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals));
1692
2296
  if (allowance < amountWeiCheck) {
1693
2297
  const nativeBalance = await provider.getBalance(this.wallet.address);
1694
- const minGasBalance = import_ethers2.ethers.parseEther("0.0005");
2298
+ const minGasBalance = import_ethers3.ethers.parseEther("0.0005");
1695
2299
  if (nativeBalance < minGasBalance) {
1696
- const nativeBNB = parseFloat(import_ethers2.ethers.formatEther(nativeBalance)).toFixed(4);
2300
+ const nativeBNB = parseFloat(import_ethers3.ethers.formatEther(nativeBalance)).toFixed(4);
1697
2301
  const isTestnet = chainName === "bnb_testnet";
1698
2302
  if (isTestnet) {
1699
2303
  throw new Error(
@@ -1866,7 +2470,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
1866
2470
  * Check ERC20 allowance for a spender
1867
2471
  */
1868
2472
  async checkAllowance(tokenAddress, spender, provider) {
1869
- const contract = new import_ethers2.ethers.Contract(
2473
+ const contract = new import_ethers3.ethers.Contract(
1870
2474
  tokenAddress,
1871
2475
  ["function allowance(address owner, address spender) view returns (uint256)"],
1872
2476
  provider
@@ -1885,7 +2489,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
1885
2489
  async signEIP3009(to, amount, chain, token = "USDC", domainOverride) {
1886
2490
  const tokenConfig = chain.tokens[token];
1887
2491
  const value = BigInt(Math.floor(amount * 10 ** tokenConfig.decimals)).toString();
1888
- const nonce = import_ethers2.ethers.hexlify(import_ethers2.ethers.randomBytes(32));
2492
+ const nonce = import_ethers3.ethers.hexlify(import_ethers3.ethers.randomBytes(32));
1889
2493
  const tokenName = domainOverride?.name || tokenConfig.eip712Name || (token === "USDC" ? "USD Coin" : "Tether USD");
1890
2494
  const tokenVersion = domainOverride?.version || "2";
1891
2495
  const envelope = buildEIP3009TypedData({
@@ -2003,7 +2607,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
2003
2607
  */
2004
2608
  static init(configDir, options) {
2005
2609
  (0, import_fs2.mkdirSync)(configDir, { recursive: true });
2006
- const wallet = import_ethers2.Wallet.createRandom();
2610
+ const wallet = import_ethers3.Wallet.createRandom();
2007
2611
  const walletData = {
2008
2612
  address: wallet.address,
2009
2613
  privateKey: wallet.privateKey,
@@ -2035,17 +2639,17 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
2035
2639
  } catch {
2036
2640
  throw new Error(`Unknown chain: ${this.config.chain}`);
2037
2641
  }
2038
- const provider = new import_ethers2.ethers.JsonRpcProvider(chain.rpc);
2642
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
2039
2643
  const tokenAbi = ["function balanceOf(address) view returns (uint256)"];
2040
2644
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
2041
2645
  provider.getBalance(this.wallet.address),
2042
- new import_ethers2.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
2043
- new import_ethers2.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
2646
+ new import_ethers3.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
2647
+ new import_ethers3.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
2044
2648
  ]);
2045
2649
  return {
2046
- usdc: parseFloat(import_ethers2.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
2047
- usdt: parseFloat(import_ethers2.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
2048
- native: parseFloat(import_ethers2.ethers.formatEther(nativeBalance))
2650
+ usdc: parseFloat(import_ethers3.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
2651
+ usdt: parseFloat(import_ethers3.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
2652
+ native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance))
2049
2653
  };
2050
2654
  }
2051
2655
  /**
@@ -2068,38 +2672,38 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
2068
2672
  supportedChains.map(async (chainName) => {
2069
2673
  try {
2070
2674
  const chain = getChain(chainName);
2071
- const provider = new import_ethers2.ethers.JsonRpcProvider(chain.rpc);
2675
+ const provider = new import_ethers3.ethers.JsonRpcProvider(chain.rpc);
2072
2676
  if (chainName === "tempo_moderato") {
2073
2677
  const [nativeBalance, pathUSD, alphaUSD, betaUSD, thetaUSD] = await Promise.all([
2074
2678
  provider.getBalance(this.wallet.address),
2075
- new import_ethers2.ethers.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2076
- new import_ethers2.ethers.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2077
- new import_ethers2.ethers.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2078
- new import_ethers2.ethers.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
2679
+ new import_ethers3.ethers.Contract(tempoTokens.pathUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2680
+ new import_ethers3.ethers.Contract(tempoTokens.alphaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2681
+ new import_ethers3.ethers.Contract(tempoTokens.betaUSD, tokenAbi, provider).balanceOf(this.wallet.address),
2682
+ new import_ethers3.ethers.Contract(tempoTokens.thetaUSD, tokenAbi, provider).balanceOf(this.wallet.address)
2079
2683
  ]);
2080
2684
  results[chainName] = {
2081
- usdc: parseFloat(import_ethers2.ethers.formatUnits(pathUSD, 6)),
2685
+ usdc: parseFloat(import_ethers3.ethers.formatUnits(pathUSD, 6)),
2082
2686
  // pathUSD as default USDC
2083
- usdt: parseFloat(import_ethers2.ethers.formatUnits(alphaUSD, 6)),
2687
+ usdt: parseFloat(import_ethers3.ethers.formatUnits(alphaUSD, 6)),
2084
2688
  // alphaUSD as default USDT
2085
- native: parseFloat(import_ethers2.ethers.formatEther(nativeBalance)),
2689
+ native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance)),
2086
2690
  tempo: {
2087
- pathUSD: parseFloat(import_ethers2.ethers.formatUnits(pathUSD, 6)),
2088
- alphaUSD: parseFloat(import_ethers2.ethers.formatUnits(alphaUSD, 6)),
2089
- betaUSD: parseFloat(import_ethers2.ethers.formatUnits(betaUSD, 6)),
2090
- thetaUSD: parseFloat(import_ethers2.ethers.formatUnits(thetaUSD, 6))
2691
+ pathUSD: parseFloat(import_ethers3.ethers.formatUnits(pathUSD, 6)),
2692
+ alphaUSD: parseFloat(import_ethers3.ethers.formatUnits(alphaUSD, 6)),
2693
+ betaUSD: parseFloat(import_ethers3.ethers.formatUnits(betaUSD, 6)),
2694
+ thetaUSD: parseFloat(import_ethers3.ethers.formatUnits(thetaUSD, 6))
2091
2695
  }
2092
2696
  };
2093
2697
  } else {
2094
2698
  const [nativeBalance, usdcBalance, usdtBalance] = await Promise.all([
2095
2699
  provider.getBalance(this.wallet.address),
2096
- new import_ethers2.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
2097
- new import_ethers2.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
2700
+ new import_ethers3.ethers.Contract(chain.tokens.USDC.address, tokenAbi, provider).balanceOf(this.wallet.address),
2701
+ new import_ethers3.ethers.Contract(chain.tokens.USDT.address, tokenAbi, provider).balanceOf(this.wallet.address)
2098
2702
  ]);
2099
2703
  results[chainName] = {
2100
- usdc: parseFloat(import_ethers2.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
2101
- usdt: parseFloat(import_ethers2.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
2102
- native: parseFloat(import_ethers2.ethers.formatEther(nativeBalance))
2704
+ usdc: parseFloat(import_ethers3.ethers.formatUnits(usdcBalance, chain.tokens.USDC.decimals)),
2705
+ usdt: parseFloat(import_ethers3.ethers.formatUnits(usdtBalance, chain.tokens.USDT.decimals)),
2706
+ native: parseFloat(import_ethers3.ethers.formatEther(nativeBalance))
2103
2707
  };
2104
2708
  }
2105
2709
  } catch (err) {
@@ -2227,6 +2831,7 @@ Run: npx moltspay approve --chain ${chainName} --spender ${spender}`
2227
2831
  };
2228
2832
  // Annotate the CommonJS export names for ESM import in node:
2229
2833
  0 && (module.exports = {
2230
- MoltsPayClient
2834
+ MoltsPayClient,
2835
+ WechatClient
2231
2836
  });
2232
2837
  //# sourceMappingURL=index.js.map