thaifi-wallet-cli 0.3.6 → 0.5.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/README.md +34 -0
- package/dist/agentAuth.js +25 -0
- package/dist/chain.js +5 -5
- package/dist/commands/deposit.js +213 -0
- package/dist/commands/fund.js +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/mcp.js +161 -0
- package/dist/commands/request.js +4 -31
- package/dist/config.js +2 -2
- package/dist/index.js +19 -4
- package/dist/paidFetch.js +256 -0
- package/dist/tokens.js +9 -5
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -44,6 +44,8 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
|
|
|
44
44
|
| `tokens` | List supported TIP-20 tokens (symbol, decimals, address) |
|
|
45
45
|
| `balance [--token <symbol\|addr>]` | Balances (default: all supported tokens) |
|
|
46
46
|
| `transfer <to> <amount> [--token <symbol\|addr>]` | Send tokens (default: pathUSD; limit enforced on-chain) |
|
|
47
|
+
| `fund [--no-browser]` | Show the deposit address and open the web Deposit page |
|
|
48
|
+
| `deposit --amount <THB> [--token THCFI\|THCOC] [--ref <id>] [--no-open] [--json]` | **PromptPay QR top-up (1 THB = 1 token)**: order a QR, show it for a human to scan, poll until the tokens are minted |
|
|
47
49
|
| `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment **and ThaiFiAgent identity auth (401/403 answered by signing the challenge)** |
|
|
48
50
|
| `services [--search q]` | Discover services from the MPP registry (`THAIFI_MPP_REGISTRY`) |
|
|
49
51
|
| `logout` | Remove the local key (revoke on-chain from the web wallet) |
|
|
@@ -51,6 +53,38 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
|
|
|
51
53
|
`--token` accepts a symbol (case-insensitive) or a contract address; unknown
|
|
52
54
|
addresses are treated as custom TIP-20s (6 decimals).
|
|
53
55
|
|
|
56
|
+
## Deposit (PromptPay top-up)
|
|
57
|
+
|
|
58
|
+
`thaifi deposit --amount 50` orders a PromptPay QR for the paired wallet and
|
|
59
|
+
waits until the tokens are minted (1 THB = 1 token). There is **no API key**:
|
|
60
|
+
the backend verifies on-chain (AccountKeychain precompile, TIP-1011) that the
|
|
61
|
+
agent's access key belongs to a wallet and credits THAT wallet — the recipient
|
|
62
|
+
never comes from the CLI.
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
$ thaifi deposit --amount 50
|
|
66
|
+
PromptPay deposit — 50.00 THB -> THCFI (1 THB = 1 token)
|
|
67
|
+
Credited wallet: 0x…
|
|
68
|
+
<terminal QR block>
|
|
69
|
+
QR_FILE=~/.thaifi/qr-<referenceNo>.png
|
|
70
|
+
REFERENCE=<referenceNo>
|
|
71
|
+
AMOUNT=50.00 THB -> THCFI
|
|
72
|
+
RESUME=thaifi deposit --ref <referenceNo>
|
|
73
|
+
STATUS=pending (scan the QR to pay)
|
|
74
|
+
STATUS=delivered
|
|
75
|
+
TX=0x… Explorer: https://exp.thaifi.com/tx/0x…
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- The QR is saved to `~/.thaifi/qr-<referenceNo>.png` (chmod 600), rendered as
|
|
79
|
+
terminal blocks, and opened in the default viewer (disable with `--no-open`).
|
|
80
|
+
- Orders expire after 15 minutes; paying late still delivers. Resume watching
|
|
81
|
+
any order with `--ref <referenceNo>` — the status response includes the QR
|
|
82
|
+
again, so a restarted CLI can re-display it.
|
|
83
|
+
- Limits: 6–10,000 THB per order, 50,000 THB per wallet per 24h, 5 pending
|
|
84
|
+
orders per wallet. `--json` prints machine-readable output.
|
|
85
|
+
- Requires an approved pairing (`thaifi login`); a revoked/expired key fails
|
|
86
|
+
with 401 on-chain verification.
|
|
87
|
+
|
|
54
88
|
## Security notes
|
|
55
89
|
|
|
56
90
|
- The recovery password / passphrase is never involved here — the agent key is
|
package/dist/agentAuth.js
CHANGED
|
@@ -18,6 +18,31 @@ const ZERO_BODY_HASH = `0x${"00".repeat(32)}`;
|
|
|
18
18
|
function b64uDecodeBytes(value) {
|
|
19
19
|
return new Uint8Array(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
|
|
20
20
|
}
|
|
21
|
+
/** base64url-encode (utf8) — used for the ThaiFiAgent credential header. */
|
|
22
|
+
export function b64uEncode(s) {
|
|
23
|
+
return Buffer.from(s, "utf8")
|
|
24
|
+
.toString("base64")
|
|
25
|
+
.replace(/\+/g, "-")
|
|
26
|
+
.replace(/\//g, "_")
|
|
27
|
+
.replace(/=+$/, "");
|
|
28
|
+
}
|
|
29
|
+
/** Parse a `WWW-Authenticate: ThaiFiAgent realm="…", challenge="<b64u>"`
|
|
30
|
+
* header — ThaiFi Agent Auth (identity proof, no payment). */
|
|
31
|
+
export function parseAgentChallenge(header) {
|
|
32
|
+
const m = /^ThaiFiAgent\s+(.+)$/i.exec(header.trim());
|
|
33
|
+
if (!m)
|
|
34
|
+
return null;
|
|
35
|
+
const params = {};
|
|
36
|
+
const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
|
|
37
|
+
let pm;
|
|
38
|
+
while ((pm = re.exec(m[1]))) {
|
|
39
|
+
const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
|
|
40
|
+
params[pm[1].toLowerCase()] = value;
|
|
41
|
+
}
|
|
42
|
+
if (!params.realm || !params.challenge)
|
|
43
|
+
return null;
|
|
44
|
+
return { realm: params.realm, nonce: params.challenge };
|
|
45
|
+
}
|
|
21
46
|
export function agentAuthHash(params) {
|
|
22
47
|
const bodyHash = params.body && params.body.length > 0 ? sha256(stringToBytes(params.body)) : ZERO_BODY_HASH;
|
|
23
48
|
return keccak256(concatHex([
|
package/dist/chain.js
CHANGED
|
@@ -3,14 +3,14 @@ import { http, defineChain } from "viem";
|
|
|
3
3
|
import { createClient, tempoActions } from "viem/tempo";
|
|
4
4
|
import { Actions, Account, Addresses, Chain as TempoChains } from "viem/tempo";
|
|
5
5
|
import { CONFIG } from "./config.js";
|
|
6
|
-
import {
|
|
6
|
+
import { PATHTHB, TOKENS } from "./tokens.js";
|
|
7
7
|
/** ThaiFi chain 17 — clone of the tempo chain definition (formatters/serializers) with our ids. */
|
|
8
8
|
export const thaiFi = defineChain({
|
|
9
9
|
...TempoChains.mainnet,
|
|
10
10
|
id: CONFIG.chainId,
|
|
11
11
|
name: "ThaiFi",
|
|
12
12
|
network: "thaifi",
|
|
13
|
-
nativeCurrency: { name: "
|
|
13
|
+
nativeCurrency: { name: "pathTHB", symbol: "pathTHB", decimals: 6 },
|
|
14
14
|
rpcUrls: { default: { http: [CONFIG.rpcUrl] } },
|
|
15
15
|
blockExplorers: {
|
|
16
16
|
default: { name: "ThaiFi Explorer", url: "https://exp.thaifi.com" },
|
|
@@ -34,8 +34,8 @@ export function createAgentClient(store, feeToken) {
|
|
|
34
34
|
/** Min balance that makes a token usable as gas (~0.01; gas ≈ 0.003/tx). */
|
|
35
35
|
const GAS_MIN_UNITS = 10000n;
|
|
36
36
|
/**
|
|
37
|
-
* Pick the gas token by balance:
|
|
38
|
-
* chosen token to the validator's
|
|
37
|
+
* Pick the gas token by balance: pathTHB → THCFI → THCOC. The chain swaps the
|
|
38
|
+
* chosen token to the validator's pathTHB through the protocol FeeAMM.
|
|
39
39
|
*/
|
|
40
40
|
export async function pickFeeToken(client, userAddress) {
|
|
41
41
|
for (const t of TOKENS) {
|
|
@@ -43,7 +43,7 @@ export async function pickFeeToken(client, userAddress) {
|
|
|
43
43
|
if (bal >= GAS_MIN_UNITS)
|
|
44
44
|
return t;
|
|
45
45
|
}
|
|
46
|
-
return
|
|
46
|
+
return PATHTHB;
|
|
47
47
|
}
|
|
48
48
|
export async function tokenBalance(client, token, address) {
|
|
49
49
|
return client.readContract({
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** `thaifi deposit` — PromptPay QR top-up (1 THB = 1 token).
|
|
2
|
+
*
|
|
3
|
+
* Creates a QR order on wallet.thaifi.com and waits until the tokens are
|
|
4
|
+
* minted. Auth is ThaiFi Agent Auth (no API key): the backend verifies
|
|
5
|
+
* on-chain (AccountKeychain precompile) that this agent's access key belongs
|
|
6
|
+
* to a wallet and credits THAT wallet — the CLI never sends a recipient.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import png from "pngjs";
|
|
11
|
+
import * as jsqrModule from "jsqr";
|
|
12
|
+
import qrcodeTerminal from "qrcode-terminal";
|
|
13
|
+
import { loadStore, storePath } from "../store.js";
|
|
14
|
+
import { CONFIG } from "../config.js";
|
|
15
|
+
import { b64uEncode, buildAgentAuthCredential, parseAgentChallenge, } from "../agentAuth.js";
|
|
16
|
+
import { openBrowser } from "./fund.js";
|
|
17
|
+
const ORDER_PATH = "/api/agent/deposit/qr";
|
|
18
|
+
const MIN_THB = 6;
|
|
19
|
+
const MAX_THB = 10000;
|
|
20
|
+
/** Orders expire after 15 min, but a late payment still delivers — keep
|
|
21
|
+
* watching that long past expiry. */
|
|
22
|
+
const LATE_GRACE_MS = 15 * 60 * 1000;
|
|
23
|
+
const TERMINAL_STATUSES = new Set(["delivered", "cancelled", "capped", "qr_error"]);
|
|
24
|
+
const { PNG } = png;
|
|
25
|
+
// NodeNext misbinds jsqr's CJS `export default` to the whole module namespace
|
|
26
|
+
// when importing from ESM; at runtime the namespace's `.default` IS the
|
|
27
|
+
// function (verified) — pick it with an explicit cast.
|
|
28
|
+
const jsQR = jsqrModule.default;
|
|
29
|
+
const STATUS_HINTS = {
|
|
30
|
+
pending: "scan the QR to pay",
|
|
31
|
+
paid: "payment received — minting",
|
|
32
|
+
delivering: "minting tokens",
|
|
33
|
+
delivered: "tokens are in the wallet",
|
|
34
|
+
cancelled: "order was cancelled",
|
|
35
|
+
capped: "over the daily cap — needs operator review",
|
|
36
|
+
qr_error: "QR creation failed",
|
|
37
|
+
};
|
|
38
|
+
// ---------------------------------------------------------------- HTTP + TAA
|
|
39
|
+
/** fetch with automatic ThaiFi Agent Auth: on a 401/403 challenge, sign with
|
|
40
|
+
* the agent access key (identity proof, no payment) and retry once. The
|
|
41
|
+
* server verifies the key against the AccountKeychain precompile. */
|
|
42
|
+
async function agentFetch(store, url, init) {
|
|
43
|
+
const headers = {};
|
|
44
|
+
if (init.body !== undefined)
|
|
45
|
+
headers["Content-Type"] = "application/json";
|
|
46
|
+
let res = await fetch(url, { method: init.method, headers, body: init.body });
|
|
47
|
+
if (res.status === 401 || res.status === 403) {
|
|
48
|
+
const challenge = parseAgentChallenge(res.headers.get("www-authenticate") ?? "");
|
|
49
|
+
if (challenge) {
|
|
50
|
+
const credential = await buildAgentAuthCredential(store, challenge, {
|
|
51
|
+
method: init.method,
|
|
52
|
+
url,
|
|
53
|
+
body: init.body,
|
|
54
|
+
});
|
|
55
|
+
res = await fetch(url, {
|
|
56
|
+
method: init.method,
|
|
57
|
+
headers: {
|
|
58
|
+
...headers,
|
|
59
|
+
Authorization: "ThaiFiAgent " + b64uEncode(JSON.stringify(credential)),
|
|
60
|
+
},
|
|
61
|
+
body: init.body,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return res;
|
|
66
|
+
}
|
|
67
|
+
// ------------------------------------------------------------------ QR output
|
|
68
|
+
/** Decode the payso QR PNG and re-render it as terminal blocks so a human
|
|
69
|
+
* watching the session can scan straight off the screen. Best-effort. */
|
|
70
|
+
function renderTerminalQr(dataUrl) {
|
|
71
|
+
try {
|
|
72
|
+
const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
|
|
73
|
+
const img = PNG.sync.read(Buffer.from(b64, "base64"));
|
|
74
|
+
const decoded = jsQR(new Uint8ClampedArray(img.data), img.width, img.height);
|
|
75
|
+
if (!decoded?.data)
|
|
76
|
+
return;
|
|
77
|
+
console.log("");
|
|
78
|
+
qrcodeTerminal.generate(decoded.data, { small: true });
|
|
79
|
+
console.log("");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// terminal rendering is a convenience — the saved PNG always works
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Save the QR image to ~/.thaifi/qr-<referenceNo>.<ext> (chmod 600) so the
|
|
86
|
+
* agent has a durable path to show the user. Returns the path or null. */
|
|
87
|
+
function saveQrImage(dataUrl, referenceNo) {
|
|
88
|
+
try {
|
|
89
|
+
const m = /^data:image\/(png|jpe?g|webp);base64,(.+)$/s.exec(dataUrl);
|
|
90
|
+
if (!m)
|
|
91
|
+
return null;
|
|
92
|
+
const ext = m[1] === "jpeg" ? "jpg" : m[1];
|
|
93
|
+
const dir = dirname(storePath);
|
|
94
|
+
const file = join(dir, `qr-${referenceNo}.${ext}`);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
writeFileSync(file, Buffer.from(m[2], "base64"), { mode: 0o600 });
|
|
97
|
+
return file;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// --------------------------------------------------------------------- polling
|
|
104
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
105
|
+
/** Poll the order until a terminal status (or the late-payment grace runs
|
|
106
|
+
* out). Prints STATUS=/TX= lines — machine-parsable like `PAIR_URL=`. */
|
|
107
|
+
async function watchOrder(store, orderUrl, order, options) {
|
|
108
|
+
const statusUrl = `${orderUrl}/${order.referenceNo}`;
|
|
109
|
+
const deadline = (order.expiresAt || Date.now()) + LATE_GRACE_MS;
|
|
110
|
+
let last = "";
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
const res = await agentFetch(store, statusUrl, { method: "GET" });
|
|
113
|
+
const data = (await res.json().catch(() => null));
|
|
114
|
+
if (res.ok && data?.status && data.status !== last) {
|
|
115
|
+
last = data.status;
|
|
116
|
+
if (options.json) {
|
|
117
|
+
const { image, ...rest } = data;
|
|
118
|
+
console.log(JSON.stringify(rest, null, 2));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const hint = STATUS_HINTS[data.status];
|
|
122
|
+
console.log(`STATUS=${data.status}${hint ? ` (${hint})` : ""}`);
|
|
123
|
+
}
|
|
124
|
+
if (data.status === "delivered" && data.txHash && !options.json) {
|
|
125
|
+
console.log(`TX=${data.txHash}`);
|
|
126
|
+
console.log(`Explorer: https://exp.thaifi.com/tx/${data.txHash}`);
|
|
127
|
+
}
|
|
128
|
+
if (TERMINAL_STATUSES.has(data.status)) {
|
|
129
|
+
if (data.status !== "delivered")
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await sleep(CONFIG.pollIntervalMs);
|
|
135
|
+
}
|
|
136
|
+
console.error("Timed out waiting for payment. Payment after expiry still delivers — resume later with:");
|
|
137
|
+
console.error(` thaifi deposit --ref ${order.referenceNo}`);
|
|
138
|
+
process.exitCode = 1;
|
|
139
|
+
}
|
|
140
|
+
export async function deposit(options = {}) {
|
|
141
|
+
const store = loadStore();
|
|
142
|
+
if (!store?.userAddress) {
|
|
143
|
+
console.error("Not paired. Run `thaifi login` first.");
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// CONFIG (env THAIFI_WALLET_URL > default) — same precedence as `login`,
|
|
148
|
+
// NOT store.walletUrl: a stale value would silently hit the wrong host.
|
|
149
|
+
const base = (process.env.THAIFI_WALLET_URL || CONFIG.walletUrl).replace(/\/+$/, "");
|
|
150
|
+
const orderUrl = base + ORDER_PATH;
|
|
151
|
+
// --ref: watch an existing order (e.g. after a restart) instead of creating one.
|
|
152
|
+
if (options.ref) {
|
|
153
|
+
const res = await agentFetch(store, `${orderUrl}/${options.ref.trim()}`, { method: "GET" });
|
|
154
|
+
const data = (await res.json().catch(() => null));
|
|
155
|
+
if (!res.ok || !data?.referenceNo) {
|
|
156
|
+
console.error(`${res.status}: ${data?.error ?? res.statusText}`);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!options.json) {
|
|
161
|
+
console.log(`REFERENCE=${data.referenceNo}`);
|
|
162
|
+
console.log(`AMOUNT=${data.total} THB -> ${data.token ?? "?"}`);
|
|
163
|
+
}
|
|
164
|
+
await watchOrder(store, orderUrl, data, options);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const token = (options.token ?? "THCFI").toUpperCase();
|
|
168
|
+
if (token !== "THCFI" && token !== "THCOC") {
|
|
169
|
+
console.error("--token must be THCFI or THCOC");
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const amount = Number(options.amount);
|
|
174
|
+
if (!options.amount || !Number.isFinite(amount) || amount < MIN_THB || amount > MAX_THB) {
|
|
175
|
+
console.error(`--amount must be ${MIN_THB}-${MAX_THB} THB`);
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const body = JSON.stringify({ amount, token });
|
|
180
|
+
const res = await agentFetch(store, orderUrl, { method: "POST", body });
|
|
181
|
+
const data = (await res.json().catch(() => null));
|
|
182
|
+
if (!res.ok || !data?.referenceNo) {
|
|
183
|
+
console.error(`${res.status}: ${data?.error ?? res.statusText}`);
|
|
184
|
+
if (data?.detail && typeof data.detail === "object") {
|
|
185
|
+
console.error(JSON.stringify(data.detail).slice(0, 400));
|
|
186
|
+
}
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const file = data.image ? saveQrImage(data.image, data.referenceNo) : null;
|
|
191
|
+
if (options.json) {
|
|
192
|
+
const { image, ...rest } = data;
|
|
193
|
+
console.log(JSON.stringify({ ...rest, qrFile: file }, null, 2));
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
console.log(`PromptPay deposit — ${data.total} THB -> ${token} (1 THB = 1 token)`);
|
|
197
|
+
console.log(`Credited wallet: ${store.userAddress}`);
|
|
198
|
+
if (data.image)
|
|
199
|
+
renderTerminalQr(data.image);
|
|
200
|
+
if (file)
|
|
201
|
+
console.log(`QR_FILE=${file}`);
|
|
202
|
+
console.log(`REFERENCE=${data.referenceNo}`);
|
|
203
|
+
console.log(`AMOUNT=${data.total} THB -> ${token}`);
|
|
204
|
+
console.log(`RESUME=thaifi deposit --ref ${data.referenceNo}`);
|
|
205
|
+
console.log("");
|
|
206
|
+
console.log("Scan the QR with any banking app — polling until the tokens are minted");
|
|
207
|
+
console.log("(Ctrl-C to stop; resume with the RESUME command above).");
|
|
208
|
+
}
|
|
209
|
+
if (file && options.open !== false) {
|
|
210
|
+
await openBrowser("file://" + file);
|
|
211
|
+
}
|
|
212
|
+
await watchOrder(store, orderUrl, data, options);
|
|
213
|
+
}
|
package/dist/commands/fund.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** `thaifi fund` — show the deposit address and open the web Deposit page. */
|
|
2
2
|
import { loadStore } from "../store.js";
|
|
3
|
-
async function openBrowser(url) {
|
|
3
|
+
export async function openBrowser(url) {
|
|
4
4
|
const { exec } = await import("node:child_process");
|
|
5
5
|
const cmd = process.platform === "darwin"
|
|
6
6
|
? `open "${url}"`
|
package/dist/commands/login.js
CHANGED
|
@@ -161,7 +161,7 @@ export async function login(options = {}) {
|
|
|
161
161
|
console.log(" Wallet account: " + status.userAddress);
|
|
162
162
|
console.log(" Spend limit: " +
|
|
163
163
|
Number(status.limitAmount ?? 0) / 10 ** 6 +
|
|
164
|
-
"
|
|
164
|
+
" pathTHB / " +
|
|
165
165
|
Math.round((status.limitPeriod ?? 0) / 86400) +
|
|
166
166
|
" days (THCFI/THCOC limits set at approval)");
|
|
167
167
|
console.log(" Key expires: " + (status.expiry ? new Date(status.expiry * 1000).toISOString() : "never"));
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `thaifi mcp` — stdio MCP server that exposes every paid service in the
|
|
3
|
+
* ThaiFi MPP catalog (mpp.thaifi.com /services) as MCP tools, paying per call
|
|
4
|
+
* with the agent access key already paired on this machine.
|
|
5
|
+
*
|
|
6
|
+
* Tools are generated from the live registry: `POST` services get an
|
|
7
|
+
* inputSchema from the catalog's `requestBody`; tool calls go through
|
|
8
|
+
* paidFetch, which pays 402 challenges automatically (the on-chain access-key
|
|
9
|
+
* spending limit still applies). THAIFI_MCP_MAX_SPEND (USD, default 5) caps
|
|
10
|
+
* every call on top.
|
|
11
|
+
*
|
|
12
|
+
* stdout carries only JSON-RPC — everything else goes to stderr.
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { paidFetch, fmtAmount } from "../paidFetch.js";
|
|
18
|
+
import { loadStore } from "../store.js";
|
|
19
|
+
const err = (msg) => {
|
|
20
|
+
console.error(msg);
|
|
21
|
+
};
|
|
22
|
+
function registryUrl() {
|
|
23
|
+
return (process.env.THAIFI_MPP_REGISTRY ?? "https://mpp.thaifi.com").replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
async function fetchCatalog() {
|
|
26
|
+
const res = await fetch(registryUrl() + "/services", {
|
|
27
|
+
headers: { "user-agent": "thaifi-wallet-cli-mcp" },
|
|
28
|
+
signal: AbortSignal.timeout(15_000),
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(`Registry returned HTTP ${res.status} for GET ${registryUrl()}/services`);
|
|
32
|
+
const body = (await res.json());
|
|
33
|
+
return (body.services ?? []).filter((s) => s.auth === "mpp");
|
|
34
|
+
}
|
|
35
|
+
/** Convert a JSON-schema property to a zod shape (registerTool takes zod). */
|
|
36
|
+
function propToZod(prop) {
|
|
37
|
+
const desc = typeof prop.description === "string" ? prop.description : undefined;
|
|
38
|
+
switch (prop.type) {
|
|
39
|
+
case "string": {
|
|
40
|
+
let s = z.string();
|
|
41
|
+
if (desc)
|
|
42
|
+
s = s.describe(desc);
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
case "number":
|
|
46
|
+
case "integer": {
|
|
47
|
+
let n = z.number();
|
|
48
|
+
if (desc)
|
|
49
|
+
n = n.describe(desc);
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
case "boolean":
|
|
53
|
+
return z.boolean();
|
|
54
|
+
case "array":
|
|
55
|
+
return z.array(z.any());
|
|
56
|
+
default:
|
|
57
|
+
return z.any();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Build a zod shape from the catalog's requestBody JSON schema. */
|
|
61
|
+
function zodFromSchema(schema) {
|
|
62
|
+
if (!schema || schema.type !== "object" || typeof schema.properties !== "object")
|
|
63
|
+
return {};
|
|
64
|
+
const shape = {};
|
|
65
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
66
|
+
for (const [key, rawProp] of Object.entries(schema.properties)) {
|
|
67
|
+
const zt = propToZod(rawProp);
|
|
68
|
+
shape[key] = required.includes(key) ? zt : zt.optional();
|
|
69
|
+
}
|
|
70
|
+
return shape;
|
|
71
|
+
}
|
|
72
|
+
/** Translate a tool call into the registry request. GET tools accept no args. */
|
|
73
|
+
function requestFor(svc, input) {
|
|
74
|
+
const url = registryUrl() + svc.path;
|
|
75
|
+
if (svc.method === "GET")
|
|
76
|
+
return { url, method: "GET" };
|
|
77
|
+
const bodyObj = svc.requestBody
|
|
78
|
+
? input
|
|
79
|
+
: Object.keys(input).length === 0
|
|
80
|
+
? undefined
|
|
81
|
+
: { body: input };
|
|
82
|
+
return { url, method: "POST", body: bodyObj ? JSON.stringify(bodyObj) : undefined };
|
|
83
|
+
}
|
|
84
|
+
export async function mcp() {
|
|
85
|
+
const store = loadStore();
|
|
86
|
+
if (!store?.userAddress || !store?.privateKey) {
|
|
87
|
+
// Not fatal for list-tools — but tell the operator loudly on stderr.
|
|
88
|
+
err("thaifi mcp: not paired — tools will refuse to pay until `thaifi login` completes.");
|
|
89
|
+
}
|
|
90
|
+
const maxSpend = BigInt(Math.max(1, Math.round(Number(process.env.THAIFI_MCP_MAX_SPEND || 5))) * 1_000_000);
|
|
91
|
+
const server = new McpServer({ name: "thaifi", version: "0.5.0" }, { capabilities: { tools: {} } });
|
|
92
|
+
let catalog = [];
|
|
93
|
+
try {
|
|
94
|
+
catalog = await fetchCatalog();
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
err(`thaifi mcp: could not load service catalog (${e instanceof Error ? e.message : String(e)}).`);
|
|
98
|
+
err("Tools will be registered once the catalog is reachable — restarting the MCP server retries it.");
|
|
99
|
+
}
|
|
100
|
+
for (const svc of catalog) {
|
|
101
|
+
const toolName = `thaifi_${svc.id}`.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
102
|
+
const shape = svc.method === "GET" ? {} : zodFromSchema(svc.requestBody);
|
|
103
|
+
server.tool(toolName, `${svc.title} — ${svc.description} [price: ${svc.price}]`, shape, async (input) => {
|
|
104
|
+
const req = requestFor(svc, input);
|
|
105
|
+
const result = await paidFetch(req.url, {
|
|
106
|
+
method: req.method,
|
|
107
|
+
body: req.body,
|
|
108
|
+
maxSpend,
|
|
109
|
+
log: (m) => err(`thaifi mcp [${toolName}]: ${m}`),
|
|
110
|
+
});
|
|
111
|
+
const content = [];
|
|
112
|
+
let parsed = null;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(result.text);
|
|
115
|
+
}
|
|
116
|
+
catch { /* not JSON */ }
|
|
117
|
+
const b64 = parsed?.image_base64 ?? parsed?.data?.image_base64;
|
|
118
|
+
if (typeof b64 === "string" && b64.length > 1000) {
|
|
119
|
+
const mime = String(parsed?.mime_type ?? parsed?.data?.mime_type ?? "image/png");
|
|
120
|
+
content.push({ type: "image", data: b64, mimeType: mime });
|
|
121
|
+
try {
|
|
122
|
+
const { writeFileSync } = await import("node:fs");
|
|
123
|
+
const id = String(parsed?.id ?? parsed?.data?.id ?? Date.now()).toString().replace(/[^\w-]/g, "");
|
|
124
|
+
const ext = mime.includes("webp") ? "webp" : mime.includes("jpeg") ? "jpg" : "png";
|
|
125
|
+
writeFileSync(`image-${id}.${ext}`, Buffer.from(b64, "base64"));
|
|
126
|
+
}
|
|
127
|
+
catch { /* saving is best-effort */ }
|
|
128
|
+
}
|
|
129
|
+
if (result.unpaid402) {
|
|
130
|
+
return { isError: true, content: [{ type: "text", text: `Unpaid (402): ${result.unpaid402}\nPair this machine with \`thaifi login\` and raise THAIFI_MCP_MAX_SPEND if needed.` }] };
|
|
131
|
+
}
|
|
132
|
+
if (result.status >= 400) {
|
|
133
|
+
content.push({ type: "text", text: `HTTP ${result.status}: ${result.text.slice(0, 2000)}` });
|
|
134
|
+
if (result.payment) {
|
|
135
|
+
content.push({ type: "text", text: `Note: payment of ${fmtAmount(result.payment.amount, 6)} was already broadcast (tx ${result.payment.hash}) — the operator may refund.` });
|
|
136
|
+
}
|
|
137
|
+
return { isError: true, content };
|
|
138
|
+
}
|
|
139
|
+
content.push({ type: "text", text: result.text.slice(0, 200_000) });
|
|
140
|
+
return { content };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
server.tool("thaifi_spend_cap", "Show the current per-call spend cap (THAIFI_MCP_MAX_SPEND) and whether this machine is paired.", {}, async () => ({
|
|
144
|
+
content: [
|
|
145
|
+
{
|
|
146
|
+
type: "text",
|
|
147
|
+
text: JSON.stringify({
|
|
148
|
+
paired: Boolean(loadStore()?.userAddress),
|
|
149
|
+
wallet: loadStore()?.userAddress ?? null,
|
|
150
|
+
registry: registryUrl(),
|
|
151
|
+
max_spend_per_call: `${fmtAmount(maxSpend, 6)} THCFI`,
|
|
152
|
+
env: "THAIFI_MCP_MAX_SPEND (USD)",
|
|
153
|
+
}, null, 2),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
}));
|
|
157
|
+
err(`thaifi mcp: ${catalog.length} tools from ${registryUrl()} (max spend ${fmtAmount(maxSpend, 6)}/call, wallet ${store?.userAddress ?? "not paired"})`);
|
|
158
|
+
await server.connect(new StdioServerTransport());
|
|
159
|
+
// Keep the process alive while the transport is open.
|
|
160
|
+
await new Promise(() => { });
|
|
161
|
+
}
|
package/dist/commands/request.js
CHANGED
|
@@ -2,23 +2,16 @@
|
|
|
2
2
|
import { concatHex, keccak256, slice, stringToBytes } from "viem";
|
|
3
3
|
import { loadStore } from "../store.js";
|
|
4
4
|
import { createAgentClient, pickFeeToken } from "../chain.js";
|
|
5
|
-
import { buildAgentAuthCredential } from "../agentAuth.js";
|
|
5
|
+
import { b64uEncode, buildAgentAuthCredential, parseAgentChallenge, } from "../agentAuth.js";
|
|
6
6
|
import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
|
|
7
7
|
import { PATHUSD, resolveToken } from "../tokens.js";
|
|
8
|
-
function b64uDecode(s) {
|
|
9
|
-
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
10
|
-
}
|
|
11
|
-
function b64uEncode(s) {
|
|
12
|
-
return Buffer.from(s, "utf8")
|
|
13
|
-
.toString("base64")
|
|
14
|
-
.replace(/\+/g, "-")
|
|
15
|
-
.replace(/\//g, "_")
|
|
16
|
-
.replace(/=+$/, "");
|
|
17
|
-
}
|
|
18
8
|
/**
|
|
19
9
|
* Parse an MPP `WWW-Authenticate: Payment id="…", request="<b64u json>"` header.
|
|
20
10
|
* See https://mpp.dev — Machine Payments Protocol.
|
|
21
11
|
*/
|
|
12
|
+
function b64uDecode(s) {
|
|
13
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
14
|
+
}
|
|
22
15
|
export function parseMppChallenge(header) {
|
|
23
16
|
const m = /^Payment\s+(.+)$/i.exec(header.trim());
|
|
24
17
|
if (!m)
|
|
@@ -53,26 +46,6 @@ export function parseMppChallenge(header) {
|
|
|
53
46
|
challenge.expires = params.expires;
|
|
54
47
|
return challenge;
|
|
55
48
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Parse a `WWW-Authenticate: ThaiFiAgent realm="…", challenge="<b64u>"`
|
|
58
|
-
* header — ThaiFi Agent Auth (identity proof, no payment). The server
|
|
59
|
-
* verifies our signature against the AccountKeychain precompile.
|
|
60
|
-
*/
|
|
61
|
-
function parseAgentChallenge(header) {
|
|
62
|
-
const m = /^ThaiFiAgent\s+(.+)$/i.exec(header.trim());
|
|
63
|
-
if (!m)
|
|
64
|
-
return null;
|
|
65
|
-
const params = {};
|
|
66
|
-
const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
|
|
67
|
-
let pm;
|
|
68
|
-
while ((pm = re.exec(m[1]))) {
|
|
69
|
-
const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
|
|
70
|
-
params[pm[1].toLowerCase()] = value;
|
|
71
|
-
}
|
|
72
|
-
if (!params.realm || !params.challenge)
|
|
73
|
-
return null;
|
|
74
|
-
return { realm: params.realm, nonce: params.challenge };
|
|
75
|
-
}
|
|
76
49
|
/** MPP tempo charge → on-chain TIP-20 transfer (push mode: we broadcast, then send the hash). */
|
|
77
50
|
function mppTempoChallenge(challenge) {
|
|
78
51
|
const req = challenge.request;
|
package/dist/config.js
CHANGED
|
@@ -3,10 +3,10 @@ export const CONFIG = {
|
|
|
3
3
|
rpcUrl: process.env.THAIFI_RPC_URL ?? "https://rpc.thaifi.com",
|
|
4
4
|
walletUrl: process.env.THAIFI_WALLET_URL ?? "https://wallet.thaifi.com",
|
|
5
5
|
chainId: Number(process.env.THAIFI_CHAIN_ID ?? 17),
|
|
6
|
-
/**
|
|
6
|
+
/** pathTHB — ThaiFi fee/stable token (TIP-20, 6 decimals). */
|
|
7
7
|
pathUsd: (process.env.THAIFI_PATHUSD ??
|
|
8
8
|
"0x20c0000000000000000000000000000000000000"),
|
|
9
|
-
/** Default spending limit: 100
|
|
9
|
+
/** Default spending limit: 100 pathTHB. */
|
|
10
10
|
defaultLimit: 100n * 10n ** 6n,
|
|
11
11
|
/** Default limit period: 30 days (seconds). */
|
|
12
12
|
defaultPeriod: 60 * 60 * 24 * 30,
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,9 @@ import { balance } from "./commands/balance.js";
|
|
|
9
9
|
import { transfer } from "./commands/transfer.js";
|
|
10
10
|
import { request } from "./commands/request.js";
|
|
11
11
|
import { services } from "./commands/services.js";
|
|
12
|
+
import { mcp } from "./commands/mcp.js";
|
|
12
13
|
import { fund } from "./commands/fund.js";
|
|
14
|
+
import { deposit } from "./commands/deposit.js";
|
|
13
15
|
import { tokens } from "./commands/tokens.js";
|
|
14
16
|
// Resolve ../package.json at runtime (works from src/ via tsx and from dist/ when installed) — never hardcode the version here again.
|
|
15
17
|
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -38,21 +40,30 @@ program
|
|
|
38
40
|
program
|
|
39
41
|
.command("balance")
|
|
40
42
|
.description("Show TIP-20 token balances of the paired account")
|
|
41
|
-
.option("--token <symbol|address>", "Token symbol (
|
|
43
|
+
.option("--token <symbol|address>", "Token symbol (pathTHB, THCFI, THCOC) or contract address (default: all)")
|
|
42
44
|
.action(balance);
|
|
43
45
|
program
|
|
44
46
|
.command("transfer")
|
|
45
47
|
.description("Transfer tokens signed by the agent access key (spend limit enforced on-chain)")
|
|
46
48
|
.argument("<to>", "Recipient address (0x…)")
|
|
47
49
|
.argument("<amount>", "Amount in human units (e.g. 1.5)")
|
|
48
|
-
.option("--token <symbol|address>", "Token symbol or contract address (default:
|
|
49
|
-
.option("--fee-token <symbol|address>", "Gas token (default: auto —
|
|
50
|
+
.option("--token <symbol|address>", "Token symbol or contract address (default: pathTHB)")
|
|
51
|
+
.option("--fee-token <symbol|address>", "Gas token (default: auto — pathTHB, else THCFI/THCOC by balance)")
|
|
50
52
|
.action(transfer);
|
|
51
53
|
program
|
|
52
54
|
.command("fund")
|
|
53
55
|
.description("Show your deposit address and open the web Deposit page")
|
|
54
56
|
.option("--no-browser", "Print the address without opening a browser")
|
|
55
57
|
.action(fund);
|
|
58
|
+
program
|
|
59
|
+
.command("deposit")
|
|
60
|
+
.description("PromptPay QR top-up (1 THB = 1 token): create a QR order, show it for the user to scan, and wait until the tokens are minted")
|
|
61
|
+
.option("--amount <thb>", "Amount in THB (6-10000)")
|
|
62
|
+
.option("--token <symbol>", "Payout token: THCFI (default) or THCOC")
|
|
63
|
+
.option("--ref <referenceNo>", "Watch an existing order instead of creating a new one")
|
|
64
|
+
.option("--no-open", "Do not open the saved QR image in a viewer")
|
|
65
|
+
.option("--json", "Machine-readable output (JSON)")
|
|
66
|
+
.action(deposit);
|
|
56
67
|
program
|
|
57
68
|
.command("request")
|
|
58
69
|
.description("HTTP request with automatic x402/MPP payments (curl-like)")
|
|
@@ -63,7 +74,7 @@ program
|
|
|
63
74
|
.option("--json <json>", "JSON body shorthand (sets method POST + content-type)")
|
|
64
75
|
.option("--dry-run", "Print the request without sending")
|
|
65
76
|
.option("--max-spend <usd>", "Maximum automatic payment (USD)", Number)
|
|
66
|
-
.option("--fee-token <symbol|address>", "Gas token for the payment tx (default: auto —
|
|
77
|
+
.option("--fee-token <symbol|address>", "Gas token for the payment tx (default: auto — pathTHB, else THCFI/THCOC by balance)")
|
|
67
78
|
.action(async (url, opts) => {
|
|
68
79
|
const headers = {};
|
|
69
80
|
for (const h of opts.header ?? []) {
|
|
@@ -91,4 +102,8 @@ program
|
|
|
91
102
|
.description("Discover paid services from the MPP/x402 registry")
|
|
92
103
|
.option("--search <query>", "Search services")
|
|
93
104
|
.action(services);
|
|
105
|
+
program
|
|
106
|
+
.command("mcp")
|
|
107
|
+
.description("Run a stdio MCP server exposing all MPP catalog services as tools (pays with the paired access key)")
|
|
108
|
+
.action(mcp);
|
|
94
109
|
program.parseAsync();
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP fetch that pays MPP challenges with the paired agent access key.
|
|
3
|
+
*
|
|
4
|
+
* Same wire logic as `thaifi request` (src/commands/request.ts is the
|
|
5
|
+
* reference implementation, kept untouched) but as a quiet, programmable
|
|
6
|
+
* function used by `thaifi mcp` — no stdout logging, returns a result object.
|
|
7
|
+
*
|
|
8
|
+
* Flow (MPP tempo charge, push mode):
|
|
9
|
+
* 1. normal fetch; if not 402 → return as-is
|
|
10
|
+
* 2. parse the `WWW-Authenticate: Payment …` challenge (x402 legacy fallback)
|
|
11
|
+
* 3. broadcast the TIP-20 transfer ourselves (memo-bound to the challenge)
|
|
12
|
+
* 4. retry with `Authorization: Payment <base64url credential>`
|
|
13
|
+
*/
|
|
14
|
+
import { keccak256, slice, stringToBytes, concatHex } from "viem";
|
|
15
|
+
import { loadStore } from "./store.js";
|
|
16
|
+
import { CONFIG, PATHUSD_DECIMALS } from "./config.js";
|
|
17
|
+
import { createAgentClient, pickFeeToken } from "./chain.js";
|
|
18
|
+
import { b64uEncode } from "./agentAuth.js";
|
|
19
|
+
/** b64url → bytes (mirrors agentAuth's internal helper, which is not exported). */
|
|
20
|
+
function b64uDecodeBytes(value) {
|
|
21
|
+
return new Uint8Array(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
|
|
22
|
+
}
|
|
23
|
+
// ── MPP attribution memo (32 bytes) per mppx Attribution encoding ─────────
|
|
24
|
+
// tag = keccak256("mpp") [0..4 bytes], MPP protocol version 0x10,
|
|
25
|
+
// serverId = realm fingerprint, clientId = "thaifi-wallet-cli" fingerprint,
|
|
26
|
+
// nonce = keccak256(challengeId) [0..7 bytes].
|
|
27
|
+
// Required by servers that don't pre-assign a memo to the challenge.
|
|
28
|
+
const MPP_TAG = slice(keccak256(stringToBytes("mpp")), 0, 4);
|
|
29
|
+
const fingerprint = (value) => slice(keccak256(stringToBytes(value)), 0, 10);
|
|
30
|
+
function attributionMemo(realm, challengeId) {
|
|
31
|
+
return concatHex([
|
|
32
|
+
MPP_TAG,
|
|
33
|
+
"0x10",
|
|
34
|
+
fingerprint(realm),
|
|
35
|
+
fingerprint("thaifi-wallet-cli"),
|
|
36
|
+
slice(keccak256(stringToBytes(challengeId)), 0, 7),
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
function storeAddress() {
|
|
40
|
+
const store = loadStore();
|
|
41
|
+
return store?.userAddress ?? "0x0";
|
|
42
|
+
}
|
|
43
|
+
function parseMppChallenge(header) {
|
|
44
|
+
// mppx emits scheme "Payment" (some servers use "MPP") — both are the same param set.
|
|
45
|
+
const m = /^(?:Payment|MPP)\s+(.+)$/i.exec(header.trim());
|
|
46
|
+
if (!m)
|
|
47
|
+
return null;
|
|
48
|
+
const params = {};
|
|
49
|
+
const re = /([a-zA-Z]+)=(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g;
|
|
50
|
+
let pm;
|
|
51
|
+
while ((pm = re.exec(m[1]))) {
|
|
52
|
+
const value = pm[3] !== undefined ? pm[3] : pm[2] !== undefined ? pm[2].replace(/\\(.)/g, "$1") : "";
|
|
53
|
+
params[pm[1].toLowerCase()] = value;
|
|
54
|
+
}
|
|
55
|
+
if (!params.request || !params.realm)
|
|
56
|
+
return null;
|
|
57
|
+
let request;
|
|
58
|
+
try {
|
|
59
|
+
request = JSON.parse(Buffer.from(b64uDecodeBytes(params.request)).toString("utf8"));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const challenge = {
|
|
65
|
+
id: params.id,
|
|
66
|
+
realm: params.realm ?? "",
|
|
67
|
+
method: params.method ?? "tempo",
|
|
68
|
+
intent: params.intent ?? "charge",
|
|
69
|
+
request,
|
|
70
|
+
requestRaw: params.request,
|
|
71
|
+
};
|
|
72
|
+
if (params.expires !== undefined)
|
|
73
|
+
challenge.expires = params.expires;
|
|
74
|
+
if (params.opaque !== undefined)
|
|
75
|
+
challenge.opaque = params.opaque;
|
|
76
|
+
return challenge;
|
|
77
|
+
}
|
|
78
|
+
/** MPP tempo charge → on-chain TIP-20 transfer (push mode: we broadcast, then send the hash). */
|
|
79
|
+
function mppTempoChallenge(challenge) {
|
|
80
|
+
const req = challenge.request;
|
|
81
|
+
if (!req?.amount || !req?.recipient)
|
|
82
|
+
return null;
|
|
83
|
+
const amount = BigInt(req.amount);
|
|
84
|
+
// mppx omits `decimals` for pathTHB-denominated challenges.
|
|
85
|
+
const decimals = req.decimals ?? 6;
|
|
86
|
+
return {
|
|
87
|
+
payTo: req.recipient,
|
|
88
|
+
amount,
|
|
89
|
+
token: req.currency,
|
|
90
|
+
memo: req.memo ?? attributionMemo(challenge.realm, challenge.id),
|
|
91
|
+
decimals,
|
|
92
|
+
proofHeader: (txHash) => {
|
|
93
|
+
// Write format: challenge.request is the base64url string, not a re-sent object.
|
|
94
|
+
const credential = {
|
|
95
|
+
challenge: {
|
|
96
|
+
id: challenge.id,
|
|
97
|
+
realm: challenge.realm,
|
|
98
|
+
method: challenge.method,
|
|
99
|
+
intent: challenge.intent,
|
|
100
|
+
request: challenge.requestRaw,
|
|
101
|
+
...(challenge.opaque !== undefined && { opaque: challenge.opaque }),
|
|
102
|
+
...(challenge.expires !== undefined && { expires: challenge.expires }),
|
|
103
|
+
},
|
|
104
|
+
payload: { type: "hash", hash: txHash },
|
|
105
|
+
source: `did:pkh:eip155:${CONFIG.chainId}:${storeAddress()}`,
|
|
106
|
+
};
|
|
107
|
+
return "Payment " + b64uEncode(JSON.stringify(credential));
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Extract a payment challenge from a 402 response.
|
|
113
|
+
* MPP first (`WWW-Authenticate: Payment …`), legacy x402 JSON body as fallback.
|
|
114
|
+
*/
|
|
115
|
+
export async function parseChallenge(res, defaultToken) {
|
|
116
|
+
const wwwAuth = res.headers.get("www-authenticate");
|
|
117
|
+
if (wwwAuth) {
|
|
118
|
+
const mpp = parseMppChallenge(wwwAuth);
|
|
119
|
+
if (mpp && mpp.method === "tempo" && mpp.intent === "charge") {
|
|
120
|
+
const parsed = mppTempoChallenge(mpp);
|
|
121
|
+
if (parsed)
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
let body = null;
|
|
125
|
+
try {
|
|
126
|
+
body = await res.json();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
body = null;
|
|
130
|
+
}
|
|
131
|
+
const accept = body?.payments?.[0] ?? body?.accepts?.[0] ?? body;
|
|
132
|
+
const payTo = accept?.payTo ?? accept?.payToAddress ?? accept?.recipient;
|
|
133
|
+
const maxAmount = accept?.maxAmountRequired ?? accept?.amount ?? accept?.price;
|
|
134
|
+
if (!payTo || maxAmount === undefined)
|
|
135
|
+
return null;
|
|
136
|
+
const amount = typeof maxAmount === "string" && /^\d+$/.test(maxAmount)
|
|
137
|
+
? BigInt(maxAmount)
|
|
138
|
+
: BigInt(Math.round(Number(maxAmount) * 10 ** PATHUSD_DECIMALS));
|
|
139
|
+
return {
|
|
140
|
+
payTo,
|
|
141
|
+
amount,
|
|
142
|
+
token: accept?.asset ?? accept?.token ?? defaultToken,
|
|
143
|
+
decimals: PATHUSD_DECIMALS,
|
|
144
|
+
proofHeader: (txHash) => Buffer.from(JSON.stringify({
|
|
145
|
+
scheme: "exact",
|
|
146
|
+
txHash,
|
|
147
|
+
from: storeAddress(),
|
|
148
|
+
to: payTo,
|
|
149
|
+
amount: "0x" + amount.toString(16),
|
|
150
|
+
token: accept?.asset ?? accept?.token ?? defaultToken,
|
|
151
|
+
})).toString("base64"),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/** Format the challenge amount for humans (uses the challenge's own decimals). */
|
|
157
|
+
export function fmtAmount(amount, decimals) {
|
|
158
|
+
const base = 10n ** BigInt(decimals);
|
|
159
|
+
const whole = amount / base;
|
|
160
|
+
const frac = amount % base;
|
|
161
|
+
return frac === 0n ? whole.toString() : `${whole}.${frac.toString().padStart(decimals, "0")}`.replace(/0+$/, "").replace(/\.$/, "");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fetch with automatic MPP payment. Payment is signed locally by the agent
|
|
165
|
+
* access key and enforced on-chain by the AccountKeychain precompile.
|
|
166
|
+
*/
|
|
167
|
+
export async function paidFetch(url, opts = {}) {
|
|
168
|
+
const log = opts.log ?? (() => { });
|
|
169
|
+
const store = loadStore();
|
|
170
|
+
if (!store?.userAddress || !store?.privateKey) {
|
|
171
|
+
return unpaid402(url, "Not paired — run `thaifi login` first.", opts.log);
|
|
172
|
+
}
|
|
173
|
+
const method = opts.method ?? "GET";
|
|
174
|
+
const res = await fetch(url, {
|
|
175
|
+
method,
|
|
176
|
+
headers: {
|
|
177
|
+
"user-agent": "thaifi-wallet-cli",
|
|
178
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
179
|
+
...opts.headers,
|
|
180
|
+
},
|
|
181
|
+
body: opts.body,
|
|
182
|
+
});
|
|
183
|
+
const contentType = res.headers.get("content-type");
|
|
184
|
+
if (res.status !== 402) {
|
|
185
|
+
return { status: res.status, contentType, text: await res.text() };
|
|
186
|
+
}
|
|
187
|
+
// 402 Payment Required — parse the challenge and pay it.
|
|
188
|
+
const challenge = await parseChallenge(res.clone(), CONFIG.pathUsd);
|
|
189
|
+
if (!challenge) {
|
|
190
|
+
const snippet = (await res.text()).slice(0, 400);
|
|
191
|
+
return unpaid402(url, `402 Payment Required — could not parse the challenge. ${snippet}`, opts.log);
|
|
192
|
+
}
|
|
193
|
+
if (opts.maxSpend !== undefined && challenge.amount > opts.maxSpend) {
|
|
194
|
+
return unpaid402(url, `Quote ${fmtAmount(challenge.amount, challenge.decimals ?? 6)} exceeds this session's max spend (${fmtAmount(opts.maxSpend, 6)}) — refusing to pay.`, opts.log);
|
|
195
|
+
}
|
|
196
|
+
const token = (challenge.token ?? CONFIG.pathUsd);
|
|
197
|
+
log(`Paying ${fmtAmount(challenge.amount, challenge.decimals ?? 6)} (base ${challenge.amount}) to ${challenge.payTo}…`);
|
|
198
|
+
// pickFeeToken needs a client; probe with the pathTHB default, then re-create
|
|
199
|
+
// the agent client with the chosen fee token (chain FeeAMM swaps it to the
|
|
200
|
+
// validator's pathTHB).
|
|
201
|
+
let feeToken;
|
|
202
|
+
try {
|
|
203
|
+
if (opts.feeToken) {
|
|
204
|
+
feeToken = opts.feeToken;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const probe = createAgentClient(store, CONFIG.pathUsd);
|
|
208
|
+
const info = await pickFeeToken(probe, store.userAddress);
|
|
209
|
+
feeToken = info.address;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
return unpaid402(url, `Could not pick a gas token: ${err instanceof Error ? err.message : String(err)}`, opts.log);
|
|
214
|
+
}
|
|
215
|
+
const client = createAgentClient(store, feeToken);
|
|
216
|
+
let hash;
|
|
217
|
+
try {
|
|
218
|
+
hash = await client.token.transfer({
|
|
219
|
+
to: challenge.payTo,
|
|
220
|
+
amount: challenge.amount,
|
|
221
|
+
token,
|
|
222
|
+
memo: challenge.memo,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
return unpaid402(url, `Payment transaction failed: ${err instanceof Error ? err.message : String(err)}`, opts.log);
|
|
227
|
+
}
|
|
228
|
+
log(`Payment tx ${hash} — waiting for receipt…`);
|
|
229
|
+
const receipt = await client.waitForTransactionReceipt({ hash });
|
|
230
|
+
if (receipt.status !== "success") {
|
|
231
|
+
log(`⚠️ Payment transaction reverted: ${hash}`);
|
|
232
|
+
return { status: res.status, contentType, text: "Payment transaction reverted — no charge applied.", payment: undefined, unpaid402: `reverted: ${hash}` };
|
|
233
|
+
}
|
|
234
|
+
const retry = await fetch(url, {
|
|
235
|
+
method,
|
|
236
|
+
headers: {
|
|
237
|
+
"user-agent": "thaifi-wallet-cli",
|
|
238
|
+
...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
239
|
+
...opts.headers,
|
|
240
|
+
authorization: challenge.proofHeader(hash),
|
|
241
|
+
},
|
|
242
|
+
body: opts.body,
|
|
243
|
+
});
|
|
244
|
+
const retryText = await retry.text();
|
|
245
|
+
log(`Paid. Retry → HTTP ${retry.status}`);
|
|
246
|
+
return {
|
|
247
|
+
status: retry.status,
|
|
248
|
+
contentType: retry.headers.get("content-type"),
|
|
249
|
+
text: retryText,
|
|
250
|
+
payment: { token, amount: challenge.amount, hash },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function unpaid402(url, message, log) {
|
|
254
|
+
log?.(message);
|
|
255
|
+
return { status: 402, contentType: null, text: JSON.stringify({ error: "unpaid", detail: message }), unpaid402: message };
|
|
256
|
+
}
|
package/dist/tokens.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** TIP-20 tokens surfaced by the CLI — all ThaiFi TIP-20s use 6 decimals. */
|
|
2
2
|
import { CONFIG } from "./config.js";
|
|
3
|
-
export const
|
|
3
|
+
export const PATHTHB = {
|
|
4
4
|
address: CONFIG.pathUsd,
|
|
5
|
-
symbol: "
|
|
6
|
-
name: "
|
|
5
|
+
symbol: "pathTHB",
|
|
6
|
+
name: "pathTHB",
|
|
7
7
|
decimals: 6,
|
|
8
8
|
};
|
|
9
9
|
// System TIP-20 tokens (native factory, 2026-09-24) — can pay gas fees via
|
|
@@ -21,13 +21,17 @@ export const THCOC = {
|
|
|
21
21
|
decimals: 6,
|
|
22
22
|
};
|
|
23
23
|
/** Tokens listed by `thaifi tokens` / shown by default in `thaifi balance`. */
|
|
24
|
-
export const TOKENS = [
|
|
24
|
+
export const TOKENS = [PATHTHB, THCFI, THCOC];
|
|
25
|
+
/** Pre-rename alias — old scripts using `pathUSD` still resolve. */
|
|
26
|
+
export const PATHUSD = PATHTHB;
|
|
25
27
|
/**
|
|
26
28
|
* Resolve a token by symbol (case-insensitive) or 0x address. Addresses
|
|
27
29
|
* outside the registry are treated as custom TIP-20s (6 decimals); the
|
|
28
30
|
* symbol is read from the chain by the caller.
|
|
29
31
|
*/
|
|
30
32
|
export function resolveToken(arg) {
|
|
33
|
+
if (arg.toLowerCase() === "pathusd")
|
|
34
|
+
return PATHTHB; // pre-rename alias
|
|
31
35
|
const bySymbol = TOKENS.find((t) => t.symbol.toLowerCase() === arg.toLowerCase());
|
|
32
36
|
if (bySymbol)
|
|
33
37
|
return bySymbol;
|
|
@@ -37,5 +41,5 @@ export function resolveToken(arg) {
|
|
|
37
41
|
return known;
|
|
38
42
|
return { address: arg, symbol: "TOKEN", name: "Custom TIP-20", decimals: 6 };
|
|
39
43
|
}
|
|
40
|
-
throw new Error(`Unknown token "${arg}" — use a symbol (
|
|
44
|
+
throw new Error(`Unknown token "${arg}" — use a symbol (pathTHB, THCFI, THCOC) or a 0x contract address.`);
|
|
41
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thaifi-wallet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "ThaiFi Wallet CLI for AI agents — pair with wallet.thaifi.com, sign with on-chain access keys (spending limits enforced on-chain), x402/MPP payments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"ai",
|
|
25
25
|
"x402",
|
|
26
26
|
"mpp",
|
|
27
|
+
"mcp",
|
|
27
28
|
"tempo",
|
|
28
29
|
"cli",
|
|
29
30
|
"payments"
|
|
@@ -33,11 +34,17 @@
|
|
|
33
34
|
"node": ">=20"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.24.0",
|
|
36
38
|
"commander": "^14.0.0",
|
|
37
|
-
"
|
|
39
|
+
"jsqr": "^1.4.0",
|
|
40
|
+
"pngjs": "^7.0.0",
|
|
41
|
+
"qrcode-terminal": "^0.12.0",
|
|
42
|
+
"viem": "^2.56.0",
|
|
43
|
+
"zod": "^3.25.0"
|
|
38
44
|
},
|
|
39
45
|
"devDependencies": {
|
|
40
46
|
"@types/node": "^24.13.2",
|
|
47
|
+
"@types/pngjs": "^6.0.5",
|
|
41
48
|
"tsx": "^4.19.0",
|
|
42
49
|
"typescript": "^5.9.0"
|
|
43
50
|
}
|