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