thaifi-wallet-cli 0.1.2 → 0.2.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 +10 -5
- package/dist/chain.js +5 -5
- package/dist/commands/balance.js +54 -35
- package/dist/commands/fund.js +2 -2
- package/dist/commands/login.js +1 -1
- package/dist/commands/request.js +163 -29
- package/dist/commands/tokens.js +9 -0
- package/dist/commands/transfer.js +30 -10
- package/dist/commands/whoami.js +23 -10
- package/dist/index.js +9 -4
- package/dist/tokens.js +39 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,13 +16,14 @@ wallet.thaifi.com/pair?id=…&code=… ← user approves (passkey / PIN)
|
|
|
16
16
|
│ 2. web sends authorizeKey(keyId, P256, expiry, limits) on-chain
|
|
17
17
|
▼
|
|
18
18
|
access key authorized on-chain → the CLI signs transactions directly,
|
|
19
|
-
spending up to the limit
|
|
19
|
+
spending up to the limit per token, fees (gas) paid in pathUSD.
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- Non-custodial: the private key never leaves the machine; the web wallet
|
|
23
23
|
never sees it.
|
|
24
24
|
- One wallet per account: revoking an app on the web cuts access immediately.
|
|
25
|
-
- Limits are on-chain: `SpendingLimitExceeded` when exceeded; keys expire.
|
|
25
|
+
- Limits are on-chain, per token: `SpendingLimitExceeded` when exceeded; keys expire.
|
|
26
|
+
- Supported tokens (TIP-20, 6 decimals): **pathUSD** (gas), **THCFI**, **THCOC**.
|
|
26
27
|
|
|
27
28
|
## Install / run
|
|
28
29
|
|
|
@@ -39,13 +40,17 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
|
|
|
39
40
|
| Command | Description |
|
|
40
41
|
|---|---|
|
|
41
42
|
| `login [--no-browser]` | Pair with the web wallet (approval URL for headless machines) |
|
|
42
|
-
| `whoami` | Account, agent key,
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
43
|
+
| `whoami` | Account, agent key, balances, remaining spend limits |
|
|
44
|
+
| `tokens` | List supported TIP-20 tokens (symbol, decimals, address) |
|
|
45
|
+
| `balance [--token <symbol\|addr>]` | Balances (default: all supported tokens) |
|
|
46
|
+
| `transfer <to> <amount> [--token <symbol\|addr>]` | Send tokens (default: pathUSD; limit enforced on-chain) |
|
|
45
47
|
| `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment |
|
|
46
48
|
| `services [--search q]` | Discover services from the MPP registry (`THAIFI_MPP_REGISTRY`) |
|
|
47
49
|
| `logout` | Remove the local key (revoke on-chain from the web wallet) |
|
|
48
50
|
|
|
51
|
+
`--token` accepts a symbol (case-insensitive) or a contract address; unknown
|
|
52
|
+
addresses are treated as custom TIP-20s (6 decimals).
|
|
53
|
+
|
|
49
54
|
## Security notes
|
|
50
55
|
|
|
51
56
|
- The recovery password / passphrase is never involved here — the agent key is
|
package/dist/chain.js
CHANGED
|
@@ -30,9 +30,9 @@ export function createAgentClient(store) {
|
|
|
30
30
|
transport: http(store.rpcUrl),
|
|
31
31
|
}).extend(tempoActions());
|
|
32
32
|
}
|
|
33
|
-
export async function
|
|
33
|
+
export async function tokenBalance(client, token, address) {
|
|
34
34
|
return client.readContract({
|
|
35
|
-
address:
|
|
35
|
+
address: token,
|
|
36
36
|
abi: [
|
|
37
37
|
{
|
|
38
38
|
name: "balanceOf",
|
|
@@ -46,12 +46,12 @@ export async function pathUsdBalance(client, address) {
|
|
|
46
46
|
args: [address],
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
/** Remaining spend allowance of the agent key (
|
|
50
|
-
export async function remainingLimit(client, userAddress, keyId) {
|
|
49
|
+
/** Remaining spend allowance of the agent key (raw units of `token`). */
|
|
50
|
+
export async function remainingLimit(client, userAddress, keyId, token = CONFIG.pathUsd) {
|
|
51
51
|
const res = await client.accessKey.getRemainingLimit({
|
|
52
52
|
account: userAddress,
|
|
53
53
|
accessKey: keyId,
|
|
54
|
-
token
|
|
54
|
+
token,
|
|
55
55
|
});
|
|
56
56
|
return res.remaining;
|
|
57
57
|
}
|
package/dist/commands/balance.js
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
/** `thaifi balance [token]` — TIP-20
|
|
1
|
+
/** `thaifi balance [--token <symbol|address>]` — TIP-20 balances of the paired account. */
|
|
2
2
|
import { loadStore } from "../store.js";
|
|
3
3
|
import { createAgentClient } from "../chain.js";
|
|
4
|
-
import {
|
|
4
|
+
import { TOKENS, resolveToken } from "../tokens.js";
|
|
5
|
+
const BALANCE_ABI = [
|
|
6
|
+
{
|
|
7
|
+
name: "balanceOf",
|
|
8
|
+
type: "function",
|
|
9
|
+
stateMutability: "view",
|
|
10
|
+
inputs: [{ type: "address" }],
|
|
11
|
+
outputs: [{ type: "uint256" }],
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
const SYMBOL_ABI = [
|
|
15
|
+
{
|
|
16
|
+
name: "symbol",
|
|
17
|
+
type: "function",
|
|
18
|
+
stateMutability: "view",
|
|
19
|
+
inputs: [],
|
|
20
|
+
outputs: [{ type: "string" }],
|
|
21
|
+
},
|
|
22
|
+
];
|
|
5
23
|
export async function balance(options = {}) {
|
|
6
24
|
const store = loadStore();
|
|
7
25
|
if (!store?.userAddress) {
|
|
@@ -9,40 +27,41 @@ export async function balance(options = {}) {
|
|
|
9
27
|
process.exitCode = 1;
|
|
10
28
|
return;
|
|
11
29
|
}
|
|
12
|
-
|
|
30
|
+
let tokens;
|
|
31
|
+
if (options.token) {
|
|
32
|
+
try {
|
|
33
|
+
tokens = [resolveToken(options.token)];
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
37
|
+
process.exitCode = 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
tokens = TOKENS;
|
|
43
|
+
}
|
|
13
44
|
const client = createAgentClient(store);
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
name: "balanceOf",
|
|
20
|
-
type: "function",
|
|
21
|
-
stateMutability: "view",
|
|
22
|
-
inputs: [{ type: "address" }],
|
|
23
|
-
outputs: [{ type: "uint256" }],
|
|
24
|
-
},
|
|
25
|
-
],
|
|
45
|
+
const userAddress = store.userAddress;
|
|
46
|
+
const rows = await Promise.all(tokens.map(async (t) => {
|
|
47
|
+
const raw = await client.readContract({
|
|
48
|
+
address: t.address,
|
|
49
|
+
abi: BALANCE_ABI,
|
|
26
50
|
functionName: "balanceOf",
|
|
27
|
-
args: [
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
})
|
|
43
|
-
.catch(() => "TOKEN"),
|
|
44
|
-
]);
|
|
45
|
-
const value = Number(balance) / 10 ** PATHUSD_DECIMALS;
|
|
46
|
-
console.log(symbol + " " + value.toLocaleString("en-US", { maximumFractionDigits: 6 }));
|
|
51
|
+
args: [userAddress],
|
|
52
|
+
});
|
|
53
|
+
// Registry tokens already know their symbol; custom ones read it from the chain.
|
|
54
|
+
const symbol = t.symbol === "TOKEN"
|
|
55
|
+
? await client
|
|
56
|
+
.readContract({ address: t.address, abi: SYMBOL_ABI, functionName: "symbol" })
|
|
57
|
+
.catch(() => "TOKEN")
|
|
58
|
+
: t.symbol;
|
|
59
|
+
const value = Number(raw) / 10 ** t.decimals;
|
|
60
|
+
return { symbol, value, token: t };
|
|
61
|
+
}));
|
|
62
|
+
const width = Math.max(...rows.map((r) => r.symbol.length));
|
|
63
|
+
for (const r of rows) {
|
|
64
|
+
console.log(r.symbol.padEnd(width) + " " + r.value.toLocaleString("en-US", { maximumFractionDigits: r.token.decimals }));
|
|
65
|
+
}
|
|
47
66
|
console.log("(" + store.userAddress + ")");
|
|
48
67
|
}
|
package/dist/commands/fund.js
CHANGED
|
@@ -19,8 +19,8 @@ export async function fund(options = {}) {
|
|
|
19
19
|
console.log("Deposit address (your wallet):");
|
|
20
20
|
console.log(" " + store.userAddress);
|
|
21
21
|
console.log("");
|
|
22
|
-
console.log("Send pathUSD (or any ThaiFi TIP-20 token) to this
|
|
23
|
-
console.log("from any ThaiFi wallet.");
|
|
22
|
+
console.log("Send pathUSD, THCFI or THCOC (or any ThaiFi TIP-20 token) to this");
|
|
23
|
+
console.log("address from any ThaiFi wallet. Keep some pathUSD — gas is paid in it.");
|
|
24
24
|
console.log("");
|
|
25
25
|
console.log("Deposit page: " + store.walletUrl + "/deposit");
|
|
26
26
|
console.log("(Bridge USDC from other chains — coming soon)");
|
package/dist/commands/login.js
CHANGED
|
@@ -93,7 +93,7 @@ export async function login(options = {}) {
|
|
|
93
93
|
console.log(" Wallet account: " + status.userAddress);
|
|
94
94
|
console.log(" Spend limit: " +
|
|
95
95
|
Number(status.limitAmount ?? 0) / 10 ** 6 +
|
|
96
|
-
"
|
|
96
|
+
" per token / " +
|
|
97
97
|
Math.round((status.limitPeriod ?? 0) / 86400) +
|
|
98
98
|
" days");
|
|
99
99
|
console.log(" Key expires: " + (status.expiry ? new Date(status.expiry * 1000).toISOString() : "never"));
|
package/dist/commands/request.js
CHANGED
|
@@ -1,13 +1,124 @@
|
|
|
1
|
-
/** `thaifi request <url>` — curl-like HTTP client with x402
|
|
1
|
+
/** `thaifi request <url>` — curl-like HTTP client with MPP/x402 auto-payments. */
|
|
2
|
+
import { concatHex, keccak256, slice, stringToBytes } from "viem";
|
|
2
3
|
import { loadStore } from "../store.js";
|
|
3
4
|
import { createAgentClient } from "../chain.js";
|
|
4
5
|
import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
|
|
6
|
+
function b64uDecode(s) {
|
|
7
|
+
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
8
|
+
}
|
|
9
|
+
function b64uEncode(s) {
|
|
10
|
+
return Buffer.from(s, "utf8")
|
|
11
|
+
.toString("base64")
|
|
12
|
+
.replace(/\+/g, "-")
|
|
13
|
+
.replace(/\//g, "_")
|
|
14
|
+
.replace(/=+$/, "");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse an MPP `WWW-Authenticate: Payment id="…", request="<b64u json>"` header.
|
|
18
|
+
* See https://mpp.dev — Machine Payments Protocol.
|
|
19
|
+
*/
|
|
20
|
+
export function parseMppChallenge(header) {
|
|
21
|
+
const m = /^Payment\s+(.+)$/i.exec(header.trim());
|
|
22
|
+
if (!m)
|
|
23
|
+
return null;
|
|
24
|
+
const params = {};
|
|
25
|
+
const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
|
|
26
|
+
let pm;
|
|
27
|
+
while ((pm = re.exec(m[1]))) {
|
|
28
|
+
const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
|
|
29
|
+
params[pm[1].toLowerCase()] = value;
|
|
30
|
+
}
|
|
31
|
+
if (!params.id || !params.request || !params.method)
|
|
32
|
+
return null;
|
|
33
|
+
let request;
|
|
34
|
+
try {
|
|
35
|
+
request = JSON.parse(b64uDecode(params.request));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const challenge = {
|
|
41
|
+
id: params.id,
|
|
42
|
+
realm: params.realm ?? "",
|
|
43
|
+
method: params.method,
|
|
44
|
+
intent: params.intent ?? "charge",
|
|
45
|
+
request,
|
|
46
|
+
requestRaw: params.request,
|
|
47
|
+
};
|
|
48
|
+
if (params.opaque !== undefined)
|
|
49
|
+
challenge.opaque = params.opaque;
|
|
50
|
+
if (params.expires !== undefined)
|
|
51
|
+
challenge.expires = params.expires;
|
|
52
|
+
return challenge;
|
|
53
|
+
}
|
|
54
|
+
/** MPP tempo charge → on-chain TIP-20 transfer (push mode: we broadcast, then send the hash). */
|
|
55
|
+
function mppTempoChallenge(challenge) {
|
|
56
|
+
const req = challenge.request;
|
|
57
|
+
if (!req.amount || !req.recipient)
|
|
58
|
+
return null;
|
|
59
|
+
const amount = BigInt(req.amount);
|
|
60
|
+
// mppx omits `decimals` for pathUSD-denominated challenges.
|
|
61
|
+
const decimals = req.decimals ?? 6;
|
|
62
|
+
return {
|
|
63
|
+
payTo: req.recipient,
|
|
64
|
+
amount,
|
|
65
|
+
token: req.currency,
|
|
66
|
+
memo: req.memo ?? attributionMemo(challenge.realm, challenge.id),
|
|
67
|
+
decimals,
|
|
68
|
+
proofHeader: (txHash) => {
|
|
69
|
+
// Wire format: challenge.request is the base64url string, not a nested object.
|
|
70
|
+
const credential = {
|
|
71
|
+
challenge: {
|
|
72
|
+
id: challenge.id,
|
|
73
|
+
realm: challenge.realm,
|
|
74
|
+
method: challenge.method,
|
|
75
|
+
intent: challenge.intent,
|
|
76
|
+
request: challenge.requestRaw,
|
|
77
|
+
...(challenge.opaque !== undefined && { opaque: challenge.opaque }),
|
|
78
|
+
...(challenge.expires !== undefined && { expires: challenge.expires }),
|
|
79
|
+
},
|
|
80
|
+
payload: { type: "hash", hash: txHash },
|
|
81
|
+
source: `did:pkh:eip155:${CONFIG.chainId}:${storeAddress()}`,
|
|
82
|
+
};
|
|
83
|
+
return "Payment " + b64uEncode(JSON.stringify(credential));
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function storeAddress() {
|
|
88
|
+
const store = loadStore();
|
|
89
|
+
return store?.userAddress ?? "0x0";
|
|
90
|
+
}
|
|
91
|
+
const fingerprint = (value) => slice(keccak256(stringToBytes(value)), 0, 10);
|
|
92
|
+
/**
|
|
93
|
+
* MPP attribution memo (bytes32) per mppx Attribution encoding:
|
|
94
|
+
* tag = keccak256("mpp")[0..4], version 0x01, serverId = realm fingerprint,
|
|
95
|
+
* clientId = CLI fingerprint, nonce = keccak256(challengeId)[0..7 bytes].
|
|
96
|
+
* Required by servers that don't pre-assign a memo to the challenge.
|
|
97
|
+
*/
|
|
98
|
+
function attributionMemo(realm, challengeId) {
|
|
99
|
+
const tag = slice(keccak256(stringToBytes("mpp")), 0, 4);
|
|
100
|
+
return concatHex([
|
|
101
|
+
tag,
|
|
102
|
+
"0x01",
|
|
103
|
+
fingerprint(realm),
|
|
104
|
+
fingerprint("thaifi-wallet-cli"),
|
|
105
|
+
slice(keccak256(stringToBytes(challengeId)), 0, 7),
|
|
106
|
+
]);
|
|
107
|
+
}
|
|
5
108
|
/**
|
|
6
|
-
* Extract
|
|
7
|
-
*
|
|
8
|
-
* `payTo`/`amount` fields; falls back to the `X-Payment-Required` header.
|
|
109
|
+
* Extract a payment challenge from a 402 response.
|
|
110
|
+
* MPP (`WWW-Authenticate: Payment …`) first; legacy x402 JSON body as fallback.
|
|
9
111
|
*/
|
|
10
112
|
async function parseChallenge(res, defaultToken) {
|
|
113
|
+
const wwwAuth = res.headers.get("www-authenticate");
|
|
114
|
+
if (wwwAuth) {
|
|
115
|
+
const mpp = parseMppChallenge(wwwAuth);
|
|
116
|
+
if (mpp && mpp.method === "tempo" && mpp.intent === "charge") {
|
|
117
|
+
const parsed = mppTempoChallenge(mpp);
|
|
118
|
+
if (parsed)
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
11
122
|
let body = null;
|
|
12
123
|
try {
|
|
13
124
|
body = await res.json();
|
|
@@ -18,18 +129,8 @@ async function parseChallenge(res, defaultToken) {
|
|
|
18
129
|
const accept = body?.accepts?.[0] ?? body?.payments?.[0] ?? body;
|
|
19
130
|
const payTo = accept?.payTo ?? accept?.payToAddress ?? accept?.recipient;
|
|
20
131
|
const maxAmount = accept?.maxAmountRequired ?? accept?.amount ?? accept?.price;
|
|
21
|
-
if (!payTo || maxAmount === undefined)
|
|
22
|
-
|
|
23
|
-
if (!header)
|
|
24
|
-
return null;
|
|
25
|
-
try {
|
|
26
|
-
const h = JSON.parse(Buffer.from(header, "base64").toString());
|
|
27
|
-
return parseChallenge(new Response(JSON.stringify(h), { status: 200 }), defaultToken);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
132
|
+
if (!payTo || maxAmount === undefined)
|
|
133
|
+
return null;
|
|
33
134
|
const amount = typeof maxAmount === "string" && /^\d+$/.test(maxAmount)
|
|
34
135
|
? BigInt(maxAmount)
|
|
35
136
|
: BigInt(Math.round(Number(maxAmount) * 10 ** PATHUSD_DECIMALS));
|
|
@@ -37,9 +138,38 @@ async function parseChallenge(res, defaultToken) {
|
|
|
37
138
|
payTo: payTo,
|
|
38
139
|
amount,
|
|
39
140
|
token: accept?.asset ?? accept?.token ?? defaultToken,
|
|
40
|
-
|
|
141
|
+
decimals: PATHUSD_DECIMALS,
|
|
142
|
+
proofHeader: (txHash) => Buffer.from(JSON.stringify({
|
|
143
|
+
scheme: "exact",
|
|
144
|
+
txHash,
|
|
145
|
+
from: storeAddress(),
|
|
146
|
+
to: payTo,
|
|
147
|
+
amount: "0x" + amount.toString(16),
|
|
148
|
+
token: accept?.asset ?? accept?.token ?? defaultToken,
|
|
149
|
+
})).toString("base64"),
|
|
41
150
|
};
|
|
42
151
|
}
|
|
152
|
+
/** If the JSON response carries a generated image, save it and print a summary. */
|
|
153
|
+
async function maybeSaveImage(text) {
|
|
154
|
+
let body;
|
|
155
|
+
try {
|
|
156
|
+
body = JSON.parse(text);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const b64 = body?.image_base64 ?? body?.data?.image_base64;
|
|
162
|
+
if (typeof b64 !== "string" || b64.length < 1000)
|
|
163
|
+
return null;
|
|
164
|
+
const id = (body?.id ?? body?.data?.id ?? "image").toString().replace(/[^\w-]/g, "");
|
|
165
|
+
const mime = (body?.mime_type ?? body?.data?.mime_type ?? "image/png").toString();
|
|
166
|
+
const ext = mime.includes("jpeg") ? "jpg" : mime.includes("webp") ? "webp" : "png";
|
|
167
|
+
const file = `iapp-${id}.${ext}`;
|
|
168
|
+
const { writeFileSync } = await import("node:fs");
|
|
169
|
+
writeFileSync(file, Buffer.from(b64, "base64"));
|
|
170
|
+
const { width, height, time } = body;
|
|
171
|
+
return `${file} (${mime}${width ? `, ${width}x${height}` : ""}${time?.total ? `, ${time.total}s` : ""})`;
|
|
172
|
+
}
|
|
43
173
|
export async function request(url, options) {
|
|
44
174
|
const store = loadStore();
|
|
45
175
|
if (!store?.userAddress) {
|
|
@@ -69,7 +199,7 @@ export async function request(url, options) {
|
|
|
69
199
|
process.exitCode = 1;
|
|
70
200
|
return;
|
|
71
201
|
}
|
|
72
|
-
const usd = Number(challenge.amount) / 10 ** PATHUSD_DECIMALS;
|
|
202
|
+
const usd = Number(challenge.amount) / 10 ** (challenge.decimals ?? PATHUSD_DECIMALS);
|
|
73
203
|
if (options.maxSpend !== undefined && usd > options.maxSpend) {
|
|
74
204
|
console.error("Payment of $" + usd + " exceeds --max-spend $" + options.maxSpend + " — aborting.");
|
|
75
205
|
process.exitCode = 1;
|
|
@@ -82,6 +212,7 @@ export async function request(url, options) {
|
|
|
82
212
|
to: challenge.payTo,
|
|
83
213
|
amount: challenge.amount,
|
|
84
214
|
token,
|
|
215
|
+
...(challenge.memo ? { memo: challenge.memo } : {}),
|
|
85
216
|
});
|
|
86
217
|
const receipt = await client.waitForTransactionReceipt({ hash });
|
|
87
218
|
if (receipt.status !== "success") {
|
|
@@ -90,17 +221,9 @@ export async function request(url, options) {
|
|
|
90
221
|
return;
|
|
91
222
|
}
|
|
92
223
|
console.log("Paid. Tx: " + hash);
|
|
93
|
-
const proof = Buffer.from(JSON.stringify({
|
|
94
|
-
scheme: "exact",
|
|
95
|
-
txHash: hash,
|
|
96
|
-
from: store.userAddress,
|
|
97
|
-
to: challenge.payTo,
|
|
98
|
-
amount: "0x" + challenge.amount.toString(16),
|
|
99
|
-
token,
|
|
100
|
-
})).toString("base64");
|
|
101
224
|
res = await fetch(url, {
|
|
102
225
|
method: options.method,
|
|
103
|
-
headers: { ...options.headers,
|
|
226
|
+
headers: { ...options.headers, Authorization: challenge.proofHeader(hash) },
|
|
104
227
|
body: options.body,
|
|
105
228
|
});
|
|
106
229
|
}
|
|
@@ -109,10 +232,21 @@ export async function request(url, options) {
|
|
|
109
232
|
const text = await res.text();
|
|
110
233
|
if (contentType.includes("json")) {
|
|
111
234
|
try {
|
|
112
|
-
|
|
235
|
+
const saved = await maybeSaveImage(text);
|
|
236
|
+
if (saved) {
|
|
237
|
+
console.log("Saved image → " + saved);
|
|
238
|
+
const body = JSON.parse(text);
|
|
239
|
+
for (const k of ["status", "id", "width", "height", "mime_type", "font"]) {
|
|
240
|
+
if (body[k] !== undefined)
|
|
241
|
+
console.log(" " + k + ": " + body[k]);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
|
246
|
+
}
|
|
113
247
|
}
|
|
114
248
|
catch {
|
|
115
|
-
console.log(text);
|
|
249
|
+
console.log(text.slice(0, 4000));
|
|
116
250
|
}
|
|
117
251
|
}
|
|
118
252
|
else {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** `thaifi tokens` — list TIP-20 tokens supported by the CLI. */
|
|
2
|
+
import { TOKENS } from "../tokens.js";
|
|
3
|
+
export async function tokens() {
|
|
4
|
+
const width = Math.max(...TOKENS.map((t) => t.symbol.length));
|
|
5
|
+
console.log("SYMBOL".padEnd(width) + " DECIMALS ADDRESS");
|
|
6
|
+
for (const t of TOKENS) {
|
|
7
|
+
console.log(t.symbol.padEnd(width) + " " + String(t.decimals).padEnd(8) + " " + t.address);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** `thaifi transfer <to> <amount> [token]` — TIP-20 transfer signed by the agent access key. */
|
|
2
|
-
import { parseUnits
|
|
1
|
+
/** `thaifi transfer <to> <amount> [--token <symbol|address>]` — TIP-20 transfer signed by the agent access key. */
|
|
2
|
+
import { parseUnits } from "viem";
|
|
3
3
|
import { loadStore } from "../store.js";
|
|
4
|
-
import { createAgentClient } from "../chain.js";
|
|
5
|
-
import {
|
|
4
|
+
import { createAgentClient, tokenBalance } from "../chain.js";
|
|
5
|
+
import { PATHUSD, resolveToken } from "../tokens.js";
|
|
6
6
|
export async function transfer(to, amount, options = {}) {
|
|
7
7
|
const store = loadStore();
|
|
8
8
|
if (!store?.userAddress) {
|
|
@@ -15,24 +15,44 @@ export async function transfer(to, amount, options = {}) {
|
|
|
15
15
|
process.exitCode = 1;
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
let token;
|
|
19
|
+
try {
|
|
20
|
+
token = resolveToken(options.token ?? PATHUSD.symbol);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
19
27
|
const client = createAgentClient(store);
|
|
28
|
+
const userAddress = store.userAddress;
|
|
29
|
+
const rawAmount = parseUnits(amount, token.decimals);
|
|
30
|
+
// Gas is always paid in pathUSD — a non-pathUSD transfer with an empty
|
|
31
|
+
// pathUSD balance can never land.
|
|
32
|
+
if (token.address.toLowerCase() !== PATHUSD.address.toLowerCase()) {
|
|
33
|
+
const gasBalance = await tokenBalance(client, PATHUSD.address, userAddress).catch(() => null);
|
|
34
|
+
if (gasBalance !== null && gasBalance <= 0n) {
|
|
35
|
+
console.error(`✗ pathUSD balance is 0 — gas is paid in pathUSD, so this ${token.symbol} transfer would fail. Receive some pathUSD first.`);
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
console.log("Token: " + token.symbol + " (" + token.address + ")");
|
|
20
41
|
console.log("To: " + to);
|
|
21
|
-
console.log("Amount: " + amount + " (raw " +
|
|
42
|
+
console.log("Amount: " + amount + " (raw " + rawAmount + ")");
|
|
22
43
|
// Signed locally by the agent access key; the on-chain spending limit is
|
|
23
44
|
// enforced by the AccountKeychain precompile (SpendingLimitExceeded on overflow).
|
|
24
45
|
const hash = await client.token.transfer({
|
|
25
46
|
to: to,
|
|
26
|
-
amount:
|
|
27
|
-
token,
|
|
47
|
+
amount: rawAmount,
|
|
48
|
+
token: token.address,
|
|
28
49
|
});
|
|
29
50
|
console.log("Tx: " + hash);
|
|
30
51
|
console.log("Waiting for receipt…");
|
|
31
52
|
const receipt = await client.waitForTransactionReceipt({ hash });
|
|
32
53
|
console.log(receipt.status === "success"
|
|
33
|
-
? "✓ Transferred " + amount + " (gas " + receipt.gasUsed + ")"
|
|
54
|
+
? "✓ Transferred " + amount + " " + token.symbol + " (gas " + receipt.gasUsed + ")"
|
|
34
55
|
: "✗ Transaction reverted (status " + receipt.status + ")");
|
|
35
56
|
if (receipt.status !== "success")
|
|
36
57
|
process.exitCode = 1;
|
|
37
|
-
void formatUnits;
|
|
38
58
|
}
|
package/dist/commands/whoami.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** `thaifi whoami` — account, agent key, balances and remaining spend
|
|
1
|
+
/** `thaifi whoami` — account, agent key, balances and remaining spend limits. */
|
|
2
2
|
import { loadStore } from "../store.js";
|
|
3
|
-
import { createAgentClient,
|
|
4
|
-
|
|
3
|
+
import { createAgentClient, tokenBalance, remainingLimit } from "../chain.js";
|
|
4
|
+
import { TOKENS, PATHUSD } from "../tokens.js";
|
|
5
|
+
const fmt = (raw, decimals = 6) => (Number(raw) / 10 ** decimals).toLocaleString("en-US", { maximumFractionDigits: decimals });
|
|
5
6
|
export async function whoami() {
|
|
6
7
|
const store = loadStore();
|
|
7
8
|
if (!store?.userAddress) {
|
|
@@ -12,19 +13,31 @@ export async function whoami() {
|
|
|
12
13
|
const client = createAgentClient(store);
|
|
13
14
|
const userAddress = store.userAddress;
|
|
14
15
|
const keyId = store.keyId;
|
|
15
|
-
const [
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const [balances, remaining] = await Promise.all([
|
|
17
|
+
Promise.all(TOKENS.map((t) => tokenBalance(client, t.address, userAddress)
|
|
18
|
+
.then((raw) => [t.symbol, raw])
|
|
19
|
+
.catch(() => [t.symbol, null]))),
|
|
20
|
+
Promise.all(TOKENS.map((t) => remainingLimit(client, userAddress, keyId, t.address)
|
|
21
|
+
.then((raw) => [t.symbol, raw])
|
|
22
|
+
.catch(() => [t.symbol, null]))),
|
|
18
23
|
]);
|
|
19
24
|
console.log("Account: " + userAddress);
|
|
20
25
|
console.log("Agent key: " + keyId);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const width = Math.max(...TOKENS.map((t) => t.symbol.length));
|
|
27
|
+
for (const [symbol, raw] of balances) {
|
|
28
|
+
const prefix = symbol === PATHUSD.symbol ? "$" : "";
|
|
29
|
+
console.log((symbol + ":").padEnd(width + 1) + " " + (raw === null ? "(read failed)" : prefix + fmt(raw)));
|
|
24
30
|
}
|
|
25
|
-
|
|
31
|
+
if (remaining.every(([, raw]) => raw === null)) {
|
|
26
32
|
console.log("Limit left: (unavailable — key may have been revoked)");
|
|
27
33
|
}
|
|
34
|
+
else {
|
|
35
|
+
const parts = remaining
|
|
36
|
+
.filter(([, raw]) => raw !== null)
|
|
37
|
+
.map(([symbol, raw]) => `${symbol} ${fmt(raw)}`)
|
|
38
|
+
.join(", ");
|
|
39
|
+
console.log("Limit left: " + parts + " (of " + Number(store.limitAmount ?? 0) / 10 ** 6 + " per token)");
|
|
40
|
+
}
|
|
28
41
|
console.log("Key expires: " + (store.keyExpiry ? new Date(store.keyExpiry * 1000).toISOString() : "never"));
|
|
29
42
|
console.log("Wallet: " + store.walletUrl);
|
|
30
43
|
}
|
package/dist/index.js
CHANGED
|
@@ -9,11 +9,12 @@ import { transfer } from "./commands/transfer.js";
|
|
|
9
9
|
import { request } from "./commands/request.js";
|
|
10
10
|
import { services } from "./commands/services.js";
|
|
11
11
|
import { fund } from "./commands/fund.js";
|
|
12
|
+
import { tokens } from "./commands/tokens.js";
|
|
12
13
|
const program = new Command();
|
|
13
14
|
program
|
|
14
15
|
.name("thaifi")
|
|
15
16
|
.description("ThaiFi Wallet CLI — pair with wallet.thaifi.com and sign with on-chain access keys")
|
|
16
|
-
.version("0.
|
|
17
|
+
.version("0.2.0");
|
|
17
18
|
program
|
|
18
19
|
.command("login")
|
|
19
20
|
.description("Pair this CLI with your ThaiFi Wallet (opens the browser to approve)")
|
|
@@ -27,17 +28,21 @@ program
|
|
|
27
28
|
.command("whoami")
|
|
28
29
|
.description("Show the paired account, agent key, balances and remaining spend limit")
|
|
29
30
|
.action(whoami);
|
|
31
|
+
program
|
|
32
|
+
.command("tokens")
|
|
33
|
+
.description("List supported TIP-20 tokens (symbol, decimals, address)")
|
|
34
|
+
.action(tokens);
|
|
30
35
|
program
|
|
31
36
|
.command("balance")
|
|
32
|
-
.description("Show
|
|
33
|
-
.option("--token <address>", "Token contract (default:
|
|
37
|
+
.description("Show TIP-20 token balances of the paired account")
|
|
38
|
+
.option("--token <symbol|address>", "Token symbol (pathUSD, THCFI, THCOC) or contract address (default: all)")
|
|
34
39
|
.action(balance);
|
|
35
40
|
program
|
|
36
41
|
.command("transfer")
|
|
37
42
|
.description("Transfer tokens signed by the agent access key (spend limit enforced on-chain)")
|
|
38
43
|
.argument("<to>", "Recipient address (0x…)")
|
|
39
44
|
.argument("<amount>", "Amount in human units (e.g. 1.5)")
|
|
40
|
-
.option("--token <address>", "Token contract (default: pathUSD)")
|
|
45
|
+
.option("--token <symbol|address>", "Token symbol or contract address (default: pathUSD)")
|
|
41
46
|
.action(transfer);
|
|
42
47
|
program
|
|
43
48
|
.command("fund")
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** TIP-20 tokens surfaced by the CLI — all ThaiFi TIP-20s use 6 decimals. */
|
|
2
|
+
import { CONFIG } from "./config.js";
|
|
3
|
+
export const PATHUSD = {
|
|
4
|
+
address: CONFIG.pathUsd,
|
|
5
|
+
symbol: "pathUSD",
|
|
6
|
+
name: "pathUSD",
|
|
7
|
+
decimals: 6,
|
|
8
|
+
};
|
|
9
|
+
export const THCFI = {
|
|
10
|
+
address: "0x185cd24d61D400d4A22469B311B3915d04e10DCF",
|
|
11
|
+
symbol: "THCFI",
|
|
12
|
+
name: "ThaiCoin ThaiFi",
|
|
13
|
+
decimals: 6,
|
|
14
|
+
};
|
|
15
|
+
export const THCOC = {
|
|
16
|
+
address: "0x5c4B54cf00Df2be0169E05DfAF34eE754cace9a4",
|
|
17
|
+
symbol: "THCOC",
|
|
18
|
+
name: "THCOC",
|
|
19
|
+
decimals: 6,
|
|
20
|
+
};
|
|
21
|
+
/** Tokens listed by `thaifi tokens` / shown by default in `thaifi balance`. */
|
|
22
|
+
export const TOKENS = [PATHUSD, THCFI, THCOC];
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a token by symbol (case-insensitive) or 0x address. Addresses
|
|
25
|
+
* outside the registry are treated as custom TIP-20s (6 decimals); the
|
|
26
|
+
* symbol is read from the chain by the caller.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveToken(arg) {
|
|
29
|
+
const bySymbol = TOKENS.find((t) => t.symbol.toLowerCase() === arg.toLowerCase());
|
|
30
|
+
if (bySymbol)
|
|
31
|
+
return bySymbol;
|
|
32
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(arg)) {
|
|
33
|
+
const known = TOKENS.find((t) => t.address.toLowerCase() === arg.toLowerCase());
|
|
34
|
+
if (known)
|
|
35
|
+
return known;
|
|
36
|
+
return { address: arg, symbol: "TOKEN", name: "Custom TIP-20", decimals: 6 };
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Unknown token "${arg}" — use a symbol (pathUSD, THCFI, THCOC) or a 0x contract address.`);
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thaifi-wallet-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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": {
|