thaifi-wallet-cli 0.1.1 → 0.1.3

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.
@@ -22,9 +22,9 @@ export async function fund(options = {}) {
22
22
  console.log("Send pathUSD (or any ThaiFi TIP-20 token) to this address");
23
23
  console.log("from any ThaiFi wallet.");
24
24
  console.log("");
25
- console.log("Deposit page: " + store.walletUrl + "/?tab=deposit");
25
+ console.log("Deposit page: " + store.walletUrl + "/deposit");
26
26
  console.log("(Bridge USDC from other chains — coming soon)");
27
27
  if (options.browser !== false) {
28
- await openBrowser(store.walletUrl + "/?tab=deposit");
28
+ await openBrowser(store.walletUrl + "/deposit");
29
29
  }
30
30
  }
@@ -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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thaifi-wallet-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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": {