@aeon-ai-pay/aigateway 0.1.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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/bin/cli.mjs +155 -0
- package/docs/env-vars.md +73 -0
- package/docs/exit-codes.md +65 -0
- package/docs/ide-setup.md +60 -0
- package/docs/output-schema.md +188 -0
- package/docs/recipes/cron-issue-cards.md +69 -0
- package/docs/recipes/error-recovery.md +53 -0
- package/docs/recipes/integrate-in-agent.md +108 -0
- package/docs/recipes/merchant-integration.md +243 -0
- package/docs/release-process.md +98 -0
- package/docs/troubleshooting.md +200 -0
- package/package.json +58 -0
- package/scripts/postinstall.mjs +40 -0
- package/skills/aigateway/SKILL.md +370 -0
- package/skills/aigateway/references/check-status.md +68 -0
- package/skills/aigateway/references/create-card.md +114 -0
- package/skills/aigateway/references/store.md +87 -0
- package/skills/aigateway/references/x402-protocol.md +143 -0
- package/src/balance.mjs +92 -0
- package/src/commands/clean.mjs +65 -0
- package/src/commands/create-card-status.mjs +67 -0
- package/src/commands/create-card.mjs +333 -0
- package/src/commands/create-image.mjs +428 -0
- package/src/commands/wallet-balance.mjs +47 -0
- package/src/commands/wallet-gas.mjs +99 -0
- package/src/commands/wallet-init.mjs +42 -0
- package/src/commands/wallet-topup.mjs +221 -0
- package/src/commands/wallet-withdraw.mjs +183 -0
- package/src/config.mjs +50 -0
- package/src/constants.mjs +22 -0
- package/src/error-codes.mjs +50 -0
- package/src/funding.mjs +216 -0
- package/src/output.mjs +85 -0
- package/src/sanitize.mjs +48 -0
- package/src/update-check.mjs +69 -0
- package/src/walletconnect.mjs +712 -0
- package/src/x402.mjs +120 -0
- package/templates/cline/.clinerules +53 -0
- package/templates/codex/AGENTS.md +56 -0
- package/templates/cursor/.cursor/rules/aigateway.mdc +60 -0
- package/templates/windsurf/.windsurfrules +48 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# x402 Protocol (v2)
|
|
2
|
+
|
|
3
|
+
A native HTTP payment protocol that monetizes APIs via blockchain. aigateway only supports **x402 v2**.
|
|
4
|
+
|
|
5
|
+
## How It Works
|
|
6
|
+
|
|
7
|
+
The x402 protocol extends HTTP with a two-phase payment flow using HTTP status code `402 Payment Required`:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Phase 1: Discovery
|
|
11
|
+
Client ──GET /resource──> Server
|
|
12
|
+
Client <──HTTP 402── Server (returns payment requirements)
|
|
13
|
+
|
|
14
|
+
Phase 2: Payment
|
|
15
|
+
Client ──GET /resource──> Server
|
|
16
|
+
+ PAYMENT-SIGNATURE header (Base64-encoded signed PaymentPayload)
|
|
17
|
+
Client <──HTTP 200── Server (returns resource + PAYMENT-RESPONSE header)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Payment Requirements (402 Response)
|
|
21
|
+
|
|
22
|
+
When the server returns 402, the response body follows the v2 `PaymentRequired` shape:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"x402Version": 2,
|
|
27
|
+
"error": "PAYMENT-SIGNATURE header is required",
|
|
28
|
+
"resource": {
|
|
29
|
+
"url": "https://api.example.com/resource",
|
|
30
|
+
"description": "x402pay",
|
|
31
|
+
"mimeType": "application/json"
|
|
32
|
+
},
|
|
33
|
+
"accepts": [
|
|
34
|
+
{
|
|
35
|
+
"scheme": "exact",
|
|
36
|
+
"network": "eip155:56",
|
|
37
|
+
"networkId": "56",
|
|
38
|
+
"amount": "5000000000000001",
|
|
39
|
+
"asset": "0x55d398326f99059fF775485246999027B3197955",
|
|
40
|
+
"payTo": "0xRecipient...",
|
|
41
|
+
"maxTimeoutSeconds": 300,
|
|
42
|
+
"extra": {
|
|
43
|
+
"name": "USDT",
|
|
44
|
+
"version": "2",
|
|
45
|
+
"network": "BSC"
|
|
46
|
+
},
|
|
47
|
+
"tokenSymbol": "USDT",
|
|
48
|
+
"tokenDecimals": 18
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Top-level fields
|
|
55
|
+
|
|
56
|
+
| Field | Type | Notes |
|
|
57
|
+
|-------|------|-------|
|
|
58
|
+
| `x402Version` | int | Always `2` |
|
|
59
|
+
| `resource` | object | `ResourceInfo`: `{ url, description, mimeType }` |
|
|
60
|
+
| `accepts` | array | List of `PaymentRequirements` the server will accept |
|
|
61
|
+
| `error` | string | Prompt text — `"PAYMENT-SIGNATURE header is required"` on first request |
|
|
62
|
+
|
|
63
|
+
### `accepts[]` element (`PaymentRequirements`)
|
|
64
|
+
|
|
65
|
+
| Field | Type | Notes |
|
|
66
|
+
|-------|------|-------|
|
|
67
|
+
| `scheme` | string | `"exact"` |
|
|
68
|
+
| `network` | string | CAIP-2 — `"eip155:56"` for BSC |
|
|
69
|
+
| `networkId` | string | Chain ID as string — `"56"` |
|
|
70
|
+
| `amount` | string | Exact atomic-unit amount **(with order-matching suffix — do not round)** |
|
|
71
|
+
| `asset` | string | Token contract address |
|
|
72
|
+
| `payTo` | string | Recipient address |
|
|
73
|
+
| `maxTimeoutSeconds` | int | Payment validity window in seconds |
|
|
74
|
+
| `extra` | object | EIP-712 domain params — `name` + `version` (token EIP-712 domain) |
|
|
75
|
+
| `tokenSymbol` | string | Informational, e.g. `"USDT"` |
|
|
76
|
+
| `tokenDecimals` | int | Informational, e.g. `18` |
|
|
77
|
+
|
|
78
|
+
## PAYMENT-SIGNATURE Header
|
|
79
|
+
|
|
80
|
+
The client signs an EIP-712 typed data structure and sends it as a Base64-encoded request header named **`PAYMENT-SIGNATURE`** (the legacy v1 `X-PAYMENT` header is no longer supported).
|
|
81
|
+
|
|
82
|
+
Decoded payload follows the v2 `PaymentPayload` shape:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"x402Version": 2,
|
|
87
|
+
"resource": { "url": "...", "description": "x402pay", "mimeType": "application/json" },
|
|
88
|
+
"accepted": { /* the chosen PaymentRequirements */ },
|
|
89
|
+
"payload": {
|
|
90
|
+
"authorization": {
|
|
91
|
+
"from": "0xPayer...",
|
|
92
|
+
"to": "0xPayee...",
|
|
93
|
+
"value": "5000000000000001",
|
|
94
|
+
"validAfter": "1700000000",
|
|
95
|
+
"validBefore": "1700000900",
|
|
96
|
+
"nonce": "0x..."
|
|
97
|
+
},
|
|
98
|
+
"signature": "0x..."
|
|
99
|
+
},
|
|
100
|
+
"extensions": {}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Notes:
|
|
105
|
+
- `authorization.value` **must equal** `accepts[i].amount` (the unique order-matching amount).
|
|
106
|
+
- Signature is EIP-712 over the token's `TransferWithAuthorization` domain — or a Facilitator-mediated domain when the token contract does not support ERC-3009 (e.g. BSC USDT).
|
|
107
|
+
|
|
108
|
+
## PAYMENT-RESPONSE Header
|
|
109
|
+
|
|
110
|
+
On success, the server returns a `PAYMENT-RESPONSE` response header (Base64-encoded JSON). Decoded content:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"success": true,
|
|
115
|
+
"transaction": "0xabc...def",
|
|
116
|
+
"network": "bsc",
|
|
117
|
+
"payer": "0xPayer..."
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Core Concepts
|
|
122
|
+
|
|
123
|
+
### Unique Amount Matching
|
|
124
|
+
|
|
125
|
+
The server generates a slightly adjusted unique amount for each order (e.g. `5.000001 USDT` instead of `5.00`). This allows the server to match a specific order via cache lookup using the on-chain transfer amount alone — no order ID is needed inside the signed payload.
|
|
126
|
+
|
|
127
|
+
### Facilitator
|
|
128
|
+
|
|
129
|
+
An intermediary service responsible for:
|
|
130
|
+
1. Verifying the signed payment payload (`POST /verify`)
|
|
131
|
+
2. Submitting the transaction on-chain (`POST /settle`)
|
|
132
|
+
3. Returning the settlement transaction hash
|
|
133
|
+
|
|
134
|
+
### Supported Networks
|
|
135
|
+
|
|
136
|
+
| Network | CAIP-2 | Chain ID | Token |
|
|
137
|
+
|---------|--------|----------|-------|
|
|
138
|
+
| BSC Mainnet | `eip155:56` | `56` | USDT (BEP-20) |
|
|
139
|
+
|
|
140
|
+
## Client Libraries (aigateway uses)
|
|
141
|
+
|
|
142
|
+
- `@aeon-ai-pay/axios` — Axios interceptor that automatically handles 402 responses
|
|
143
|
+
- `@aeon-ai-pay/evm` — EVM signing utilities (EIP-712, ERC-3009, Facilitator scheme)
|
package/src/balance.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 钱包余额查询(共享模块)
|
|
3
|
+
*/
|
|
4
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
5
|
+
import { createPublicClient, http, formatUnits } from "viem";
|
|
6
|
+
import { bsc } from "viem/chains";
|
|
7
|
+
import { BSC_RPC_URL, USDT_BSC, FACILITATOR_ADDRESS } from "./constants.mjs";
|
|
8
|
+
|
|
9
|
+
const ERC20_BALANCE_ABI = [
|
|
10
|
+
{
|
|
11
|
+
name: "balanceOf",
|
|
12
|
+
type: "function",
|
|
13
|
+
stateMutability: "view",
|
|
14
|
+
inputs: [{ name: "account", type: "address" }],
|
|
15
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const ERC20_ALLOWANCE_ABI = [
|
|
20
|
+
{
|
|
21
|
+
name: "allowance",
|
|
22
|
+
type: "function",
|
|
23
|
+
stateMutability: "view",
|
|
24
|
+
inputs: [
|
|
25
|
+
{ name: "owner", type: "address" },
|
|
26
|
+
{ name: "spender", type: "address" },
|
|
27
|
+
],
|
|
28
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
let cachedClient = null;
|
|
33
|
+
|
|
34
|
+
function getClient() {
|
|
35
|
+
if (!cachedClient) {
|
|
36
|
+
cachedClient = createPublicClient({
|
|
37
|
+
chain: bsc,
|
|
38
|
+
transport: http(BSC_RPC_URL, { timeout: 6000, retryCount: 1 }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return cachedClient;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 通过地址查询 BNB 和 USDT 余额(不需要私钥)
|
|
46
|
+
* @param {string} address - EVM 地址
|
|
47
|
+
*/
|
|
48
|
+
export async function getBalanceByAddress(address) {
|
|
49
|
+
const client = getClient();
|
|
50
|
+
|
|
51
|
+
const [bnbRaw, usdtRaw] = await Promise.all([
|
|
52
|
+
client.getBalance({ address }),
|
|
53
|
+
client.readContract({
|
|
54
|
+
address: USDT_BSC,
|
|
55
|
+
abi: ERC20_BALANCE_ABI,
|
|
56
|
+
functionName: "balanceOf",
|
|
57
|
+
args: [address],
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
address,
|
|
63
|
+
bnb: formatUnits(bnbRaw, 18),
|
|
64
|
+
usdt: formatUnits(usdtRaw, 18),
|
|
65
|
+
bnbRaw,
|
|
66
|
+
usdtRaw,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 通过私钥查询钱包 BNB 和 USDT 余额
|
|
72
|
+
* @param {string} privateKey
|
|
73
|
+
*/
|
|
74
|
+
export async function getWalletBalance(privateKey) {
|
|
75
|
+
const account = privateKeyToAccount(privateKey);
|
|
76
|
+
return getBalanceByAddress(account.address);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 查询 session key 对 facilitator 的 USDT allowance
|
|
81
|
+
* @param {string} ownerAddress - session key 地址
|
|
82
|
+
* @returns {bigint} 当前 allowance(wei)
|
|
83
|
+
*/
|
|
84
|
+
export async function getAllowance(ownerAddress) {
|
|
85
|
+
const client = getClient();
|
|
86
|
+
return client.readContract({
|
|
87
|
+
address: USDT_BSC,
|
|
88
|
+
abi: ERC20_ALLOWANCE_ABI,
|
|
89
|
+
functionName: "allowance",
|
|
90
|
+
args: [ownerAddress, FACILITATOR_ADDRESS],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { emitOk, logInfo, logError } from "../output.mjs";
|
|
6
|
+
|
|
7
|
+
export async function clean() {
|
|
8
|
+
const home = homedir();
|
|
9
|
+
const removed = [];
|
|
10
|
+
|
|
11
|
+
// 1. 用 skills CLI 移除(覆盖所有工具)
|
|
12
|
+
try {
|
|
13
|
+
execFileSync("npx", ["skills", "remove", "aigateway", "-g", "-y"], {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
timeout: 30000,
|
|
16
|
+
});
|
|
17
|
+
logInfo("Removed aigateway skill via skills CLI");
|
|
18
|
+
removed.push("skills");
|
|
19
|
+
} catch {
|
|
20
|
+
// skills CLI 不可用,手动清理 Claude Code
|
|
21
|
+
const skillDir = join(home, ".claude", "skills", "aigateway");
|
|
22
|
+
if (existsSync(skillDir)) {
|
|
23
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
24
|
+
logInfo(`Removed skill: ${skillDir}`);
|
|
25
|
+
removed.push(skillDir);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. 卸载全局包
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("npm", ["uninstall", "-g", "@aeon-ai-pay/aigateway"], {
|
|
32
|
+
stdio: "inherit",
|
|
33
|
+
timeout: 30000,
|
|
34
|
+
});
|
|
35
|
+
logInfo("Uninstalled @aeon-ai-pay/aigateway globally");
|
|
36
|
+
removed.push("npm-global");
|
|
37
|
+
} catch {
|
|
38
|
+
logInfo("Global package not installed, skipping uninstall");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. 清理 npm 缓存
|
|
42
|
+
try {
|
|
43
|
+
execFileSync("npm", ["cache", "clean", "--force"], {
|
|
44
|
+
stdio: "inherit",
|
|
45
|
+
timeout: 30000,
|
|
46
|
+
});
|
|
47
|
+
logInfo("npm cache cleaned");
|
|
48
|
+
removed.push("npm-cache");
|
|
49
|
+
} catch {
|
|
50
|
+
logError("Failed to clean npm cache, skipping");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. 清理 npx 缓存
|
|
54
|
+
const npxCache = join(home, ".npm", "_npx");
|
|
55
|
+
if (existsSync(npxCache)) {
|
|
56
|
+
rmSync(npxCache, { recursive: true, force: true });
|
|
57
|
+
logInfo(`Removed npx cache: ${npxCache}`);
|
|
58
|
+
removed.push("npx-cache");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logInfo("\nClean complete. Reinstall with:");
|
|
62
|
+
logInfo(" npm install -g @aeon-ai-pay/aigateway@latest");
|
|
63
|
+
|
|
64
|
+
emitOk("clean", { removed }, { success: true, removed });
|
|
65
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { resolve } from "../config.mjs";
|
|
2
|
+
import { POLL_INTERVAL, MAX_POLLS } from "../constants.mjs";
|
|
3
|
+
import { sanitizeOutput } from "../sanitize.mjs";
|
|
4
|
+
import { emitOk, emitErr, logInfo } from "../output.mjs";
|
|
5
|
+
|
|
6
|
+
export async function status(opts) {
|
|
7
|
+
const { default: axios } = await import("axios");
|
|
8
|
+
const serviceUrl = resolve(opts.serviceUrl, "AIGATEWAY_SERVICE_URL", "serviceUrl");
|
|
9
|
+
const { orderNo, poll, appId } = opts;
|
|
10
|
+
|
|
11
|
+
if (!serviceUrl) {
|
|
12
|
+
emitErr("create-card-status", "SERVICE_URL_MISSING", {
|
|
13
|
+
message: "Missing service URL. Set env AIGATEWAY_SERVICE_URL if you need to override the built-in default.",
|
|
14
|
+
appId,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const url = `${serviceUrl}/open/ai/x402/card/status?orderNo=${encodeURIComponent(orderNo)}&appId=${encodeURIComponent(appId)}`;
|
|
20
|
+
|
|
21
|
+
if (!poll) {
|
|
22
|
+
try {
|
|
23
|
+
const res = await axios.get(url);
|
|
24
|
+
const sanitized = sanitizeOutput(res.data);
|
|
25
|
+
emitOk("create-card-status", { appId, ...sanitized }, sanitized);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
emitErr("create-card-status", "SERVICE_UNAVAILABLE", {
|
|
28
|
+
message: error.message,
|
|
29
|
+
status: error.response?.status,
|
|
30
|
+
data: error.response?.data,
|
|
31
|
+
appId,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logInfo(`Polling ${url} every ${POLL_INTERVAL / 1000}s (max ${MAX_POLLS} times)`);
|
|
38
|
+
|
|
39
|
+
for (let i = 1; i <= MAX_POLLS; i++) {
|
|
40
|
+
try {
|
|
41
|
+
const res = await axios.get(url);
|
|
42
|
+
const model = res.data?.model;
|
|
43
|
+
|
|
44
|
+
logInfo(
|
|
45
|
+
`[${i}/${MAX_POLLS}] orderStatus=${model?.orderStatus} channelStatus=${model?.channelStatus} cardStatus=${model?.cardStatus || "-"}`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (model?.orderStatus === "SUCCESS" || model?.orderStatus === "FAIL") {
|
|
49
|
+
const sanitized = sanitizeOutput(res.data);
|
|
50
|
+
emitOk("create-card-status", { appId, ...sanitized }, sanitized);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
logInfo(`[${i}/${MAX_POLLS}] Error: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (i < MAX_POLLS) {
|
|
58
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emitErr("create-card-status", "POLL_TIMEOUT", {
|
|
63
|
+
orderNo,
|
|
64
|
+
appId,
|
|
65
|
+
message: "Polling timeout. Card may still be provisioning.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* create-card:通过 x402 协议在 BSC 上用 USDT 支付,发一张一次性虚拟卡
|
|
3
|
+
*
|
|
4
|
+
* 服务端路径:GET {serviceUrl}/open/ai/x402/card/create?amount=<usd>&appId=<merchant>
|
|
5
|
+
* 流程:fetch payment requirements → balance + allowance 检查
|
|
6
|
+
* → (余额不足时)走 funding.mjs/fundSessionKey 充值
|
|
7
|
+
* → x402 EIP-712 签名提交 → 可选轮询 status
|
|
8
|
+
*/
|
|
9
|
+
import { createX402Api, decodePaymentResponse, fetchPaymentRequirements } from "../x402.mjs";
|
|
10
|
+
import { resolve } from "../config.mjs";
|
|
11
|
+
import { getWalletBalance, getAllowance } from "../balance.mjs";
|
|
12
|
+
import { sanitizeOutput } from "../sanitize.mjs";
|
|
13
|
+
import axios from "axios";
|
|
14
|
+
import {
|
|
15
|
+
MIN_AMOUNT, MAX_AMOUNT, POLL_INTERVAL, MAX_POLLS,
|
|
16
|
+
} from "../constants.mjs";
|
|
17
|
+
import { WalletConnectError } from "../walletconnect.mjs";
|
|
18
|
+
import {
|
|
19
|
+
fundSessionKey,
|
|
20
|
+
promptTopupAmount,
|
|
21
|
+
MIN_TOPUP_USDT,
|
|
22
|
+
TOPUP_PRESETS,
|
|
23
|
+
} from "../funding.mjs";
|
|
24
|
+
import { emitOk, emitErr, logInfo } from "../output.mjs";
|
|
25
|
+
|
|
26
|
+
export async function createCard(opts) {
|
|
27
|
+
logInfo("Creating Agent Card...");
|
|
28
|
+
const serviceUrl = resolve(opts.serviceUrl, "AIGATEWAY_SERVICE_URL", "serviceUrl");
|
|
29
|
+
const privateKey = resolve(opts.privateKey, "EVM_PRIVATE_KEY", "privateKey");
|
|
30
|
+
const { amount, poll, appId, dryRun } = opts;
|
|
31
|
+
const amountNum = parseFloat(amount);
|
|
32
|
+
|
|
33
|
+
if (!serviceUrl) {
|
|
34
|
+
emitErr("create-card", "SERVICE_URL_MISSING", {
|
|
35
|
+
message: "Missing service URL. Set env AIGATEWAY_SERVICE_URL if you need to override the built-in default.",
|
|
36
|
+
appId,
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!privateKey) {
|
|
41
|
+
emitErr("create-card", "WALLET_NOT_CONFIGURED", { appId });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (isNaN(amountNum) || amountNum < MIN_AMOUNT) {
|
|
45
|
+
emitErr("create-card", "AMOUNT_OUT_OF_RANGE", {
|
|
46
|
+
message: `Amount must be at least $${MIN_AMOUNT}. Allowed range: $${MIN_AMOUNT} ~ $${MAX_AMOUNT} USD.`,
|
|
47
|
+
min: MIN_AMOUNT,
|
|
48
|
+
max: MAX_AMOUNT,
|
|
49
|
+
appId,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (amountNum > MAX_AMOUNT) {
|
|
54
|
+
emitErr("create-card", "AMOUNT_OUT_OF_RANGE", {
|
|
55
|
+
message: `Amount must not exceed $${MAX_AMOUNT}. Allowed range: $${MIN_AMOUNT} ~ $${MAX_AMOUNT} USD.`,
|
|
56
|
+
min: MIN_AMOUNT,
|
|
57
|
+
max: MAX_AMOUNT,
|
|
58
|
+
appId,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const url = `${serviceUrl}/open/ai/x402/card/create?amount=${encodeURIComponent(amount)}&appId=${encodeURIComponent(appId)}`;
|
|
64
|
+
logInfo("Fetching payment requirements...");
|
|
65
|
+
let requiredUsdt;
|
|
66
|
+
let paymentReq;
|
|
67
|
+
try {
|
|
68
|
+
paymentReq = await fetchPaymentRequirements(url);
|
|
69
|
+
requiredUsdt = paymentReq.amountUsdt;
|
|
70
|
+
logInfo(`Required: ${requiredUsdt} USDT (pay to ${paymentReq.payTo})`);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
emitErr("create-card", "PAYMENT_FETCH_FAILED", {
|
|
73
|
+
message: `Failed to fetch payment requirements: ${e.message}`,
|
|
74
|
+
appId,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logInfo("Checking wallet...");
|
|
80
|
+
let needTopup = false;
|
|
81
|
+
let needGas = false;
|
|
82
|
+
let sessionAddress;
|
|
83
|
+
let topupAmount = null;
|
|
84
|
+
let balanceInitialUsdt = null;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const { address, usdt, bnb, bnbRaw } = await getWalletBalance(privateKey);
|
|
88
|
+
sessionAddress = address;
|
|
89
|
+
balanceInitialUsdt = usdt;
|
|
90
|
+
const usdtNum = parseFloat(usdt);
|
|
91
|
+
logInfo(`Wallet: ${address}`);
|
|
92
|
+
logInfo(`Balance: ${usdt} USDT, ${bnb} BNB`);
|
|
93
|
+
|
|
94
|
+
const allowance = await getAllowance(address);
|
|
95
|
+
const requiredWei = BigInt(paymentReq.amountWei);
|
|
96
|
+
if (requiredWei === 0n) {
|
|
97
|
+
emitErr("create-card", "INVALID_PAYMENT_AMOUNT", {
|
|
98
|
+
message: "Server returned invalid payment amount (0). Please retry later.",
|
|
99
|
+
appId,
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (allowance >= requiredWei) {
|
|
104
|
+
logInfo("Allowance sufficient, no approve needed.");
|
|
105
|
+
} else {
|
|
106
|
+
logInfo(`Allowance ${allowance} < required ${requiredWei}; approve needed.`);
|
|
107
|
+
if (bnbRaw === 0n) {
|
|
108
|
+
needGas = true;
|
|
109
|
+
logInfo("No BNB for approve gas, will request BNB transfer.");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (usdtNum < requiredUsdt) {
|
|
114
|
+
needTopup = true;
|
|
115
|
+
const shortfall = requiredUsdt - usdtNum;
|
|
116
|
+
const minTopup = Math.max(MIN_TOPUP_USDT, Math.ceil(shortfall));
|
|
117
|
+
logInfo(`USDT insufficient: have ${usdtNum}, need ${requiredUsdt}, shortfall ${shortfall.toFixed(6)} (top-up minimum: ${minTopup} USDT)`);
|
|
118
|
+
|
|
119
|
+
if (opts.topupAmount != null && String(opts.topupAmount).trim() !== "") {
|
|
120
|
+
const amt = Number(opts.topupAmount);
|
|
121
|
+
if (!Number.isFinite(amt) || amt <= 0) {
|
|
122
|
+
emitErr("create-card", "AMOUNT_INVALID", {
|
|
123
|
+
message: `Invalid --topup-amount: ${opts.topupAmount}`,
|
|
124
|
+
appId,
|
|
125
|
+
});
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (amt < minTopup) {
|
|
129
|
+
emitErr("create-card", "TOPUP_AMOUNT_TOO_SMALL", {
|
|
130
|
+
message: `--topup-amount ${amt} USDT is below the ${minTopup} USDT minimum for this call.`,
|
|
131
|
+
minTopup,
|
|
132
|
+
appId,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
topupAmount = String(opts.topupAmount);
|
|
137
|
+
logInfo(`Using --topup-amount: ${topupAmount} USDT`);
|
|
138
|
+
} else if (process.stdin.isTTY) {
|
|
139
|
+
topupAmount = await promptTopupAmount(minTopup);
|
|
140
|
+
logInfo(`Selected top-up amount: ${topupAmount} USDT`);
|
|
141
|
+
} else {
|
|
142
|
+
const presets = TOPUP_PRESETS.filter((v) => v >= minTopup);
|
|
143
|
+
emitErr("create-card", "TOPUP_REQUIRED", {
|
|
144
|
+
message: `USDT balance is below the ${minTopup} USDT minimum for this call. Choose a top-up amount and rerun with --topup-amount <usdt>.`,
|
|
145
|
+
minTopup,
|
|
146
|
+
required: requiredUsdt,
|
|
147
|
+
currentBalance: balanceInitialUsdt,
|
|
148
|
+
address: sessionAddress,
|
|
149
|
+
appId,
|
|
150
|
+
presets,
|
|
151
|
+
hint: `Rerun: aigateway wallet-topup --amount <usdt> --app-id ${appId}`,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
emitErr("create-card", "BALANCE_CHECK_FAILED", {
|
|
158
|
+
message: `Balance check failed: ${e.message}`,
|
|
159
|
+
appId,
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Dry-run:跑完前置检查就退出
|
|
165
|
+
if (dryRun) {
|
|
166
|
+
const preview = {
|
|
167
|
+
dryRun: true,
|
|
168
|
+
appId,
|
|
169
|
+
url,
|
|
170
|
+
paymentRequirements: {
|
|
171
|
+
amountUsdt: requiredUsdt,
|
|
172
|
+
amountWei: paymentReq.amountWei,
|
|
173
|
+
asset: paymentReq.asset,
|
|
174
|
+
payTo: paymentReq.payTo,
|
|
175
|
+
orderNo: paymentReq.orderNo,
|
|
176
|
+
},
|
|
177
|
+
wallet: { address: sessionAddress },
|
|
178
|
+
decision: { needTopup, needGas, topupAmount },
|
|
179
|
+
will: [
|
|
180
|
+
...(needTopup ? ["fund_usdt_via_walletconnect"] : []),
|
|
181
|
+
...(needGas ? ["fund_bnb_via_walletconnect"] : []),
|
|
182
|
+
"approve_or_skip",
|
|
183
|
+
"sign_payment_eip712",
|
|
184
|
+
"submit_to_facilitator",
|
|
185
|
+
...(poll ? ["poll_status"] : []),
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
emitOk("create-card", preview, { success: true, ...preview });
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// WalletConnect 充值
|
|
193
|
+
if (needTopup || needGas) {
|
|
194
|
+
logInfo("Funding flow triggered...");
|
|
195
|
+
try {
|
|
196
|
+
await fundSessionKey({
|
|
197
|
+
sessionAddress,
|
|
198
|
+
usdtAmount: needTopup ? topupAmount : null,
|
|
199
|
+
needGas,
|
|
200
|
+
});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e instanceof WalletConnectError) {
|
|
203
|
+
emitErr("create-card", e.code, { message: e.message, address: sessionAddress, appId });
|
|
204
|
+
} else {
|
|
205
|
+
emitErr("create-card", "FUNDING_FAILED", { message: e.message, address: sessionAddress, appId });
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
logInfo("Re-checking wallet balance...");
|
|
211
|
+
try {
|
|
212
|
+
const { usdt, bnbRaw } = await getWalletBalance(privateKey);
|
|
213
|
+
const usdtNum = parseFloat(usdt);
|
|
214
|
+
if (needGas && bnbRaw === 0n) {
|
|
215
|
+
emitErr("create-card", "INSUFFICIENT_BNB", {
|
|
216
|
+
message: "No BNB for approve transaction after funding. Run 'aigateway wallet-gas' to add BNB manually.",
|
|
217
|
+
address: sessionAddress,
|
|
218
|
+
appId,
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (usdtNum < requiredUsdt) {
|
|
223
|
+
emitErr("create-card", "INSUFFICIENT_USDT", {
|
|
224
|
+
message: "Still insufficient USDT after funding.",
|
|
225
|
+
required: `${requiredUsdt} USDT`,
|
|
226
|
+
available: `${usdt} USDT`,
|
|
227
|
+
address: sessionAddress,
|
|
228
|
+
appId,
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {
|
|
233
|
+
emitErr("create-card", "BALANCE_CHECK_FAILED", {
|
|
234
|
+
message: `Balance re-check failed: ${e.message}`,
|
|
235
|
+
appId,
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { client } = createX402Api(privateKey);
|
|
242
|
+
logInfo(`Creating card: $${amount} USD via ${url}`);
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const { x402HTTPClient } = await import("@aeon-ai-pay/core/client");
|
|
246
|
+
const httpClient = new x402HTTPClient(client);
|
|
247
|
+
|
|
248
|
+
const raw402 = paymentReq.raw402Response;
|
|
249
|
+
const getHeader = (name) => {
|
|
250
|
+
const value = raw402.headers[name] ?? raw402.headers[name.toLowerCase()];
|
|
251
|
+
return typeof value === "string" ? value : undefined;
|
|
252
|
+
};
|
|
253
|
+
const paymentRequired = httpClient.getPaymentRequiredResponse(getHeader, raw402.data);
|
|
254
|
+
const paymentPayload = await client.createPaymentPayload(paymentRequired);
|
|
255
|
+
const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload);
|
|
256
|
+
|
|
257
|
+
const response = await axios.get(url, {
|
|
258
|
+
headers: { ...paymentHeaders, "Access-Control-Expose-Headers": "PAYMENT-RESPONSE" },
|
|
259
|
+
});
|
|
260
|
+
const paymentResponse = decodePaymentResponse(response.headers);
|
|
261
|
+
const orderNo = paymentReq.orderNo || response.data?.model?.orderNo || response.data?.orderNo;
|
|
262
|
+
|
|
263
|
+
const sanitizedData = sanitizeOutput(response.data);
|
|
264
|
+
const successData = {
|
|
265
|
+
appId,
|
|
266
|
+
orderNo,
|
|
267
|
+
data: sanitizedData,
|
|
268
|
+
paymentResponse,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function findCardStatus(obj) {
|
|
272
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
273
|
+
if (obj.cardStatus) return obj.cardStatus;
|
|
274
|
+
for (const v of Object.values(obj)) {
|
|
275
|
+
const found = findCardStatus(v);
|
|
276
|
+
if (found) return found;
|
|
277
|
+
}
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const initialOrderStatus = response.data?.model?.orderStatus;
|
|
281
|
+
const initialCardStatus = findCardStatus(response.data);
|
|
282
|
+
const cardReady = initialOrderStatus === "SUCCESS" || initialOrderStatus === "FAIL" || initialCardStatus === "ACTIVE";
|
|
283
|
+
|
|
284
|
+
if (cardReady) {
|
|
285
|
+
logInfo(`Card ready (orderStatus=${initialOrderStatus}, cardStatus=${initialCardStatus}), no polling needed.`);
|
|
286
|
+
emitOk("create-card", successData, { success: true, ...successData });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (poll && orderNo) {
|
|
291
|
+
logInfo(`\nPolling status for orderNo: ${orderNo}`);
|
|
292
|
+
const pollResult = await pollStatus(serviceUrl, orderNo, appId);
|
|
293
|
+
successData.pollResult = pollResult;
|
|
294
|
+
emitOk("create-card", successData, { success: true, ...successData, pollResult });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (poll && !orderNo) {
|
|
299
|
+
logInfo("Warning: No orderNo available for polling. Query status manually.");
|
|
300
|
+
}
|
|
301
|
+
emitOk("create-card", successData, { success: true, ...successData });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
emitErr("create-card", "PAYMENT_FAILED", {
|
|
304
|
+
message: error.message,
|
|
305
|
+
status: error.response?.status,
|
|
306
|
+
data: error.response?.data,
|
|
307
|
+
appId,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function pollStatus(serviceUrl, orderNo, appId) {
|
|
313
|
+
for (let i = 1; i <= MAX_POLLS; i++) {
|
|
314
|
+
if (i > 1) {
|
|
315
|
+
const delay = i <= 5 ? 2000 : POLL_INTERVAL;
|
|
316
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const res = await axios.get(
|
|
320
|
+
`${serviceUrl}/open/ai/x402/card/status?orderNo=${encodeURIComponent(orderNo)}&appId=${encodeURIComponent(appId)}`,
|
|
321
|
+
);
|
|
322
|
+
const model = res.data?.model;
|
|
323
|
+
logInfo(`[${i}/${MAX_POLLS}] orderStatus=${model?.orderStatus} channelStatus=${model?.channelStatus}`);
|
|
324
|
+
if (model?.orderStatus === "SUCCESS" || model?.orderStatus === "FAIL" || model?.cardStatus === "ACTIVE") {
|
|
325
|
+
return sanitizeOutput(model);
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
logInfo(`[${i}/${MAX_POLLS}] Poll error: ${e.message}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
logInfo(`Polling timeout after ${MAX_POLLS} attempts. Check manually with: aigateway create-card-status --order-no ${orderNo}`);
|
|
332
|
+
return null;
|
|
333
|
+
}
|