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.
- package/.env.example +23 -0
- package/CHANGELOG.md +551 -0
- package/README.md +466 -8
- package/dist/cdp/index.js.map +1 -1
- package/dist/cdp/index.mjs.map +1 -1
- package/dist/chains/index.d.mts +69 -4
- package/dist/chains/index.d.ts +69 -4
- package/dist/chains/index.js +45 -1
- package/dist/chains/index.js.map +1 -1
- package/dist/chains/index.mjs +39 -1
- package/dist/chains/index.mjs.map +1 -1
- package/dist/cli/index.js +5444 -2126
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +5457 -2133
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/index.d.mts +307 -1
- package/dist/client/index.d.ts +307 -1
- package/dist/client/index.js +639 -34
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +656 -52
- package/dist/client/index.mjs.map +1 -1
- package/dist/client/web/index.mjs.map +1 -1
- package/dist/facilitators/index.d.mts +512 -10
- package/dist/facilitators/index.d.ts +512 -10
- package/dist/facilitators/index.js +925 -13
- package/dist/facilitators/index.js.map +1 -1
- package/dist/facilitators/index.mjs +906 -12
- package/dist/facilitators/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2843 -551
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2849 -558
- package/dist/index.mjs.map +1 -1
- package/dist/mcp/index.js +635 -32
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/index.mjs +660 -57
- package/dist/mcp/index.mjs.map +1 -1
- package/dist/server/index.d.mts +252 -11
- package/dist/server/index.d.ts +252 -11
- package/dist/server/index.js +2049 -261
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +2049 -261
- package/dist/server/index.mjs.map +1 -1
- package/dist/verify/index.js.map +1 -1
- package/dist/verify/index.mjs.map +1 -1
- package/dist/wallet/index.js.map +1 -1
- package/dist/wallet/index.mjs.map +1 -1
- package/package.json +14 -2
- 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
|
|
9
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
10
10
|
import { createServer } from "http";
|
|
11
|
-
import * as
|
|
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:
|
|
897
|
-
const wallet = new
|
|
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:
|
|
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 =
|
|
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:
|
|
960
|
-
const provider = new
|
|
961
|
-
const wallet = new
|
|
962
|
-
const tokenContract = new
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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/
|
|
1613
|
-
import
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
-
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
-
*
|
|
1888
|
+
* Validate keys parse, apiv3 key length, and gateway reachability. Does
|
|
1889
|
+
* NOT make a business API call.
|
|
1808
1890
|
*/
|
|
1809
|
-
async
|
|
1810
|
-
const
|
|
1811
|
-
|
|
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/
|
|
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
|
|
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 =
|
|
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 =
|
|
2038
|
-
const resolvePem = (p, kind) => toPem(
|
|
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
|
|
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.
|
|
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, {
|
|
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
|
-
*
|
|
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
|
-
|
|
2263
|
-
|
|
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
|
|
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:
|
|
2287
|
-
execute:
|
|
2288
|
-
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
|
|
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
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
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: `
|
|
2504
|
-
|
|
4326
|
+
error: `Balance deduction failed: ${settlement.error}`,
|
|
4327
|
+
code: settlement.status,
|
|
4328
|
+
facilitator: "balance"
|
|
2505
4329
|
});
|
|
2506
4330
|
}
|
|
2507
|
-
console.log(`[MoltsPay]
|
|
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
|
-
|
|
2526
|
-
|
|
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:
|
|
2543
|
-
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:
|
|
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
|
|
2554
|
-
* isn't configured for this server or this service.
|
|
2555
|
-
*
|
|
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
|
-
|
|
2559
|
-
if (!this.
|
|
4388
|
+
buildBalanceChallenge(config) {
|
|
4389
|
+
if (!this.balanceFacilitator || !config.balance) return null;
|
|
2560
4390
|
try {
|
|
2561
|
-
|
|
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]
|
|
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:
|
|
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
|
|
2811
|
-
acceptedCurrencies:
|
|
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
|
|
3344
|
-
import { homedir as
|
|
3345
|
-
import { join as
|
|
3346
|
-
import {
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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 ??
|
|
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() ||
|
|
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 ??
|
|
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 ||
|
|
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
|
-
|
|
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
|
|
4579
|
-
if (!
|
|
4580
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
4588
|
-
*
|
|
4589
|
-
*
|
|
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
|
|
4596
|
-
const
|
|
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
|
|
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
|
|
4611
|
-
"
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
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:
|
|
7036
|
+
explicitRail: WECHAT_RAIL,
|
|
4631
7037
|
preference: this.railPreference,
|
|
4632
7038
|
availability: { evmReady: this.isInitialized }
|
|
4633
7039
|
});
|
|
4634
|
-
const
|
|
4635
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
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
|
|
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 =
|
|
7194
|
+
const minGasBalance = ethers5.parseEther("0.0005");
|
|
4757
7195
|
if (nativeBalance < minGasBalance) {
|
|
4758
|
-
const nativeBNB = parseFloat(
|
|
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
|
|
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 =
|
|
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 =
|
|
7434
|
+
const configPath = join6(this.configDir, "config.json");
|
|
4997
7435
|
if (existsSync4(configPath)) {
|
|
4998
|
-
const content =
|
|
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
|
-
|
|
5005
|
-
const configPath =
|
|
5006
|
-
|
|
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 =
|
|
7450
|
+
const spendingPath = join6(this.configDir, "spending.json");
|
|
5013
7451
|
if (existsSync4(spendingPath)) {
|
|
5014
7452
|
try {
|
|
5015
|
-
const data = JSON.parse(
|
|
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
|
-
|
|
5035
|
-
const spendingPath =
|
|
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
|
-
|
|
7479
|
+
writeFileSync3(spendingPath, JSON.stringify(data, null, 2));
|
|
5042
7480
|
}
|
|
5043
7481
|
loadWallet() {
|
|
5044
|
-
const walletPath =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
5075
|
-
|
|
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 =
|
|
5084
|
-
|
|
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
|
|
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
|
|
5105
|
-
new
|
|
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(
|
|
5109
|
-
usdt: parseFloat(
|
|
5110
|
-
native: parseFloat(
|
|
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
|
|
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
|
|
5138
|
-
new
|
|
5139
|
-
new
|
|
5140
|
-
new
|
|
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(
|
|
7581
|
+
usdc: parseFloat(ethers5.formatUnits(pathUSD, 6)),
|
|
5144
7582
|
// pathUSD as default USDC
|
|
5145
|
-
usdt: parseFloat(
|
|
7583
|
+
usdt: parseFloat(ethers5.formatUnits(alphaUSD, 6)),
|
|
5146
7584
|
// alphaUSD as default USDT
|
|
5147
|
-
native: parseFloat(
|
|
7585
|
+
native: parseFloat(ethers5.formatEther(nativeBalance)),
|
|
5148
7586
|
tempo: {
|
|
5149
|
-
pathUSD: parseFloat(
|
|
5150
|
-
alphaUSD: parseFloat(
|
|
5151
|
-
betaUSD: parseFloat(
|
|
5152
|
-
thetaUSD: parseFloat(
|
|
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
|
|
5159
|
-
new
|
|
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(
|
|
5163
|
-
usdt: parseFloat(
|
|
5164
|
-
native: parseFloat(
|
|
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
|
|
7730
|
+
import { ethers as ethers6 } from "ethers";
|
|
5293
7731
|
|
|
5294
7732
|
// src/wallet/createWallet.ts
|
|
5295
|
-
import { ethers as
|
|
5296
|
-
import { writeFileSync as
|
|
5297
|
-
import { join as
|
|
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 =
|
|
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 ||
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
7796
|
+
mkdirSync4(dir, { recursive: true });
|
|
5359
7797
|
}
|
|
5360
|
-
|
|
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 ||
|
|
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(
|
|
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
|
|
5396
|
-
if (!existsSync5(
|
|
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(
|
|
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
|
|
5408
|
-
return existsSync5(
|
|
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
|
|
5562
|
-
var DEFAULT_STORAGE_DIR2 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
5680
|
-
const provider = new
|
|
5681
|
-
const usdcContract = new
|
|
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:
|
|
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:
|
|
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
|
|
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,
|