thaifi-wallet-cli 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/chain.js +58 -0
- package/dist/commands/balance.js +48 -0
- package/dist/commands/login.js +102 -0
- package/dist/commands/logout.js +14 -0
- package/dist/commands/request.js +123 -0
- package/dist/commands/services.js +41 -0
- package/dist/commands/transfer.js +38 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/config.js +19 -0
- package/dist/index.js +78 -0
- package/dist/store.js +33 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ThaiFi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# ThaiFi Wallet CLI (`thaifi`)
|
|
2
|
+
|
|
3
|
+
Command-line wallet for AI agents on the **ThaiFi** chain (chain 17) — pairs with
|
|
4
|
+
**wallet.thaifi.com** and signs with an on-chain **access key** whose spending
|
|
5
|
+
limit is enforced by the chain itself (AccountKeychain precompile, TIP-1011).
|
|
6
|
+
|
|
7
|
+
Agent setup guide for AI agents: https://wallet.thaifi.com/SKILL.md
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
~/.thaifi/store.json (agent P256 key, chmod 600)
|
|
13
|
+
│ 1. thaifi login → POST /api/agent/pair/start {keyId, name}
|
|
14
|
+
▼
|
|
15
|
+
wallet.thaifi.com/pair?id=…&code=… ← user approves (passkey / PIN)
|
|
16
|
+
│ 2. web sends authorizeKey(keyId, P256, expiry, limits) on-chain
|
|
17
|
+
▼
|
|
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.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Non-custodial: the private key never leaves the machine; the web wallet
|
|
23
|
+
never sees it.
|
|
24
|
+
- One wallet per account: revoking an app on the web cuts access immediately.
|
|
25
|
+
- Limits are on-chain: `SpendingLimitExceeded` when exceeded; keys expire.
|
|
26
|
+
|
|
27
|
+
## Install / run
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install
|
|
31
|
+
npx tsx src/index.ts --help # dev
|
|
32
|
+
npm run build && node dist/index.js # compiled
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Set `THAIFI_WALLET_URL` / `THAIFI_RPC_URL` to target other deployments.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Description |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `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) |
|
|
45
|
+
| `request <url> [-X -H -d --json --dry-run --max-spend]` | HTTP with automatic x402/MPP payment |
|
|
46
|
+
| `services [--search q]` | Discover services from the MPP registry (`THAIFI_MPP_REGISTRY`) |
|
|
47
|
+
| `logout` | Remove the local key (revoke on-chain from the web wallet) |
|
|
48
|
+
|
|
49
|
+
## Security notes
|
|
50
|
+
|
|
51
|
+
- The recovery password / passphrase is never involved here — the agent key is
|
|
52
|
+
authorized by the account owner via the web wallet.
|
|
53
|
+
- Losing the machine = losing the agent key: re-pair a new key from the web
|
|
54
|
+
wallet and revoke the old one under Authorized Apps.
|
package/dist/chain.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** ThaiFi chain client (viem/tempo) for the agent access key. */
|
|
2
|
+
import { http, defineChain } from "viem";
|
|
3
|
+
import { createClient, tempoActions } from "viem/tempo";
|
|
4
|
+
import { Actions, Account, Addresses, Chain as TempoChains } from "viem/tempo";
|
|
5
|
+
import { CONFIG } from "./config.js";
|
|
6
|
+
/** ThaiFi chain 17 — clone of the tempo chain definition (formatters/serializers) with our ids. */
|
|
7
|
+
export const thaiFi = defineChain({
|
|
8
|
+
...TempoChains.mainnet,
|
|
9
|
+
id: CONFIG.chainId,
|
|
10
|
+
name: "ThaiFi",
|
|
11
|
+
network: "thaifi",
|
|
12
|
+
nativeCurrency: { name: "pathUSD", symbol: "pathUSD", decimals: 6 },
|
|
13
|
+
rpcUrls: { default: { http: [CONFIG.rpcUrl] } },
|
|
14
|
+
blockExplorers: {
|
|
15
|
+
default: { name: "ThaiFi Explorer", url: "https://exp.thaifi.com" },
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
/** The account keychain precompile (access keys, TIP-1011). */
|
|
19
|
+
export const accountKeychain = Addresses.accountKeychain;
|
|
20
|
+
export function createAgentClient(store) {
|
|
21
|
+
if (!store.userAddress)
|
|
22
|
+
throw new Error("Not paired yet — run `thaifi login` first.");
|
|
23
|
+
const account = Account.fromP256(store.privateKey, {
|
|
24
|
+
access: store.userAddress,
|
|
25
|
+
});
|
|
26
|
+
return createClient({
|
|
27
|
+
account,
|
|
28
|
+
chain: thaiFi,
|
|
29
|
+
feeToken: CONFIG.pathUsd,
|
|
30
|
+
transport: http(store.rpcUrl),
|
|
31
|
+
}).extend(tempoActions());
|
|
32
|
+
}
|
|
33
|
+
export async function pathUsdBalance(client, address) {
|
|
34
|
+
return client.readContract({
|
|
35
|
+
address: CONFIG.pathUsd,
|
|
36
|
+
abi: [
|
|
37
|
+
{
|
|
38
|
+
name: "balanceOf",
|
|
39
|
+
type: "function",
|
|
40
|
+
stateMutability: "view",
|
|
41
|
+
inputs: [{ type: "address" }],
|
|
42
|
+
outputs: [{ type: "uint256" }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
functionName: "balanceOf",
|
|
46
|
+
args: [address],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Remaining spend allowance of the agent key (pathUSD, raw units). */
|
|
50
|
+
export async function remainingLimit(client, userAddress, keyId) {
|
|
51
|
+
const res = await client.accessKey.getRemainingLimit({
|
|
52
|
+
account: userAddress,
|
|
53
|
+
accessKey: keyId,
|
|
54
|
+
token: CONFIG.pathUsd,
|
|
55
|
+
});
|
|
56
|
+
return res.remaining;
|
|
57
|
+
}
|
|
58
|
+
export { Actions };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** `thaifi balance [token]` — TIP-20 balance of the paired account. */
|
|
2
|
+
import { loadStore } from "../store.js";
|
|
3
|
+
import { createAgentClient } from "../chain.js";
|
|
4
|
+
import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
|
|
5
|
+
export async function balance(options = {}) {
|
|
6
|
+
const store = loadStore();
|
|
7
|
+
if (!store?.userAddress) {
|
|
8
|
+
console.error("Not paired. Run `thaifi login` first.");
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const token = (options.token ?? CONFIG.pathUsd);
|
|
13
|
+
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
|
+
],
|
|
26
|
+
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 }));
|
|
47
|
+
console.log("(" + store.userAddress + ")");
|
|
48
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** `thaifi login` — pair this CLI with a wallet.thaifi.com account. */
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { generatePrivateKey } from "viem/accounts";
|
|
4
|
+
import { Account } from "viem/tempo";
|
|
5
|
+
import { CONFIG } from "../config.js";
|
|
6
|
+
import { loadStore, saveStore, storePath } from "../store.js";
|
|
7
|
+
const signatureType = "p256";
|
|
8
|
+
async function openBrowser(url) {
|
|
9
|
+
const { exec } = await import("node:child_process");
|
|
10
|
+
const cmd = process.platform === "darwin"
|
|
11
|
+
? `open "${url}"`
|
|
12
|
+
: process.platform === "win32"
|
|
13
|
+
? `start "" "${url}"`
|
|
14
|
+
: `xdg-open "${url}"`;
|
|
15
|
+
exec(cmd, () => undefined);
|
|
16
|
+
}
|
|
17
|
+
export async function login(options = {}) {
|
|
18
|
+
if (loadStore()) {
|
|
19
|
+
console.error("Already paired (found " + storePath + "). Run `thaifi logout` first to re-pair.");
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
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;
|
|
40
|
+
}
|
|
41
|
+
const { pairingId, code } = (await startRes.json());
|
|
42
|
+
const url = `${CONFIG.walletUrl}/pair?id=${pairingId}&code=${code}`;
|
|
43
|
+
console.log("Authorize this CLI in the ThaiFi Wallet:");
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(" " + url);
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log("Pairing code: " + code);
|
|
48
|
+
if (options.browser !== false) {
|
|
49
|
+
await openBrowser(url);
|
|
50
|
+
console.log("(opened in your browser — use --no-browser on a headless machine)");
|
|
51
|
+
}
|
|
52
|
+
console.log("");
|
|
53
|
+
console.log("Waiting for approval (timeout " + CONFIG.pollTimeoutMs / 60000 + " min)…");
|
|
54
|
+
// 3. Poll until approved.
|
|
55
|
+
const deadline = Date.now() + CONFIG.pollTimeoutMs;
|
|
56
|
+
let status = null;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
await new Promise((r) => setTimeout(r, CONFIG.pollIntervalMs));
|
|
59
|
+
const res = await fetch(`${CONFIG.walletUrl}/api/agent/pair/status/${pairingId}`);
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
continue;
|
|
62
|
+
status = (await res.json());
|
|
63
|
+
if (status.status === "approved")
|
|
64
|
+
break;
|
|
65
|
+
if (status.status === "rejected" || status.status === "claimed") {
|
|
66
|
+
console.error("Pairing was " + status.status + " on the web wallet.");
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(".");
|
|
71
|
+
}
|
|
72
|
+
if (status?.status !== "approved") {
|
|
73
|
+
console.error("\nPairing timed out.");
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 4. Persist.
|
|
78
|
+
saveStore({
|
|
79
|
+
privateKey,
|
|
80
|
+
keyId,
|
|
81
|
+
name,
|
|
82
|
+
walletUrl: CONFIG.walletUrl,
|
|
83
|
+
rpcUrl: CONFIG.rpcUrl,
|
|
84
|
+
chainId: CONFIG.chainId,
|
|
85
|
+
userAddress: status.userAddress,
|
|
86
|
+
pairedAt: Date.now(),
|
|
87
|
+
limitAmount: status.limitAmount,
|
|
88
|
+
limitPeriod: status.limitPeriod,
|
|
89
|
+
keyExpiry: status.expiry,
|
|
90
|
+
});
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log("✓ Paired!");
|
|
93
|
+
console.log(" Wallet account: " + status.userAddress);
|
|
94
|
+
console.log(" Spend limit: " +
|
|
95
|
+
Number(status.limitAmount ?? 0) / 10 ** 6 +
|
|
96
|
+
" pathUSD / " +
|
|
97
|
+
Math.round((status.limitPeriod ?? 0) / 86400) +
|
|
98
|
+
" days");
|
|
99
|
+
console.log(" Key expires: " + (status.expiry ? new Date(status.expiry * 1000).toISOString() : "never"));
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log("Try: thaifi whoami");
|
|
102
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** `thaifi logout` — remove the local agent key (revoke on the web to cut on-chain access). */
|
|
2
|
+
import { loadStore, clearStore, storePath } from "../store.js";
|
|
3
|
+
export async function logout() {
|
|
4
|
+
const store = loadStore();
|
|
5
|
+
if (!store) {
|
|
6
|
+
console.log("No local key found.");
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
clearStore();
|
|
10
|
+
console.log("✓ Removed local key " + store.keyId + " (" + storePath + ")");
|
|
11
|
+
console.log("Note: the key may still be authorized on-chain — revoke it at " +
|
|
12
|
+
store.walletUrl +
|
|
13
|
+
" → Authorized Apps.");
|
|
14
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** `thaifi request <url>` — curl-like HTTP client with x402/MPP auto-payments. */
|
|
2
|
+
import { loadStore } from "../store.js";
|
|
3
|
+
import { createAgentClient } from "../chain.js";
|
|
4
|
+
import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
|
|
5
|
+
/**
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
async function parseChallenge(res, defaultToken) {
|
|
11
|
+
let body = null;
|
|
12
|
+
try {
|
|
13
|
+
body = await res.json();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
body = null;
|
|
17
|
+
}
|
|
18
|
+
const accept = body?.accepts?.[0] ?? body?.payments?.[0] ?? body;
|
|
19
|
+
const payTo = accept?.payTo ?? accept?.payToAddress ?? accept?.recipient;
|
|
20
|
+
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
|
+
}
|
|
33
|
+
const amount = typeof maxAmount === "string" && /^\d+$/.test(maxAmount)
|
|
34
|
+
? BigInt(maxAmount)
|
|
35
|
+
: BigInt(Math.round(Number(maxAmount) * 10 ** PATHUSD_DECIMALS));
|
|
36
|
+
return {
|
|
37
|
+
payTo: payTo,
|
|
38
|
+
amount,
|
|
39
|
+
token: accept?.asset ?? accept?.token ?? defaultToken,
|
|
40
|
+
raw: body,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function request(url, options) {
|
|
44
|
+
const store = loadStore();
|
|
45
|
+
if (!store?.userAddress) {
|
|
46
|
+
console.error("Not paired. Run `thaifi login` first.");
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (options.dryRun) {
|
|
51
|
+
console.log("→ " + options.method + " " + url);
|
|
52
|
+
for (const [k, v] of Object.entries(options.headers))
|
|
53
|
+
console.log(" " + k + ": " + v);
|
|
54
|
+
if (options.body)
|
|
55
|
+
console.log(" body: " + options.body);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let res = await fetch(url, {
|
|
59
|
+
method: options.method,
|
|
60
|
+
headers: options.headers,
|
|
61
|
+
body: options.body,
|
|
62
|
+
});
|
|
63
|
+
// 402 → pay on-chain (TIP-20 transfer from the agent's spend limit) → retry once.
|
|
64
|
+
if (res.status === 402) {
|
|
65
|
+
const challenge = await parseChallenge(res, CONFIG.pathUsd);
|
|
66
|
+
if (!challenge) {
|
|
67
|
+
console.error("402 Payment Required — could not parse the challenge.");
|
|
68
|
+
console.error((await res.text()).slice(0, 400));
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const usd = Number(challenge.amount) / 10 ** PATHUSD_DECIMALS;
|
|
73
|
+
if (options.maxSpend !== undefined && usd > options.maxSpend) {
|
|
74
|
+
console.error("Payment of $" + usd + " exceeds --max-spend $" + options.maxSpend + " — aborting.");
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const token = (challenge.token ?? CONFIG.pathUsd);
|
|
79
|
+
console.log("402 — paying $" + usd + " to " + challenge.payTo + " …");
|
|
80
|
+
const client = createAgentClient(store);
|
|
81
|
+
const hash = await client.token.transfer({
|
|
82
|
+
to: challenge.payTo,
|
|
83
|
+
amount: challenge.amount,
|
|
84
|
+
token,
|
|
85
|
+
});
|
|
86
|
+
const receipt = await client.waitForTransactionReceipt({ hash });
|
|
87
|
+
if (receipt.status !== "success") {
|
|
88
|
+
console.error("Payment transaction reverted: " + hash);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
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
|
+
res = await fetch(url, {
|
|
102
|
+
method: options.method,
|
|
103
|
+
headers: { ...options.headers, "X-Payment": proof },
|
|
104
|
+
body: options.body,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
console.log("← HTTP " + res.status);
|
|
108
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
109
|
+
const text = await res.text();
|
|
110
|
+
if (contentType.includes("json")) {
|
|
111
|
+
try {
|
|
112
|
+
console.log(JSON.stringify(JSON.parse(text), null, 2));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
console.log(text);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.log(text.slice(0, 4000));
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok)
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** `thaifi services [--search q]` — discover paid services from an MPP/x402 registry. */
|
|
2
|
+
const REGISTRY = process.env.THAIFI_MPP_REGISTRY ?? "https://mpp.thaifi.com";
|
|
3
|
+
export async function services(options = {}) {
|
|
4
|
+
const base = REGISTRY.replace(/\/$/, "");
|
|
5
|
+
const url = options.search ? `${base}/services?search=${encodeURIComponent(options.search)}` : `${base}/services`;
|
|
6
|
+
console.log("Registry: " + base);
|
|
7
|
+
let res;
|
|
8
|
+
try {
|
|
9
|
+
res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
console.error("Registry unreachable (" + (err instanceof Error ? err.message : err) + ").");
|
|
13
|
+
console.error("Set THAIFI_MPP_REGISTRY to your MPP/x402 registry URL.");
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
console.error("Registry returned HTTP " + res.status);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const data = (await res.json());
|
|
23
|
+
const list = Array.isArray(data) ? data : (data.services ?? []);
|
|
24
|
+
if (list.length === 0) {
|
|
25
|
+
console.log("No services found.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const s of list) {
|
|
29
|
+
const id = s.id ?? s.serviceId ?? "?";
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log(id + " — " + (s.name ?? id));
|
|
32
|
+
if (s.description)
|
|
33
|
+
console.log(" " + s.description);
|
|
34
|
+
if (s.url || s.endpoint)
|
|
35
|
+
console.log(" " + (s.url ?? s.endpoint));
|
|
36
|
+
if (s.price !== undefined)
|
|
37
|
+
console.log(" price: " + s.price);
|
|
38
|
+
}
|
|
39
|
+
console.log("");
|
|
40
|
+
console.log("Call a service: thaifi request <SERVICE_URL>");
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** `thaifi transfer <to> <amount> [token]` — TIP-20 transfer signed by the agent access key. */
|
|
2
|
+
import { parseUnits, formatUnits } from "viem";
|
|
3
|
+
import { loadStore } from "../store.js";
|
|
4
|
+
import { createAgentClient } from "../chain.js";
|
|
5
|
+
import { CONFIG, PATHUSD_DECIMALS } from "../config.js";
|
|
6
|
+
export async function transfer(to, amount, options = {}) {
|
|
7
|
+
const store = loadStore();
|
|
8
|
+
if (!store?.userAddress) {
|
|
9
|
+
console.error("Not paired. Run `thaifi login` first.");
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(to)) {
|
|
14
|
+
console.error("Invalid recipient address: " + to);
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const token = (options.token ?? CONFIG.pathUsd);
|
|
19
|
+
const client = createAgentClient(store);
|
|
20
|
+
console.log("To: " + to);
|
|
21
|
+
console.log("Amount: " + amount + " (raw " + parseUnits(amount, PATHUSD_DECIMALS) + ")");
|
|
22
|
+
// Signed locally by the agent access key; the on-chain spending limit is
|
|
23
|
+
// enforced by the AccountKeychain precompile (SpendingLimitExceeded on overflow).
|
|
24
|
+
const hash = await client.token.transfer({
|
|
25
|
+
to: to,
|
|
26
|
+
amount: parseUnits(amount, PATHUSD_DECIMALS),
|
|
27
|
+
token,
|
|
28
|
+
});
|
|
29
|
+
console.log("Tx: " + hash);
|
|
30
|
+
console.log("Waiting for receipt…");
|
|
31
|
+
const receipt = await client.waitForTransactionReceipt({ hash });
|
|
32
|
+
console.log(receipt.status === "success"
|
|
33
|
+
? "✓ Transferred " + amount + " (gas " + receipt.gasUsed + ")"
|
|
34
|
+
: "✗ Transaction reverted (status " + receipt.status + ")");
|
|
35
|
+
if (receipt.status !== "success")
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
void formatUnits;
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** `thaifi whoami` — account, agent key, balances and remaining spend limit. */
|
|
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 });
|
|
5
|
+
export async function whoami() {
|
|
6
|
+
const store = loadStore();
|
|
7
|
+
if (!store?.userAddress) {
|
|
8
|
+
console.error("Not paired. Run `thaifi login` first.");
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const client = createAgentClient(store);
|
|
13
|
+
const userAddress = store.userAddress;
|
|
14
|
+
const keyId = store.keyId;
|
|
15
|
+
const [balance, remaining] = await Promise.all([
|
|
16
|
+
pathUsdBalance(client, userAddress),
|
|
17
|
+
remainingLimit(client, userAddress, keyId).catch(() => null),
|
|
18
|
+
]);
|
|
19
|
+
console.log("Account: " + userAddress);
|
|
20
|
+
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")));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.log("Limit left: (unavailable — key may have been revoked)");
|
|
27
|
+
}
|
|
28
|
+
console.log("Key expires: " + (store.keyExpiry ? new Date(store.keyExpiry * 1000).toISOString() : "never"));
|
|
29
|
+
console.log("Wallet: " + store.walletUrl);
|
|
30
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** ThaiFi Wallet CLI configuration — overridable via env for testing. */
|
|
2
|
+
export const CONFIG = {
|
|
3
|
+
rpcUrl: process.env.THAIFI_RPC_URL ?? "https://rpc.thaifi.com",
|
|
4
|
+
walletUrl: process.env.THAIFI_WALLET_URL ?? "https://wallet.thaifi.com",
|
|
5
|
+
chainId: Number(process.env.THAIFI_CHAIN_ID ?? 17),
|
|
6
|
+
/** pathUSD — ThaiFi fee/stable token (TIP-20, 6 decimals). */
|
|
7
|
+
pathUsd: (process.env.THAIFI_PATHUSD ??
|
|
8
|
+
"0x20c0000000000000000000000000000000000000"),
|
|
9
|
+
/** Default spending limit: 100 pathUSD. */
|
|
10
|
+
defaultLimit: 100n * 10n ** 6n,
|
|
11
|
+
/** Default limit period: 30 days (seconds). */
|
|
12
|
+
defaultPeriod: 60 * 60 * 24 * 30,
|
|
13
|
+
/** Default key expiry: 90 days (seconds). */
|
|
14
|
+
defaultExpiryDays: 90,
|
|
15
|
+
/** Pairing poll interval / timeout. */
|
|
16
|
+
pollIntervalMs: 3_000,
|
|
17
|
+
pollTimeoutMs: 15 * 60 * 1_000,
|
|
18
|
+
};
|
|
19
|
+
export const PATHUSD_DECIMALS = 6;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** ThaiFi Wallet CLI — agent wallet for the ThaiFi chain (chain 17). */
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { login } from "./commands/login.js";
|
|
5
|
+
import { logout } from "./commands/logout.js";
|
|
6
|
+
import { whoami } from "./commands/whoami.js";
|
|
7
|
+
import { balance } from "./commands/balance.js";
|
|
8
|
+
import { transfer } from "./commands/transfer.js";
|
|
9
|
+
import { request } from "./commands/request.js";
|
|
10
|
+
import { services } from "./commands/services.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name("thaifi")
|
|
14
|
+
.description("ThaiFi Wallet CLI — pair with wallet.thaifi.com and sign with on-chain access keys")
|
|
15
|
+
.version("0.1.0");
|
|
16
|
+
program
|
|
17
|
+
.command("login")
|
|
18
|
+
.description("Pair this CLI with your ThaiFi Wallet (opens the browser to approve)")
|
|
19
|
+
.option("--no-browser", "Print the approval URL instead of opening a browser (headless machines)")
|
|
20
|
+
.action(login);
|
|
21
|
+
program
|
|
22
|
+
.command("logout")
|
|
23
|
+
.description("Remove the local agent key (revoke on-chain access from the web wallet)")
|
|
24
|
+
.action(logout);
|
|
25
|
+
program
|
|
26
|
+
.command("whoami")
|
|
27
|
+
.description("Show the paired account, agent key, balances and remaining spend limit")
|
|
28
|
+
.action(whoami);
|
|
29
|
+
program
|
|
30
|
+
.command("balance")
|
|
31
|
+
.description("Show the TIP-20 token balance of the paired account")
|
|
32
|
+
.option("--token <address>", "Token contract (default: pathUSD)")
|
|
33
|
+
.action(balance);
|
|
34
|
+
program
|
|
35
|
+
.command("transfer")
|
|
36
|
+
.description("Transfer tokens signed by the agent access key (spend limit enforced on-chain)")
|
|
37
|
+
.argument("<to>", "Recipient address (0x…)")
|
|
38
|
+
.argument("<amount>", "Amount in human units (e.g. 1.5)")
|
|
39
|
+
.option("--token <address>", "Token contract (default: pathUSD)")
|
|
40
|
+
.action(transfer);
|
|
41
|
+
program
|
|
42
|
+
.command("request")
|
|
43
|
+
.description("HTTP request with automatic x402/MPP payments (curl-like)")
|
|
44
|
+
.argument("<url>")
|
|
45
|
+
.option("-X, --method <method>", "HTTP method", "GET")
|
|
46
|
+
.option("-H, --header <pairs...>", "Headers as 'Key: Value'", [])
|
|
47
|
+
.option("-d, --data <body>", "Request body")
|
|
48
|
+
.option("--json <json>", "JSON body shorthand (sets method POST + content-type)")
|
|
49
|
+
.option("--dry-run", "Print the request without sending")
|
|
50
|
+
.option("--max-spend <usd>", "Maximum automatic payment (USD)", Number)
|
|
51
|
+
.action(async (url, opts) => {
|
|
52
|
+
const headers = {};
|
|
53
|
+
for (const h of opts.header ?? []) {
|
|
54
|
+
const idx = h.indexOf(":");
|
|
55
|
+
if (idx > 0)
|
|
56
|
+
headers[h.slice(0, idx).trim()] = h.slice(idx + 1).trim();
|
|
57
|
+
}
|
|
58
|
+
let body = opts.data;
|
|
59
|
+
let method = opts.method;
|
|
60
|
+
if (opts.json) {
|
|
61
|
+
body = opts.json;
|
|
62
|
+
method = "POST";
|
|
63
|
+
headers["Content-Type"] = "application/json";
|
|
64
|
+
}
|
|
65
|
+
await request(url, {
|
|
66
|
+
method,
|
|
67
|
+
headers,
|
|
68
|
+
body,
|
|
69
|
+
dryRun: opts.dryRun,
|
|
70
|
+
maxSpend: opts.maxSpend,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
program
|
|
74
|
+
.command("services")
|
|
75
|
+
.description("Discover paid services from the MPP/x402 registry")
|
|
76
|
+
.option("--search <query>", "Search services")
|
|
77
|
+
.action(services);
|
|
78
|
+
program.parseAsync();
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Local agent key store — ~/.thaifi/store.json (chmod 600).
|
|
2
|
+
// The private key NEVER leaves this file; the web wallet only ever sees the
|
|
3
|
+
// derived address (key_id).
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
const DIR = join(homedir(), ".thaifi");
|
|
8
|
+
const FILE = join(DIR, "store.json");
|
|
9
|
+
export function loadStore() {
|
|
10
|
+
if (!existsSync(FILE))
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(FILE, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveStore(store) {
|
|
20
|
+
mkdirSync(DIR, { recursive: true });
|
|
21
|
+
writeFileSync(FILE, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
22
|
+
try {
|
|
23
|
+
chmodSync(FILE, 0o600);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// best-effort on platforms without chmod
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function clearStore() {
|
|
30
|
+
if (existsSync(FILE))
|
|
31
|
+
rmSync(FILE);
|
|
32
|
+
}
|
|
33
|
+
export const storePath = FILE;
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "thaifi-wallet-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"thaifi": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"prepare": "npm run build",
|
|
18
|
+
"cli": "tsx src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"thaifi",
|
|
22
|
+
"wallet",
|
|
23
|
+
"agent",
|
|
24
|
+
"ai",
|
|
25
|
+
"x402",
|
|
26
|
+
"mpp",
|
|
27
|
+
"tempo",
|
|
28
|
+
"cli",
|
|
29
|
+
"payments"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"commander": "^14.0.0",
|
|
37
|
+
"viem": "^2.56.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.13.2",
|
|
41
|
+
"tsx": "^4.19.0",
|
|
42
|
+
"typescript": "^5.9.0"
|
|
43
|
+
}
|
|
44
|
+
}
|