@inversealtruism/cairn-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # cairn-cli
2
2
 
3
- A command-line client for a Cairn signal board on Compute Substrate. Browse what the community is
4
- backing, and with a token, propose or support items, all from your terminal.
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
- Cairn is a fee-weighted "paid attention" board: people spend CSD to surface what should be built,
7
- fixed, or funded. `cairn-cli` is a thin HTTP client for a Cairn instance. Browsing needs no `csd`
8
- binary and no key files.
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,48 @@ cairn leaderboard # top builders by reputation
49
52
  cairn ls --json # machine-readable output
50
53
  ```
51
54
 
52
- Posting needs a token from the board operator:
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
- export CAIRN_TOKEN=… # the instance's write token
56
- cairn propose --domain csd:features --title "Wallet GUI" --body "A graphical wallet…" --link https://…
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
- | `CAIRN_TOKEN` | | required only to post (propose or support) |
66
- | `CAIRN_RPC` | – | optional csd node RPC; enables fully trustless `verify` by recomputing the hash and confirming the one on-chain |
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`, and `watch` read the board's public API.
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` and `support` post through the instance, which records the item and submits the on-chain
74
- proposal or attestation. Fees go to miners. Support is a paid demand signal, not a payment to the
75
- author.
76
-
77
- Fees are in base units (1 CSD = 1e8). Minimums are 0.25 CSD to propose and 0.05 CSD to attest.
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 the key never leaves your
95
+ machine and never touches cairn-cli), then submits the signed transaction through the proxy and (for
96
+ proposals) registers the off-chain content. Sealed claims and Sign-in-with-CSD live in the Cairn Wallet.
78
97
 
79
98
  ## License
80
99
 
package/dist/cli.js CHANGED
@@ -1,10 +1,83 @@
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 { c, banner, bannerAnimated, rule, badge, bar, csd as csdFmt, ok, warn, err, key as kdim, pad, spinner, sleep, isTty, clearScreen } from "./lib/ui.js";
8
+ 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";
9
+ const CSD = (n) => Number.isFinite(n) ? Math.round(n * CSD_PER_COIN) : NaN; // CSD → base units
10
+ // Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
11
+ // unless we must derive it locally from the user's own csd wallet config (then we cache
12
+ // only the public address). Order: --address → CAIRN_ADDR → cached → derive via csd.
13
+ async function resolveAddr(a) {
14
+ const flag = a.flags.address ? String(a.flags.address) : (CAIRN_ADDR || loadLocalConfig().address);
15
+ if (flag && /^0x[0-9a-fA-F]{40}$/.test(flag))
16
+ return flag;
17
+ const cfg = await csd.walletConfig();
18
+ // Prefer the address csd already exposes (change addr) — avoids re-deriving from the
19
+ // privkey, which would put the key on the `csd` argv (visible via /proc on a shared host).
20
+ if (cfg?.default_change_addr20 && /^0x[0-9a-fA-F]{40}$/.test(String(cfg.default_change_addr20))) {
21
+ const addr = String(cfg.default_change_addr20);
22
+ saveLocalConfig({ address: addr });
23
+ return addr;
24
+ }
25
+ if (cfg?.default_privkey) {
26
+ const addr = await csd.deriveAddr(cfg.default_privkey);
27
+ if (addr) {
28
+ saveLocalConfig({ address: addr });
29
+ return addr;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ // Run a csd build/sign command (easy-path propose/attest/spend — they sign with the user's
35
+ // wallet CONFIG key, so we pass no key) and submit the resulting signed tx through the Cairn
36
+ // proxy ourselves. We do NOT trust csd's own auto-submit: it targets csd's configured node,
37
+ // which may be a different node than the one the Cairn board (and its miner) read — so a tx
38
+ // could sit in the wrong mempool and never get mined into the board's view. Always submit via
39
+ // the proxy (the board's miner-connected node). A repeat that comes back "already present /
40
+ // known" for OUR txid is success (the tx is in that node's mempool); a true double-spend
41
+ // "conflict" is the only ambiguous case, so we confirm via a tx lookup before claiming ok.
42
+ async function signAndSubmit(csdArgs) {
43
+ const r = await csd.run(csdArgs);
44
+ if (!r.ok)
45
+ return { ok: false, error: (r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0] };
46
+ let out = null;
47
+ try {
48
+ out = JSON.parse(r.stdout);
49
+ }
50
+ catch { /* unexpected */ }
51
+ if (!out?.tx)
52
+ return { ok: false, error: "csd produced no signed transaction" };
53
+ const txid = out.txid;
54
+ const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
55
+ if (sub.ok)
56
+ return { ok: true, txid: sub.txid || txid };
57
+ // A benign "already present / mempool conflict" for OUR txid means the tx is already in a
58
+ // mempool (e.g. csd's own auto-submit reached this same node first, or a re-run) — success.
59
+ // For a single-key wallet this is safe: only the key owner can produce a conflicting spend
60
+ // of their own UTXO, so a conflict on our freshly-built tx is our own prior submit, not a
61
+ // third party. (The narrow exception — two DIFFERENT local spends of one UTXO fired at once
62
+ // — is on the user.) The node can't be queried for mempool membership (its /tx indexes only
63
+ // mined txs), so we rely on the matching txid + benign message.
64
+ if (txid && /already|present|known|in mempool|conflict/i.test(String(sub.err ?? "")))
65
+ return { ok: true, txid };
66
+ return { ok: false, error: sub.err || "submit rejected by node", txid };
67
+ }
68
+ // Guard: a write needs `csd` installed + a configured wallet (or an explicit --address + csd key).
69
+ async function requireCsd() {
70
+ if (!(await csd.available())) {
71
+ 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."));
72
+ return false;
73
+ }
74
+ const cfg = await csd.walletConfig();
75
+ if (!cfg?.default_privkey) {
76
+ 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."));
77
+ return false;
78
+ }
79
+ return true;
80
+ }
8
81
  function parse(argv) {
9
82
  const _ = [];
10
83
  const flags = {};
@@ -27,6 +100,16 @@ function parse(argv) {
27
100
  }
28
101
  return { _, flags, multi };
29
102
  }
103
+ // Do two URLs point at the same host? (used to refuse a "trustless" verify claim when the
104
+ // node RPC and the board API are the same operator). Unparseable → treat as same (safe).
105
+ function sameHost(a, b) {
106
+ try {
107
+ return new URL(a).host.toLowerCase() === new URL(b).host.toLowerCase();
108
+ }
109
+ catch {
110
+ return true;
111
+ }
112
+ }
30
113
  const age = (sec) => {
31
114
  if (!sec)
32
115
  return "—";
@@ -50,9 +133,9 @@ function printRows(items, sort = "totalWeight") {
50
133
  const lens = !["totalWeight", "supporterCount"].includes(sort) && r[sort] != null
51
134
  ? c.gray(" · " + (sort === "createdHeight" ? "h" + r[sort] : csdFmt(r[sort]) + " " + sort)) : "";
52
135
  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)}…`));
