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