@inversealtruism/cairn-cli 0.3.1 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,28 +5,130 @@ import { CAIRN_API, CAIRN_ADDR, CAIRN_TOKEN, CAIRN_RPC, MIN_FEE_PROPOSE, MIN_FEE
5
5
  import * as api from "./lib/api.js";
6
6
  import * as csd from "./lib/csd.js";
7
7
  import { buildCommitment } from "./lib/item.js";
8
+ import { buildGatewayRecord, buildPeerRecord, buildIdentityCommit, buildIdentityReveal } from "@inversealtruism/csd-registry";
9
+ import { canonicalJson } from "@inversealtruism/csd-codec";
10
+ import { cairnxGet, activeCairnxBase, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
11
+ import { randomBytes } from "node:crypto";
12
+ import { createInterface } from "node:readline";
8
13
  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
14
  const CSD = (n) => Number.isFinite(n) ? Math.round(n * CSD_PER_COIN) : NaN; // CSD → base units
15
+ // ── max-fee sanity guard (UTXO-VALUE-1) ──────────────────────────────────────────────────────
16
+ // A CSD fee is implicit (Σin − Σout) and the chain enforces NO maximum, so a hostile proxy that
17
+ // UNDER-reports the picked UTXO's value would make `csd` compute too-small a change and the
18
+ // difference is silently burned to the miner as fee — the user's own funds, with no on-chain
19
+ // protection. cairn-cli has no codec to recompute the input's REAL value (the wallet's
20
+ // verifyInputValues path), so it applies a proportionate SANITY cap on the fee instead: the fee
21
+ // must be ≤ max(1 CSD absolute, 25% of the tx value). Every honest fee (a 0.01 CSD transfer fee,
22
+ // a 0.25 CSD propose) passes; a typo / hostile-inflated fee is refused. `--max-fee <CSD>` overrides.
23
+ // Returns null if the fee is acceptable, or a human error string to print and abort.
24
+ const MAX_FEE_ABS = 100_000_000; // 1 CSD absolute floor — every honest fee is well under this
25
+ const MAX_FEE_VALUE_FRACTION = 0.25; // …and ≤ 25% of the value moved
26
+ function feeCap(txValue, a) {
27
+ if (a.flags["max-fee"] !== undefined) {
28
+ const m = CSD(Number(a.flags["max-fee"]));
29
+ if (Number.isSafeInteger(m) && m >= 0)
30
+ return m;
31
+ }
32
+ return Math.max(MAX_FEE_ABS, Math.floor(Math.max(0, txValue) * MAX_FEE_VALUE_FRACTION));
33
+ }
34
+ // Guard a value-write before it is built/signed. `txValue` = the value the user means to move
35
+ // (recipients for a send; the fee itself for a fee-only propose/attest). Prints + returns false on abort.
36
+ function feeSanity(fee, txValue, a) {
37
+ const cap = feeCap(txValue, a);
38
+ if (fee > cap) {
39
+ console.log(err(`fee ${csdToCoins(fee)} CSD looks abnormally high (cap ${csdToCoins(cap)} CSD).`) +
40
+ c.gray(" A proxy under-reporting your input can silently burn the difference as fee. Lower ") + c.cyan("--fee") +
41
+ c.gray(", or override with ") + c.cyan("--max-fee <CSD>") + c.gray(" if this is intentional."));
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ // Implied-fee transparency for a single-UTXO spend: the on-chain fee a hostile proxy can inflate is
47
+ // (real input − value − change). We can only see the REPORTED input, but we can flag when the
48
+ // reported change is implausibly small for the input (the collapsed-change signature of an
49
+ // under-report). Non-fatal — a warning the user sees before they commit.
50
+ function warnIfChangeCollapsed(inputValue, txValue, fee) {
51
+ const change = inputValue - txValue - fee;
52
+ if (change < 0)
53
+ return; // pickInput already ensures coverage; defensive
54
+ // a healthy spend leaves change ≫ fee unless the user genuinely picked a tight UTXO; flag the
55
+ // case where the implied fee dwarfs the change (what an under-reporting proxy produces).
56
+ if (change > 0 && fee > change * 4) {
57
+ console.log(warn(`heads up: change (${csdToCoins(change)} CSD) is much smaller than the fee (${csdToCoins(fee)} CSD).`) +
58
+ c.gray(" If your proxy under-reports this input, extra value is burned as fee — verify with your own node, or use ") + c.cyan("--max-fee") + c.gray("."));
59
+ }
60
+ }
61
+ // Pick a spendable input from the proxy, INDEPENDENTLY verify its value against CAIRN_RPC
62
+ // (UTXO-VALUE-1 cross-source cure), and ALWAYS display the input/change so the implied fee is
63
+ // visible on EVERY write (not just `send`). `txValue` = value moved to recipients (0 for a fee-
64
+ // only propose/attest); `fee` = the fee. Returns the csd input triple, or null (reason printed).
65
+ // Defense layering: (1) if an independent node is configured we cryptographically catch an
66
+ // under-report and REFUSE; (2) otherwise the value is proxy-trusted and the only backstop is the
67
+ // feeSanity cap + the visible change line — the user is told to set CAIRN_RPC for a real check.
68
+ async function pickAndShow(addr, need, txValue, fee) {
69
+ const picked = await api.pickInput(addr, need).catch(() => null);
70
+ if (!picked) {
71
+ console.log(err("no single confirmed UTXO covers this spend") + c.gray(" — fund this address, or consolidate (a node + `csd … --auto-input` can combine inputs)."));
72
+ return null;
73
+ }
74
+ const [ptxid, pvoutS] = picked.input.split(":");
75
+ const v = await api.verifyInputValue(addr, ptxid, Number(pvoutS), picked.value).catch(() => ({ checked: false }));
76
+ if (v.checked && v.ok === false) {
77
+ if (v.missing)
78
+ console.log(err("the picked input is NOT in your independent node's UTXO set (CAIRN_RPC).") + c.gray(" The proxy may be misreporting your coins — refusing to spend. Check your node is synced, or unset CAIRN_RPC to override."));
79
+ else
80
+ console.log(err(`input value MISMATCH — proxy says ${csdToCoins(picked.value)} CSD, your node says ${csdToCoins(v.value ?? 0)} CSD.`) + c.gray(" A proxy under-reporting your input would burn the difference as fee — refusing to spend."));
81
+ return null;
82
+ }
83
+ const verified = v.checked && v.ok === true;
84
+ const inputValue = verified ? Number(v.value) : picked.value;
85
+ const tag = verified ? c.green(" ✓ verified vs your node")
86
+ : (CAIRN_RPC ? c.gray(" (independent value check unavailable)") : c.gray(" (set CAIRN_RPC to your own node to independently verify this value)"));
87
+ console.log(`${kdim("input")} ${csdToCoins(inputValue)} CSD ${c.gray("(one UTXO)")}${tag} ${kdim("change")} ${csdToCoins(Math.max(0, inputValue - txValue - fee))} CSD ${c.gray("back to you")}`);
88
+ warnIfChangeCollapsed(inputValue, txValue, fee);
89
+ return picked.input;
90
+ }
10
91
  // Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
11
92
  // 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.
93
+ // only the public address). Order: --address → CAIRN_ADDR (both EXPLICIT user intent for this
94
+ // invocation) → the csd wallet's real change address → cached config.address (LAST, and never
95
+ // above the wallet — see below).
96
+ //
97
+ // F13/R18: the cached config.address is attacker-tamperable (a poisoned
98
+ // ~/.config/cairn-cli/config.json would otherwise redirect `cairn address` output, so a funder
99
+ // piping it pays the attacker). So when csd is available we re-derive the wallet's REAL change
100
+ // address and treat THAT as authoritative; if it disagrees with the cached value we refuse and
101
+ // rewrite the cache. The cache is only used as a fallback when csd can't tell us the truth.
13
102
  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;
103
+ // explicit, per-invocation choice the user can deliberately target any address
104
+ const explicit = a.flags.address ? String(a.flags.address) : CAIRN_ADDR;
105
+ if (explicit && /^0x[0-9a-fA-F]{40}$/.test(explicit))
106
+ return explicit;
107
+ const cached = loadLocalConfig().address;
17
108
  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).
109
+ // Best case: the wallet exposes its change address directly authoritative AND needs no key
110
+ // (no argv exposure). The cache is then only an anti-poison cross-check (F13/R18): if a tampered
111
+ // config disagrees, say so loudly and use the wallet's address.
20
112
  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;
113
+ const real = String(cfg.default_change_addr20);
114
+ if (cached && cached.toLowerCase() !== real.toLowerCase())
115
+ console.log(warn(`cached address ${c.cyan(san(cached))} does NOT match your csd wallet ${c.cyan(real)}`) + c.gray(" — using the wallet's address and refreshing the cache (a tampered config can't redirect you)."));
116
+ saveLocalConfig({ address: real });
117
+ return real;
24
118
  }
119
+ // No change address configured. Audit H-2: re-deriving from the privkey on EVERY call runs
120
+ // `csd wallet recover --privkey <KEY>`, putting the key on the argv (readable via /proc on a
121
+ // shared host). Avoid that — prefer a previously-cached address; only DERIVE when we have
122
+ // nothing else (then cache it + warn once, and nudge the user to set a change address so the
123
+ // key is never needed again, which also restores the F13 anti-poison cross-check above).
124
+ if (cached && /^0x[0-9a-fA-F]{40}$/.test(cached))
125
+ return cached;
25
126
  if (cfg?.default_privkey) {
26
- const addr = await csd.deriveAddr(cfg.default_privkey);
27
- if (addr) {
28
- saveLocalConfig({ address: addr });
29
- return addr;
127
+ const real = await csd.deriveAddr(cfg.default_privkey);
128
+ if (real && /^0x[0-9a-fA-F]{40}$/.test(real)) {
129
+ console.log(warn("derived your address from the wallet key once.") + c.gray(" " + csd.keyExposureWarning));
130
+ saveLocalConfig({ address: real });
131
+ return real;
30
132
  }
31
133
  }
32
134
  return null;
@@ -36,9 +138,13 @@ async function resolveAddr(a) {
36
138
  // proxy ourselves. We do NOT trust csd's own auto-submit: it targets csd's configured node,
37
139
  // which may be a different node than the one the Cairn board (and its miner) read — so a tx
38
140
  // 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.
141
+ // the proxy (the board's miner-connected node).
142
+ //
143
+ // Success is EVIDENCE-BASED, never message-based: a hostile proxy (or a real double-spend)
144
+ // can return an "already present / conflict" string for a tx that is NOT actually ours, so we
145
+ // never treat any error message as success. A clean submit ack is good; otherwise we ask the
146
+ // node directly whether OUR exact txid is on-chain (api.confirmTxMined), and only claim success
147
+ // when the node confirms it. If we can't confirm, we surface the real node message.
42
148
  async function signAndSubmit(csdArgs) {
43
149
  const r = await csd.run(csdArgs);
44
150
  if (!r.ok)
@@ -54,17 +160,40 @@ async function signAndSubmit(csdArgs) {
54
160
  const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
55
161
  if (sub.ok)
56
162
  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 ?? "")))
163
+ // Submit was NOT acked. Don't trust the error STRING a forged "already present" can hide a
164
+ // rejected/conflicting tx. Confirm against the chain: only if the node reports OUR exact txid
165
+ // as mined is this our own prior submit (a genuine "already in"); otherwise the conflict is a
166
+ // DIFFERENT spend (a real double-spend / hostile reply) and we surface it as a failure.
167
+ if (txid && await api.confirmTxMined(txid))
65
168
  return { ok: true, txid };
66
169
  return { ok: false, error: sub.err || "submit rejected by node", txid };
67
170
  }
171
+ // Freshness gate (R12): before building any value tx, consult the proxy's chain-view status so
172
+ // we never sign against a FROZEN or forked tip (the proxy may be failing over to an honest-but-
173
+ // stale or wedged node). Fail CLOSED on a stale tip; fail OPEN (warn only) if the freshness
174
+ // surface is unreachable — that matches the rest of the CLI's 'cannot reach' UX and an old node
175
+ // without /api/rpc/status must still be usable. `--force-stale` lets an operator override.
176
+ async function freshTip(a) {
177
+ let s;
178
+ try {
179
+ s = await api.rpcStatus();
180
+ }
181
+ catch {
182
+ console.log(warn("could not check chain freshness (status surface unreachable) — proceeding"));
183
+ return true;
184
+ }
185
+ const secs = Number(s?.secondsSinceAdvance ?? 0);
186
+ const threshold = Number(s?.staleSecsThreshold ?? 600);
187
+ const stale = s?.stale === true || (Number.isFinite(secs) && Number.isFinite(threshold) && threshold > 0 && secs > threshold);
188
+ if (!stale)
189
+ return true;
190
+ if (a.flags["force-stale"]) {
191
+ console.log(warn(`chain tip looks STALE (${secs}s since last advance) — proceeding anyway (--force-stale)`));
192
+ return true;
193
+ }
194
+ console.log(err(`chain tip looks STALE — last advanced ${secs}s ago (threshold ${threshold}s).`) + c.gray(" The node may be frozen or failing over to a stale view; refusing to build a tx. Retry shortly, or override with ") + c.cyan("--force-stale") + c.gray("."));
195
+ return false;
196
+ }
68
197
  // Guard: a write needs `csd` installed + a configured wallet (or an explicit --address + csd key).
69
198
  async function requireCsd() {
70
199
  if (!(await csd.available())) {
@@ -100,11 +229,19 @@ function parse(argv) {
100
229
  }
101
230
  return { _, flags, multi };
102
231
  }
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).
232
+ // Do two URLs point at the same MACHINE? (used to refuse a "trustless" verify claim when the
233
+ // node RPC and the board API are the same operator). Compares hostname only NOT host — so a
234
+ // same-box node:8789 + board:7777 are correctly judged same-machine (a port difference does not
235
+ // make two endpoints independent), and canonicalizes the loopback aliases (127.0.0.1 / localhost
236
+ // / ::1) so they all compare equal. Unparseable → treat as same (the SAFE default: never claim
237
+ // trustless independence we can't establish).
238
+ function canonHost(h) {
239
+ const x = h.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
240
+ return (x === "localhost" || x === "127.0.0.1" || x === "::1" || x === "0.0.0.0") ? "localhost" : x;
241
+ }
105
242
  function sameHost(a, b) {
106
243
  try {
107
- return new URL(a).host.toLowerCase() === new URL(b).host.toLowerCase();
244
+ return canonHost(new URL(a).hostname) === canonHost(new URL(b).hostname);
108
245
  }
109
246
  catch {
110
247
  return true;
@@ -268,7 +405,12 @@ async function cmdSetup() {
268
405
  banner();
269
406
  rule("setup — cairn over your csd wallet");
270
407
  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")}`);
408
+ // H-1: show the RESOLVED absolute path of the binary that will SIGN, so the user can verify it
409
+ // (and see the refusal if it was resolved from an untrusted location).
410
+ const bin = csd.csdPathInfo();
411
+ console.log(` ${kdim("csd binary")} ${has ? ok("found") + c.gray(" " + (bin.path ?? "")) + (bin.explicit ? c.gray(" (CAIRN_CSD)") : c.gray(" (resolved)")) : err(bin.error || "not found — install Compute Substrate's csd CLI, or set CAIRN_CSD to its absolute path")}`);
412
+ if (bin.warning)
413
+ console.log(` ${kdim("")} ${warn(bin.warning)}`);
272
414
  if (!has) {
273
415
  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
416
  return;
@@ -343,30 +485,61 @@ async function cmdSend(a) {
343
485
  for (const o of outs)
344
486
  console.log(`${kdim("to")} ${c.cyan(o.to)} ${c.gray("→ " + csdToCoins(o.value) + " CSD")}`);
345
487
  console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("total")} ${csdToCoins(total + fee)} CSD`);
488
+ // max-fee sanity: an absurd fee (typo, or a hostile proxy under-reporting the input → burned
489
+ // change) is refused BEFORE we build/sign. --dry-run still SHOWS the abnormal-fee warning (so the
490
+ // user sees it without spending) but never aborts the preview. --max-fee overrides a deliberate fee.
491
+ const feeOk = feeSanity(fee, total, a);
346
492
  if (a.flags["dry-run"]) {
347
493
  console.log(c.gray("\n[dry-run] not sent"));
348
494
  return;
349
495
  }
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)"));
496
+ if (!feeOk)
497
+ return;
498
+ if (!(await freshTip(a)))
499
+ return;
500
+ const input = await pickAndShow(addr, total + fee, total, fee);
501
+ if (!input)
355
502
  return;
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
503
  const sp2 = spinner("csd signs → submit");
363
504
  const args = ["spend"];
364
505
  for (const o of outs)
365
506
  args.push("--output", `${o.to}:${o.value}`);
366
- args.push("--change", addr, "--fee", String(fee), "--input", picked.input);
507
+ args.push("--change", addr, "--fee", String(fee), "--input", input);
367
508
  const r = await signAndSubmit(args);
368
509
  sp2.stop();
369
- console.log(r.ok ? ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
510
+ if (!r.ok) {
511
+ console.log(err(r.error || "failed"));
512
+ return;
513
+ }
514
+ console.log(ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
515
+ await confirmMined(r.txid, "transfer", !!a.flags.wait);
516
+ }
517
+ // Post-submit feedback for value writes (send/support). The txid is already proven submitted by the
518
+ // evidence-based signAndSubmit; mining is a separate ~120s event, so by DEFAULT we DON'T block on it —
519
+ // we report submitted + how to track it (matching token-send). Pass `--wait` to block until OUR exact
520
+ // txid is mined (the old behavior; useful in scripts that chain on confirmation). Non-fatal either way.
521
+ async function confirmMined(txid, label, wait) {
522
+ if (!wait) {
523
+ console.log(c.gray(` ${label} submitted — usually mines within ~2 min; track with `) + c.cyan(`cairn show ${txid.slice(0, 10)}…`));
524
+ return;
525
+ }
526
+ const sp = spinner("waiting for the tx to mine (--wait)");
527
+ const mined = await api.confirmTxMined(txid).catch(() => false);
528
+ // H-7: the proxy can string-echo a "mined" reply. Only assert "confirmed on-chain" when an
529
+ // INDEPENDENT node (CAIRN_RPC) agrees; otherwise soften/flag so the user isn't given a false
530
+ // settlement guarantee they might release goods against.
531
+ const indep = mined ? await api.chainTxMined(txid).catch(() => null) : null;
532
+ sp.stop();
533
+ if (!mined) {
534
+ console.log(warn(`${label} submitted — not mined yet; re-check with `) + c.cyan(`cairn show`) + warn(" once a block lands"));
535
+ return;
536
+ }
537
+ if (indep === true)
538
+ console.log(ok(`${label} confirmed on-chain`) + c.gray(" (verified against your independent node)"));
539
+ else if (indep === false)
540
+ console.log(err(`${label}: the proxy reports it mined but your node (CAIRN_RPC) does NOT — treat as UNCONFIRMED.`) + c.gray(" A hostile proxy can forge a 'mined' reply; trust your own node."));
541
+ else
542
+ console.log(warn(`${label}: proxy reports mined`) + c.gray(" — set CAIRN_RPC to your own node to confirm independently (a proxy can forge this signal)."));
370
543
  }
371
544
  async function cmdPropose(a) {
372
545
  const domain = String(a.flags.domain ?? "");
@@ -408,19 +581,21 @@ async function cmdPropose(a) {
408
581
  console.log(`${kdim("title")} ${c.white(title)}`);
409
582
  console.log(`${kdim("hash")} ${c.magenta(payloadHash)} ${c.gray("· uri " + uri)}`);
410
583
  console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("from")} ${c.cyan(addr)}`);
584
+ feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (non-aborting on dry-run)
411
585
  console.log(c.gray("\n[dry-run] not signed or submitted"));
412
586
  return;
413
587
  }
414
- const sp = spinner("fetching input → csd signs → submit");
415
- const picked = await api.pickInput(addr, fee).catch(() => null);
416
- if (!picked) {
417
- sp.stop();
418
- console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
588
+ if (!feeSanity(fee, fee, a))
589
+ return;
590
+ if (!(await freshTip(a)))
591
+ return;
592
+ const input = await pickAndShow(addr, fee, 0, fee);
593
+ if (!input)
419
594
  return;
420
- }
421
595
  const tip = await api.tipHeight().catch(() => 0);
422
596
  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]);
597
+ const sp = spinner("csd signs submit");
598
+ 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", input]);
424
599
  sp.stop();
425
600
  if (!r.ok) {
426
601
  console.log(err(r.error || "failed"));
@@ -469,19 +644,27 @@ async function cmdSupport(a) {
469
644
  if (a.flags["dry-run"]) {
470
645
  console.log(`${kdim("support")} ${c.cyan(id)}`);
471
646
  console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${c.gray("· score " + score + " · confidence " + confidence)} ${kdim("from")} ${c.cyan(addr)}`);
647
+ feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (the attest weight IS the fee)
472
648
  console.log(c.gray("\n[dry-run] not signed or submitted"));
473
649
  return;
474
650
  }
475
- const sp = spinner("fetching input csd signs submit");
476
- const picked = await api.pickInput(addr, fee).catch(() => null);
477
- if (!picked) {
478
- sp.stop();
479
- console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
651
+ // an attest's fee IS the deliberate weight/stake; only guard against an absurd typo above the cap.
652
+ if (!feeSanity(fee, fee, a))
480
653
  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]);
654
+ if (!(await freshTip(a)))
655
+ return;
656
+ const input = await pickAndShow(addr, fee, 0, fee);
657
+ if (!input)
658
+ return;
659
+ const sp = spinner("csd signs → submit");
660
+ const r = await signAndSubmit(["attest", "--proposal-id", id, "--score", String(score), "--confidence", String(confidence), "--fee", String(fee), "--change", addr, "--input", input]);
483
661
  sp.stop();
484
- console.log(r.ok ? ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)") : err(r.error || "failed"));
662
+ if (!r.ok) {
663
+ console.log(err(r.error || "failed"));
664
+ return;
665
+ }
666
+ console.log(ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
667
+ await confirmMined(r.txid, "support", !!a.flags.wait);
485
668
  }
486
669
  async function cmdWall(a) {
487
670
  if (a._[1] === "place") {
@@ -628,12 +811,19 @@ async function help() {
628
811
  console.log(c.bold(" wallet") + c.gray(" (signs with your installed csd wallet — cairn never holds your key)"));
629
812
  cmd("setup", "", "check csd + wallet, show your address (alias: doctor)");
630
813
  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)");
814
+ cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --max-fee <CSD>, --dry-run)");
632
815
  cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (alias: post; + --fee, --expires-days, --dry-run)");
633
816
  cmd("support", "<id> --fee <CSD>", "back an item (+ --score, --confidence, --dry-run)");
817
+ console.log("");
818
+ console.log(c.bold(" cairnx") + c.gray(" (tokens + .csd names on the CairnX layer)"));
819
+ cmd("tokens", "[address]", "token balances + .csd names (default: your address)");
820
+ cmd("token-info", "<TICKER>", "supply · minted · mint mode · top-10 holders (alias: token)");
821
+ cmd("token-send", "--ticker T --to 0x…40 --amount <n>", "send tokens (anchors a 0.25 CSD transfer record; --dry-run, --yes)");
822
+ cmd("names", "[address]", "owned .csd names");
823
+ cmd("name", "<name>", "one name: owner · lease · open offer");
634
824
  console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
635
825
  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)"));
826
+ console.log(c.gray(" config: CAIRN_API (board) · CAIRNX_API (token layer) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
637
827
  console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
638
828
  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."));
639
829
  }
@@ -664,7 +854,380 @@ async function main() {
664
854
  case "propose":
665
855
  case "post": return cmdPropose(a);
666
856
  case "support": return cmdSupport(a);
857
+ case "tokens": return cmdTokens(a);
858
+ case "token-info":
859
+ case "token": return cmdTokenInfo(a);
860
+ case "token-send": return cmdTokenSend(a);
861
+ case "names": return cmdNames(a);
862
+ case "name": return cmdName(a);
863
+ case "gateway": return cmdGateway(a);
864
+ case "peer": return cmdPeer(a);
865
+ case "identity": return cmdIdentity(a);
667
866
  default: return help();
668
867
  }
669
868
  }
869
+ // ── L3 registry publish commands (build a signed record → anchor Propose → serve bytes) ──
870
+ // Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
871
+ // then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
872
+ async function anchorRecord(rec, addr, fee, days, label) {
873
+ // max-fee sanity: a fixed-floor record anchor should never burn more than the 1 CSD abs cap.
874
+ if (fee > MAX_FEE_ABS) {
875
+ console.log(err(`fee ${csdToCoins(fee)} CSD looks abnormally high for a ${label} record (cap ${csdToCoins(MAX_FEE_ABS)} CSD).`) + c.gray(" Lower ") + c.cyan("--fee") + c.gray("."));
876
+ return false;
877
+ }
878
+ const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
879
+ const input = await pickAndShow(addr, fee, 0, fee);
880
+ if (!input)
881
+ return false;
882
+ const tip = await api.tipHeight().catch(() => 0);
883
+ const sp = spinner("csd signs → submit");
884
+ 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", input]);
885
+ sp.stop();
886
+ if (!r.ok) {
887
+ console.log(err(r.error || "failed"));
888
+ return false;
889
+ }
890
+ console.log(ok(`${label} anchored ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
891
+ const sp2 = spinner("publishing content (waits for the tx to mine)");
892
+ const done = await api.registerRawContent(canonicalJson(rec.content), r.txid);
893
+ sp2.stop();
894
+ console.log(done ? ok("content published — record is now resolvable") : warn("content not published yet — re-run once mined"));
895
+ return done;
896
+ }
897
+ // Shared setup for the registry commands: require csd, the privkey (to sign the binding
898
+ // locally — never networked), and the address.
899
+ async function registryPrep(a) {
900
+ if (!(await requireCsd()))
901
+ return null;
902
+ const cfg = await csd.walletConfig();
903
+ const priv = cfg?.default_privkey;
904
+ if (!priv) {
905
+ console.log(err("no csd wallet key configured.") + c.gray(" Run ") + c.cyan("csd wallet new"));
906
+ return null;
907
+ }
908
+ const addr = await resolveAddr(a);
909
+ if (!addr) {
910
+ console.log(err("could not resolve your address — run ") + c.cyan("cairn setup"));
911
+ return null;
912
+ }
913
+ return { priv, addr };
914
+ }
915
+ async function cmdGateway(a) {
916
+ if (a._[1] !== "register") {
917
+ console.log(warn("usage: ") + c.cyan("cairn gateway register --url https://gw/content/0x{hash} [--pin] [--fee 0.25]"));
918
+ return;
919
+ }
920
+ const url = String(a.flags.url ?? "");
921
+ if (!url.includes("{hash}")) {
922
+ console.log(err("--url must contain the {hash} template, e.g. https://gw/content/0x{hash}"));
923
+ return;
924
+ }
925
+ const p = await registryPrep(a);
926
+ if (!p)
927
+ return;
928
+ const rec = buildGatewayRecord({ priv: p.priv, url, kind: a.flags.pin ? "pin" : "gateway", address: p.addr });
929
+ const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
930
+ if (a.flags["dry-run"]) {
931
+ console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("url")} ${c.white(url)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
932
+ console.log(c.gray("\n[dry-run] not signed or submitted"));
933
+ return;
934
+ }
935
+ await anchorRecord(rec, p.addr, fee, 10, "gateway");
936
+ }
937
+ async function cmdPeer(a) {
938
+ if (a._[1] !== "announce") {
939
+ console.log(warn("usage: ") + c.cyan("cairn peer announce --peer-id <id> --addr /ip4/…/tcp/… [--addr …] [--cap full] [--fee 0.25]"));
940
+ return;
941
+ }
942
+ const peerId = String(a.flags["peer-id"] ?? "");
943
+ const multiaddrs = (a.multi.addr ?? (a.flags.addr ? [String(a.flags.addr)] : [])).filter(Boolean);
944
+ if (!peerId || multiaddrs.length === 0) {
945
+ console.log(err("--peer-id and at least one --addr required"));
946
+ return;
947
+ }
948
+ const p = await registryPrep(a);
949
+ if (!p)
950
+ return;
951
+ const caps = (a.multi.cap ?? (a.flags.cap ? [String(a.flags.cap)] : [])).filter(Boolean);
952
+ const rec = buildPeerRecord({ priv: p.priv, peer_id: peerId, multiaddrs, caps: caps.length ? caps : undefined, address: p.addr });
953
+ const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
954
+ if (a.flags["dry-run"]) {
955
+ console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("peer")} ${c.white(peerId)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
956
+ console.log(c.gray("\n[dry-run] not signed or submitted"));
957
+ return;
958
+ }
959
+ await anchorRecord(rec, p.addr, fee, 10, "peer");
960
+ }
961
+ async function cmdIdentity(a) {
962
+ const sub = a._[1];
963
+ const handle = String(a.flags.handle ?? a._[2] ?? "");
964
+ if (sub !== "claim" || !handle) {
965
+ console.log(warn("usage: ") + c.cyan("cairn identity claim <handle> [--salt <hex>] [--commit-only|--reveal] [--fee 0.25]"));
966
+ console.log(c.gray(" step 1: --commit-only (saves a salt) · step 2 (next epoch): --reveal --salt <hex>"));
967
+ return;
968
+ }
969
+ if (!/^[a-z0-9_.-]{3,32}$/i.test(handle)) {
970
+ console.log(err("handle must be 3–32 chars [a-z0-9_.-]"));
971
+ return;
972
+ }
973
+ const p = await registryPrep(a);
974
+ if (!p)
975
+ return;
976
+ const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
977
+ if (a.flags.reveal) {
978
+ const salt = String(a.flags.salt ?? "");
979
+ if (!/^[0-9a-f]{16,}$/i.test(salt)) {
980
+ console.log(err("--salt <hex> from your earlier --commit-only step is required to reveal"));
981
+ return;
982
+ }
983
+ const rec = buildIdentityReveal({ priv: p.priv, handle, salt, address: p.addr });
984
+ if (a.flags["dry-run"]) {
985
+ console.log(`${kdim("reveal")} ${c.white(handle)} → ${c.cyan(p.addr)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
986
+ console.log(c.gray("\n[dry-run] not signed"));
987
+ return;
988
+ }
989
+ await anchorRecord(rec, p.addr, fee, 90, "identity reveal");
990
+ return;
991
+ }
992
+ // default / --commit-only: step 1
993
+ const salt = String(a.flags.salt ?? randomBytes(16).toString("hex"));
994
+ const rec = buildIdentityCommit({ handle, salt, address: p.addr });
995
+ if (a.flags["dry-run"]) {
996
+ console.log(`${kdim("commit")} ${c.white(handle)}\n${kdim("salt")} ${c.magenta(salt)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
997
+ console.log(c.gray("\n[dry-run] not signed"));
998
+ return;
999
+ }
1000
+ const okc = await anchorRecord(rec, p.addr, fee, 90, "identity commit");
1001
+ if (okc)
1002
+ console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
1003
+ }
1004
+ // ── CairnX: tokens + .csd names (reads via the CairnX state API; the one write —
1005
+ // token-send — anchors a canonical transfer record as a cairnx:v1 Propose) ──
1006
+ // Display: base units → human, with thousands grouping. decimals===undefined (a ticker the
1007
+ // API doesn't know) falls back to raw base units rather than guessing a scale.
1008
+ const group = (s) => s.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1009
+ function tokAmt(base, decimals) {
1010
+ if (decimals === undefined)
1011
+ return `${group(String(base))} (base units)`;
1012
+ const [i, f] = baseToHuman(String(base), decimals).split(".");
1013
+ return group(i) + (f ? "." + f : "");
1014
+ }
1015
+ // The address a CairnX read targets: positional arg → --address/CAIRN_ADDR/csd wallet.
1016
+ async function resolveCairnxAddr(a, positional) {
1017
+ if (positional !== undefined) {
1018
+ if (!/^0x[0-9a-fA-F]{40}$/.test(positional)) {
1019
+ console.log(err(`bad address: ${san(positional)}`));
1020
+ return null;
1021
+ }
1022
+ return positional.toLowerCase();
1023
+ }
1024
+ const addr = await resolveAddr(a);
1025
+ if (!addr)
1026
+ console.log(err("no address — pass one (cairn tokens 0x…), or --address, or run ") + c.cyan("cairn setup"));
1027
+ return addr ? addr.toLowerCase() : null;
1028
+ }
1029
+ // ticker → decimals map from /tokens (best-effort: an unreachable list degrades to raw units).
1030
+ async function tokenDecimals() {
1031
+ const list = await cairnxGet("/tokens").catch(() => []);
1032
+ const map = {};
1033
+ for (const t of Array.isArray(list) ? list : [])
1034
+ if (typeof t?.ticker === "string" && Number.isInteger(t?.decimals))
1035
+ map[t.ticker] = t.decimals;
1036
+ return map;
1037
+ }
1038
+ async function cmdTokens(a) {
1039
+ const addr = await resolveCairnxAddr(a, a._[1]);
1040
+ if (!addr)
1041
+ return;
1042
+ const [acct, dec] = await Promise.all([cairnxGet(`/address/${encodeURIComponent(addr)}`), tokenDecimals()]);
1043
+ if (a.flags.json) {
1044
+ console.log(JSON.stringify(acct, null, 2));
1045
+ return;
1046
+ }
1047
+ banner();
1048
+ rule(`cairnx · ${addr.slice(0, 10)}… · ${String(activeCairnxBase() ?? "").replace(/^https?:\/\//, "")}`);
1049
+ const bals = Object.entries(acct.balances ?? {});
1050
+ if (!bals.length)
1051
+ console.log(c.gray(" no token balances"));
1052
+ for (const [ticker, b] of bals) {
1053
+ const locked = BigInt(String(b.locked ?? "0"));
1054
+ console.log(` ${c.cyan(pad(san(ticker), 14))} ${c.white(tokAmt(String(b.available ?? "0"), dec[ticker]))}${locked > 0n ? c.gray(` · ${tokAmt(String(b.locked), dec[ticker])} locked in open offers`) : ""}`);
1055
+ }
1056
+ const names = acct.names ?? [];
1057
+ console.log(`\n ${kdim(".csd names")} ${names.length ? names.map((n) => c.green(san(n))).join(c.gray(" · ")) : c.gray("none")}`);
1058
+ }
1059
+ async function cmdTokenInfo(a) {
1060
+ const ticker = String(a._[1] ?? "").toUpperCase();
1061
+ if (!TICKER_RE.test(ticker)) {
1062
+ console.log(warn("usage: ") + c.cyan("cairn token-info <TICKER>"));
1063
+ return;
1064
+ }
1065
+ const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
1066
+ if (!t)
1067
+ return;
1068
+ banner();
1069
+ rule(`token · ${san(t.ticker)}`);
1070
+ const row = (k, v) => console.log(` ${kdim(pad(k, 11))} ${v}`);
1071
+ row("name", c.white(san(t.name ?? t.ticker)));
1072
+ row("decimals", c.white(String(t.decimals)));
1073
+ row("supply", `${c.white(tokAmt(String(t.supply), t.decimals))} ${c.gray("max")}`);
1074
+ const minted = BigInt(String(t.minted ?? "0")), supply = BigInt(String(t.supply ?? "0"));
1075
+ row("minted", `${c.white(tokAmt(String(t.minted), t.decimals))}${supply > 0n ? c.gray(` · ${Number((minted * 10000n) / supply) / 100}% of supply`) : ""}`);
1076
+ row("mint", t.mint === "open" ? c.green("open") + c.gray(` · up to ${tokAmt(String(t.mintLimit ?? "0"), t.decimals)} per mint`) : c.gray("issuer-only"));
1077
+ row("deployer", c.gray(san(t.deployer)));
1078
+ row("deployed", c.gray(`height ${Number(t.height)} · id ${san(String(t.deployId ?? "")).slice(0, 22)}…`));
1079
+ // top-10 holders by total (available + locked) — the same reading the explorer shows
1080
+ const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: BigInt(String(b.available ?? "0")) + BigInt(String(b.locked ?? "0")) }))
1081
+ .filter((x) => x.total > 0n).sort((x, y) => (y.total > x.total ? 1 : y.total < x.total ? -1 : 0));
1082
+ console.log(`\n ${kdim("holders")} ${c.white(String(holders.length))}${holders.length > 10 ? c.gray(" · top 10") : ""}`);
1083
+ const max = holders[0]?.total ?? 1n;
1084
+ for (const { h, total } of holders.slice(0, 10)) {
1085
+ const pct = minted > 0n ? Number((total * 10000n) / minted) / 100 : 0;
1086
+ console.log(` ${bar(Number((total * 1000n) / max), 1000)} ${c.gray(san(h).slice(0, 12) + "…")} ${c.white(tokAmt(total.toString(), t.decimals))} ${c.gray(`· ${pct}%`)}`);
1087
+ }
1088
+ }
1089
+ // y/N gate for the one CairnX write. Non-interactive runs behave like the CLI's other
1090
+ // writes (no prompt — use --dry-run to preview); --yes skips the prompt when interactive.
1091
+ async function confirmSend(q) {
1092
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1093
+ return true;
1094
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1095
+ const ans = await new Promise((res) => rl.question(q, res));
1096
+ rl.close();
1097
+ return /^y(es)?$/i.test(ans.trim());
1098
+ }
1099
+ async function cmdTokenSend(a) {
1100
+ const ticker = String(a.flags.ticker ?? "").toUpperCase();
1101
+ const to = String(a.flags.to ?? "");
1102
+ const amountStr = String(a.flags.amount ?? "");
1103
+ if (!ticker || !a.flags.to || a.flags.amount === undefined) {
1104
+ console.log(warn("usage: ") + c.cyan("cairn token-send --ticker CAIRN --to 0x…40 --amount 1.5 [--dry-run] [--yes]"));
1105
+ return;
1106
+ }
1107
+ if (!TICKER_RE.test(ticker)) {
1108
+ console.log(err(`bad ticker: ${san(ticker)}`));
1109
+ return;
1110
+ }
1111
+ if (!/^0x[0-9a-fA-F]{40}$/.test(to)) {
1112
+ console.log(err(`bad recipient: ${san(to)}`));
1113
+ return;
1114
+ }
1115
+ // decimals are AUTHORITATIVE from the token's deploy record — never guessed
1116
+ const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
1117
+ if (!t)
1118
+ return;
1119
+ let amount;
1120
+ try {
1121
+ amount = humanToBase(amountStr, Number(t.decimals));
1122
+ }
1123
+ catch (e) {
1124
+ console.log(err(e.message));
1125
+ return;
1126
+ }
1127
+ if (amount <= 0n) {
1128
+ console.log(err("amount must be > 0"));
1129
+ return;
1130
+ }
1131
+ const from = await resolveAddr(a);
1132
+ if (!from) {
1133
+ console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
1134
+ return;
1135
+ }
1136
+ // balance check against the same state the resolver will apply the transfer to
1137
+ const acct = await cairnxGet(`/address/${encodeURIComponent(from.toLowerCase())}`);
1138
+ const avail = BigInt(String(acct.balances?.[ticker]?.available ?? "0"));
1139
+ if (avail < amount) {
1140
+ console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(), t.decimals)}, tried to send ${tokAmt(amount.toString(), t.decimals)}${BigInt(String(acct.balances?.[ticker]?.locked ?? "0")) > 0n ? ` (${tokAmt(String(acct.balances[ticker].locked), t.decimals)} more is locked in open offers)` : ""}`));
1141
+ return;
1142
+ }
1143
+ let built;
1144
+ try {
1145
+ built = buildTransferRecord({ ticker, to, amount });
1146
+ }
1147
+ catch (e) {
1148
+ console.log(err(e.message));
1149
+ return;
1150
+ }
1151
+ // clear-print exactly what will be anchored before anything signs
1152
+ console.log(`${kdim("send")} ${c.white(tokAmt(amount.toString(), t.decimals))} ${c.cyan(ticker)} ${c.gray(`(${amount} base units · ${t.decimals} decimals)`)}`);
1153
+ console.log(`${kdim("to")} ${c.cyan(to.toLowerCase())}`);
1154
+ console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(), t.decimals)}`)}`);
1155
+ console.log(`${kdim("record")} ${c.white(built.uri)}`);
1156
+ console.log(`${kdim("hash")} ${c.magenta(built.payloadHash)}`);
1157
+ console.log(`${kdim("anchor")} ${c.gray(`Propose on ${CAIRNX_DOMAIN} — costs `)}${c.white(csdToCoins(CAIRNX_ANCHOR_FEE) + " CSD")}${c.gray(" (the chain fee; the tokens themselves move by record)")}`);
1158
+ if (a.flags["dry-run"]) {
1159
+ console.log(c.gray("\n[dry-run] not signed or submitted"));
1160
+ return;
1161
+ }
1162
+ if (!(await requireCsd()))
1163
+ return;
1164
+ if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), t.decimals)} ${ticker} for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
1165
+ console.log(c.gray("aborted"));
1166
+ return;
1167
+ }
1168
+ if (!(await freshTip(a)))
1169
+ return;
1170
+ const input = await pickAndShow(from, CAIRNX_ANCHOR_FEE, 0, CAIRNX_ANCHOR_FEE);
1171
+ if (!input)
1172
+ return;
1173
+ const tip = await api.tipHeight().catch(() => 0);
1174
+ const sp3 = spinner("csd signs → submit");
1175
+ const r = await signAndSubmit(["propose", "--domain", CAIRNX_DOMAIN, "--payload-hash", built.payloadHash, "--uri", built.uri, "--expires-epoch", String(Math.floor(tip / 30) + 24), "--fee", String(CAIRNX_ANCHOR_FEE), "--change", from, "--input", input]);
1176
+ sp3.stop();
1177
+ console.log(r.ok ? ok(`transfer anchored ${c.cyan(r.txid)}`) + c.gray(" (tokens move when it mines — check `cairn tokens`)") : err(r.error || "failed"));
1178
+ }
1179
+ async function cmdNames(a) {
1180
+ const addr = await resolveCairnxAddr(a, a._[1]);
1181
+ if (!addr)
1182
+ return;
1183
+ const acct = await cairnxGet(`/address/${encodeURIComponent(addr)}`);
1184
+ const names = acct.names ?? [];
1185
+ if (a.flags.json) {
1186
+ console.log(JSON.stringify(names, null, 2));
1187
+ return;
1188
+ }
1189
+ banner();
1190
+ rule(`.csd names · ${addr.slice(0, 10)}…`);
1191
+ if (!names.length) {
1192
+ console.log(c.gray(" no names owned — claim one on " + (activeCairnxBase()?.includes("127.0.0.1") ? "the /trade marketplace" : "https://cairn-substrate.com/trade")));
1193
+ return;
1194
+ }
1195
+ for (const n of names)
1196
+ console.log(` ${c.green(san(n))}`);
1197
+ console.log(c.gray(`\n ${names.length} name${names.length === 1 ? "" : "s"} · cairn name <name> for detail`));
1198
+ }
1199
+ async function cmdName(a) {
1200
+ const n = String(a._[1] ?? "").toLowerCase();
1201
+ if (!n || !NAME_RE.test(n)) {
1202
+ console.log(warn("usage: ") + c.cyan("cairn name <name>") + c.gray(" (lowercase, 3–32 chars [a-z0-9-])"));
1203
+ return;
1204
+ }
1205
+ const r = await cairnxGet(`/name/${encodeURIComponent(n)}`).catch((e) => { console.log(e.status === 404 ? err(`unregistered name "${n}"`) + c.gray(" — claimable via commit-reveal on /trade") : err(e.message)); return null; });
1206
+ if (!r)
1207
+ return;
1208
+ banner();
1209
+ rule(`name · ${san(r.name ?? n)}`);
1210
+ const row = (k, v) => console.log(` ${kdim(pad(k, 10))} ${v}`);
1211
+ row("owner", c.cyan(san(r.owner)));
1212
+ row("claimed", c.gray(`height ${Number(r.height)}${Number(r.effectiveHeight) !== Number(r.height) ? ` · effective ${Number(r.effectiveHeight)}` : ""} · claim ${san(String(r.claimId ?? "")).slice(0, 22)}…`));
1213
+ // lease: the API reports paidThroughEpoch once the v1.5 lease model is live; the ETA is
1214
+ // computed from the chain tip (1 epoch = 30 blocks · 120s target ⇒ ~1h per epoch).
1215
+ if (r.paidThroughEpoch != null) {
1216
+ const tip = await api.tipHeight().catch(() => 0);
1217
+ const blocksLeft = (Number(r.paidThroughEpoch) + 1) * 30 - tip;
1218
+ const eta = !tip ? "" : blocksLeft <= 0 ? " · " + "EXPIRED" : ` · expires in ~${blocksLeft >= 720 ? (blocksLeft / 720).toFixed(1) + " days" : Math.max(1, Math.round(blocksLeft / 30)) + "h"}`;
1219
+ row("lease", `${c.white("paid through epoch " + Number(r.paidThroughEpoch))}${blocksLeft <= 0 && tip ? " " + err("EXPIRED") : c.gray(eta)}`);
1220
+ }
1221
+ else
1222
+ row("lease", c.gray("— (no lease data from this API)"));
1223
+ if (r.locked)
1224
+ row("locked", c.gray("yes — a sale/transfer is in flight"));
1225
+ if (r.offer) {
1226
+ const want = r.offer.want ?? {};
1227
+ const price = want.value !== undefined ? `${csdToCoins(Number(want.value))} CSD` : `${san(String(want.amount))} ${san(String(want.ticker))}`;
1228
+ row("offer", `${c.green("FOR SALE")} ${c.white("· " + price)} ${c.gray(`· seller ${san(String(r.offer.seller ?? r.owner)).slice(0, 12)}… · offer ${san(String(r.offer.id ?? "")).slice(0, 18)}…${r.offer.taker ? " · reserved for a taker" : ""}`)}`);
1229
+ }
1230
+ else
1231
+ row("offer", c.gray("no open offer"));
1232
+ }
670
1233
  main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });