thaifi-wallet-cli 0.4.0 → 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/dist/chain.js +5 -5
- package/dist/commands/login.js +1 -1
- package/dist/commands/mcp.js +161 -0
- package/dist/config.js +2 -2
- package/dist/index.js +9 -4
- package/dist/paidFetch.js +256 -0
- package/dist/tokens.js +9 -5
- package/package.json +5 -2
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({
|
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/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,6 +9,7 @@ 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";
|
|
13
14
|
import { deposit } from "./commands/deposit.js";
|
|
14
15
|
import { tokens } from "./commands/tokens.js";
|
|
@@ -39,15 +40,15 @@ program
|
|
|
39
40
|
program
|
|
40
41
|
.command("balance")
|
|
41
42
|
.description("Show TIP-20 token balances of the paired account")
|
|
42
|
-
.option("--token <symbol|address>", "Token symbol (
|
|
43
|
+
.option("--token <symbol|address>", "Token symbol (pathTHB, THCFI, THCOC) or contract address (default: all)")
|
|
43
44
|
.action(balance);
|
|
44
45
|
program
|
|
45
46
|
.command("transfer")
|
|
46
47
|
.description("Transfer tokens signed by the agent access key (spend limit enforced on-chain)")
|
|
47
48
|
.argument("<to>", "Recipient address (0x…)")
|
|
48
49
|
.argument("<amount>", "Amount in human units (e.g. 1.5)")
|
|
49
|
-
.option("--token <symbol|address>", "Token symbol or contract address (default:
|
|
50
|
-
.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)")
|
|
51
52
|
.action(transfer);
|
|
52
53
|
program
|
|
53
54
|
.command("fund")
|
|
@@ -73,7 +74,7 @@ program
|
|
|
73
74
|
.option("--json <json>", "JSON body shorthand (sets method POST + content-type)")
|
|
74
75
|
.option("--dry-run", "Print the request without sending")
|
|
75
76
|
.option("--max-spend <usd>", "Maximum automatic payment (USD)", Number)
|
|
76
|
-
.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)")
|
|
77
78
|
.action(async (url, opts) => {
|
|
78
79
|
const headers = {};
|
|
79
80
|
for (const h of opts.header ?? []) {
|
|
@@ -101,4 +102,8 @@ program
|
|
|
101
102
|
.description("Discover paid services from the MPP/x402 registry")
|
|
102
103
|
.option("--search <query>", "Search services")
|
|
103
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);
|
|
104
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,13 @@
|
|
|
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",
|
|
38
40
|
"pngjs": "^7.0.0",
|
|
39
41
|
"qrcode-terminal": "^0.12.0",
|
|
40
|
-
"viem": "^2.56.0"
|
|
42
|
+
"viem": "^2.56.0",
|
|
43
|
+
"zod": "^3.25.0"
|
|
41
44
|
},
|
|
42
45
|
"devDependencies": {
|
|
43
46
|
"@types/node": "^24.13.2",
|