136
+ 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") : ""}`);
137
+ 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}`);
138
+ console.log(c.gray(` ${san(r.domain)} · id ${san(String(r.id).slice(0, 22))}…`));
56
139
  });
57
140
  }
58
141
  async function cmdList(a) {
@@ -78,16 +161,34 @@ async function cmdWatch(a) {
78
161
  printRows((await api.apiBoard(domain, window)).items);
79
162
  return;
80
163
  }
81
- process.stdout.write("\x1b[?25l");
82
- process.on("SIGINT", () => { process.stdout.write("\x1b[?25h\n"); process.exit(0); });
164
+ const PERIOD = 5; // seconds between refreshes
165
+ const PULSE = ["", "◓", "◑", ""]; // a spinning "live" mark in the footer
166
+ process.stdout.write("\x1b[?25l"); // hide cursor
167
+ const restore = () => process.stdout.write("\x1b[?25h\n");
168
+ process.on("SIGINT", () => { restore(); process.exit(0); });
169
+ clearScreen();
170
+ let first = true;
83
171
  for (;;) {
84
172
  const r = await api.apiBoard(domain, window).catch(() => ({ items: [] }));
85
- clearScreen();
173
+ // first paint clears the screen; subsequent paints repaint from home (no black flash)
174
+ if (first) {
175
+ clearScreen();
176
+ first = false;
177
+ }
178
+ else
179
+ cursorHome();
86
180
  banner();
87
181
  rule(`watch · ${domain} · ${window} · ${new Date().toLocaleTimeString()}`);
88
182
  printRows(r.items);
89
- console.log(c.gray("\n ") + c.green("●") + c.gray(" live · refreshes every 5s · Ctrl+C to exit"));
90
- await sleep(5000);
183
+ process.stdout.write("\n"); // reserve the footer line, then redraw it in place each second
184
+ // animated footer: a phosphor pulse + a "next refresh" countdown (single line, \r-redrawn)
185
+ for (let s = PERIOD; s > 0; s--) {
186
+ const mark = c.green(PULSE[(PERIOD - s) % PULSE.length]);
187
+ process.stdout.write(`\r\x1b[K ${mark} ${c.gray("live · " + (r.items?.length ?? 0) + " items · next refresh in " + s + "s · Ctrl+C to exit")}`);
188
+ await sleep(anim ? 1000 : PERIOD * 1000);
189
+ if (!anim)
190
+ break;
191
+ }
91
192
  }
92
193
  }
93
194
  async function cmdRecent() {
@@ -96,7 +197,7 @@ async function cmdRecent() {
96
197
  rule("recent activity");
97
198
  for (const ev of r.activity ?? []) {
98
199
  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")}`);
