thaifi-wallet-cli 0.1.2 → 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.
- package/dist/commands/request.js +163 -29
- package/package.json +1 -1
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 {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thaifi-wallet-cli",
|
|
3
|
-
"version": "0.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": {
|