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 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 (pathUSD), fees paid from the user's balance.
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, balance, remaining spend limit |
43
- | `balance [--token <addr>]` | TIP-20 balance |
44
- | `transfer <to> <amount> [--token <addr>]` | Send tokens (limit enforced on-chain) |
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 pathUsdBalance(client, address) {
33
+ export async function tokenBalance(client, token, address) {
34
34
  return client.readContract({
35
- address: CONFIG.pathUsd,
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 (pathUSD, raw units). */
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: CONFIG.pathUsd,
54
+ token,
55
55
  });
56
56
  return res.remaining;
57
57
  }
@@ -1,7 +1,25 @@
1
- /** `thaifi balance [token]` — TIP-20 balance of the paired account. */
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 { CONFIG, PATHUSD_DECIMALS } from "../config.js";
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
- const token = (options.token ?? CONFIG.pathUsd);
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 [balance, symbol] = await Promise.all([
15
- client.readContract({
16
- address: token,
17
- abi: [
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: [store.userAddress],
28
- }),
29
- client
30
- .readContract({
31
- address: token,
32
- abi: [
33
- {
34
- name: "symbol",
35
- type: "function",
36
- stateMutability: "view",
37
- inputs: [],
38
- outputs: [{ type: "string" }],
39
- },
40
- ],
41
- functionName: "symbol",
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
  }
@@ -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 address");
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)");
@@ -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
- " pathUSD / " +
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"));
@@ -1,13 +1,124 @@
1
- /** `thaifi request <url>` — curl-like HTTP client with x402/MPP auto-payments. */
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 an x402/MPP payment challenge from a 402 response.
7
- * Supports the common shapes: JSON body `{ accepts: [...] }` (x402) and
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
- const header = res.headers.get("x-payment-required");
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
- raw: body,
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, "X-Payment": proof },
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
- console.log(JSON.stringify(JSON.parse(text), null, 2));
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, formatUnits } from "viem";
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 { CONFIG, PATHUSD_DECIMALS } from "../config.js";
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
- const token = (options.token ?? CONFIG.pathUsd);
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 " + parseUnits(amount, PATHUSD_DECIMALS) + ")");
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: parseUnits(amount, PATHUSD_DECIMALS),
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
  }
@@ -1,7 +1,8 @@
1
- /** `thaifi whoami` — account, agent key, balances and remaining spend limit. */
1
+ /** `thaifi whoami` — account, agent key, balances and remaining spend limits. */
2
2
  import { loadStore } from "../store.js";
3
- import { createAgentClient, pathUsdBalance, remainingLimit } from "../chain.js";
4
- const fmt = (raw) => (Number(raw) / 1e6).toLocaleString("en-US", { maximumFractionDigits: 6 });
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 [balance, remaining] = await Promise.all([
16
- pathUsdBalance(client, userAddress),
17
- remainingLimit(client, userAddress, keyId).catch(() => null),
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
- console.log("pathUSD: $" + fmt(balance));
22
- if (remaining !== null) {
23
- console.log("Limit left: $" + fmt(remaining) + " of $" + fmt(BigInt(store.limitAmount ?? "0")));
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
- else {
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.1.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 the TIP-20 token balance of the paired account")
33
- .option("--token <address>", "Token contract (default: pathUSD)")
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.1.2",
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": {