200
+ console.log(` ${verb} ${c.white(san(String(ev.item).slice(0, 42)))} ${c.gray("· " + age(ev.time) + " ago · " + (Number(ev.amount) / 1e8) + " CSD")}`);
100
201
  }
101
202
  }
102
203
  async function cmdShow(a) {
@@ -111,15 +212,15 @@ async function cmdShow(a) {
111
212
  return;
112
213
  }
113
214
  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`);
215
+ rule(san(it.title));
216
+ console.log(` ${badge(it.source)} ${c.gray("·")} ${c.cyan(san(it.domain))}`);
217
+ console.log(`\n ${c.white(san(it.body))}\n`);
117
218
  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);
219
+ console.log(` ${kdim("links")} ${it.links.map((l) => c.cyan(san(l))).join(", ")}`);
220
+ const total = (r.supports ?? []).reduce((x, s) => x + Number(s.weight), 0);
120
221
  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)}`);
222
+ console.log(` ${kdim("proposer")} ${c.gray(san(it.proposerHandle || it.proposer))}`);
223
+ console.log(` ${kdim("hash")} ${c.magenta(san(it.payloadHash))}`);
123
224
  console.log(` ${kdim("integrity")} ${r.integrityOk ? ok("content matches commitment") : err("MISMATCH")}`);
124
225
  }
