thaifi-wallet-cli 0.3.5 → 0.4.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
@@ -44,6 +44,8 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
44
44
  | `tokens` | List supported TIP-20 tokens (symbol, decimals, address) |
45
45
  | `balance [--token <symbol\|addr>]` | Balances (default: all supported tokens) |
46
46
  | `transfer <to> <amount> [--token <symbol\|addr>]` | Send tokens (default: pathUSD; limit enforced on-chain) |
47
+ | `fund [--no-browser]` | Show the deposit address and open the web Deposit page |
48
+ | `deposit --amount <THB> [--token THCFI\|THCOC] [--ref <id>] [--no-open] [--json]` | **PromptPay QR top-up (1 THB = 1 token)**: order a QR, show it for a human to scan, poll until the tokens are minted |
47
49
  | `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment **and ThaiFiAgent identity auth (401/403 answered by signing the challenge)** |
48
50
  | `services [--search q]` | Discover services from the MPP registry (`THAIFI_MPP_REGISTRY`) |
49
51
  | `logout` | Remove the local key (revoke on-chain from the web wallet) |
@@ -51,6 +53,38 @@ Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
51
53
  `--token` accepts a symbol (case-insensitive) or a contract address; unknown
52
54
  addresses are treated as custom TIP-20s (6 decimals).
53
55
 
56
+ ## Deposit (PromptPay top-up)
57
+
58
+ `thaifi deposit --amount 50` orders a PromptPay QR for the paired wallet and
59
+ waits until the tokens are minted (1 THB = 1 token). There is **no API key**:
60
+ the backend verifies on-chain (AccountKeychain precompile, TIP-1011) that the
61
+ agent's access key belongs to a wallet and credits THAT wallet — the recipient
62
+ never comes from the CLI.
63
+
64
+ ```
65
+ $ thaifi deposit --amount 50
66
+ PromptPay deposit — 50.00 THB -> THCFI (1 THB = 1 token)
67
+ Credited wallet: 0x…
68
+ <terminal QR block>
69
+ QR_FILE=~/.thaifi/qr-<referenceNo>.png
70
+ REFERENCE=<referenceNo>
71
+ AMOUNT=50.00 THB -> THCFI
72
+ RESUME=thaifi deposit --ref <referenceNo>
73
+ STATUS=pending (scan the QR to pay)
74
+ STATUS=delivered
75
+ TX=0x… Explorer: https://exp.thaifi.com/tx/0x…
76
+ ```
77
+
78
+ - The QR is saved to `~/.thaifi/qr-<referenceNo>.png` (chmod 600), rendered as
79
+ terminal blocks, and opened in the default viewer (disable with `--no-open`).
80
+ - Orders expire after 15 minutes; paying late still delivers. Resume watching
81
+ any order with `--ref <referenceNo>` — the status response includes the QR
82
+ again, so a restarted CLI can re-display it.
83
+ - Limits: 6–10,000 THB per order, 50,000 THB per wallet per 24h, 5 pending
84
+ orders per wallet. `--json` prints machine-readable output.
85
+ - Requires an approved pairing (`thaifi login`); a revoked/expired key fails
86
+ with 401 on-chain verification.
87
+
54
88
  ## Security notes
55
89
 
56
90
  - The recovery password / passphrase is never involved here — the agent key is
package/dist/agentAuth.js CHANGED
@@ -18,6 +18,31 @@ const ZERO_BODY_HASH = `0x${"00".repeat(32)}`;
18
18
  function b64uDecodeBytes(value) {
19
19
  return new Uint8Array(Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"));
20
20
  }
21
+ /** base64url-encode (utf8) — used for the ThaiFiAgent credential header. */
22
+ export function b64uEncode(s) {
23
+ return Buffer.from(s, "utf8")
24
+ .toString("base64")
25
+ .replace(/\+/g, "-")
26
+ .replace(/\//g, "_")
27
+ .replace(/=+$/, "");
28
+ }
29
+ /** Parse a `WWW-Authenticate: ThaiFiAgent realm="…", challenge="<b64u>"`
30
+ * header — ThaiFi Agent Auth (identity proof, no payment). */
31
+ export function parseAgentChallenge(header) {
32
+ const m = /^ThaiFiAgent\s+(.+)$/i.exec(header.trim());
33
+ if (!m)
34
+ return null;
35
+ const params = {};
36
+ const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
37
+ let pm;
38
+ while ((pm = re.exec(m[1]))) {
39
+ const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
40
+ params[pm[1].toLowerCase()] = value;
41
+ }
42
+ if (!params.realm || !params.challenge)
43
+ return null;
44
+ return { realm: params.realm, nonce: params.challenge };
45
+ }
21
46
  export function agentAuthHash(params) {
22
47
  const bodyHash = params.body && params.body.length > 0 ? sha256(stringToBytes(params.body)) : ZERO_BODY_HASH;
23
48
  return keccak256(concatHex([
@@ -0,0 +1,213 @@
1
+ /** `thaifi deposit` — PromptPay QR top-up (1 THB = 1 token).
2
+ *
3
+ * Creates a QR order on wallet.thaifi.com and waits until the tokens are
4
+ * minted. Auth is ThaiFi Agent Auth (no API key): the backend verifies
5
+ * on-chain (AccountKeychain precompile) that this agent's access key belongs
6
+ * to a wallet and credits THAT wallet — the CLI never sends a recipient.
7
+ */
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import png from "pngjs";
11
+ import * as jsqrModule from "jsqr";
12
+ import qrcodeTerminal from "qrcode-terminal";
13
+ import { loadStore, storePath } from "../store.js";
14
+ import { CONFIG } from "../config.js";
15
+ import { b64uEncode, buildAgentAuthCredential, parseAgentChallenge, } from "../agentAuth.js";
16
+ import { openBrowser } from "./fund.js";
17
+ const ORDER_PATH = "/api/agent/deposit/qr";
18
+ const MIN_THB = 6;
19
+ const MAX_THB = 10000;
20
+ /** Orders expire after 15 min, but a late payment still delivers — keep
21
+ * watching that long past expiry. */
22
+ const LATE_GRACE_MS = 15 * 60 * 1000;
23
+ const TERMINAL_STATUSES = new Set(["delivered", "cancelled", "capped", "qr_error"]);
24
+ const { PNG } = png;
25
+ // NodeNext misbinds jsqr's CJS `export default` to the whole module namespace
26
+ // when importing from ESM; at runtime the namespace's `.default` IS the
27
+ // function (verified) — pick it with an explicit cast.
28
+ const jsQR = jsqrModule.default;
29
+ const STATUS_HINTS = {
30
+ pending: "scan the QR to pay",
31
+ paid: "payment received — minting",
32
+ delivering: "minting tokens",
33
+ delivered: "tokens are in the wallet",
34
+ cancelled: "order was cancelled",
35
+ capped: "over the daily cap — needs operator review",
36
+ qr_error: "QR creation failed",
37
+ };
38
+ // ---------------------------------------------------------------- HTTP + TAA
39
+ /** fetch with automatic ThaiFi Agent Auth: on a 401/403 challenge, sign with
40
+ * the agent access key (identity proof, no payment) and retry once. The
41
+ * server verifies the key against the AccountKeychain precompile. */
42
+ async function agentFetch(store, url, init) {
43
+ const headers = {};
44
+ if (init.body !== undefined)
45
+ headers["Content-Type"] = "application/json";
46
+ let res = await fetch(url, { method: init.method, headers, body: init.body });
47
+ if (res.status === 401 || res.status === 403) {
48
+ const challenge = parseAgentChallenge(res.headers.get("www-authenticate") ?? "");
49
+ if (challenge) {
50
+ const credential = await buildAgentAuthCredential(store, challenge, {
51
+ method: init.method,
52
+ url,
53
+ body: init.body,
54
+ });
55
+ res = await fetch(url, {
56
+ method: init.method,
57
+ headers: {
58
+ ...headers,
59
+ Authorization: "ThaiFiAgent " + b64uEncode(JSON.stringify(credential)),
60
+ },
61
+ body: init.body,
62
+ });
63
+ }
64
+ }
65
+ return res;
66
+ }
67
+ // ------------------------------------------------------------------ QR output
68
+ /** Decode the payso QR PNG and re-render it as terminal blocks so a human
69
+ * watching the session can scan straight off the screen. Best-effort. */
70
+ function renderTerminalQr(dataUrl) {
71
+ try {
72
+ const b64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
73
+ const img = PNG.sync.read(Buffer.from(b64, "base64"));
74
+ const decoded = jsQR(new Uint8ClampedArray(img.data), img.width, img.height);
75
+ if (!decoded?.data)
76
+ return;
77
+ console.log("");
78
+ qrcodeTerminal.generate(decoded.data, { small: true });
79
+ console.log("");
80
+ }
81
+ catch {
82
+ // terminal rendering is a convenience — the saved PNG always works
83
+ }
84
+ }
85
+ /** Save the QR image to ~/.thaifi/qr-<referenceNo>.<ext> (chmod 600) so the
86
+ * agent has a durable path to show the user. Returns the path or null. */
87
+ function saveQrImage(dataUrl, referenceNo) {
88
+ try {
89
+ const m = /^data:image\/(png|jpe?g|webp);base64,(.+)$/s.exec(dataUrl);
90
+ if (!m)
91
+ return null;
92
+ const ext = m[1] === "jpeg" ? "jpg" : m[1];
93
+ const dir = dirname(storePath);
94
+ const file = join(dir, `qr-${referenceNo}.${ext}`);
95
+ mkdirSync(dir, { recursive: true });
96
+ writeFileSync(file, Buffer.from(m[2], "base64"), { mode: 0o600 });
97
+ return file;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ // --------------------------------------------------------------------- polling
104
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
105
+ /** Poll the order until a terminal status (or the late-payment grace runs
106
+ * out). Prints STATUS=/TX= lines — machine-parsable like `PAIR_URL=`. */
107
+ async function watchOrder(store, orderUrl, order, options) {
108
+ const statusUrl = `${orderUrl}/${order.referenceNo}`;
109
+ const deadline = (order.expiresAt || Date.now()) + LATE_GRACE_MS;
110
+ let last = "";
111
+ while (Date.now() < deadline) {
112
+ const res = await agentFetch(store, statusUrl, { method: "GET" });
113
+ const data = (await res.json().catch(() => null));
114
+ if (res.ok && data?.status && data.status !== last) {
115
+ last = data.status;
116
+ if (options.json) {
117
+ const { image, ...rest } = data;
118
+ console.log(JSON.stringify(rest, null, 2));
119
+ }
120
+ else {
121
+ const hint = STATUS_HINTS[data.status];
122
+ console.log(`STATUS=${data.status}${hint ? ` (${hint})` : ""}`);
123
+ }
124
+ if (data.status === "delivered" && data.txHash && !options.json) {
125
+ console.log(`TX=${data.txHash}`);
126
+ console.log(`Explorer: https://exp.thaifi.com/tx/${data.txHash}`);
127
+ }
128
+ if (TERMINAL_STATUSES.has(data.status)) {
129
+ if (data.status !== "delivered")
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ }
134
+ await sleep(CONFIG.pollIntervalMs);
135
+ }
136
+ console.error("Timed out waiting for payment. Payment after expiry still delivers — resume later with:");
137
+ console.error(` thaifi deposit --ref ${order.referenceNo}`);
138
+ process.exitCode = 1;
139
+ }
140
+ export async function deposit(options = {}) {
141
+ const store = loadStore();
142
+ if (!store?.userAddress) {
143
+ console.error("Not paired. Run `thaifi login` first.");
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ // CONFIG (env THAIFI_WALLET_URL > default) — same precedence as `login`,
148
+ // NOT store.walletUrl: a stale value would silently hit the wrong host.
149
+ const base = (process.env.THAIFI_WALLET_URL || CONFIG.walletUrl).replace(/\/+$/, "");
150
+ const orderUrl = base + ORDER_PATH;
151
+ // --ref: watch an existing order (e.g. after a restart) instead of creating one.
152
+ if (options.ref) {
153
+ const res = await agentFetch(store, `${orderUrl}/${options.ref.trim()}`, { method: "GET" });
154
+ const data = (await res.json().catch(() => null));
155
+ if (!res.ok || !data?.referenceNo) {
156
+ console.error(`${res.status}: ${data?.error ?? res.statusText}`);
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+ if (!options.json) {
161
+ console.log(`REFERENCE=${data.referenceNo}`);
162
+ console.log(`AMOUNT=${data.total} THB -> ${data.token ?? "?"}`);
163
+ }
164
+ await watchOrder(store, orderUrl, data, options);
165
+ return;
166
+ }
167
+ const token = (options.token ?? "THCFI").toUpperCase();
168
+ if (token !== "THCFI" && token !== "THCOC") {
169
+ console.error("--token must be THCFI or THCOC");
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ const amount = Number(options.amount);
174
+ if (!options.amount || !Number.isFinite(amount) || amount < MIN_THB || amount > MAX_THB) {
175
+ console.error(`--amount must be ${MIN_THB}-${MAX_THB} THB`);
176
+ process.exitCode = 1;
177
+ return;
178
+ }
179
+ const body = JSON.stringify({ amount, token });
180
+ const res = await agentFetch(store, orderUrl, { method: "POST", body });
181
+ const data = (await res.json().catch(() => null));
182
+ if (!res.ok || !data?.referenceNo) {
183
+ console.error(`${res.status}: ${data?.error ?? res.statusText}`);
184
+ if (data?.detail && typeof data.detail === "object") {
185
+ console.error(JSON.stringify(data.detail).slice(0, 400));
186
+ }
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ const file = data.image ? saveQrImage(data.image, data.referenceNo) : null;
191
+ if (options.json) {
192
+ const { image, ...rest } = data;
193
+ console.log(JSON.stringify({ ...rest, qrFile: file }, null, 2));
194
+ }
195
+ else {
196
+ console.log(`PromptPay deposit — ${data.total} THB -> ${token} (1 THB = 1 token)`);
197
+ console.log(`Credited wallet: ${store.userAddress}`);
198
+ if (data.image)
199
+ renderTerminalQr(data.image);
200
+ if (file)
201
+ console.log(`QR_FILE=${file}`);
202
+ console.log(`REFERENCE=${data.referenceNo}`);
203
+ console.log(`AMOUNT=${data.total} THB -> ${token}`);
204
+ console.log(`RESUME=thaifi deposit --ref ${data.referenceNo}`);
205
+ console.log("");
206
+ console.log("Scan the QR with any banking app — polling until the tokens are minted");
207
+ console.log("(Ctrl-C to stop; resume with the RESUME command above).");
208
+ }
209
+ if (file && options.open !== false) {
210
+ await openBrowser("file://" + file);
211
+ }
212
+ await watchOrder(store, orderUrl, data, options);
213
+ }
@@ -1,6 +1,6 @@
1
1
  /** `thaifi fund` — show the deposit address and open the web Deposit page. */
2
2
  import { loadStore } from "../store.js";
3
- async function openBrowser(url) {
3
+ export async function openBrowser(url) {
4
4
  const { exec } = await import("node:child_process");
5
5
  const cmd = process.platform === "darwin"
6
6
  ? `open "${url}"`
@@ -1,9 +1,17 @@
1
- /** `thaifi login` — pair this CLI with a wallet.thaifi.com account. */
1
+ /** `thaifi login` — pair this CLI with a wallet.thaifi.com account.
2
+ *
3
+ * Reliability guarantees (0.3.6+):
4
+ * - The approval URL is always written to ~/.thaifi/pair.log (chmod 600), so
5
+ * background launchers that lose stdout can recover it.
6
+ * - A pairing created but not yet approved is persisted in the store; re-running
7
+ * `thaifi login` within the poll window REUSES it (same URL, same key) instead
8
+ * of creating throwaway pairings on the server.
9
+ */
2
10
  import { hostname } from "node:os";
3
11
  import { generatePrivateKey } from "viem/accounts";
4
12
  import { Account } from "viem/tempo";
5
13
  import { CONFIG } from "../config.js";
6
- import { loadStore, saveStore, storePath } from "../store.js";
14
+ import { loadStore, saveStore, storePath, writePairLog, pairLogPath } from "../store.js";
7
15
  const signatureType = "p256";
8
16
  async function openBrowser(url) {
9
17
  const { exec } = await import("node:child_process");
@@ -14,44 +22,97 @@ async function openBrowser(url) {
14
22
  : `xdg-open "${url}"`;
15
23
  exec(cmd, () => undefined);
16
24
  }
25
+ function pairLogContent(p, status) {
26
+ return [
27
+ "# ThaiFi CLI pairing",
28
+ "created=" + new Date(p.createdAt).toISOString(),
29
+ "PAIR_URL=" + p.url,
30
+ "PAIR_CODE=" + p.code,
31
+ "KEY_ID=" + p.keyId,
32
+ "MACHINE=" + p.name,
33
+ "STATUS=" + status,
34
+ "",
35
+ ].join("\n");
36
+ }
17
37
  export async function login(options = {}) {
18
- if (loadStore()) {
38
+ const existing = loadStore();
39
+ if (existing?.userAddress) {
19
40
  console.error("Already paired (found " + storePath + "). Run `thaifi logout` first to re-pair.");
20
41
  process.exitCode = 1;
21
42
  return;
22
43
  }
23
- // 1. Generate the agent access key (P256 — never leaves this machine).
24
- const privateKey = generatePrivateKey();
25
- const name = hostname();
26
- const keyId = Account.fromP256(privateKey).address;
27
- console.log("Agent key: " + keyId);
28
- console.log("Machine: " + name);
29
- console.log("");
30
- // 2. Start pairing with the web wallet.
31
- const startRes = await fetch(CONFIG.walletUrl + "/api/agent/pair/start", {
32
- method: "POST",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify({ keyId, name, signatureType }),
35
- });
36
- if (!startRes.ok) {
37
- console.error("Pairing failed to start: HTTP " + startRes.status + " " + (await startRes.text()));
38
- process.exitCode = 1;
39
- return;
44
+ // Reuse a still-fresh pending pairing instead of creating a new one —
45
+ // a re-run of `thaifi login` (crashed launcher, lost stdout, impatient
46
+ // retry) must never pile up throwaway pairings on the server.
47
+ const pending = existing?.pending;
48
+ let reused = false;
49
+ let privateKey;
50
+ let name;
51
+ let keyId;
52
+ let pairingId;
53
+ let code;
54
+ let url;
55
+ if (pending && Date.now() - pending.createdAt < CONFIG.pollTimeoutMs) {
56
+ reused = true;
57
+ privateKey = pending.privateKey;
58
+ keyId = pending.keyId;
59
+ name = pending.name;
60
+ pairingId = pending.pairingId;
61
+ code = pending.code;
62
+ url = pending.url;
63
+ console.log("Pairing already waiting for approval (started " +
64
+ Math.round((Date.now() - pending.createdAt) / 60000) +
65
+ " min ago) — reusing it, no new pairing created.");
66
+ }
67
+ else {
68
+ // 1. Generate the agent access key (P256 — never leaves this machine).
69
+ privateKey = generatePrivateKey();
70
+ name = hostname();
71
+ keyId = Account.fromP256(privateKey).address;
72
+ console.log("Agent key: " + keyId);
73
+ console.log("Machine: " + name);
74
+ console.log("");
75
+ // 2. Start pairing with the web wallet.
76
+ const startRes = await fetch(CONFIG.walletUrl + "/api/agent/pair/start", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json" },
79
+ body: JSON.stringify({ keyId, name, signatureType }),
80
+ });
81
+ if (!startRes.ok) {
82
+ console.error("Pairing failed to start: HTTP " + startRes.status + " " + (await startRes.text()));
83
+ process.exitCode = 1;
84
+ return;
85
+ }
86
+ ({ pairingId, code } = (await startRes.json()));
87
+ url = `${CONFIG.walletUrl}/pair?id=${pairingId}&code=${code}`;
88
+ // Persist the pending pairing BEFORE printing anything — if this process
89
+ // dies, a re-run recovers the same pairing instead of orphaning it.
90
+ saveStore({
91
+ privateKey,
92
+ keyId,
93
+ name,
94
+ walletUrl: CONFIG.walletUrl,
95
+ rpcUrl: CONFIG.rpcUrl,
96
+ chainId: CONFIG.chainId,
97
+ pending: { pairingId, code, url, keyId, name, privateKey, createdAt: Date.now() },
98
+ });
40
99
  }
41
- const { pairingId, code } = (await startRes.json());
42
- const url = `${CONFIG.walletUrl}/pair?id=${pairingId}&code=${code}`;
100
+ // 3. Print + persist the approval URL (both routes land here).
43
101
  console.log("Authorize this CLI in the ThaiFi Wallet:");
44
102
  console.log("");
45
103
  console.log(" " + url);
46
104
  console.log("");
105
+ console.log("PAIR_URL=" + url);
47
106
  console.log("Pairing code: " + code);
107
+ writePairLog(pairLogContent({ pairingId, code, url, keyId, name, privateKey, createdAt: pending?.createdAt ?? Date.now() }, reused ? "waiting-for-approval (reused)" : "waiting-for-approval"));
108
+ console.log("(approval link saved to " + pairLogPath + ")");
48
109
  if (options.browser !== false) {
49
110
  await openBrowser(url);
50
111
  console.log("(opened in your browser — use --no-browser on a headless machine)");
51
112
  }
52
113
  console.log("");
53
114
  console.log("Waiting for approval (timeout " + CONFIG.pollTimeoutMs / 60000 + " min)…");
54
- // 3. Poll until approved.
115
+ // 4. Poll until approved.
55
116
  const deadline = Date.now() + CONFIG.pollTimeoutMs;
56
117
  let status = null;
57
118
  while (Date.now() < deadline) {
@@ -64,17 +125,22 @@ export async function login(options = {}) {
64
125
  break;
65
126
  if (status.status === "rejected" || status.status === "claimed") {
66
127
  console.error("Pairing was " + status.status + " on the web wallet.");
128
+ // Clear the dead pending pairing so the next login starts fresh.
129
+ if (existing)
130
+ saveStore({ ...existing, pending: undefined });
67
131
  process.exitCode = 1;
68
132
  return;
69
133
  }
70
134
  process.stdout.write(".");
71
135
  }
72
136
  if (status?.status !== "approved") {
73
- console.error("\nPairing timed out.");
137
+ console.error("\nPairing timed out. The approval link is still in " +
138
+ pairLogPath +
139
+ " — run `thaifi login` again to reuse it (recommending the user approve) or wait for it to expire server-side.");
74
140
  process.exitCode = 1;
75
141
  return;
76
142
  }
77
- // 4. Persist.
143
+ // 5. Persist the completed pairing (and clear pending).
78
144
  saveStore({
79
145
  privateKey,
80
146
  keyId,
@@ -87,7 +153,9 @@ export async function login(options = {}) {
87
153
  limitAmount: status.limitAmount,
88
154
  limitPeriod: status.limitPeriod,
89
155
  keyExpiry: status.expiry,
156
+ pending: undefined,
90
157
  });
158
+ writePairLog(pairLogContent({ pairingId, code, url, keyId, name, privateKey, createdAt: pending?.createdAt ?? Date.now() }, "approved"));
91
159
  console.log("");
92
160
  console.log("✓ Paired!");
93
161
  console.log(" Wallet account: " + status.userAddress);
@@ -2,23 +2,16 @@
2
2
  import { concatHex, keccak256, slice, stringToBytes } from "viem";
3
3
  import { loadStore } from "../store.js";
4
4
  import { createAgentClient, pickFeeToken } from "../chain.js";
5
- import { buildAgentAuthCredential } from "../agentAuth.js";
5
+ import { b64uEncode, buildAgentAuthCredential, parseAgentChallenge, } from "../agentAuth.js";
6
6
  import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
7
7
  import { PATHUSD, resolveToken } from "../tokens.js";
8
- function b64uDecode(s) {
9
- return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
10
- }
11
- function b64uEncode(s) {
12
- return Buffer.from(s, "utf8")
13
- .toString("base64")
14
- .replace(/\+/g, "-")
15
- .replace(/\//g, "_")
16
- .replace(/=+$/, "");
17
- }
18
8
  /**
19
9
  * Parse an MPP `WWW-Authenticate: Payment id="…", request="<b64u json>"` header.
20
10
  * See https://mpp.dev — Machine Payments Protocol.
21
11
  */
12
+ function b64uDecode(s) {
13
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
14
+ }
22
15
  export function parseMppChallenge(header) {
23
16
  const m = /^Payment\s+(.+)$/i.exec(header.trim());
24
17
  if (!m)
@@ -53,26 +46,6 @@ export function parseMppChallenge(header) {
53
46
  challenge.expires = params.expires;
54
47
  return challenge;
55
48
  }
56
- /**
57
- * Parse a `WWW-Authenticate: ThaiFiAgent realm="…", challenge="<b64u>"`
58
- * header — ThaiFi Agent Auth (identity proof, no payment). The server
59
- * verifies our signature against the AccountKeychain precompile.
60
- */
61
- function parseAgentChallenge(header) {
62
- const m = /^ThaiFiAgent\s+(.+)$/i.exec(header.trim());
63
- if (!m)
64
- return null;
65
- const params = {};
66
- const re = /([a-zA-Z]+)=((?:"((?:[^"\\]|\\.)*)")|[^\s,]+)/g;
67
- let pm;
68
- while ((pm = re.exec(m[1]))) {
69
- const value = pm[3] !== undefined ? pm[3].replace(/\\(.)/g, "$1") : pm[2];
70
- params[pm[1].toLowerCase()] = value;
71
- }
72
- if (!params.realm || !params.challenge)
73
- return null;
74
- return { realm: params.realm, nonce: params.challenge };
75
- }
76
49
  /** MPP tempo charge → on-chain TIP-20 transfer (push mode: we broadcast, then send the hash). */
77
50
  function mppTempoChallenge(challenge) {
78
51
  const req = challenge.request;
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { transfer } from "./commands/transfer.js";
10
10
  import { request } from "./commands/request.js";
11
11
  import { services } from "./commands/services.js";
12
12
  import { fund } from "./commands/fund.js";
13
+ import { deposit } from "./commands/deposit.js";
13
14
  import { tokens } from "./commands/tokens.js";
14
15
  // Resolve ../package.json at runtime (works from src/ via tsx and from dist/ when installed) — never hardcode the version here again.
15
16
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -53,6 +54,15 @@ program
53
54
  .description("Show your deposit address and open the web Deposit page")
54
55
  .option("--no-browser", "Print the address without opening a browser")
55
56
  .action(fund);
57
+ program
58
+ .command("deposit")
59
+ .description("PromptPay QR top-up (1 THB = 1 token): create a QR order, show it for the user to scan, and wait until the tokens are minted")
60
+ .option("--amount <thb>", "Amount in THB (6-10000)")
61
+ .option("--token <symbol>", "Payout token: THCFI (default) or THCOC")
62
+ .option("--ref <referenceNo>", "Watch an existing order instead of creating a new one")
63
+ .option("--no-open", "Do not open the saved QR image in a viewer")
64
+ .option("--json", "Machine-readable output (JSON)")
65
+ .action(deposit);
56
66
  program
57
67
  .command("request")
58
68
  .description("HTTP request with automatic x402/MPP payments (curl-like)")
package/dist/store.js CHANGED
@@ -6,6 +6,7 @@ import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  const DIR = join(homedir(), ".thaifi");
8
8
  const FILE = join(DIR, "store.json");
9
+ const PAIR_LOG = join(DIR, "pair.log");
9
10
  export function loadStore() {
10
11
  if (!existsSync(FILE))
11
12
  return null;
@@ -31,3 +32,17 @@ export function clearStore() {
31
32
  rmSync(FILE);
32
33
  }
33
34
  export const storePath = FILE;
35
+ export const pairLogPath = PAIR_LOG;
36
+ /** Writes ~/.thaifi/pair.log (chmod 600) — the durable copy of the approval
37
+ * URL. Background launchers that lose stdout can always recover the link
38
+ * from this file. Overwritten on every `thaifi login`. */
39
+ export function writePairLog(content) {
40
+ mkdirSync(DIR, { recursive: true });
41
+ writeFileSync(PAIR_LOG, content, { mode: 0o600 });
42
+ try {
43
+ chmodSync(PAIR_LOG, 0o600);
44
+ }
45
+ catch {
46
+ // best-effort on platforms without chmod
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaifi-wallet-cli",
3
- "version": "0.3.5",
3
+ "version": "0.4.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": {
@@ -34,10 +34,14 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "commander": "^14.0.0",
37
+ "jsqr": "^1.4.0",
38
+ "pngjs": "^7.0.0",
39
+ "qrcode-terminal": "^0.12.0",
37
40
  "viem": "^2.56.0"
38
41
  },
39
42
  "devDependencies": {
40
43
  "@types/node": "^24.13.2",
44
+ "@types/pngjs": "^6.0.5",
41
45
  "tsx": "^4.19.0",
42
46
  "typescript": "^5.9.0"
43
47
  }