@inversealtruism/cairn-cli 0.2.0 → 0.3.2
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 +44 -18
- package/dist/cli.js +525 -65
- package/dist/lib/api.js +66 -1
- package/dist/lib/config.js +21 -1
- package/dist/lib/csd.js +41 -0
- package/dist/lib/ui.js +61 -13
- package/package.json +33 -11
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# cairn-cli
|
|
2
2
|
|
|
3
|
-
A command-line client for
|
|
4
|
-
|
|
3
|
+
A command-line client for **Compute Substrate / Cairn** — browse the board, and **send CSD,
|
|
4
|
+
propose, attest, and place stones on the Wall** straight from your terminal.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
For the people who'd rather not put a key in a browser extension: **cairn-cli never holds your
|
|
7
|
+
key.** Reads are plain HTTP. For writes it drives your **own installed `csd` wallet** — `csd`
|
|
8
|
+
signs with your key (CSD_SIG_V1), and cairn-cli adds the Cairn layer on top: it computes the
|
|
9
|
+
canonical payload hash, fetches a spendable input from the Cairn proxy (so you don't need a
|
|
10
|
+
synced local node), registers your off-chain content, and gives you the board / wall / network
|
|
11
|
+
views the raw `csd` CLI doesn't have. Browsing needs no `csd` binary and no keys.
|
|
9
12
|
|
|
10
13
|
## Install
|
|
11
14
|
|
|
@@ -49,32 +52,55 @@ cairn leaderboard # top builders by reputation
|
|
|
49
52
|
cairn ls --json # machine-readable output
|
|
50
53
|
```
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
## Wallet (transacting — uses your own `csd` wallet)
|
|
56
|
+
|
|
57
|
+
One-time: install Compute Substrate's `csd` CLI and create/import your key.
|
|
53
58
|
|
|
54
59
|
```bash
|
|
55
|
-
|
|
56
|
-
cairn
|
|
57
|
-
cairn support <id> --fee 5000000 --score 90 --confidence 80
|
|
60
|
+
csd wallet new # or: csd wallet init --privkey <your key>
|
|
61
|
+
cairn setup # checks csd + wallet, shows your address + balance
|
|
58
62
|
```
|
|
59
63
|
|
|
64
|
+
Then transact — cairn-cli builds the request, `csd` signs with your key, and the tx is submitted
|
|
65
|
+
through the Cairn proxy (no local node required):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
cairn address # your address + balance (alias: whoami, balance)
|
|
69
|
+
cairn send --to 0x… --amount 1.5 # transfer CSD (--output 0x…:0.5 ×N for many, --fee <CSD>)
|
|
70
|
+
cairn propose --domain csd:features --title "Wallet GUI" --body "…" --link https://…
|
|
71
|
+
cairn support <id> --fee 0.1 --score 90 --confidence 80
|
|
72
|
+
cairn wall place "gm, Compute Substrate"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Fees and amounts are in **CSD** (e.g. `--amount 1.5`, `--fee 0.05`). Minimums: 0.25 CSD to propose,
|
|
76
|
+
0.05 CSD to attest. Support is a paid demand signal, not a payment to the author; fees go to miners.
|
|
77
|
+
|
|
60
78
|
## Configuration (environment variables)
|
|
61
79
|
|
|
62
80
|
| Variable | Default | Purpose |
|
|
63
81
|
|---|---|---|
|
|
64
|
-
| `CAIRN_API` | `https://cairn-substrate.com` | the board to talk to (use your own, e.g. `http://127.0.0.1:7777`) |
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
82
|
+
| `CAIRN_API` | `https://cairn-substrate.com` | the board / proxy to talk to (use your own, e.g. `http://127.0.0.1:7777`) |
|
|
83
|
+
| `CAIRN_CSD` | `csd` | path to your installed `csd` binary (signs your transactions) |
|
|
84
|
+
| `CAIRN_ADDR` | – | your public addr20; skips deriving it from the csd wallet |
|
|
85
|
+
| `CAIRN_RPC` | – | optional csd node RPC; enables fully trustless `verify` (recompute the hash + confirm on-chain) |
|
|
86
|
+
| `CAIRN_TOKEN` | – | board-operator write token (operator convenience; normal users sign with `csd` instead) |
|
|
67
87
|
|
|
68
88
|
## How it works
|
|
69
89
|
|
|
70
|
-
- `browse`, `show`, `recent`,
|
|
90
|
+
- `browse`, `show`, `recent`, `watch`, `wall`, `network`, `quests` read the board's public API.
|
|
71
91
|
- `verify` fetches an item, recomputes `sha256(canonical content)` locally, and if `CAIRN_RPC` is set,
|
|
72
92
|
confirms that hash is the one committed on-chain. You trust the math, not the server.
|
|
73
|
-
- `propose`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
- `send` / `propose` / `support` / `wall place`: cairn-cli fetches a spendable input from the Cairn
|
|
94
|
+
proxy, hands it to **your** `csd` (which signs with your wallet key — for these commands the key
|
|
95
|
+
stays inside `csd` and never enters the cairn-cli process), then submits the signed transaction
|
|
96
|
+
through the proxy and (for proposals) registers the off-chain content. Sealed claims and
|
|
97
|
+
Sign-in-with-CSD live in the Cairn Wallet.
|
|
98
|
+
- **L3 registry commands** (`gateway register`, `peer announce`, `identity claim`) are the one
|
|
99
|
+
exception: they sign a registry *binding* with `@inversealtruism/csd-registry`, so cairn-cli reads
|
|
100
|
+
your private key from `csd wallet config` and signs **in-process** (the key is never networked — only
|
|
101
|
+
the signed canonical content is published). Because these load key material into the Node process,
|
|
102
|
+
the `csd-registry` / `csd-codec` dependencies are **pinned to exact versions** (no caret ranges) to
|
|
103
|
+
shrink the supply-chain surface. If you only ever `send`/`propose`/`support`, your key never leaves `csd`.
|
|
78
104
|
|
|
79
105
|
## License
|
|
80
106
|
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// cairn — command-line client for a Cairn signal board on Compute Substrate.
|
|
3
3
|
// Reads are public; posting needs CAIRN_TOKEN. Config via env: CAIRN_API, CAIRN_TOKEN, CAIRN_RPC.
|
|
4
|
-
import { CAIRN_API, MIN_FEE_PROPOSE, MIN_FEE_ATTEST, CSD_PER_COIN, csdToCoins } from "./lib/config.js";
|
|
4
|
+
import { CAIRN_API, CAIRN_ADDR, CAIRN_TOKEN, CAIRN_RPC, MIN_FEE_PROPOSE, MIN_FEE_ATTEST, CSD_PER_COIN, csdToCoins, loadLocalConfig, saveLocalConfig } from "./lib/config.js";
|
|
5
5
|
import * as api from "./lib/api.js";
|
|
6
|
+
import * as csd from "./lib/csd.js";
|
|
6
7
|
import { buildCommitment } from "./lib/item.js";
|
|
7
|
-
import {
|
|
8
|
+
import { buildGatewayRecord, buildPeerRecord, buildIdentityCommit, buildIdentityReveal } from "@inversealtruism/csd-registry";
|
|
9
|
+
import { canonicalJson } from "@inversealtruism/csd-codec";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { c, banner, bannerAnimated, rule, badge, bar, csd as csdFmt, ok, warn, err, key as kdim, pad, spinner, sleep, isTty, anim, clearScreen, cursorHome, san } from "./lib/ui.js";
|
|
12
|
+
const CSD = (n) => Number.isFinite(n) ? Math.round(n * CSD_PER_COIN) : NaN; // CSD → base units
|
|
13
|
+
// Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
|
|
14
|
+
// unless we must derive it locally from the user's own csd wallet config (then we cache
|
|
15
|
+
// only the public address). Order: --address → CAIRN_ADDR → cached → derive via csd.
|
|
16
|
+
async function resolveAddr(a) {
|
|
17
|
+
const flag = a.flags.address ? String(a.flags.address) : (CAIRN_ADDR || loadLocalConfig().address);
|
|
18
|
+
if (flag && /^0x[0-9a-fA-F]{40}$/.test(flag))
|
|
19
|
+
return flag;
|
|
20
|
+
const cfg = await csd.walletConfig();
|
|
21
|
+
// Prefer the address csd already exposes (change addr) — avoids re-deriving from the
|
|
22
|
+
// privkey, which would put the key on the `csd` argv (visible via /proc on a shared host).
|
|
23
|
+
if (cfg?.default_change_addr20 && /^0x[0-9a-fA-F]{40}$/.test(String(cfg.default_change_addr20))) {
|
|
24
|
+
const addr = String(cfg.default_change_addr20);
|
|
25
|
+
saveLocalConfig({ address: addr });
|
|
26
|
+
return addr;
|
|
27
|
+
}
|
|
28
|
+
if (cfg?.default_privkey) {
|
|
29
|
+
const addr = await csd.deriveAddr(cfg.default_privkey);
|
|
30
|
+
if (addr) {
|
|
31
|
+
saveLocalConfig({ address: addr });
|
|
32
|
+
return addr;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Run a csd build/sign command (easy-path propose/attest/spend — they sign with the user's
|
|
38
|
+
// wallet CONFIG key, so we pass no key) and submit the resulting signed tx through the Cairn
|
|
39
|
+
// proxy ourselves. We do NOT trust csd's own auto-submit: it targets csd's configured node,
|
|
40
|
+
// which may be a different node than the one the Cairn board (and its miner) read — so a tx
|
|
41
|
+
// could sit in the wrong mempool and never get mined into the board's view. Always submit via
|
|
42
|
+
// the proxy (the board's miner-connected node). A repeat that comes back "already present /
|
|
43
|
+
// known" for OUR txid is success (the tx is in that node's mempool); a true double-spend
|
|
44
|
+
// "conflict" is the only ambiguous case, so we confirm via a tx lookup before claiming ok.
|
|
45
|
+
async function signAndSubmit(csdArgs) {
|
|
46
|
+
const r = await csd.run(csdArgs);
|
|
47
|
+
if (!r.ok)
|
|
48
|
+
return { ok: false, error: (r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0] };
|
|
49
|
+
let out = null;
|
|
50
|
+
try {
|
|
51
|
+
out = JSON.parse(r.stdout);
|
|
52
|
+
}
|
|
53
|
+
catch { /* unexpected */ }
|
|
54
|
+
if (!out?.tx)
|
|
55
|
+
return { ok: false, error: "csd produced no signed transaction" };
|
|
56
|
+
const txid = out.txid;
|
|
57
|
+
const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
|
|
58
|
+
if (sub.ok)
|
|
59
|
+
return { ok: true, txid: sub.txid || txid };
|
|
60
|
+
// A benign "already present / mempool conflict" for OUR txid means the tx is already in a
|
|
61
|
+
// mempool (e.g. csd's own auto-submit reached this same node first, or a re-run) — success.
|
|
62
|
+
// For a single-key wallet this is safe: only the key owner can produce a conflicting spend
|
|
63
|
+
// of their own UTXO, so a conflict on our freshly-built tx is our own prior submit, not a
|
|
64
|
+
// third party. (The narrow exception — two DIFFERENT local spends of one UTXO fired at once
|
|
65
|
+
// — is on the user.) The node can't be queried for mempool membership (its /tx indexes only
|
|
66
|
+
// mined txs), so we rely on the matching txid + benign message.
|
|
67
|
+
if (txid && /already|present|known|in mempool|conflict/i.test(String(sub.err ?? "")))
|
|
68
|
+
return { ok: true, txid };
|
|
69
|
+
return { ok: false, error: sub.err || "submit rejected by node", txid };
|
|
70
|
+
}
|
|
71
|
+
// Guard: a write needs `csd` installed + a configured wallet (or an explicit --address + csd key).
|
|
72
|
+
async function requireCsd() {
|
|
73
|
+
if (!(await csd.available())) {
|
|
74
|
+
console.log(err("`csd` not found.") + c.gray(" Install the Compute Substrate CLI, then ") + c.cyan("csd wallet new") + c.gray(" / ") + c.cyan("csd wallet init --privkey <key>") + c.gray(". Or set CAIRN_CSD to its path."));
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const cfg = await csd.walletConfig();
|
|
78
|
+
if (!cfg?.default_privkey) {
|
|
79
|
+
console.log(err("no csd wallet key configured.") + c.gray(" Run ") + c.cyan("csd wallet new") + c.gray(" then ") + c.cyan("csd wallet init --privkey <key>") + c.gray(" — cairn signs with your csd wallet."));
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
8
84
|
function parse(argv) {
|
|
9
85
|
const _ = [];
|
|
10
86
|
const flags = {};
|
|
@@ -27,6 +103,16 @@ function parse(argv) {
|
|
|
27
103
|
}
|
|
28
104
|
return { _, flags, multi };
|
|
29
105
|
}
|
|
106
|
+
// Do two URLs point at the same host? (used to refuse a "trustless" verify claim when the
|
|
107
|
+
// node RPC and the board API are the same operator). Unparseable → treat as same (safe).
|
|
108
|
+
function sameHost(a, b) {
|
|
109
|
+
try {
|
|
110
|
+
return new URL(a).host.toLowerCase() === new URL(b).host.toLowerCase();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
30
116
|
const age = (sec) => {
|
|
31
117
|
if (!sec)
|
|
32
118
|
return "—";
|
|
@@ -50,9 +136,9 @@ function printRows(items, sort = "totalWeight") {
|
|
|
50
136
|
const lens = !["totalWeight", "supporterCount"].includes(sort) && r[sort] != null
|
|
51
137
|
? c.gray(" · " + (sort === "createdHeight" ? "h" + r[sort] : csdFmt(r[sort]) + " " + sort)) : "";
|
|
52
138
|
console.log("");
|
|
53
|
-
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(r.title))} ${badge(r.source)}${r.sealed ? " " + c.gray(r.revealed ? "🔓 revealed" : "🔒 sealed") : ""}`);
|
|
54
|
-
console.log(` ${bar(r[sort] || r.totalWeight, max)} ${csdFmt(r.totalWeight)} ${c.gray("·")} ${c.green(String(r.supporterCount))} ${c.gray("supporters · score " + r.avgScore + " · " + age(r.createdTime) + " ago")}${lens}`);
|
|
55
|
-
console.log(c.gray(` ${r.domain} · id ${String(r.id).slice(0, 22)}…`));
|
|
139
|
+
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(san(r.title)))} ${badge(r.source)}${r.sealed ? " " + c.gray(r.revealed ? "🔓 revealed" : "🔒 sealed") : ""}`);
|
|
140
|
+
console.log(` ${bar(r[sort] || r.totalWeight, max)} ${csdFmt(r.totalWeight)} ${c.gray("·")} ${c.green(String(r.supporterCount))} ${c.gray("supporters · score " + Number(r.avgScore) + " · " + age(r.createdTime) + " ago")}${lens}`);
|
|
141
|
+
console.log(c.gray(` ${san(r.domain)} · id ${san(String(r.id).slice(0, 22))}…`));
|
|
56
142
|
});
|
|
57
143
|
}
|
|
58
144
|
async function cmdList(a) {
|
|
@@ -78,16 +164,34 @@ async function cmdWatch(a) {
|
|
|
78
164
|
printRows((await api.apiBoard(domain, window)).items);
|
|
79
165
|
return;
|
|
80
166
|
}
|
|
81
|
-
|
|
82
|
-
|
|
167
|
+
const PERIOD = 5; // seconds between refreshes
|
|
168
|
+
const PULSE = ["◐", "◓", "◑", "◒"]; // a spinning "live" mark in the footer
|
|
169
|
+
process.stdout.write("\x1b[?25l"); // hide cursor
|
|
170
|
+
const restore = () => process.stdout.write("\x1b[?25h\n");
|
|
171
|
+
process.on("SIGINT", () => { restore(); process.exit(0); });
|
|
172
|
+
clearScreen();
|
|
173
|
+
let first = true;
|
|
83
174
|
for (;;) {
|
|
84
175
|
const r = await api.apiBoard(domain, window).catch(() => ({ items: [] }));
|
|
85
|
-
|
|
176
|
+
// first paint clears the screen; subsequent paints repaint from home (no black flash)
|
|
177
|
+
if (first) {
|
|
178
|
+
clearScreen();
|
|
179
|
+
first = false;
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
cursorHome();
|
|
86
183
|
banner();
|
|
87
184
|
rule(`watch · ${domain} · ${window} · ${new Date().toLocaleTimeString()}`);
|
|
88
185
|
printRows(r.items);
|
|
89
|
-
|
|
90
|
-
|
|
186
|
+
process.stdout.write("\n"); // reserve the footer line, then redraw it in place each second
|
|
187
|
+
// animated footer: a phosphor pulse + a "next refresh" countdown (single line, \r-redrawn)
|
|
188
|
+
for (let s = PERIOD; s > 0; s--) {
|
|
189
|
+
const mark = c.green(PULSE[(PERIOD - s) % PULSE.length]);
|
|
190
|
+
process.stdout.write(`\r\x1b[K ${mark} ${c.gray("live · " + (r.items?.length ?? 0) + " items · next refresh in " + s + "s · Ctrl+C to exit")}`);
|
|
191
|
+
await sleep(anim ? 1000 : PERIOD * 1000);
|
|
192
|
+
if (!anim)
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
91
195
|
}
|
|
92
196
|
}
|
|
93
197
|
async function cmdRecent() {
|
|
@@ -96,7 +200,7 @@ async function cmdRecent() {
|
|
|
96
200
|
rule("recent activity");
|
|
97
201
|
for (const ev of r.activity ?? []) {
|
|
98
202
|
const verb = ev.type === "support" ? c.green("◈ supported") : c.cyan("✎ proposed ");
|
|
99
|
-
console.log(` ${verb} ${c.white(String(ev.item).slice(0, 42))} ${c.gray("· " + age(ev.time) + " ago · " + (ev.amount / 1e8) + " CSD")}`);
|
|
203
|
+
console.log(` ${verb} ${c.white(san(String(ev.item).slice(0, 42)))} ${c.gray("· " + age(ev.time) + " ago · " + (Number(ev.amount) / 1e8) + " CSD")}`);
|
|
100
204
|
}
|
|
101
205
|
}
|
|
102
206
|
async function cmdShow(a) {
|
|
@@ -111,15 +215,15 @@ async function cmdShow(a) {
|
|
|
111
215
|
return;
|
|
112
216
|
}
|
|
113
217
|
const it = r.item;
|
|
114
|
-
rule(it.title);
|
|
115
|
-
console.log(` ${badge(it.source)} ${c.gray("·")} ${c.cyan(it.domain)}`);
|
|
116
|
-
console.log(`\n ${c.white(it.body)}\n`);
|
|
218
|
+
rule(san(it.title));
|
|
219
|
+
console.log(` ${badge(it.source)} ${c.gray("·")} ${c.cyan(san(it.domain))}`);
|
|
220
|
+
console.log(`\n ${c.white(san(it.body))}\n`);
|
|
117
221
|
if (it.links?.length)
|
|
118
|
-
console.log(` ${kdim("links")} ${it.links.map((l) => c.cyan(l)).join(", ")}`);
|
|
119
|
-
const total = (r.supports ?? []).reduce((x, s) => x + s.weight, 0);
|
|
222
|
+
console.log(` ${kdim("links")} ${it.links.map((l) => c.cyan(san(l))).join(", ")}`);
|
|
223
|
+
const total = (r.supports ?? []).reduce((x, s) => x + Number(s.weight), 0);
|
|
120
224
|
console.log(` ${kdim("support")} ${csdFmt(total)} ${c.gray("from")} ${c.green(String(new Set((r.supports ?? []).map((s) => s.attester)).size))} ${c.gray("supporters")}`);
|
|
121
|
-
console.log(` ${kdim("proposer")} ${c.gray(it.proposerHandle || it.proposer)}`);
|
|
122
|
-
console.log(` ${kdim("hash")} ${c.magenta(it.payloadHash)}`);
|
|
225
|
+
console.log(` ${kdim("proposer")} ${c.gray(san(it.proposerHandle || it.proposer))}`);
|
|
226
|
+
console.log(` ${kdim("hash")} ${c.magenta(san(it.payloadHash))}`);
|
|
123
227
|
console.log(` ${kdim("integrity")} ${r.integrityOk ? ok("content matches commitment") : err("MISMATCH")}`);
|
|
124
228
|
}
|
|
125
229
|
async function cmdVerify(a) {
|
|
@@ -136,21 +240,136 @@ async function cmdVerify(a) {
|
|
|
136
240
|
return;
|
|
137
241
|
}
|
|
138
242
|
const it = r.item;
|
|
243
|
+
// Hash the RAW server-reported content (never san()'d — we must hash the exact bytes).
|
|
139
244
|
const { payloadHash } = buildCommitment({ v: 1, domain: it.domain, title: it.title, body: it.body, links: it.links ?? [] });
|
|
140
|
-
|
|
245
|
+
// Only consult the chain RPC for a well-formed id (the server echoes it.id back; an
|
|
246
|
+
// attacker-shaped id must not be spliced into the RPC URL — see api.chainProposal).
|
|
247
|
+
const chain = /^0x[0-9a-fA-F]{64}$/.test(String(it.id ?? "")) ? await api.chainProposal(it.id) : null;
|
|
141
248
|
sp.stop();
|
|
142
249
|
console.log(`${kdim("recomputed")} ${c.magenta(payloadHash)}`);
|
|
143
|
-
console.log(`${kdim("reported")} ${c.magenta(it.payloadHash)}`);
|
|
250
|
+
console.log(`${kdim("reported")} ${c.magenta(san(it.payloadHash))}`);
|
|
144
251
|
const contentOk = payloadHash.toLowerCase() === String(it.payloadHash).toLowerCase();
|
|
145
252
|
if (chain?.payload_hash) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
253
|
+
// "trustless" only holds if CAIRN_RPC is an INDEPENDENT node — if it's the same host as
|
|
254
|
+
// the board API, the same operator controls both answers, so don't claim trustlessness.
|
|
255
|
+
const independent = !sameHost(CAIRN_RPC, CAIRN_API);
|
|
256
|
+
const chainOk = contentOk && String(chain.payload_hash).toLowerCase() === payloadHash.toLowerCase();
|
|
257
|
+
console.log(`${kdim("on-chain")} ${c.magenta(san(chain.payload_hash))}`);
|
|
258
|
+
if (chainOk)
|
|
259
|
+
console.log(independent
|
|
260
|
+
? ok("VERIFIED — content matches the on-chain commitment (trustless, via an independent CAIRN_RPC)")
|
|
261
|
+
: ok("content matches the commitment reported by this RPC") + c.gray(" ⚠ CAIRN_RPC shares a host with CAIRN_API — point it at an independent node for a trustless check"));
|
|
262
|
+
else
|
|
263
|
+
console.log(err("MISMATCH"));
|
|
150
264
|
}
|
|
151
265
|
else {
|
|
152
|
-
console.log(contentOk ? ok("content matches the reported commitment") + c.gray(" (set CAIRN_RPC to also check the chain directly)") : err("content does NOT match the reported hash"));
|
|
266
|
+
console.log(contentOk ? ok("content matches the reported commitment") + c.gray(" (set CAIRN_RPC to an independent node to also check the chain directly)") : err("content does NOT match the reported hash"));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ── wallet (on top of the user's installed `csd` — cairn never holds the key) ──
|
|
270
|
+
async function cmdSetup() {
|
|
271
|
+
banner();
|
|
272
|
+
rule("setup — cairn over your csd wallet");
|
|
273
|
+
const has = await csd.available();
|
|
274
|
+
console.log(` ${kdim("csd binary")} ${has ? ok("found (" + csd.CSD_BIN + ")") : err("not found — install Compute Substrate's csd CLI, or set CAIRN_CSD to its path")}`);
|
|
275
|
+
if (!has) {
|
|
276
|
+
console.log(c.gray("\n cairn signs nothing itself — it drives your csd wallet. Install csd, then re-run ") + c.cyan("cairn setup") + c.gray("."));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const cfg = await csd.walletConfig();
|
|
280
|
+
console.log(` ${kdim("csd wallet")} ${cfg?.default_privkey ? ok("key configured") : warn("no key — run ") + c.cyan("csd wallet new") + c.gray(" then ") + c.cyan("csd wallet init --privkey <key>")}`);
|
|
281
|
+
const addr = await resolveAddr(a0());
|
|
282
|
+
if (addr) {
|
|
283
|
+
console.log(` ${kdim("address")} ${c.cyan(addr)}`);
|
|
284
|
+
const b = await api.confirmedBalance(addr).catch(() => null);
|
|
285
|
+
if (b)
|
|
286
|
+
console.log(` ${kdim("balance")} ${c.white(csdToCoins(b.balance))} CSD ${c.gray("(" + b.utxos + " utxos)")}`);
|
|
153
287
|
}
|
|
288
|
+
console.log(` ${kdim("api")} ${c.gray(CAIRN_API)}`);
|
|
289
|
+
if (has && cfg?.default_privkey)
|
|
290
|
+
console.log(c.gray("\n ready: ") + c.cyan("cairn send · cairn propose · cairn support · cairn wall place"));
|
|
291
|
+
}
|
|
292
|
+
const a0 = () => ({ _: [], flags: {}, multi: {} });
|
|
293
|
+
async function cmdAddress(a) {
|
|
294
|
+
const addr = await resolveAddr(a);
|
|
295
|
+
if (!addr) {
|
|
296
|
+
console.log(err("no address — run ") + c.cyan("cairn setup") + err(" (needs a configured csd wallet) or pass --address"));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (!isTty) {
|
|
300
|
+
console.log(addr);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
banner();
|
|
304
|
+
rule("your address");
|
|
305
|
+
console.log(` ${kdim("address")} ${c.cyan(addr)}`);
|
|
306
|
+
const b = await api.confirmedBalance(addr).catch(() => null);
|
|
307
|
+
if (b)
|
|
308
|
+
console.log(` ${kdim("balance")} ${c.white(csdToCoins(b.balance))} CSD ${c.gray("(" + b.utxos + " utxos)")}`);
|
|
309
|
+
}
|
|
310
|
+
async function cmdBalance(a) { return cmdAddress(a); }
|
|
311
|
+
function gatherOutputs(a) {
|
|
312
|
+
const outs = [];
|
|
313
|
+
for (const spec of (a.multi.output ?? [])) {
|
|
314
|
+
const i = String(spec).lastIndexOf(":");
|
|
315
|
+
if (i < 0)
|
|
316
|
+
return `bad --output (want <addr>:<CSD>): ${spec}`;
|
|
317
|
+
outs.push({ to: String(spec).slice(0, i), value: CSD(Number(String(spec).slice(i + 1))) });
|
|
318
|
+
}
|
|
319
|
+
if (a.flags.to !== undefined || a.flags.amount !== undefined)
|
|
320
|
+
outs.push({ to: String(a.flags.to ?? ""), value: CSD(Number(a.flags.amount ?? 0)) });
|
|
321
|
+
for (const o of outs) {
|
|
322
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(o.to))
|
|
323
|
+
return `bad recipient: ${o.to}`;
|
|
324
|
+
if (!(o.value > 0) || !Number.isSafeInteger(o.value))
|
|
325
|
+
return `bad amount for ${o.to}`;
|
|
326
|
+
}
|
|
327
|
+
return outs.length ? outs : "no outputs";
|
|
328
|
+
}
|
|
329
|
+
async function cmdSend(a) {
|
|
330
|
+
const outs = gatherOutputs(a);
|
|
331
|
+
if (typeof outs === "string") {
|
|
332
|
+
console.log(outs === "no outputs" ? warn("usage: ") + c.cyan("cairn send --to <0x…40> --amount <CSD> [--fee <CSD>]") + c.gray(" (repeat --output <a>:<CSD> for many)") : err(outs));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!(await requireCsd()))
|
|
336
|
+
return;
|
|
337
|
+
const addr = await resolveAddr(a);
|
|
338
|
+
if (!addr) {
|
|
339
|
+
console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.01;
|
|
343
|
+
const fee = (Number.isFinite(feeCsd) && feeCsd >= 0) ? CSD(feeCsd) : 1_000_000;
|
|
344
|
+
const total = outs.reduce((s, o) => s + o.value, 0);
|
|
345
|
+
console.log(`${kdim("from")} ${c.cyan(addr)}`);
|
|
346
|
+
for (const o of outs)
|
|
347
|
+
console.log(`${kdim("to")} ${c.cyan(o.to)} ${c.gray("→ " + csdToCoins(o.value) + " CSD")}`);
|
|
348
|
+
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("total")} ${csdToCoins(total + fee)} CSD`);
|
|
349
|
+
if (a.flags["dry-run"]) {
|
|
350
|
+
console.log(c.gray("\n[dry-run] not sent"));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const sp = spinner("fetching input → csd signs → submit");
|
|
354
|
+
const picked = await api.pickInput(addr, total + fee).catch(() => null);
|
|
355
|
+
if (!picked) {
|
|
356
|
+
sp.stop();
|
|
357
|
+
console.log(err("no single confirmed UTXO covers amount + fee") + c.gray(" — fund this address, or consolidate (a node + `csd … --auto-input` can combine inputs)"));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
sp.stop();
|
|
361
|
+
// transparency: show the input value + change so a hostile proxy under-reporting the input
|
|
362
|
+
// (which would silently inflate the burned fee) is visible before we sign. Change goes to
|
|
363
|
+
// your own address; the proxy can never redirect it.
|
|
364
|
+
console.log(`${kdim("input")} ${csdToCoins(picked.value)} CSD ${c.gray("(one UTXO)")} ${kdim("change")} ${csdToCoins(Math.max(0, picked.value - total - fee))} CSD ${c.gray("back to you")}`);
|
|
365
|
+
const sp2 = spinner("csd signs → submit");
|
|
366
|
+
const args = ["spend"];
|
|
367
|
+
for (const o of outs)
|
|
368
|
+
args.push("--output", `${o.to}:${o.value}`);
|
|
369
|
+
args.push("--change", addr, "--fee", String(fee), "--input", picked.input);
|
|
370
|
+
const r = await signAndSubmit(args);
|
|
371
|
+
sp2.stop();
|
|
372
|
+
console.log(r.ok ? ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
|
|
154
373
|
}
|
|
155
374
|
async function cmdPropose(a) {
|
|
156
375
|
const domain = String(a.flags.domain ?? "");
|
|
@@ -158,60 +377,152 @@ async function cmdPropose(a) {
|
|
|
158
377
|
const body = String(a.flags.body ?? "");
|
|
159
378
|
const links = a.multi.link ?? [];
|
|
160
379
|
if (!domain || !title) {
|
|
161
|
-
console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <
|
|
380
|
+
console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <CSD>] [--expires-days N]"));
|
|
162
381
|
return;
|
|
163
382
|
}
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
sp
|
|
169
|
-
|
|
383
|
+
const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.25;
|
|
384
|
+
const fee = Math.max(MIN_FEE_PROPOSE, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_PROPOSE);
|
|
385
|
+
// operator-token path stays available for the instance operator
|
|
386
|
+
if (CAIRN_TOKEN && !(await csd.available())) {
|
|
387
|
+
const sp = spinner("posting via operator token");
|
|
388
|
+
try {
|
|
389
|
+
const r = await api.apiPropose({ domain, title, body, links, fee });
|
|
390
|
+
sp.stop();
|
|
391
|
+
console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
sp.stop();
|
|
395
|
+
console.log(err(e.message));
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
170
398
|
}
|
|
171
|
-
|
|
399
|
+
if (!(await requireCsd()))
|
|
400
|
+
return;
|
|
401
|
+
const addr = await resolveAddr(a);
|
|
402
|
+
if (!addr) {
|
|
403
|
+
console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const content = { v: 1, domain, title, body, links };
|
|
407
|
+
const { payloadHash } = buildCommitment(content);
|
|
408
|
+
const uri = "cairn:v1:" + payloadHash.slice(2, 14);
|
|
409
|
+
if (a.flags["dry-run"]) {
|
|
410
|
+
console.log(`${kdim("domain")} ${c.cyan(domain)}`);
|
|
411
|
+
console.log(`${kdim("title")} ${c.white(title)}`);
|
|
412
|
+
console.log(`${kdim("hash")} ${c.magenta(payloadHash)} ${c.gray("· uri " + uri)}`);
|
|
413
|
+
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("from")} ${c.cyan(addr)}`);
|
|
414
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const sp = spinner("fetching input → csd signs → submit");
|
|
418
|
+
const picked = await api.pickInput(addr, fee).catch(() => null);
|
|
419
|
+
if (!picked) {
|
|
172
420
|
sp.stop();
|
|
173
|
-
console.log(err(
|
|
421
|
+
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
422
|
+
return;
|
|
174
423
|
}
|
|
424
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
425
|
+
const days = Math.max(1, parseInt(String(a.flags["expires-days"] ?? 30)) || 30);
|
|
426
|
+
const r = await signAndSubmit(["propose", "--domain", domain, "--payload-hash", payloadHash, "--uri", uri, "--expires-epoch", String(Math.floor(tip / 30) + days * 24), "--fee", String(fee), "--change", addr, "--input", picked.input]);
|
|
427
|
+
sp.stop();
|
|
428
|
+
if (!r.ok) {
|
|
429
|
+
console.log(err(r.error || "failed"));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
console.log(ok(`proposed ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
433
|
+
const sp2 = spinner("registering content (waits for the tx to mine)");
|
|
434
|
+
const done = await api.registerContent({ domain, title, body, links }, r.txid);
|
|
435
|
+
sp2.stop();
|
|
436
|
+
console.log(done ? ok("content registered — visible on the board") : warn("content not registered yet — re-run once mined"));
|
|
175
437
|
}
|
|
176
438
|
async function cmdSupport(a) {
|
|
177
439
|
const id = a._[1];
|
|
178
440
|
if (!id) {
|
|
179
|
-
console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <
|
|
441
|
+
console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <CSD> [--score 0-100] [--confidence 0-100]"));
|
|
180
442
|
return;
|
|
181
443
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const r = await api.apiSupport({ id, fee: Number.isFinite(fee) && fee >= MIN_FEE_ATTEST ? Math.floor(fee) : MIN_FEE_ATTEST, score: Number(a.flags.score ?? 75), confidence: Number(a.flags.confidence ?? 60) });
|
|
186
|
-
sp.stop();
|
|
187
|
-
console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) : err(r.error || "failed"));
|
|
444
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(id)) {
|
|
445
|
+
console.log(err("proposal id must be 0x…64-hex"));
|
|
446
|
+
return;
|
|
188
447
|
}
|
|
189
|
-
|
|
448
|
+
const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.05;
|
|
449
|
+
const fee = Math.max(MIN_FEE_ATTEST, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_ATTEST);
|
|
450
|
+
const score = Math.max(0, Math.min(100, parseInt(String(a.flags.score ?? 75)) || 0));
|
|
451
|
+
const confidence = Math.max(0, Math.min(100, parseInt(String(a.flags.confidence ?? 60)) || 0));
|
|
452
|
+
if (CAIRN_TOKEN && !(await csd.available())) {
|
|
453
|
+
const sp = spinner("posting via operator token");
|
|
454
|
+
try {
|
|
455
|
+
const r = await api.apiSupport({ id, fee, score, confidence });
|
|
456
|
+
sp.stop();
|
|
457
|
+
console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
sp.stop();
|
|
461
|
+
console.log(err(e.message));
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!(await requireCsd()))
|
|
466
|
+
return;
|
|
467
|
+
const addr = await resolveAddr(a);
|
|
468
|
+
if (!addr) {
|
|
469
|
+
console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (a.flags["dry-run"]) {
|
|
473
|
+
console.log(`${kdim("support")} ${c.cyan(id)}`);
|
|
474
|
+
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${c.gray("· score " + score + " · confidence " + confidence)} ${kdim("from")} ${c.cyan(addr)}`);
|
|
475
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const sp = spinner("fetching input → csd signs → submit");
|
|
479
|
+
const picked = await api.pickInput(addr, fee).catch(() => null);
|
|
480
|
+
if (!picked) {
|
|
190
481
|
sp.stop();
|
|
191
|
-
console.log(err(
|
|
482
|
+
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
483
|
+
return;
|
|
192
484
|
}
|
|
485
|
+
const r = await signAndSubmit(["attest", "--proposal-id", id, "--score", String(score), "--confidence", String(confidence), "--fee", String(fee), "--change", addr, "--input", picked.input]);
|
|
486
|
+
sp.stop();
|
|
487
|
+
console.log(r.ok ? ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
|
|
488
|
+
}
|
|
489
|
+
async function cmdWall(a) {
|
|
490
|
+
if (a._[1] === "place") {
|
|
491
|
+
const msg = a._.slice(2).join(" ").trim() || String(a.flags.message ?? "").trim();
|
|
492
|
+
if (!msg) {
|
|
493
|
+
console.log(warn("usage: ") + c.cyan('cairn wall place "<message>" [--fee <CSD>] [--dry-run]'));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// forward the write-relevant flags (fee, address, dry-run) through to the propose path
|
|
497
|
+
const fwd = { domain: "cairn:wall", title: msg };
|
|
498
|
+
for (const k of ["fee", "address", "dry-run"])
|
|
499
|
+
if (a.flags[k] !== undefined)
|
|
500
|
+
fwd[k] = a.flags[k];
|
|
501
|
+
return cmdPropose({ _: ["propose"], flags: fwd, multi: {} });
|
|
502
|
+
}
|
|
503
|
+
return cmdWallView();
|
|
193
504
|
}
|
|
194
505
|
async function cmdDomains() {
|
|
195
506
|
const r = await api.apiDomains();
|
|
196
507
|
banner();
|
|
197
508
|
rule("categories");
|
|
198
509
|
for (const dom of r.domains ?? [])
|
|
199
|
-
console.log(` ${c.cyan(pad(dom.key, 20))} ${c.white(dom.title)} ${c.gray(dom.count != null ? "(" + dom.count + ")" : "")}`);
|
|
510
|
+
console.log(` ${c.cyan(pad(san(dom.key), 20))} ${c.white(san(dom.title))} ${c.gray(dom.count != null ? "(" + Number(dom.count) + ")" : "")}`);
|
|
200
511
|
// open domains: anyone can create one by proposing into it (cairn ls <domain> works for any).
|
|
201
512
|
const disc = r.discovered ?? [];
|
|
202
513
|
if (disc.length) {
|
|
203
514
|
console.log(c.gray("\n open domains (created by proposing into them):"));
|
|
204
515
|
for (const d of disc)
|
|
205
|
-
console.log(` ${c.cyan(pad(d.key, 20))} ${c.gray((d.count != null ? d.count + " items" : "") + (d.totalWeight ? " · " + csdToCoins(d.totalWeight) + " CSD" : ""))}`);
|
|
516
|
+
console.log(` ${c.cyan(pad(san(d.key), 20))} ${c.gray((d.count != null ? Number(d.count) + " items" : "") + (d.totalWeight ? " · " + csdToCoins(d.totalWeight) + " CSD" : ""))}`);
|
|
206
517
|
}
|
|
207
518
|
}
|
|
208
|
-
async function
|
|
519
|
+
async function cmdWallView() {
|
|
209
520
|
const r = await api.apiWall();
|
|
210
521
|
const stones = r.stones ?? [];
|
|
211
522
|
banner();
|
|
212
523
|
rule(`the wall · ${r.totals?.stones ?? 0} stones · ${r.totals?.boosts ?? 0} boosts · epoch ${r.epoch ?? "?"}`);
|
|
213
524
|
if (r.king)
|
|
214
|
-
console.log(` ${c.green("★ KING")} ${c.white(c.bold(r.king.message))} ${csdFmt(r.king.weight)} ${c.gray("· " + r.king.boosts + " boosts")}`);
|
|
525
|
+
console.log(` ${c.green("★ KING")} ${c.white(c.bold(san(r.king.message)))} ${csdFmt(r.king.weight)} ${c.gray("· " + Number(r.king.boosts) + " boosts")}`);
|
|
215
526
|
if (!stones.length) {
|
|
216
527
|
console.log(c.gray("\n no stones yet — place one with the Cairn Wallet, or:"));
|
|
217
528
|
console.log(c.green(" cairn propose --domain cairn:wall --title '<message>'"));
|
|
@@ -220,8 +531,8 @@ async function cmdWall() {
|
|
|
220
531
|
const max = stones[0]?.weight || 1;
|
|
221
532
|
stones.slice(0, 25).forEach((s, i) => {
|
|
222
533
|
console.log("");
|
|
223
|
-
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(s.message))}${i === 0 ? " " + c.green("★") : ""}`);
|
|
224
|
-
console.log(` ${bar(s.weight, max)} ${csdFmt(s.weight)} ${c.gray("·")} ${c.green(String(s.boosts))} ${c.gray("boosts · " + age(s.ts) + " ago")}${(s.tags && s.tags.length) ? c.gray(" #" + s.tags.join(" #")) : ""}`);
|
|
534
|
+
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(san(s.message)))}${i === 0 ? " " + c.green("★") : ""}`);
|
|
535
|
+
console.log(` ${bar(s.weight, max)} ${csdFmt(s.weight)} ${c.gray("·")} ${c.green(String(s.boosts))} ${c.gray("boosts · " + age(s.ts) + " ago")}${(s.tags && s.tags.length) ? c.gray(" #" + s.tags.map((t) => san(t)).join(" #")) : ""}`);
|
|
225
536
|
});
|
|
226
537
|
}
|
|
227
538
|
async function cmdNetwork() {
|
|
@@ -258,14 +569,14 @@ async function cmdProfile(a) {
|
|
|
258
569
|
}
|
|
259
570
|
const p = r.profile || {}, rep = r.reputation || {};
|
|
260
571
|
banner();
|
|
261
|
-
rule(`profile · ${p.handle || addr}`);
|
|
572
|
+
rule(`profile · ${san(p.handle || addr)}`);
|
|
262
573
|
if (p.handle)
|
|
263
|
-
console.log(` ${kdim(pad("handle", 13))} ${c.white(p.handle)}`);
|
|
574
|
+
console.log(` ${kdim(pad("handle", 13))} ${c.white(san(p.handle))}`);
|
|
264
575
|
if (p.bio)
|
|
265
|
-
console.log(` ${kdim(pad("bio", 13))} ${c.gray(p.bio)}`);
|
|
576
|
+
console.log(` ${kdim(pad("bio", 13))} ${c.gray(san(p.bio))}`);
|
|
266
577
|
if (p.github)
|
|
267
|
-
console.log(` ${kdim(pad("github", 13))} ${c.cyan(p.github)} ${p.githubVerified ? ok("verified") : c.gray("(unverified)")}`);
|
|
268
|
-
console.log(` ${kdim(pad("address", 13))} ${c.gray(p.addr || addr)}`);
|
|
578
|
+
console.log(` ${kdim(pad("github", 13))} ${c.cyan(san(p.github))} ${p.githubVerified ? ok("verified") : c.gray("(unverified)")}`);
|
|
579
|
+
console.log(` ${kdim(pad("address", 13))} ${c.gray(san(p.addr || addr))}`);
|
|
269
580
|
console.log(` ${kdim(pad("trust", 13))} ${c.white((rep.trust ?? 0).toFixed(2))}`);
|
|
270
581
|
console.log(` ${kdim(pad("work", 13))} ${c.green(String(rep.proposed ?? 0))} proposed ${c.gray("·")} ${c.green(String(rep.shipped ?? 0))} shipped ${c.gray("·")} ${c.green(String(rep.acceptedWork ?? 0))} accepted ${c.gray("·")} ${c.green(String(rep.reviews ?? 0))} reviews`);
|
|
271
582
|
}
|
|
@@ -279,7 +590,7 @@ async function cmdLeaderboard() {
|
|
|
279
590
|
return;
|
|
280
591
|
}
|
|
281
592
|
lb.slice(0, 25).forEach((e, i) => {
|
|
282
|
-
console.log(` ${c.magenta(c.bold(pad("#" + (i + 1), 4)))} ${c.white(pad(e.handle || e.addr, 26))} ${c.gray("trust")} ${c.white((e.trust
|
|
593
|
+
console.log(` ${c.magenta(c.bold(pad("#" + (i + 1), 4)))} ${c.white(pad(san(e.handle || e.addr), 26))} ${c.gray("trust")} ${c.white((Number(e.trust) || 0).toFixed(2))} ${c.gray("· " + Number(e.shipped ?? e.acceptedWork ?? 0) + " shipped · " + Number(e.proposed ?? 0) + " proposed")}`);
|
|
283
594
|
});
|
|
284
595
|
}
|
|
285
596
|
async function cmdQuests() {
|
|
@@ -294,9 +605,9 @@ async function cmdQuests() {
|
|
|
294
605
|
qs.slice(0, 25).forEach((q, i) => {
|
|
295
606
|
const reward = q.quest?.reward?.build ? csdToCoins(q.quest.reward.build) + " CSD" : "—";
|
|
296
607
|
console.log("");
|
|
297
|
-
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(q.title))} ${c.gray("· " + (q.status || "?"))}`);
|
|
298
|
-
console.log(` ${c.gray("reward " + reward + " · demand " + csdFmt(q.demandWeight || 0) + " · " + (q.demandSupporters || 0) + " backers")}`);
|
|
299
|
-
console.log(c.gray(` id ${String(q.id).slice(0, 22)}…`));
|
|
608
|
+
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(san(q.title)))} ${c.gray("· " + san(q.status || "?"))}`);
|
|
609
|
+
console.log(` ${c.gray("reward " + reward + " · demand " + csdFmt(q.demandWeight || 0) + " · " + Number(q.demandSupporters || 0) + " backers")}`);
|
|
610
|
+
console.log(c.gray(` id ${san(String(q.id).slice(0, 22))}…`));
|
|
300
611
|
});
|
|
301
612
|
}
|
|
302
613
|
async function help() {
|
|
@@ -315,13 +626,19 @@ async function help() {
|
|
|
315
626
|
cmd("quests", "", "open quests");
|
|
316
627
|
cmd("profile", "<addr>", "identity + reputation");
|
|
317
628
|
cmd("leaderboard", "", "top builders by reputation");
|
|
318
|
-
cmd("
|
|
319
|
-
|
|
629
|
+
cmd("wall place", '"<message>"', "place a stone on the Wall (a cairn:wall proposal)");
|
|
630
|
+
console.log("");
|
|
631
|
+
console.log(c.bold(" wallet") + c.gray(" (signs with your installed csd wallet — cairn never holds your key)"));
|
|
632
|
+
cmd("setup", "", "check csd + wallet, show your address (alias: doctor)");
|
|
633
|
+
cmd("address", "", "your address + balance (alias: whoami, balance)");
|
|
634
|
+
cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --dry-run)");
|
|
635
|
+
cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (alias: post; + --fee, --expires-days, --dry-run)");
|
|
636
|
+
cmd("support", "<id> --fee <CSD>", "back an item (+ --score, --confidence, --dry-run)");
|
|
320
637
|
console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
|
|
321
|
-
console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${MIN_FEE_PROPOSE} · attest ≥ ${MIN_FEE_ATTEST}`));
|
|
322
|
-
console.log(c.gray(" config: CAIRN_API (board
|
|
638
|
+
console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${csdToCoins(MIN_FEE_PROPOSE)} · attest ≥ ${csdToCoins(MIN_FEE_ATTEST)} CSD`));
|
|
639
|
+
console.log(c.gray(" config: CAIRN_API (board) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
|
|
323
640
|
console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
|
|
324
|
-
console.log(c.gray("
|
|
641
|
+
console.log(c.gray(" writes are signed by your own ") + c.cyan("csd") + c.gray(" wallet (csd wallet new / init); cairn supplies the input + Cairn content. Sealed claims + Sign-in: use the Cairn Wallet."));
|
|
325
642
|
}
|
|
326
643
|
async function main() {
|
|
327
644
|
const a = parse(process.argv.slice(2));
|
|
@@ -334,16 +651,159 @@ async function main() {
|
|
|
334
651
|
case "recent": return cmdRecent();
|
|
335
652
|
case "show": return cmdShow(a);
|
|
336
653
|
case "verify": return cmdVerify(a);
|
|
337
|
-
case "wall": return cmdWall();
|
|
654
|
+
case "wall": return cmdWall(a);
|
|
338
655
|
case "network":
|
|
339
656
|
case "stats": return cmdNetwork();
|
|
340
657
|
case "quests": return cmdQuests();
|
|
341
658
|
case "profile": return cmdProfile(a);
|
|
342
659
|
case "leaderboard":
|
|
343
660
|
case "lb": return cmdLeaderboard();
|
|
344
|
-
case "
|
|
661
|
+
case "setup":
|
|
662
|
+
case "doctor": return cmdSetup();
|
|
663
|
+
case "address":
|
|
664
|
+
case "whoami": return cmdAddress(a);
|
|
665
|
+
case "balance": return cmdBalance(a);
|
|
666
|
+
case "send": return cmdSend(a);
|
|
667
|
+
case "propose":
|
|
668
|
+
case "post": return cmdPropose(a);
|
|
345
669
|
case "support": return cmdSupport(a);
|
|
670
|
+
case "gateway": return cmdGateway(a);
|
|
671
|
+
case "peer": return cmdPeer(a);
|
|
672
|
+
case "identity": return cmdIdentity(a);
|
|
346
673
|
default: return help();
|
|
347
674
|
}
|
|
348
675
|
}
|
|
676
|
+
// ── L3 registry publish commands (build a signed record → anchor Propose → serve bytes) ──
|
|
677
|
+
// Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
|
|
678
|
+
// then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
|
|
679
|
+
async function anchorRecord(rec, addr, fee, days, label) {
|
|
680
|
+
const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
|
|
681
|
+
const sp = spinner("fetching input → csd signs → submit");
|
|
682
|
+
const picked = await api.pickInput(addr, fee).catch(() => null);
|
|
683
|
+
if (!picked) {
|
|
684
|
+
sp.stop();
|
|
685
|
+
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
689
|
+
const r = await signAndSubmit(["propose", "--domain", rec.domain, "--payload-hash", rec.payloadHash, "--uri", uri, "--expires-epoch", String(Math.floor(tip / 30) + days * 24), "--fee", String(fee), "--change", addr, "--input", picked.input]);
|
|
690
|
+
sp.stop();
|
|
691
|
+
if (!r.ok) {
|
|
692
|
+
console.log(err(r.error || "failed"));
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
console.log(ok(`${label} anchored ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
696
|
+
const sp2 = spinner("publishing content (waits for the tx to mine)");
|
|
697
|
+
const done = await api.registerRawContent(canonicalJson(rec.content), r.txid);
|
|
698
|
+
sp2.stop();
|
|
699
|
+
console.log(done ? ok("content published — record is now resolvable") : warn("content not published yet — re-run once mined"));
|
|
700
|
+
return done;
|
|
701
|
+
}
|
|
702
|
+
// Shared setup for the registry commands: require csd, the privkey (to sign the binding
|
|
703
|
+
// locally — never networked), and the address.
|
|
704
|
+
async function registryPrep(a) {
|
|
705
|
+
if (!(await requireCsd()))
|
|
706
|
+
return null;
|
|
707
|
+
const cfg = await csd.walletConfig();
|
|
708
|
+
const priv = cfg?.default_privkey;
|
|
709
|
+
if (!priv) {
|
|
710
|
+
console.log(err("no csd wallet key configured.") + c.gray(" Run ") + c.cyan("csd wallet new"));
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const addr = await resolveAddr(a);
|
|
714
|
+
if (!addr) {
|
|
715
|
+
console.log(err("could not resolve your address — run ") + c.cyan("cairn setup"));
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
return { priv, addr };
|
|
719
|
+
}
|
|
720
|
+
async function cmdGateway(a) {
|
|
721
|
+
if (a._[1] !== "register") {
|
|
722
|
+
console.log(warn("usage: ") + c.cyan("cairn gateway register --url https://gw/content/0x{hash} [--pin] [--fee 0.25]"));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const url = String(a.flags.url ?? "");
|
|
726
|
+
if (!url.includes("{hash}")) {
|
|
727
|
+
console.log(err("--url must contain the {hash} template, e.g. https://gw/content/0x{hash}"));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const p = await registryPrep(a);
|
|
731
|
+
if (!p)
|
|
732
|
+
return;
|
|
733
|
+
const rec = buildGatewayRecord({ priv: p.priv, url, kind: a.flags.pin ? "pin" : "gateway", address: p.addr });
|
|
734
|
+
const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
|
|
735
|
+
if (a.flags["dry-run"]) {
|
|
736
|
+
console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("url")} ${c.white(url)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
|
|
737
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
await anchorRecord(rec, p.addr, fee, 10, "gateway");
|
|
741
|
+
}
|
|
742
|
+
async function cmdPeer(a) {
|
|
743
|
+
if (a._[1] !== "announce") {
|
|
744
|
+
console.log(warn("usage: ") + c.cyan("cairn peer announce --peer-id <id> --addr /ip4/…/tcp/… [--addr …] [--cap full] [--fee 0.25]"));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const peerId = String(a.flags["peer-id"] ?? "");
|
|
748
|
+
const multiaddrs = (a.multi.addr ?? (a.flags.addr ? [String(a.flags.addr)] : [])).filter(Boolean);
|
|
749
|
+
if (!peerId || multiaddrs.length === 0) {
|
|
750
|
+
console.log(err("--peer-id and at least one --addr required"));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
const p = await registryPrep(a);
|
|
754
|
+
if (!p)
|
|
755
|
+
return;
|
|
756
|
+
const caps = (a.multi.cap ?? (a.flags.cap ? [String(a.flags.cap)] : [])).filter(Boolean);
|
|
757
|
+
const rec = buildPeerRecord({ priv: p.priv, peer_id: peerId, multiaddrs, caps: caps.length ? caps : undefined, address: p.addr });
|
|
758
|
+
const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
|
|
759
|
+
if (a.flags["dry-run"]) {
|
|
760
|
+
console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("peer")} ${c.white(peerId)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
|
|
761
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
await anchorRecord(rec, p.addr, fee, 10, "peer");
|
|
765
|
+
}
|
|
766
|
+
async function cmdIdentity(a) {
|
|
767
|
+
const sub = a._[1];
|
|
768
|
+
const handle = String(a.flags.handle ?? a._[2] ?? "");
|
|
769
|
+
if (sub !== "claim" || !handle) {
|
|
770
|
+
console.log(warn("usage: ") + c.cyan("cairn identity claim <handle> [--salt <hex>] [--commit-only|--reveal] [--fee 0.25]"));
|
|
771
|
+
console.log(c.gray(" step 1: --commit-only (saves a salt) · step 2 (next epoch): --reveal --salt <hex>"));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!/^[a-z0-9_.-]{3,32}$/i.test(handle)) {
|
|
775
|
+
console.log(err("handle must be 3–32 chars [a-z0-9_.-]"));
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const p = await registryPrep(a);
|
|
779
|
+
if (!p)
|
|
780
|
+
return;
|
|
781
|
+
const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
|
|
782
|
+
if (a.flags.reveal) {
|
|
783
|
+
const salt = String(a.flags.salt ?? "");
|
|
784
|
+
if (!/^[0-9a-f]{16,}$/i.test(salt)) {
|
|
785
|
+
console.log(err("--salt <hex> from your earlier --commit-only step is required to reveal"));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const rec = buildIdentityReveal({ priv: p.priv, handle, salt, address: p.addr });
|
|
789
|
+
if (a.flags["dry-run"]) {
|
|
790
|
+
console.log(`${kdim("reveal")} ${c.white(handle)} → ${c.cyan(p.addr)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
|
|
791
|
+
console.log(c.gray("\n[dry-run] not signed"));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
await anchorRecord(rec, p.addr, fee, 90, "identity reveal");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// default / --commit-only: step 1
|
|
798
|
+
const salt = String(a.flags.salt ?? randomBytes(16).toString("hex"));
|
|
799
|
+
const rec = buildIdentityCommit({ handle, salt, address: p.addr });
|
|
800
|
+
if (a.flags["dry-run"]) {
|
|
801
|
+
console.log(`${kdim("commit")} ${c.white(handle)}\n${kdim("salt")} ${c.magenta(salt)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
|
|
802
|
+
console.log(c.gray("\n[dry-run] not signed"));
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const okc = await anchorRecord(rec, p.addr, fee, 90, "identity commit");
|
|
806
|
+
if (okc)
|
|
807
|
+
console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
|
|
808
|
+
}
|
|
349
809
|
main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
|
package/dist/lib/api.js
CHANGED
|
@@ -19,6 +19,10 @@ function writeReq(path, body) {
|
|
|
19
19
|
throw new Error("posting needs a token — set CAIRN_TOKEN (the operator's write token)");
|
|
20
20
|
return req(path, {
|
|
21
21
|
method: "POST",
|
|
22
|
+
// never follow a redirect on a write: undici keeps custom headers across cross-origin
|
|
23
|
+
// 30x, so a hostile/MITM'd CAIRN_API could otherwise bounce the x-cairn-token to an
|
|
24
|
+
// attacker host. Fail closed instead.
|
|
25
|
+
redirect: "error",
|
|
22
26
|
headers: { "content-type": "application/json", "x-cairn-token": CAIRN_TOKEN },
|
|
23
27
|
body: JSON.stringify(body),
|
|
24
28
|
});
|
|
@@ -36,12 +40,73 @@ export const apiLeaderboard = () => req("/api/leaderboard");
|
|
|
36
40
|
export const apiQuests = () => req("/api/quests");
|
|
37
41
|
export const apiPropose = (body) => writeReq("/api/propose", body);
|
|
38
42
|
export const apiSupport = (body) => writeReq("/api/support", body);
|
|
43
|
+
// ── proxy bridge: lets a node-less user transact via `csd`. We fetch a spendable input
|
|
44
|
+
// + the chain tip from the Cairn instance's public /api/rpc/* proxy, hand the input to
|
|
45
|
+
// `csd … --input`, and submit through `--rpc-url <CAIRN_API>/api/rpc`. ──
|
|
46
|
+
export const rpcBase = () => `${CAIRN_API}/api/rpc`;
|
|
47
|
+
// One confirmed, mature, non-coinbase UTXO worth > minValue (smallest sufficient) for addr,
|
|
48
|
+
// as the csd input triple "<txid>:<vout>:<value>" — or null.
|
|
49
|
+
// One confirmed, mature, non-coinbase UTXO worth > minValue (smallest sufficient) for addr.
|
|
50
|
+
// Returns { input: "<txid>:<vout>:<value>", value } — value is the proxy-reported amount the
|
|
51
|
+
// caller surfaces so the user sees the true implied fee (a hostile proxy under-reporting it
|
|
52
|
+
// only inflates the fee it burns to the miner; it can never redirect funds — change goes to
|
|
53
|
+
// the user's own --change addr). txid/vout are format-validated so a malformed value can't
|
|
54
|
+
// produce a junk (rejected) tx.
|
|
55
|
+
const HEX64 = /^0x?[0-9a-fA-F]{64}$/;
|
|
56
|
+
export async function pickInput(addr, minValue) {
|
|
57
|
+
const j = await req(`/api/rpc/utxos-all/${encodeURIComponent(addr)}`);
|
|
58
|
+
const ok = (x) => Number(x.confirmations ?? 0) >= 1 &&
|
|
59
|
+
Number.isSafeInteger(Number(x.value)) && Number(x.value) > minValue &&
|
|
60
|
+
Number.isInteger(Number(x.vout)) && Number(x.vout) >= 0 &&
|
|
61
|
+
typeof x.txid === "string" && HEX64.test(x.txid) &&
|
|
62
|
+
!x.coinbase;
|
|
63
|
+
const cand = (j.utxos ?? []).filter(ok).sort((a, b) => Number(a.value) - Number(b.value));
|
|
64
|
+
const x = cand[0] ?? (j.utxos ?? []).find(ok);
|
|
65
|
+
return x ? { input: `${x.txid}:${Number(x.vout)}:${Number(x.value)}`, value: Number(x.value) } : null;
|
|
66
|
+
}
|
|
67
|
+
export async function confirmedBalance(addr) {
|
|
68
|
+
const j = await req(`/api/rpc/utxos-all/${encodeURIComponent(addr)}`);
|
|
69
|
+
return { balance: Number(j.confirmed_balance ?? 0), utxos: (j.utxos ?? []).length };
|
|
70
|
+
}
|
|
71
|
+
export async function tipHeight() { return Number((await req("/api/rpc/tip")).height ?? 0); }
|
|
72
|
+
// Submit a node-JSON tx through the proxy (for `csd spend`, which builds+signs but doesn't
|
|
73
|
+
// reliably submit to a proxy URL). Returns the node's response.
|
|
74
|
+
export async function submitTx(txNodeJson) { return req("/api/rpc/tx/submit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tx: txNodeJson }) }); }
|
|
75
|
+
// Register a proposal's off-chain content (self-certifying; accepted once mined + hash-matched).
|
|
76
|
+
export async function registerContent(content, txid, attempts = 20) {
|
|
77
|
+
for (let i = 0; i < attempts; i++) {
|
|
78
|
+
try {
|
|
79
|
+
const r = await req("/api/content", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...content, txid }) });
|
|
80
|
+
if (r.ok)
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch { /* keep trying while it mines */ }
|
|
84
|
+
await new Promise((res) => setTimeout(res, 8000));
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// Register an L3 registry record's EXACT canonical bytes (origin serves them verbatim;
|
|
89
|
+
// accepted only if sha256(bytes) == the on-chain payload_hash). Retries while it mines.
|
|
90
|
+
export async function registerRawContent(bytes, txid, attempts = 20) {
|
|
91
|
+
for (let i = 0; i < attempts; i++) {
|
|
92
|
+
try {
|
|
93
|
+
const r = await req("/api/content", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ bytes, txid }) });
|
|
94
|
+
if (r.ok)
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch { /* keep trying while it mines */ }
|
|
98
|
+
await new Promise((res) => setTimeout(res, 8000));
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
39
102
|
// optional: query a raw csd node RPC (for trustless verify)
|
|
40
103
|
export async function chainProposal(id) {
|
|
41
104
|
if (!CAIRN_RPC)
|
|
42
105
|
return null;
|
|
106
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(id))
|
|
107
|
+
return null; // never splice an unshaped id into the URL
|
|
43
108
|
try {
|
|
44
|
-
const r = await fetch(`${CAIRN_RPC}/proposal/${id}`, { signal: AbortSignal.timeout(6000) });
|
|
109
|
+
const r = await fetch(`${CAIRN_RPC}/proposal/${encodeURIComponent(id)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
|
|
45
110
|
if (!r.ok)
|
|
46
111
|
return null;
|
|
47
112
|
const j = await r.json();
|
package/dist/lib/config.js
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
// cairn-cli configuration (env-overridable).
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
5
|
export const CAIRN_API = (process.env.CAIRN_API ?? "https://cairn-substrate.com").replace(/\/+$/, "");
|
|
3
|
-
export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; //
|
|
6
|
+
export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; // optional operator write token (falls back to local csd wallet)
|
|
4
7
|
export const CAIRN_RPC = process.env.CAIRN_RPC ?? ""; // optional: a csd node RPC, enables trustless verify
|
|
8
|
+
export const CAIRN_CSD = process.env.CAIRN_CSD ?? "csd"; // the user's installed `csd` binary (signs with their wallet)
|
|
9
|
+
export const CAIRN_ADDR = process.env.CAIRN_ADDR ?? ""; // optional: your public addr20 (skips deriving it from csd)
|
|
5
10
|
export const CSD_PER_COIN = 100_000_000;
|
|
6
11
|
export const MIN_FEE_PROPOSE = 25_000_000; // 0.25 CSD
|
|
7
12
|
export const MIN_FEE_ATTEST = 5_000_000; // 0.05 CSD
|
|
8
13
|
export function csdToCoins(base) {
|
|
9
14
|
return (base / CSD_PER_COIN).toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
10
15
|
}
|
|
16
|
+
// small local config: caches ONLY the user's public address (never a key).
|
|
17
|
+
const CFG_PATH = process.env.CAIRN_CLI_CONFIG ?? join(homedir(), ".config", "cairn-cli", "config.json");
|
|
18
|
+
export function loadLocalConfig() { try {
|
|
19
|
+
return JSON.parse(readFileSync(CFG_PATH, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
} }
|
|
24
|
+
export function saveLocalConfig(patch) {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(dirname(CFG_PATH), { recursive: true });
|
|
27
|
+
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n");
|
|
28
|
+
}
|
|
29
|
+
catch { /* best-effort */ }
|
|
30
|
+
}
|
package/dist/lib/csd.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Thin wrapper around the user's INSTALLED `csd` CLI. cairn-cli never holds a private
|
|
2
|
+
// key: for any write it shells out to `csd`, which signs with the user's own csd wallet
|
|
3
|
+
// config key (CSD_SIG_V1). We only orchestrate — supply the input (fetched from the Cairn
|
|
4
|
+
// proxy, so no local node is required) + the Cairn payload — and `csd` does the signing
|
|
5
|
+
// and submit. Binary resolution: CAIRN_CSD env → `csd` on PATH → the substrate default.
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
const pexec = promisify(execFile);
|
|
9
|
+
export const CSD_BIN = process.env.CAIRN_CSD || "csd";
|
|
10
|
+
function extractTxid(s) {
|
|
11
|
+
const m = s.match(/txid["':\s]+0x([0-9a-fA-F]{64})/) || s.match(/0x[0-9a-fA-F]{64}/);
|
|
12
|
+
return m ? (m[1] ? "0x" + m[1] : m[0]) : undefined;
|
|
13
|
+
}
|
|
14
|
+
export async function run(args) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout, stderr } = await pexec(CSD_BIN, args, { timeout: 30000 });
|
|
17
|
+
return { ok: true, stdout, stderr, txid: extractTxid(stdout + stderr) };
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
const out = (e.stdout ?? "") + (e.stderr ?? "");
|
|
21
|
+
return { ok: false, stdout: e.stdout ?? "", stderr: e.stderr ?? String(e.message ?? e), txid: extractTxid(out) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Is `csd` installed + runnable?
|
|
25
|
+
export async function available() { try {
|
|
26
|
+
await pexec(CSD_BIN, ["--version"], { timeout: 5000 });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
} }
|
|
32
|
+
// The user's csd wallet config (default_privkey / default_rpc_url / …). null if csd absent.
|
|
33
|
+
export async function walletConfig() { const r = await run(["wallet", "config"]); if (!r.ok)
|
|
34
|
+
return null; try {
|
|
35
|
+
return JSON.parse(r.stdout);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
} }
|
|
40
|
+
// Derive the public addr20 from a privkey via `csd wallet recover` (local; key never networked).
|
|
41
|
+
export async function deriveAddr(priv) { const r = await run(["wallet", "recover", "--privkey", priv]); const m = r.stdout.match(/addr20:\s*(0x[0-9a-fA-F]{40})/i); return m ? m[1] : null; }
|
package/dist/lib/ui.js
CHANGED
|
@@ -66,6 +66,13 @@ export function bar(value, max, width = 16) {
|
|
|
66
66
|
export function csd(base) {
|
|
67
67
|
return c.green(`${(base / 1e8).toLocaleString(undefined, { maximumFractionDigits: 4 })}`) + c.gray(" CSD");
|
|
68
68
|
}
|
|
69
|
+
// Strip C0/C1 control chars (incl. ESC) from UNTRUSTED strings before printing them to a
|
|
70
|
+
// TTY, so a hostile server/chain field (title, body, message, handle, bio, domain, id…)
|
|
71
|
+
// can't inject ANSI/OSC escapes to spoof output — e.g. cursor-up + repaint to overwrite a
|
|
72
|
+
// "✗ MISMATCH" verdict with "✓ VERIFIED", rewrite the window title, or hijack the terminal.
|
|
73
|
+
// Display-only: NEVER apply to bytes that get hashed/verified (it would change the hash).
|
|
74
|
+
const CTRL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f]", "g");
|
|
75
|
+
export function san(s) { return String(s ?? "").replace(CTRL, ""); }
|
|
69
76
|
export function ok(s) { return c.green("✓ ") + s; }
|
|
70
77
|
export function warn(s) { return c.gray("⚠ ") + s; }
|
|
71
78
|
export function err(s) { return c.red("✗ ") + s; }
|
|
@@ -76,16 +83,27 @@ export function pad(s, n) {
|
|
|
76
83
|
}
|
|
77
84
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
78
85
|
export const isTty = TTY;
|
|
86
|
+
export const anim = ANIM;
|
|
79
87
|
// Phosphor braille spinner for network/async work. Writes to STDERR (clig.dev: keep
|
|
80
88
|
// stdout clean for piping) and is a no-op when output isn't an interactive terminal.
|
|
89
|
+
// After ~1.5s it shows an elapsed counter so a slow node read doesn't look hung;
|
|
90
|
+
// stop() prints a clean ✓ (or a caller-supplied final line) on stdout.
|
|
81
91
|
export function spinner(label) {
|
|
82
92
|
if (!ANIM)
|
|
83
93
|
return { stop: (final) => { if (final)
|
|
84
|
-
console.log(final); } };
|
|
94
|
+
console.log(final); }, update: () => { } };
|
|
85
95
|
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
86
|
-
let i = 0;
|
|
87
|
-
const
|
|
96
|
+
let i = 0, lbl = label;
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
const paint = () => {
|
|
99
|
+
const el = (Date.now() - t0) / 1000;
|
|
100
|
+
const tail = el >= 1.5 ? c.faint(` ${el.toFixed(0)}s`) : "";
|
|
101
|
+
process.stderr.write(`\r\x1b[K${c.green(frames[i++ % frames.length])} ${c.gray(lbl)}${tail} `);
|
|
102
|
+
};
|
|
103
|
+
const t = setInterval(paint, 80);
|
|
104
|
+
paint();
|
|
88
105
|
return {
|
|
106
|
+
update: (l) => { lbl = l; },
|
|
89
107
|
stop: (final) => {
|
|
90
108
|
clearInterval(t);
|
|
91
109
|
process.stderr.write("\r\x1b[K");
|
|
@@ -94,28 +112,58 @@ export function spinner(label) {
|
|
|
94
112
|
},
|
|
95
113
|
};
|
|
96
114
|
}
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
115
|
+
// Type a line out character-by-character to stdout — the site's boot typewriter, in the
|
|
116
|
+
// terminal. Falls back to an instant write when animation is off. ~`cps` chars/sec.
|
|
117
|
+
export async function typeOut(text, cps = 220) {
|
|
118
|
+
if (!ANIM) {
|
|
119
|
+
process.stdout.write(text + "\n");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const step = Math.max(4, Math.round(1000 / cps));
|
|
123
|
+
for (let i = 1; i <= text.length; i++) {
|
|
124
|
+
process.stdout.write(`\r\x1b[K${text.slice(0, i)}${c.green("▋")}`);
|
|
125
|
+
await sleep(step);
|
|
126
|
+
}
|
|
127
|
+
process.stdout.write(`\r\x1b[K${text}\n`);
|
|
128
|
+
}
|
|
129
|
+
// Decode-reveal of the CAIRN wordmark: each glyph scrambles a few times then locks to a
|
|
130
|
+
// bright-white letter, resolving left→right behind a green cursor — the site's "boot"
|
|
131
|
+
// feel. ~420ms cap, interactive TTY only (static banner otherwise, so `help` piped to a
|
|
132
|
+
// file isn't escape-code garbage). Followed by a typed strap-line + rule.
|
|
100
133
|
export async function bannerAnimated() {
|
|
101
134
|
if (!ANIM) {
|
|
102
135
|
banner();
|
|
103
136
|
return;
|
|
104
137
|
}
|
|
105
138
|
const word = "CAIRN";
|
|
106
|
-
const pool = "▖▗▘▙▚▛▜▝▞▟01
|
|
139
|
+
const pool = "▖▗▘▙▚▛▜▝▞▟01#%/\\<>=$".split("");
|
|
107
140
|
const rnd = () => pool[Math.floor(Math.random() * pool.length)];
|
|
141
|
+
const HOLD = 3; // scramble frames each not-yet-locked glyph shows before the head passes it
|
|
108
142
|
for (let step = 0; step <= word.length; step++) {
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
143
|
+
for (let f = 0; f < (step < word.length ? HOLD : 1); f++) {
|
|
144
|
+
let s = "";
|
|
145
|
+
for (let i = 0; i < word.length; i++) {
|
|
146
|
+
if (i < step)
|
|
147
|
+
s += c.white(c.bold(word[i]));
|
|
148
|
+
else if (i === step)
|
|
149
|
+
s += c.green(c.bold(rnd())); // the glyph currently resolving
|
|
150
|
+
else
|
|
151
|
+
s += c.faint(rnd());
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write(`\r\x1b[K ${c.gray("▓▒░")} ${s} ${c.green("▋")} ${c.faint("· " + TAG)}`);
|
|
154
|
+
await sleep(26);
|
|
155
|
+
}
|
|
114
156
|
}
|
|
115
|
-
process.stdout.write(`\r ${c.gray("▓▒░")} ${c.white(c.bold(word))} ${c.green("▮")} ${c.gray("· " + TAG)}\n`);
|
|
157
|
+
process.stdout.write(`\r\x1b[K ${c.gray("▓▒░")} ${c.white(c.bold(word))} ${c.green("▮")} ${c.gray("· " + TAG)}\n`);
|
|
116
158
|
rule();
|
|
117
159
|
}
|
|
118
160
|
export function clearScreen() {
|
|
119
161
|
if (TTY)
|
|
120
162
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
121
163
|
}
|
|
164
|
+
// Move the cursor home and clear from there to the end of screen — repaints in place
|
|
165
|
+
// without the full-screen black flash `clearScreen` causes. Used by `watch`.
|
|
166
|
+
export function cursorHome() {
|
|
167
|
+
if (TTY)
|
|
168
|
+
process.stdout.write("\x1b[H\x1b[0J");
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,25 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inversealtruism/cairn-cli",
|
|
3
|
-
"version": "0.2
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "CLI for Compute Substrate / Cairn — browse the board/wall/network, and send CSD, propose, attest, and place stones non-custodially by driving your own installed `csd` wallet (cairn never holds your key; works node-less via the Cairn proxy).",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"cairn": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/InverseAltruism/cairn-cli.git"
|
|
19
|
+
},
|
|
10
20
|
"homepage": "https://cairn-substrate.com",
|
|
11
|
-
"publishConfig": {
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
12
24
|
"scripts": {
|
|
13
|
-
"
|
|
25
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
|
+
"build": "npm run clean && tsc",
|
|
14
27
|
"dev": "tsx src/cli.ts",
|
|
15
|
-
"test": "
|
|
16
|
-
"prepare": "tsc"
|
|
28
|
+
"test": "npm run build && node test/security.mjs && node test/e2e.mjs",
|
|
29
|
+
"prepare": "npm run clean && tsc",
|
|
30
|
+
"prepublishOnly": "npm run clean && tsc"
|
|
17
31
|
},
|
|
18
|
-
"keywords": [
|
|
32
|
+
"keywords": [
|
|
33
|
+
"cairn",
|
|
34
|
+
"compute-substrate",
|
|
35
|
+
"cli"
|
|
36
|
+
],
|
|
19
37
|
"license": "MIT",
|
|
20
38
|
"devDependencies": {
|
|
21
39
|
"@types/node": "^22.10.0",
|
|
22
40
|
"tsx": "^4.19.2",
|
|
23
41
|
"typescript": "^5.7.2"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@inversealtruism/csd-codec": "0.1.3",
|
|
45
|
+
"@inversealtruism/csd-registry": "0.1.3"
|
|
24
46
|
}
|
|
25
47
|
}
|