125
226
  async function cmdVerify(a) {
@@ -136,21 +237,136 @@ async function cmdVerify(a) {
136
237
  return;
137
238
  }
138
239
  const it = r.item;
240
+ // Hash the RAW server-reported content (never san()'d — we must hash the exact bytes).
139
241
  const { payloadHash } = buildCommitment({ v: 1, domain: it.domain, title: it.title, body: it.body, links: it.links ?? [] });
140
- const chain = await api.chainProposal(it.id);
242
+ // Only consult the chain RPC for a well-formed id (the server echoes it.id back; an
243
+ // attacker-shaped id must not be spliced into the RPC URL — see api.chainProposal).
244
+ const chain = /^0x[0-9a-fA-F]{64}$/.test(String(it.id ?? "")) ? await api.chainProposal(it.id) : null;
141
245
  sp.stop();
142
246
  console.log(`${kdim("recomputed")} ${c.magenta(payloadHash)}`);
143
- console.log(`${kdim("reported")} ${c.magenta(it.payloadHash)}`);
247
+ console.log(`${kdim("reported")} ${c.magenta(san(it.payloadHash))}`);
144
248
  const contentOk = payloadHash.toLowerCase() === String(it.payloadHash).toLowerCase();
145
249
  if (chain?.payload_hash) {
146
- console.log(`${kdim("on-chain")} ${c.magenta(chain.payload_hash)}`);
147
- console.log(contentOk && String(chain.payload_hash).toLowerCase() === payloadHash.toLowerCase()
148
- ? ok("VERIFIED content matches the on-chain commitment (trustless, via CAIRN_RPC)")
149
- : err("MISMATCH"));
250
+ // "trustless" only holds if CAIRN_RPC is an INDEPENDENT node — if it's the same host as
251
+ // the board API, the same operator controls both answers, so don't claim trustlessness.
252
+ const independent = !sameHost(CAIRN_RPC, CAIRN_API);
253
+ const chainOk = contentOk && String(chain.payload_hash).toLowerCase() === payloadHash.toLowerCase();
254
+ console.log(`${kdim("on-chain")} ${c.magenta(san(chain.payload_hash))}`);
255
+ if (chainOk)
256
+ console.log(independent
257
+ ? ok("VERIFIED — content matches the on-chain commitment (trustless, via an independent CAIRN_RPC)")
258
+ : 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"));
259
+ else
260
+ console.log(err("MISMATCH"));
150
261
  }
151
262
  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"));
263
+ 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"));
264
+ }
265
+ }
266
+ // ── wallet (on top of the user's installed `csd` — cairn never holds the key) ──
267
+ async function cmdSetup() {
268
+ banner();
269
+ rule("setup — cairn over your csd wallet");
270
+ const has = await csd.available();
271
+ 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")}`);
272
+ if (!has) {
273
+ 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("."));
274
+ return;
275
+ }
276
+ const cfg = await csd.walletConfig();
277
+ 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>")}`);
278
+ const addr = await resolveAddr(a0());
279
+ if (addr) {
280
+ console.log(` ${kdim("address")} ${c.cyan(addr)}`);
281
+ const b = await api.confirmedBalance(addr).catch(() => null);
282
+ if (b)
283
+ console.log(` ${kdim("balance")} ${c.white(csdToCoins(b.balance))} CSD ${c.gray("(" + b.utxos + " utxos)")}`);
284
+ }
285
+ console.log(` ${kdim("api")} ${c.gray(CAIRN_API)}`);
286
+ if (has && cfg?.default_privkey)
287
+ console.log(c.gray("\n ready: ") + c.cyan("cairn send · cairn propose · cairn support · cairn wall place"));
288
+ }
289
+ const a0 = () => ({ _: [], flags: {}, multi: {} });
290
+ async function cmdAddress(a) {
291
+ const addr = await resolveAddr(a);
292
+ if (!addr) {
293
+ console.log(err("no address — run ") + c.cyan("cairn setup") + err(" (needs a configured csd wallet) or pass --address"));
294
+ return;
295
+ }
296
+ if (!isTty) {
297
+ console.log(addr);
298
+ return;
299
+ }
300
+ banner();
301
+ rule("your address");
302
+ console.log(` ${kdim("address")} ${c.cyan(addr)}`);
303
+ const b = await api.confirmedBalance(addr).catch(() => null);
304
+ if (b)
305
+ console.log(` ${kdim("balance")} ${c.white(csdToCoins(b.balance))} CSD ${c.gray("(" + b.utxos + " utxos)")}`);
306
+ }
307
+ async function cmdBalance(a) { return cmdAddress(a); }
308
+ function gatherOutputs(a) {
309
+ const outs = [];
310
+ for (const spec of (a.multi.output ?? [])) {
311
+ const i = String(spec).lastIndexOf(":");
312
+ if (i < 0)
313
+ return `bad --output (want <addr>:<CSD>): ${spec}`;
314
+ outs.push({ to: String(spec).slice(0, i), value: CSD(Number(String(spec).slice(i + 1))) });
315
+ }
316
+ if (a.flags.to !== undefined || a.flags.amount !== undefined)
317
+ outs.push({ to: String(a.flags.to ?? ""), value: CSD(Number(a.flags.amount ?? 0)) });
318
+ for (const o of outs) {
319
+ if (!/^0x[0-9a-fA-F]{40}$/.test(o.to))
320
+ return `bad recipient: ${o.to}`;
321
+ if (!(o.value > 0) || !Number.isSafeInteger(o.value))
322
+ return `bad amount for ${o.to}`;
323
+ }
324
+ return outs.length ? outs : "no outputs";
325
+ }
326
+ async function cmdSend(a) {
327
+ const outs = gatherOutputs(a);
328
+ if (typeof outs === "string") {
329
+ 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));
330
+ return;
331
+ }
332
+ if (!(await requireCsd()))
333
+ return;
334
+ const addr = await resolveAddr(a);
335
+ if (!addr) {
336
+ console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
337
+ return;
338
+ }
339
+ const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.01;
340
+ const fee = (Number.isFinite(feeCsd) && feeCsd >= 0) ? CSD(feeCsd) : 1_000_000;
341
+ const total = outs.reduce((s, o) => s + o.value, 0);
342
+ console.log(`${kdim("from")} ${c.cyan(addr)}`);
343
+ for (const o of outs)
344
+ console.log(`${kdim("to")} ${c.cyan(o.to)} ${c.gray("→ " + csdToCoins(o.value) + " CSD")}`);
345
+ console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("total")} ${csdToCoins(total + fee)} CSD`);
346
+ if (a.flags["dry-run"]) {
347
+ console.log(c.gray("\n[dry-run] not sent"));
348
+ return;
349
+ }
350
+ const sp = spinner("fetching input → csd signs → submit");
351
+ const picked = await api.pickInput(addr, total + fee).catch(() => null);
352
+ if (!picked) {
353
+ sp.stop();
354
+ 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)"));
355
+ return;
153
356
  }
357
+ sp.stop();
358
+ // transparency: show the input value + change so a hostile proxy under-reporting the input
359
+ // (which would silently inflate the burned fee) is visible before we sign. Change goes to
360
+ // your own address; the proxy can never redirect it.
361
+ 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")}`);
362
+ const sp2 = spinner("csd signs → submit");
363
+ const args = ["spend"];
364
+ for (const o of outs)
365
+ args.push("--output", `${o.to}:${o.value}`);
366
+ args.push("--change", addr, "--fee", String(fee), "--input", picked.input);
367
+ const r = await signAndSubmit(args);
368
+ sp2.stop();
369
+ console.log(r.ok ? ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
154
370
  }
