thaifi-wallet-cli 0.1.3 → 0.2.1

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)");
@@ -95,7 +95,7 @@ export async function login(options = {}) {
95
95
  Number(status.limitAmount ?? 0) / 10 ** 6 +
96
96
  " pathUSD / " +
97
97
  Math.round((status.limitPeriod ?? 0) / 86400) +
98
- " days");
98
+ " days (THCFI/THCOC limits set at approval)");
99
99
  console.log(" Key expires: " + (status.expiry ? new Date(status.expiry * 1000).toISOString() : "never"));
100
100
  console.log("");
101
101
  console.log("Try: thaifi whoami");
@@ -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 + " (pathUSD cap " + Number(store.limitAmount ?? 0) / 10 ** 6 + ")");
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.1");
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: "ThaiCoin OpenCraft",
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.3",
3
+ "version": "0.2.1",
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": {