155
371
  async function cmdPropose(a) {
156
372
  const domain = String(a.flags.domain ?? "");
@@ -158,60 +374,152 @@ async function cmdPropose(a) {
158
374
  const body = String(a.flags.body ?? "");
159
375
  const links = a.multi.link ?? [];
160
376
  if (!domain || !title) {
161
- console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <base>]"));
377
+ console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <CSD>] [--expires-days N]"));
162
378
  return;
163
379
  }
164
- const fee = Number(a.flags.fee ?? MIN_FEE_PROPOSE);
165
- const sp = spinner("posting to Cairn");
166
- try {
167
- const r = await api.apiPropose({ domain, title, body, links, fee: Number.isFinite(fee) && fee >= MIN_FEE_PROPOSE ? Math.floor(fee) : MIN_FEE_PROPOSE });
168
- sp.stop();
169
- console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) : err(r.error || "failed"));
380
+ const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.25;
381
+ const fee = Math.max(MIN_FEE_PROPOSE, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_PROPOSE);
382
+ // operator-token path stays available for the instance operator
383
+ if (CAIRN_TOKEN && !(await csd.available())) {
384
+ const sp = spinner("posting via operator token");
385
+ try {
386
+ const r = await api.apiPropose({ domain, title, body, links, fee });
387
+ sp.stop();
388
+ console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
389
+ }
390
+ catch (e) {
391
+ sp.stop();
392
+ console.log(err(e.message));
393
+ }
394
+ return;
395
+ }
396
+ if (!(await requireCsd()))
397
+ return;
398
+ const addr = await resolveAddr(a);
399
+ if (!addr) {
400
+ console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
401
+ return;
402
+ }
403
+ const content = { v: 1, domain, title, body, links };
404
+ const { payloadHash } = buildCommitment(content);
405
+ const uri = "cairn:v1:" + payloadHash.slice(2, 14);
406
+ if (a.flags["dry-run"]) {
407
+ console.log(`${kdim("domain")} ${c.cyan(domain)}`);
408
+ console.log(`${kdim("title")} ${c.white(title)}`);
409
+ console.log(`${kdim("hash")} ${c.magenta(payloadHash)} ${c.gray("· uri " + uri)}`);
410
+ console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("from")} ${c.cyan(addr)}`);
411
+ console.log(c.gray("\n[dry-run] not signed or submitted"));
412
+ return;
170
413
  }
171
- catch (e) {
414
+ const sp = spinner("fetching input → csd signs → submit");
415
+ const picked = await api.pickInput(addr, fee).catch(() => null);
416
+ if (!picked) {
172
417
  sp.stop();
173
- console.log(err(e.message));
418
+ console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
419
+ return;
420
+ }
421
+ const tip = await api.tipHeight().catch(() => 0);
422
+ const days = Math.max(1, parseInt(String(a.flags["expires-days"] ?? 30)) || 30);
423
+ 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]);
424
+ sp.stop();
425
+ if (!r.ok) {
426
+ console.log(err(r.error || "failed"));
427
+ return;
174
428
  }
429
+ console.log(ok(`proposed ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
430
+ const sp2 = spinner("registering content (waits for the tx to mine)");
431
+ const done = await api.registerContent({ domain, title, body, links }, r.txid);
432
+ sp2.stop();
433
+ console.log(done ? ok("content registered — visible on the board") : warn("content not registered yet — re-run once mined"));
175
434
  }
176
435
  async function cmdSupport(a) {
177
436
  const id = a._[1];
178
437
  if (!id) {
179
- console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <base> [--score 0-100] [--confidence 0-100]"));
438
+ console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <CSD> [--score 0-100] [--confidence 0-100]"));
180
439
  return;
181
440
  }
182
- const fee = Number(a.flags.fee ?? MIN_FEE_ATTEST);
183
- const sp = spinner("posting support to Cairn");
184
- try {
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"));
441
+ if (!/^0x[0-9a-fA-F]{64}$/.test(id)) {
442
+ console.log(err("proposal id must be 0x…64-hex"));
443
+ return;
444
+ }
445
+ const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.05;
446
+ const fee = Math.max(MIN_FEE_ATTEST, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_ATTEST);
447
+ const score = Math.max(0, Math.min(100, parseInt(String(a.flags.score ?? 75)) || 0));
448
+ const confidence = Math.max(0, Math.min(100, parseInt(String(a.flags.confidence ?? 60)) || 0));
449
+ if (CAIRN_TOKEN && !(await csd.available())) {
450
+ const sp = spinner("posting via operator token");
451
+ try {
452
+ const r = await api.apiSupport({ id, fee, score, confidence });
453
+ sp.stop();
454
+ console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
455
+ }
456
+ catch (e) {
457
+ sp.stop();
458
+ console.log(err(e.message));
459
+ }
460
+ return;
461
+ }
462
+ if (!(await requireCsd()))
463
+ return;
464
+ const addr = await resolveAddr(a);
465
+ if (!addr) {
466
+ console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
467
+ return;
468
+ }
469
+ if (a.flags["dry-run"]) {
470
+ console.log(`${kdim("support")} ${c.cyan(id)}`);
471
+ console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${c.gray("· score " + score + " · confidence " + confidence)} ${kdim("from")} ${c.cyan(addr)}`);
472
+ console.log(c.gray("\n[dry-run] not signed or submitted"));
473
+ return;
188
474
  }
189
- catch (e) {
475
+ const sp = spinner("fetching input → csd signs → submit");
476
+ const picked = await api.pickInput(addr, fee).catch(() => null);
477
+ if (!picked) {
190
478
  sp.stop();
191
- console.log(err(e.message));
479
+ console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
480
+ return;
481
+ }
482
+ const r = await signAndSubmit(["attest", "--proposal-id", id, "--score", String(score), "--confidence", String(confidence), "--fee", String(fee), "--change", addr, "--input", picked.input]);
483
+ sp.stop();
484
+ console.log(r.ok ? ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
485
+ }
486
+ async function cmdWall(a) {
487
+ if (a._[1] === "place") {
488
+ const msg = a._.slice(2).join(" ").trim() || String(a.flags.message ?? "").trim();
489
+ if (!msg) {
490
+ console.log(warn("usage: ") + c.cyan('cairn wall place "<message>" [--fee <CSD>] [--dry-run]'));
491
+ return;
492
+ }
493
+ // forward the write-relevant flags (fee, address, dry-run) through to the propose path
494
+ const fwd = { domain: "cairn:wall", title: msg };
495
+ for (const k of ["fee", "address", "dry-run"])
496
+ if (a.flags[k] !== undefined)
497
+ fwd[k] = a.flags[k];
498
+ return cmdPropose({ _: ["propose"], flags: fwd, multi: {} });
192
499
  }
500
+ return cmdWallView();
193
501
  }
194
502
  async function cmdDomains() {
195
503
  const r = await api.apiDomains();
196
504
  banner();
197
505
  rule("categories");
198
506
  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 + ")" : "")}`);
507
+ console.log(` ${c.cyan(pad(san(dom.key), 20))} ${c.white(san(dom.title))} ${c.gray(dom.count != null ? "(" + Number(dom.count) + ")" : "")}`);
200
508
  // open domains: anyone can create one by proposing into it (cairn ls <domain> works for any).
201
509
  const disc = r.discovered ?? [];
202
510
  if (disc.length) {
203
511
  console.log(c.gray("\n open domains (created by proposing into them):"));
204
512
  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" : ""))}`);
513
+ 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
514
  }
207
515
  }
208
- async function cmdWall() {
516
+ async function cmdWallView() {
209
517
  const r = await api.apiWall();
210
518
  const stones = r.stones ?? [];
211
519
  banner();
212
520
  rule(`the wall · ${r.totals?.stones ?? 0} stones · ${r.totals?.boosts ?? 0} boosts · epoch ${r.epoch ?? "?"}`);
213
521
  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")}`);
522
+ 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
523
  if (!stones.length) {
216
524
  console.log(c.gray("\n no stones yet — place one with the Cairn Wallet, or:"));
217
525
  console.log(c.green(" cairn propose --domain cairn:wall --title '<message>'"));
@@ -220,8 +528,8 @@ async function cmdWall() {
220
528
  const max = stones[0]?.weight || 1;
221
529
  stones.slice(0, 25).forEach((s, i) => {
222
530
  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(" #")) : ""}`);
531
+ console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(san(s.message)))}${i === 0 ? " " + c.green("★") : ""}`);
532
+ 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
533
  });
226
534
  }
227
535
  async function cmdNetwork() {
@@ -258,14 +566,14 @@ async function cmdProfile(a) {
258
566
  }
259
567
  const p = r.profile || {}, rep = r.reputation || {};
260
568
  banner();
261
- rule(`profile · ${p.handle || addr}`);
569
+ rule(`profile · ${san(p.handle || addr)}`);
262
570
  if (p.handle)
263
- console.log(` ${kdim(pad("handle", 13))} ${c.white(p.handle)}`);
571
+ console.log(` ${kdim(pad("handle", 13))} ${c.white(san(p.handle))}`);
264
572
  if (p.bio)
265
- console.log(` ${kdim(pad("bio", 13))} ${c.gray(p.bio)}`);
573
+ console.log(` ${kdim(pad("bio", 13))} ${c.gray(san(p.bio))}`);
266
574
  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)}`);
575
+ console.log(` ${kdim(pad("github", 13))} ${c.cyan(san(p.github))} ${p.githubVerified ? ok("verified") : c.gray("(unverified)")}`);
576
+ console.log(` ${kdim(pad("address", 13))} ${c.gray(san(p.addr || addr))}`);
269
577
  console.log(` ${kdim(pad("trust", 13))} ${c.white((rep.trust ?? 0).toFixed(2))}`);
270
578
  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
579
  }
@@ -279,7 +587,7 @@ async function cmdLeaderboard() {
279
587
  return;
280
588
  }
281
589
  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 ?? 0).toFixed(2))} ${c.gray("· " + (e.shipped ?? e.acceptedWork ?? 0) + " shipped · " + (e.proposed ?? 0) + " proposed")}`);
590
+ 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
591
  });
284
592
  }
285
593
  async function cmdQuests() {
@@ -294,9 +602,9 @@ async function cmdQuests() {
294
602
  qs.slice(0, 25).forEach((q, i) => {
295
603
  const reward = q.quest?.reward?.build ? csdToCoins(q.quest.reward.build) + " CSD" : "—";
296
604
  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)}…`));
605
+ console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(san(q.title)))} ${c.gray("· " + san(q.status || "?"))}`);
606
+ console.log(` ${c.gray("reward " + reward + " · demand " + csdFmt(q.demandWeight || 0) + " · " + Number(q.demandSupporters || 0) + " backers")}`);
607
+ console.log(c.gray(` id ${san(String(q.id).slice(0, 22))}…`));
300
608
  });
301
609
  }
302
610
  async function help() {
@@ -315,13 +623,19 @@ async function help() {
315
623
  cmd("quests", "", "open quests");
316
624
  cmd("profile", "<addr>", "identity + reputation");
317
625
  cmd("leaderboard", "", "top builders by reputation");
318
- cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (needs CAIRN_TOKEN)");
319
- cmd("support", "<id> --fee <base>", "back an item (needs CAIRN_TOKEN)");
626
+ cmd("wall place", '"<message>"', "place a stone on the Wall (a cairn:wall proposal)");
627
+ console.log("");
628
+ console.log(c.bold(" wallet") + c.gray(" (signs with your installed csd wallet — cairn never holds your key)"));
629
+ cmd("setup", "", "check csd + wallet, show your address (alias: doctor)");
630
+ cmd("address", "", "your address + balance (alias: whoami, balance)");
631
+ cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --dry-run)");
632
+ cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (alias: post; + --fee, --expires-days, --dry-run)");
633
+ cmd("support", "<id> --fee <CSD>", "back an item (+ --score, --confidence, --dry-run)");
320
634
  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 url) · CAIRN_TOKEN (instance write token, to post) · CAIRN_RPC (trustless verify)"));
635
+ console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${csdToCoins(MIN_FEE_PROPOSE)} · attest ≥ ${csdToCoins(MIN_FEE_ATTEST)} CSD`));
636
+ console.log(c.gray(" config: CAIRN_API (board) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
323
637
  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(" keyless non-custodial posting + sealed claims: use the Cairn Wallet (browser extension)."));
638
+ 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
639
  }
326
640
  async function main() {
327
641
  const a = parse(process.argv.slice(2));
@@ -334,14 +648,21 @@ async function main() {
334
648
  case "recent": return cmdRecent();
335
649
  case "show": return cmdShow(a);
336
650
  case "verify": return cmdVerify(a);
337
- case "wall": return cmdWall();
651
+ case "wall": return cmdWall(a);
338
652
  case "network":
339
653
  case "stats": return cmdNetwork();
340
654
  case "quests": return cmdQuests();
341
655
  case "profile": return cmdProfile(a);
342
656
  case "leaderboard":
343
657
  case "lb": return cmdLeaderboard();
344
- case "propose": return cmdPropose(a);
658
+ case "setup":
659
+ case "doctor": return cmdSetup();
660
+ case "address":
661
+ case "whoami": return cmdAddress(a);
662
+ case "balance": return cmdBalance(a);
663
+ case "send": return cmdSend(a);
664
+ case "propose":
665
+ case "post": return cmdPropose(a);
345
666
  case "support": return cmdSupport(a);
346
667
  default: return help();
347
668
  }
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,59 @@ 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
+ }
39
88
  // optional: query a raw csd node RPC (for trustless verify)
40
89
  export async function chainProposal(id) {
41
90
  if (!CAIRN_RPC)
42
91
  return null;
92
+ if (!/^0x[0-9a-fA-F]{64}$/.test(id))
93
+ return null; // never splice an unshaped id into the URL
43
94
  try {
44
- const r = await fetch(`${CAIRN_RPC}/proposal/${id}`, { signal: AbortSignal.timeout(6000) });
95
+ const r = await fetch(`${CAIRN_RPC}/proposal/${encodeURIComponent(id)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
45
96
  if (!r.ok)
46
97
  return null;
47
98
  const j = await r.json();
@@ -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 ?? ""; // required only for posting
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
+ }
@@ -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 t = setInterval(() => process.stderr.write(`\r${c.green(frames[i++ % frames.length])} ${c.gray(label)} `), 80);
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
- // Fast decode-reveal of the CAIRN wordmark: scramble glyphs resolve L→R into white
98
- // letters with a green cursor the site's "boot" feel. ~360ms, and only on an
99
- // interactive TTY (static banner otherwise, so help piped to a file isn't garbage).
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#%/\\<>=".split("");
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 s = "";
110
- for (let i = 0; i < word.length; i++)
111
- s += i < step ? c.white(c.bold(word[i])) : c.green(rnd());
112
- process.stdout.write(`\r ${c.gray("▓▒░")} ${s} ${c.green("▋")} ${c.gray("· " + TAG)} `);
113
- await sleep(60);
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,21 +1,37 @@
1
1
  {
2
2
  "name": "@inversealtruism/cairn-cli",
3
- "version": "0.2.0",
4
- "description": "Command-line client for a Cairn signal board on Compute Substrate browse, propose, and back what to build.",
3
+ "version": "0.3.1",
4
+ "description": "CLI for Compute Substrate / Cairn \u2014 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": { "cairn": "dist/cli.js" },
7
- "files": ["dist", "README.md"],
8
- "engines": { "node": ">=20" },
9
- "repository": { "type": "git", "url": "git+https://github.com/InverseAltruism/cairn-cli.git" },
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": { "access": "public" },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
12
24
  "scripts": {
13
25
  "build": "tsc",
14
26
  "dev": "tsx src/cli.ts",
15
- "test": "tsc && node test/e2e.mjs",
27
+ "test": "tsc && node test/security.mjs && node test/e2e.mjs",
16
28
  "prepare": "tsc"
17
29
  },
18
- "keywords": ["cairn", "compute-substrate", "cli"],
30
+ "keywords": [
31
+ "cairn",
32
+ "compute-substrate",
33
+ "cli"
34
+ ],
19
35
  "license": "MIT",
20
36
  "devDependencies": {
21
37
  "@types/node": "^22.10.0",