@inversealtruism/cairn-cli 0.3.2 → 0.3.6
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 +39 -9
- package/dist/cli.js +575 -80
- package/dist/lib/api.js +84 -0
- package/dist/lib/cairnx.js +110 -0
- package/dist/lib/config.js +15 -5
- package/dist/lib/csd.js +134 -7
- package/dist/lib/ui.js +11 -6
- package/package.json +4 -4
package/dist/cli.js
CHANGED
|
@@ -7,29 +7,145 @@ import * as csd from "./lib/csd.js";
|
|
|
7
7
|
import { buildCommitment } from "./lib/item.js";
|
|
8
8
|
import { buildGatewayRecord, buildPeerRecord, buildIdentityCommit, buildIdentityReveal } from "@inversealtruism/csd-registry";
|
|
9
9
|
import { canonicalJson } from "@inversealtruism/csd-codec";
|
|
10
|
+
import { cairnxGet, activeCairnxBase, defaultBases, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
|
|
10
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
11
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";
|
|
12
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) {
|
|
57
|
+
console.log(warn(`heads up: change (${csdToCoins(change)} CSD) is 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 — CAIRN_RPC unreachable)") : c.gray(" (UNVERIFIED — set CAIRN_RPC)"));
|
|
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
|
+
// CLI-C1: cairn-cli is node-less and has no independent view of the picked UTXO's REAL value, so a
|
|
90
|
+
// hostile/MITM'd CAIRN_API that under-reports it makes `csd` compute a tiny change and silently burn
|
|
91
|
+
// the rest of YOUR coin as miner fee (the recipient still gets the signed amount; the loss is your
|
|
92
|
+
// change). The on-chain fee is implicit (Σin−Σout) with NO chain-enforced maximum. The only robust
|
|
93
|
+
// defense is an INDEPENDENT value source: set CAIRN_RPC to your own node. Make its absence un-missable.
|
|
94
|
+
if (!verified) {
|
|
95
|
+
console.log(warn("input value is NOT independently verified.") +
|
|
96
|
+
c.gray(" A hostile proxy that under-reports it can burn the difference as fee. Set ") + c.cyan("CAIRN_RPC") +
|
|
97
|
+
c.gray(" to your own node to verify this value cryptographically before spending."));
|
|
98
|
+
}
|
|
99
|
+
return picked.input;
|
|
100
|
+
}
|
|
13
101
|
// Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
|
|
14
102
|
// unless we must derive it locally from the user's own csd wallet config (then we cache
|
|
15
|
-
// only the public address). Order: --address → CAIRN_ADDR
|
|
103
|
+
// only the public address). Order: --address → CAIRN_ADDR (both EXPLICIT user intent for this
|
|
104
|
+
// invocation) → the csd wallet's real change address → cached config.address (LAST, and never
|
|
105
|
+
// above the wallet — see below).
|
|
106
|
+
//
|
|
107
|
+
// F13/R18: the cached config.address is attacker-tamperable (a poisoned
|
|
108
|
+
// ~/.config/cairn-cli/config.json would otherwise redirect `cairn address` output, so a funder
|
|
109
|
+
// piping it pays the attacker). So when csd is available we re-derive the wallet's REAL change
|
|
110
|
+
// address and treat THAT as authoritative; if it disagrees with the cached value we refuse and
|
|
111
|
+
// rewrite the cache. The cache is only used as a fallback when csd can't tell us the truth.
|
|
16
112
|
async function resolveAddr(a) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
113
|
+
// explicit, per-invocation choice — the user can deliberately target any address
|
|
114
|
+
const explicit = a.flags.address ? String(a.flags.address) : CAIRN_ADDR;
|
|
115
|
+
if (explicit && /^0x[0-9a-fA-F]{40}$/.test(explicit))
|
|
116
|
+
return explicit;
|
|
117
|
+
const cached = loadLocalConfig().address;
|
|
20
118
|
const cfg = await csd.walletConfig();
|
|
21
|
-
//
|
|
22
|
-
//
|
|
119
|
+
// Best case: the wallet exposes its change address directly — authoritative AND needs no key
|
|
120
|
+
// (no argv exposure). The cache is then only an anti-poison cross-check (F13/R18): if a tampered
|
|
121
|
+
// config disagrees, say so loudly and use the wallet's address.
|
|
23
122
|
if (cfg?.default_change_addr20 && /^0x[0-9a-fA-F]{40}$/.test(String(cfg.default_change_addr20))) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
123
|
+
const real = String(cfg.default_change_addr20);
|
|
124
|
+
if (cached && cached.toLowerCase() !== real.toLowerCase())
|
|
125
|
+
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)."));
|
|
126
|
+
saveLocalConfig({ address: real });
|
|
127
|
+
return real;
|
|
128
|
+
}
|
|
129
|
+
// No change address configured. Audit H-2: re-deriving from the privkey on EVERY call runs
|
|
130
|
+
// `csd wallet recover --privkey <KEY>`, putting the key on the argv (readable via /proc on a
|
|
131
|
+
// shared host). Avoid that — prefer a previously-cached address; only DERIVE when we have
|
|
132
|
+
// nothing else (then cache it + warn once, and nudge the user to set a change address so the
|
|
133
|
+
// key is never needed again, which also restores the F13 anti-poison cross-check above).
|
|
134
|
+
if (cached && /^0x[0-9a-fA-F]{40}$/.test(cached))
|
|
135
|
+
return cached;
|
|
28
136
|
if (cfg?.default_privkey) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
saveLocalConfig({ address:
|
|
32
|
-
|
|
137
|
+
const real = await csd.deriveAddr(cfg.default_privkey);
|
|
138
|
+
if (real && /^0x[0-9a-fA-F]{40}$/.test(real)) {
|
|
139
|
+
const cached2 = saveLocalConfig({ address: real });
|
|
140
|
+
console.log(warn("derived your address from the wallet key once.") + c.gray(" " + csd.keyExposureWarning));
|
|
141
|
+
// CLI-C2-DERIVEADDR: if the cache write FAILED (read-only HOME, unwritable CAIRN_CLI_CONFIG, …)
|
|
142
|
+
// the "derive at most once" guarantee is broken — every subsequent call would re-derive and
|
|
143
|
+
// re-expose the key on the csd argv. Surface it so the user can set a change address / CAIRN_ADDR.
|
|
144
|
+
if (!cached2)
|
|
145
|
+
console.log(warn("could NOT cache your address (config write failed)") +
|
|
146
|
+
c.gray(" — set a change address (") + c.cyan("csd wallet init --privkey <key>") + c.gray(") or ") + c.cyan("CAIRN_ADDR") +
|
|
147
|
+
c.gray(", otherwise the key is re-derived (and briefly re-exposed on the csd argv) every run."));
|
|
148
|
+
return real;
|
|
33
149
|
}
|
|
34
150
|
}
|
|
35
151
|
return null;
|
|
@@ -39,13 +155,17 @@ async function resolveAddr(a) {
|
|
|
39
155
|
// proxy ourselves. We do NOT trust csd's own auto-submit: it targets csd's configured node,
|
|
40
156
|
// which may be a different node than the one the Cairn board (and its miner) read — so a tx
|
|
41
157
|
// could sit in the wrong mempool and never get mined into the board's view. Always submit via
|
|
42
|
-
// the proxy (the board's miner-connected node).
|
|
43
|
-
//
|
|
44
|
-
//
|
|
158
|
+
// the proxy (the board's miner-connected node).
|
|
159
|
+
//
|
|
160
|
+
// Success is EVIDENCE-BASED, never message-based: a hostile proxy (or a real double-spend)
|
|
161
|
+
// can return an "already present / conflict" string for a tx that is NOT actually ours, so we
|
|
162
|
+
// never treat any error message as success. A clean submit ack is good; otherwise we ask the
|
|
163
|
+
// node directly whether OUR exact txid is on-chain (api.confirmTxMined), and only claim success
|
|
164
|
+
// when the node confirms it. If we can't confirm, we surface the real node message.
|
|
45
165
|
async function signAndSubmit(csdArgs) {
|
|
46
166
|
const r = await csd.run(csdArgs);
|
|
47
167
|
if (!r.ok)
|
|
48
|
-
return { ok: false, error: (r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0] };
|
|
168
|
+
return { ok: false, error: san((r.stderr || r.stdout || "csd failed").trim().split("\n").slice(-1)[0]) };
|
|
49
169
|
let out = null;
|
|
50
170
|
try {
|
|
51
171
|
out = JSON.parse(r.stdout);
|
|
@@ -55,18 +175,50 @@ async function signAndSubmit(csdArgs) {
|
|
|
55
175
|
return { ok: false, error: "csd produced no signed transaction" };
|
|
56
176
|
const txid = out.txid;
|
|
57
177
|
const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
178
|
+
// CLI-C3: the AUTHORITATIVE txid is the LOCALLY-signed one (csd computed it from the exact bytes
|
|
179
|
+
// it signed). NEVER adopt the proxy-reported sub.txid — a hostile/MITM'd proxy could drop our tx
|
|
180
|
+
// and ack a DIFFERENT, already-mined chain tx; that substituted txid would then pass confirmMined
|
|
181
|
+
// AND an independent chainTxMined (it IS a real tx) and falsely read "confirmed on-chain",
|
|
182
|
+
// defeating the H-7 evidence-based cure. A divergent proxy txid is a hard failure.
|
|
183
|
+
const norm = (t) => String(t ?? "").toLowerCase().replace(/^0x/, "");
|
|
184
|
+
if (sub.ok) {
|
|
185
|
+
if (sub.txid && txid && norm(sub.txid) !== norm(txid))
|
|
186
|
+
return { ok: false, error: "proxy reported a different txid than the one we signed — refusing", txid };
|
|
187
|
+
return { ok: true, txid: txid || sub.txid };
|
|
188
|
+
}
|
|
189
|
+
// Submit was NOT acked. Don't trust the error STRING — a forged "already present" can hide a
|
|
190
|
+
// rejected/conflicting tx. Confirm against the chain: only if the node reports OUR exact txid
|
|
191
|
+
// as mined is this our own prior submit (a genuine "already in"); otherwise the conflict is a
|
|
192
|
+
// DIFFERENT spend (a real double-spend / hostile reply) and we surface it as a failure.
|
|
193
|
+
if (txid && await api.confirmTxMined(txid))
|
|
68
194
|
return { ok: true, txid };
|
|
69
|
-
return { ok: false, error: sub.err || "submit rejected by node", txid };
|
|
195
|
+
return { ok: false, error: san(sub.err || "submit rejected by node"), txid };
|
|
196
|
+
}
|
|
197
|
+
// Freshness gate (R12): before building any value tx, consult the proxy's chain-view status so
|
|
198
|
+
// we never sign against a FROZEN or forked tip (the proxy may be failing over to an honest-but-
|
|
199
|
+
// stale or wedged node). Fail CLOSED on a stale tip; fail OPEN (warn only) if the freshness
|
|
200
|
+
// surface is unreachable — that matches the rest of the CLI's 'cannot reach' UX and an old node
|
|
201
|
+
// without /api/rpc/status must still be usable. `--force-stale` lets an operator override.
|
|
202
|
+
async function freshTip(a) {
|
|
203
|
+
let s;
|
|
204
|
+
try {
|
|
205
|
+
s = await api.rpcStatus();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
console.log(warn("could not check chain freshness (status surface unreachable) — proceeding"));
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
const secs = Number(s?.secondsSinceAdvance ?? 0);
|
|
212
|
+
const threshold = Number(s?.staleSecsThreshold ?? 600);
|
|
213
|
+
const stale = s?.stale === true || (Number.isFinite(secs) && Number.isFinite(threshold) && threshold > 0 && secs > threshold);
|
|
214
|
+
if (!stale)
|
|
215
|
+
return true;
|
|
216
|
+
if (a.flags["force-stale"]) {
|
|
217
|
+
console.log(warn(`chain tip looks STALE (${secs}s since last advance) — proceeding anyway (--force-stale)`));
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
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("."));
|
|
221
|
+
return false;
|
|
70
222
|
}
|
|
71
223
|
// Guard: a write needs `csd` installed + a configured wallet (or an explicit --address + csd key).
|
|
72
224
|
async function requireCsd() {
|
|
@@ -103,11 +255,25 @@ function parse(argv) {
|
|
|
103
255
|
}
|
|
104
256
|
return { _, flags, multi };
|
|
105
257
|
}
|
|
106
|
-
// Do two URLs point at the same
|
|
107
|
-
// node RPC and the board API are the same operator).
|
|
258
|
+
// Do two URLs point at the same MACHINE? (used to refuse a "trustless" verify claim when the
|
|
259
|
+
// node RPC and the board API are the same operator). Compares hostname only — NOT host — so a
|
|
260
|
+
// same-box node:8789 + board:7777 are correctly judged same-machine (a port difference does not
|
|
261
|
+
// make two endpoints independent), and canonicalizes the loopback aliases (127.0.0.1 / localhost
|
|
262
|
+
// / ::1) so they all compare equal. Unparseable → treat as same (the SAFE default: never claim
|
|
263
|
+
// trustless independence we can't establish).
|
|
264
|
+
function canonHost(h) {
|
|
265
|
+
const x = h.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
266
|
+
// CLI-C3-CANONHOST (M-15): the ENTIRE 127.0.0.0/8 range is loopback (one machine), so 127.0.0.1
|
|
267
|
+
// and 127.0.0.2 must canonicalize equal — else a two-loopback-alias setup (board on 127.0.0.1, a
|
|
268
|
+
// sham "independent node" on 127.0.0.2) would falsely earn the "trustless via independent CAIRN_RPC"
|
|
269
|
+
// badge. Map every loopback alias to "localhost".
|
|
270
|
+
if (x === "localhost" || x === "::1" || x === "0.0.0.0" || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(x))
|
|
271
|
+
return "localhost";
|
|
272
|
+
return x;
|
|
273
|
+
}
|
|
108
274
|
function sameHost(a, b) {
|
|
109
275
|
try {
|
|
110
|
-
return new URL(a).
|
|
276
|
+
return canonHost(new URL(a).hostname) === canonHost(new URL(b).hostname);
|
|
111
277
|
}
|
|
112
278
|
catch {
|
|
113
279
|
return true;
|
|
@@ -271,7 +437,12 @@ async function cmdSetup() {
|
|
|
271
437
|
banner();
|
|
272
438
|
rule("setup — cairn over your csd wallet");
|
|
273
439
|
const has = await csd.available();
|
|
274
|
-
|
|
440
|
+
// H-1: show the RESOLVED absolute path of the binary that will SIGN, so the user can verify it
|
|
441
|
+
// (and see the refusal if it was resolved from an untrusted location).
|
|
442
|
+
const bin = csd.csdPathInfo();
|
|
443
|
+
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")}`);
|
|
444
|
+
if (bin.warning)
|
|
445
|
+
console.log(` ${kdim("")} ${warn(bin.warning)}`);
|
|
275
446
|
if (!has) {
|
|
276
447
|
console.log(c.gray("\n cairn signs nothing itself — it drives your csd wallet. Install csd, then re-run ") + c.cyan("cairn setup") + c.gray("."));
|
|
277
448
|
return;
|
|
@@ -346,30 +517,66 @@ async function cmdSend(a) {
|
|
|
346
517
|
for (const o of outs)
|
|
347
518
|
console.log(`${kdim("to")} ${c.cyan(o.to)} ${c.gray("→ " + csdToCoins(o.value) + " CSD")}`);
|
|
348
519
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("total")} ${csdToCoins(total + fee)} CSD`);
|
|
520
|
+
// max-fee sanity: an absurd fee (typo, or a hostile proxy under-reporting the input → burned
|
|
521
|
+
// change) is refused BEFORE we build/sign. --dry-run still SHOWS the abnormal-fee warning (so the
|
|
522
|
+
// user sees it without spending) but never aborts the preview. --max-fee overrides a deliberate fee.
|
|
523
|
+
const feeOk = feeSanity(fee, total, a);
|
|
349
524
|
if (a.flags["dry-run"]) {
|
|
350
525
|
console.log(c.gray("\n[dry-run] not sent"));
|
|
351
526
|
return;
|
|
352
527
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!
|
|
356
|
-
|
|
357
|
-
|
|
528
|
+
if (!feeOk)
|
|
529
|
+
return;
|
|
530
|
+
if (!(await freshTip(a)))
|
|
531
|
+
return;
|
|
532
|
+
const input = await pickAndShow(addr, total + fee, total, fee);
|
|
533
|
+
if (!input)
|
|
358
534
|
return;
|
|
359
|
-
}
|
|
360
|
-
sp.stop();
|
|
361
|
-
// transparency: show the input value + change so a hostile proxy under-reporting the input
|
|
362
|
-
// (which would silently inflate the burned fee) is visible before we sign. Change goes to
|
|
363
|
-
// your own address; the proxy can never redirect it.
|
|
364
|
-
console.log(`${kdim("input")} ${csdToCoins(picked.value)} CSD ${c.gray("(one UTXO)")} ${kdim("change")} ${csdToCoins(Math.max(0, picked.value - total - fee))} CSD ${c.gray("back to you")}`);
|
|
365
535
|
const sp2 = spinner("csd signs → submit");
|
|
366
536
|
const args = ["spend"];
|
|
367
537
|
for (const o of outs)
|
|
368
538
|
args.push("--output", `${o.to}:${o.value}`);
|
|
369
|
-
args.push("--change", addr, "--fee", String(fee), "--input",
|
|
539
|
+
args.push("--change", addr, "--fee", String(fee), "--input", input);
|
|
370
540
|
const r = await signAndSubmit(args);
|
|
371
541
|
sp2.stop();
|
|
372
|
-
|
|
542
|
+
if (!r.ok) {
|
|
543
|
+
console.log(err(r.error || "failed"));
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
console.log(ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
547
|
+
await confirmMined(r.txid, "transfer", !!a.flags.wait);
|
|
548
|
+
}
|
|
549
|
+
// Post-submit feedback for value writes (send/support). The txid is already proven submitted by the
|
|
550
|
+
// evidence-based signAndSubmit; mining is a separate ~120s event, so by DEFAULT we DON'T block on it —
|
|
551
|
+
// we report submitted + how to track it (matching token-send). Pass `--wait` to block until OUR exact
|
|
552
|
+
// txid is mined (the old behavior; useful in scripts that chain on confirmation). Non-fatal either way.
|
|
553
|
+
async function confirmMined(txid, label, wait) {
|
|
554
|
+
if (!wait) {
|
|
555
|
+
console.log(c.gray(` ${label} submitted — usually mines within ~2 min; track with `) + c.cyan(`cairn show ${txid.slice(0, 10)}…`));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const sp = spinner("waiting for the tx to mine (--wait)");
|
|
559
|
+
const mined = await api.confirmTxMined(txid).catch(() => false);
|
|
560
|
+
// H-7: the proxy can string-echo a "mined" reply. Only assert "confirmed on-chain" when an
|
|
561
|
+
// INDEPENDENT node (CAIRN_RPC) agrees; otherwise soften/flag so the user isn't given a false
|
|
562
|
+
// settlement guarantee they might release goods against.
|
|
563
|
+
const indep = mined ? await api.chainTxMined(txid).catch(() => null) : null;
|
|
564
|
+
sp.stop();
|
|
565
|
+
if (!mined) {
|
|
566
|
+
console.log(warn(`${label} submitted — not mined yet; re-check with `) + c.cyan(`cairn show`) + warn(" once a block lands"));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// CLI-C3-CONFIRM-SAMEHOST: only call it an INDEPENDENT confirmation when CAIRN_RPC is a different
|
|
570
|
+
// machine than CAIRN_API (a same-host node is the same operator, not an independent check) — the
|
|
571
|
+
// same discipline cmdVerify already applies to its trustless badge.
|
|
572
|
+
if (indep === true)
|
|
573
|
+
console.log(ok(`${label} confirmed on-chain`) + (sameHost(CAIRN_RPC, CAIRN_API)
|
|
574
|
+
? c.gray(" ⚠ CAIRN_RPC shares a host with CAIRN_API — point it at an independent node for a trustless check")
|
|
575
|
+
: c.gray(" (verified against your independent node)")));
|
|
576
|
+
else if (indep === false)
|
|
577
|
+
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."));
|
|
578
|
+
else
|
|
579
|
+
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)."));
|
|
373
580
|
}
|
|
374
581
|
async function cmdPropose(a) {
|
|
375
582
|
const domain = String(a.flags.domain ?? "");
|
|
@@ -388,11 +595,11 @@ async function cmdPropose(a) {
|
|
|
388
595
|
try {
|
|
389
596
|
const r = await api.apiPropose({ domain, title, body, links, fee });
|
|
390
597
|
sp.stop();
|
|
391
|
-
console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
|
|
598
|
+
console.log(r.ok ? ok(`proposed ${c.cyan(san(r.id))}`) + c.gray(" (operator)") : err(san(r.error || "failed")));
|
|
392
599
|
}
|
|
393
600
|
catch (e) {
|
|
394
601
|
sp.stop();
|
|
395
|
-
console.log(err(e.message));
|
|
602
|
+
console.log(err(san(e.message)));
|
|
396
603
|
}
|
|
397
604
|
return;
|
|
398
605
|
}
|
|
@@ -411,19 +618,21 @@ async function cmdPropose(a) {
|
|
|
411
618
|
console.log(`${kdim("title")} ${c.white(title)}`);
|
|
412
619
|
console.log(`${kdim("hash")} ${c.magenta(payloadHash)} ${c.gray("· uri " + uri)}`);
|
|
413
620
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("from")} ${c.cyan(addr)}`);
|
|
621
|
+
feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (non-aborting on dry-run)
|
|
414
622
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
415
623
|
return;
|
|
416
624
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (!
|
|
420
|
-
|
|
421
|
-
|
|
625
|
+
if (!feeSanity(fee, fee, a))
|
|
626
|
+
return;
|
|
627
|
+
if (!(await freshTip(a)))
|
|
628
|
+
return;
|
|
629
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
630
|
+
if (!input)
|
|
422
631
|
return;
|
|
423
|
-
}
|
|
424
632
|
const tip = await api.tipHeight().catch(() => 0);
|
|
425
633
|
const days = Math.max(1, parseInt(String(a.flags["expires-days"] ?? 30)) || 30);
|
|
426
|
-
const
|
|
634
|
+
const sp = spinner("csd signs → submit");
|
|
635
|
+
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]);
|
|
427
636
|
sp.stop();
|
|
428
637
|
if (!r.ok) {
|
|
429
638
|
console.log(err(r.error || "failed"));
|
|
@@ -454,11 +663,11 @@ async function cmdSupport(a) {
|
|
|
454
663
|
try {
|
|
455
664
|
const r = await api.apiSupport({ id, fee, score, confidence });
|
|
456
665
|
sp.stop();
|
|
457
|
-
console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) + c.gray(" (operator)") : err(r.error || "failed"));
|
|
666
|
+
console.log(r.ok ? ok(`supported ${c.cyan(san(r.id))}`) + c.gray(" (operator)") : err(san(r.error || "failed")));
|
|
458
667
|
}
|
|
459
668
|
catch (e) {
|
|
460
669
|
sp.stop();
|
|
461
|
-
console.log(err(e.message));
|
|
670
|
+
console.log(err(san(e.message)));
|
|
462
671
|
}
|
|
463
672
|
return;
|
|
464
673
|
}
|
|
@@ -472,19 +681,27 @@ async function cmdSupport(a) {
|
|
|
472
681
|
if (a.flags["dry-run"]) {
|
|
473
682
|
console.log(`${kdim("support")} ${c.cyan(id)}`);
|
|
474
683
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${c.gray("· score " + score + " · confidence " + confidence)} ${kdim("from")} ${c.cyan(addr)}`);
|
|
684
|
+
feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (the attest weight IS the fee)
|
|
475
685
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
476
686
|
return;
|
|
477
687
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (!picked) {
|
|
481
|
-
sp.stop();
|
|
482
|
-
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
688
|
+
// an attest's fee IS the deliberate weight/stake; only guard against an absurd typo above the cap.
|
|
689
|
+
if (!feeSanity(fee, fee, a))
|
|
483
690
|
return;
|
|
484
|
-
|
|
485
|
-
|
|
691
|
+
if (!(await freshTip(a)))
|
|
692
|
+
return;
|
|
693
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
694
|
+
if (!input)
|
|
695
|
+
return;
|
|
696
|
+
const sp = spinner("csd signs → submit");
|
|
697
|
+
const r = await signAndSubmit(["attest", "--proposal-id", id, "--score", String(score), "--confidence", String(confidence), "--fee", String(fee), "--change", addr, "--input", input]);
|
|
486
698
|
sp.stop();
|
|
487
|
-
|
|
699
|
+
if (!r.ok) {
|
|
700
|
+
console.log(err(r.error || "failed"));
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
console.log(ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
704
|
+
await confirmMined(r.txid, "support", !!a.flags.wait);
|
|
488
705
|
}
|
|
489
706
|
async function cmdWall(a) {
|
|
490
707
|
if (a._[1] === "place") {
|
|
@@ -631,12 +848,19 @@ async function help() {
|
|
|
631
848
|
console.log(c.bold(" wallet") + c.gray(" (signs with your installed csd wallet — cairn never holds your key)"));
|
|
632
849
|
cmd("setup", "", "check csd + wallet, show your address (alias: doctor)");
|
|
633
850
|
cmd("address", "", "your address + balance (alias: whoami, balance)");
|
|
634
|
-
cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --dry-run)");
|
|
851
|
+
cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --max-fee <CSD>, --dry-run)");
|
|
635
852
|
cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (alias: post; + --fee, --expires-days, --dry-run)");
|
|
636
853
|
cmd("support", "<id> --fee <CSD>", "back an item (+ --score, --confidence, --dry-run)");
|
|
854
|
+
console.log("");
|
|
855
|
+
console.log(c.bold(" cairnx") + c.gray(" (tokens + .csd names on the CairnX layer)"));
|
|
856
|
+
cmd("tokens", "[address]", "token balances + .csd names (default: your address)");
|
|
857
|
+
cmd("token-info", "<TICKER>", "supply · minted · mint mode · top-10 holders (alias: token)");
|
|
858
|
+
cmd("token-send", "--ticker T --to 0x…40 --amount <n>", "send tokens (anchors a 0.25 CSD transfer record; --dry-run, --yes)");
|
|
859
|
+
cmd("names", "[address]", "owned .csd names");
|
|
860
|
+
cmd("name", "<name>", "one name: owner · lease · open offer");
|
|
637
861
|
console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
|
|
638
862
|
console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${csdToCoins(MIN_FEE_PROPOSE)} · attest ≥ ${csdToCoins(MIN_FEE_ATTEST)} CSD`));
|
|
639
|
-
console.log(c.gray(" config: CAIRN_API (board) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
|
|
863
|
+
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)"));
|
|
640
864
|
console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
|
|
641
865
|
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."));
|
|
642
866
|
}
|
|
@@ -667,6 +891,12 @@ async function main() {
|
|
|
667
891
|
case "propose":
|
|
668
892
|
case "post": return cmdPropose(a);
|
|
669
893
|
case "support": return cmdSupport(a);
|
|
894
|
+
case "tokens": return cmdTokens(a);
|
|
895
|
+
case "token-info":
|
|
896
|
+
case "token": return cmdTokenInfo(a);
|
|
897
|
+
case "token-send": return cmdTokenSend(a);
|
|
898
|
+
case "names": return cmdNames(a);
|
|
899
|
+
case "name": return cmdName(a);
|
|
670
900
|
case "gateway": return cmdGateway(a);
|
|
671
901
|
case "peer": return cmdPeer(a);
|
|
672
902
|
case "identity": return cmdIdentity(a);
|
|
@@ -676,17 +906,23 @@ async function main() {
|
|
|
676
906
|
// ── L3 registry publish commands (build a signed record → anchor Propose → serve bytes) ──
|
|
677
907
|
// Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
|
|
678
908
|
// then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
|
|
679
|
-
async function anchorRecord(rec, addr, fee, days, label) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
if (!picked) {
|
|
684
|
-
sp.stop();
|
|
685
|
-
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
909
|
+
async function anchorRecord(a, rec, addr, fee, days, label) {
|
|
910
|
+
// max-fee sanity: a fixed-floor record anchor should never burn more than the 1 CSD abs cap.
|
|
911
|
+
if (fee > MAX_FEE_ABS) {
|
|
912
|
+
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("."));
|
|
686
913
|
return false;
|
|
687
914
|
}
|
|
915
|
+
// CLI-C6-ANCHOR-FRESHTIP: registry writes move value (the propose fee) + are in-process-signed, so
|
|
916
|
+
// they must NOT skip the stale/forked-tip gate every other value-write enforces.
|
|
917
|
+
if (!(await freshTip(a)))
|
|
918
|
+
return false;
|
|
919
|
+
const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
|
|
920
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
921
|
+
if (!input)
|
|
922
|
+
return false;
|
|
688
923
|
const tip = await api.tipHeight().catch(() => 0);
|
|
689
|
-
const
|
|
924
|
+
const sp = spinner("csd signs → submit");
|
|
925
|
+
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]);
|
|
690
926
|
sp.stop();
|
|
691
927
|
if (!r.ok) {
|
|
692
928
|
console.log(err(r.error || "failed"));
|
|
@@ -737,7 +973,7 @@ async function cmdGateway(a) {
|
|
|
737
973
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
738
974
|
return;
|
|
739
975
|
}
|
|
740
|
-
await anchorRecord(rec, p.addr, fee, 10, "gateway");
|
|
976
|
+
await anchorRecord(a, rec, p.addr, fee, 10, "gateway");
|
|
741
977
|
}
|
|
742
978
|
async function cmdPeer(a) {
|
|
743
979
|
if (a._[1] !== "announce") {
|
|
@@ -761,7 +997,7 @@ async function cmdPeer(a) {
|
|
|
761
997
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
762
998
|
return;
|
|
763
999
|
}
|
|
764
|
-
await anchorRecord(rec, p.addr, fee, 10, "peer");
|
|
1000
|
+
await anchorRecord(a, rec, p.addr, fee, 10, "peer");
|
|
765
1001
|
}
|
|
766
1002
|
async function cmdIdentity(a) {
|
|
767
1003
|
const sub = a._[1];
|
|
@@ -791,7 +1027,7 @@ async function cmdIdentity(a) {
|
|
|
791
1027
|
console.log(c.gray("\n[dry-run] not signed"));
|
|
792
1028
|
return;
|
|
793
1029
|
}
|
|
794
|
-
await anchorRecord(rec, p.addr, fee, 90, "identity reveal");
|
|
1030
|
+
await anchorRecord(a, rec, p.addr, fee, 90, "identity reveal");
|
|
795
1031
|
return;
|
|
796
1032
|
}
|
|
797
1033
|
// default / --commit-only: step 1
|
|
@@ -802,8 +1038,267 @@ async function cmdIdentity(a) {
|
|
|
802
1038
|
console.log(c.gray("\n[dry-run] not signed"));
|
|
803
1039
|
return;
|
|
804
1040
|
}
|
|
805
|
-
const okc = await anchorRecord(rec, p.addr, fee, 90, "identity commit");
|
|
1041
|
+
const okc = await anchorRecord(a, rec, p.addr, fee, 90, "identity commit");
|
|
806
1042
|
if (okc)
|
|
807
1043
|
console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
|
|
808
1044
|
}
|
|
809
|
-
|
|
1045
|
+
// ── CairnX: tokens + .csd names (reads via the CairnX state API; the one write —
|
|
1046
|
+
// token-send — anchors a canonical transfer record as a cairnx:v1 Propose) ──
|
|
1047
|
+
// Display: base units → human, with thousands grouping. decimals===undefined (a ticker the
|
|
1048
|
+
// API doesn't know) falls back to raw base units rather than guessing a scale.
|
|
1049
|
+
const group = (s) => s.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
1050
|
+
// CLI-C4-AMOUNTDOS: read-API amounts are attacker-controlled strings; a non-numeric one would throw
|
|
1051
|
+
// inside BigInt()/baseToHuman() and crash the whole read/display. Parse defensively (0n on garbage)
|
|
1052
|
+
// so one hostile balance can't take down `tokens`/`token-info`.
|
|
1053
|
+
const big = (x) => { try {
|
|
1054
|
+
return BigInt(String(x ?? "0"));
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
return 0n;
|
|
1058
|
+
} };
|
|
1059
|
+
function tokAmt(base, decimals) {
|
|
1060
|
+
if (decimals === undefined)
|
|
1061
|
+
return `${group(String(base))} (base units)`;
|
|
1062
|
+
try {
|
|
1063
|
+
const [i, f] = baseToHuman(big(base), decimals).split(".");
|
|
1064
|
+
return group(i) + (f ? "." + f : "");
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
return `${group(big(base).toString())} (base units)`;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// The address a CairnX read targets: positional arg → --address/CAIRN_ADDR/csd wallet.
|
|
1071
|
+
async function resolveCairnxAddr(a, positional) {
|
|
1072
|
+
if (positional !== undefined) {
|
|
1073
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(positional)) {
|
|
1074
|
+
console.log(err(`bad address: ${san(positional)}`));
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
return positional.toLowerCase();
|
|
1078
|
+
}
|
|
1079
|
+
const addr = await resolveAddr(a);
|
|
1080
|
+
if (!addr)
|
|
1081
|
+
console.log(err("no address — pass one (cairn tokens 0x…), or --address, or run ") + c.cyan("cairn setup"));
|
|
1082
|
+
return addr ? addr.toLowerCase() : null;
|
|
1083
|
+
}
|
|
1084
|
+
// ticker → decimals map from /tokens (best-effort: an unreachable list degrades to raw units).
|
|
1085
|
+
async function tokenDecimals() {
|
|
1086
|
+
const list = await cairnxGet("/tokens").catch(() => []);
|
|
1087
|
+
const map = {};
|
|
1088
|
+
for (const t of Array.isArray(list) ? list : [])
|
|
1089
|
+
if (typeof t?.ticker === "string" && Number.isInteger(t?.decimals))
|
|
1090
|
+
map[t.ticker] = t.decimals;
|
|
1091
|
+
return map;
|
|
1092
|
+
}
|
|
1093
|
+
async function cmdTokens(a) {
|
|
1094
|
+
const addr = await resolveCairnxAddr(a, a._[1]);
|
|
1095
|
+
if (!addr)
|
|
1096
|
+
return;
|
|
1097
|
+
const [acct, dec] = await Promise.all([cairnxGet(`/address/${encodeURIComponent(addr)}`), tokenDecimals()]);
|
|
1098
|
+
if (a.flags.json) {
|
|
1099
|
+
console.log(JSON.stringify(acct, null, 2));
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
banner();
|
|
1103
|
+
rule(`cairnx · ${addr.slice(0, 10)}… · ${String(activeCairnxBase() ?? "").replace(/^https?:\/\//, "")}`);
|
|
1104
|
+
const bals = Object.entries(acct.balances ?? {});
|
|
1105
|
+
if (!bals.length)
|
|
1106
|
+
console.log(c.gray(" no token balances"));
|
|
1107
|
+
for (const [ticker, b] of bals) {
|
|
1108
|
+
const locked = big(b.locked);
|
|
1109
|
+
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`) : ""}`);
|
|
1110
|
+
}
|
|
1111
|
+
const names = acct.names ?? [];
|
|
1112
|
+
console.log(`\n ${kdim(".csd names")} ${names.length ? names.map((n) => c.green(san(n))).join(c.gray(" · ")) : c.gray("none")}`);
|
|
1113
|
+
}
|
|
1114
|
+
async function cmdTokenInfo(a) {
|
|
1115
|
+
const ticker = String(a._[1] ?? "").toUpperCase();
|
|
1116
|
+
if (!TICKER_RE.test(ticker)) {
|
|
1117
|
+
console.log(warn("usage: ") + c.cyan("cairn token-info <TICKER>"));
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${san(ticker)}`) : err(san(e.message))); return null; });
|
|
1121
|
+
if (!t)
|
|
1122
|
+
return;
|
|
1123
|
+
banner();
|
|
1124
|
+
rule(`token · ${san(t.ticker)}`);
|
|
1125
|
+
const row = (k, v) => console.log(` ${kdim(pad(k, 11))} ${v}`);
|
|
1126
|
+
row("name", c.white(san(t.name ?? t.ticker)));
|
|
1127
|
+
row("decimals", c.white(String(t.decimals)));
|
|
1128
|
+
row("supply", `${c.white(tokAmt(String(t.supply), t.decimals))} ${c.gray("max")}`);
|
|
1129
|
+
const minted = big(t.minted), supply = big(t.supply);
|
|
1130
|
+
row("minted", `${c.white(tokAmt(String(t.minted), t.decimals))}${supply > 0n ? c.gray(` · ${Number((minted * 10000n) / supply) / 100}% of supply`) : ""}`);
|
|
1131
|
+
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"));
|
|
1132
|
+
row("deployer", c.gray(san(t.deployer)));
|
|
1133
|
+
row("deployed", c.gray(`height ${Number(t.height)} · id ${san(String(t.deployId ?? "")).slice(0, 22)}…`));
|
|
1134
|
+
// top-10 holders by total (available + locked) — the same reading the explorer shows
|
|
1135
|
+
const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: big(b.available) + big(b.locked) }))
|
|
1136
|
+
.filter((x) => x.total > 0n).sort((x, y) => (y.total > x.total ? 1 : y.total < x.total ? -1 : 0));
|
|
1137
|
+
console.log(`\n ${kdim("holders")} ${c.white(String(holders.length))}${holders.length > 10 ? c.gray(" · top 10") : ""}`);
|
|
1138
|
+
const max = holders[0]?.total ?? 1n;
|
|
1139
|
+
for (const { h, total } of holders.slice(0, 10)) {
|
|
1140
|
+
const pct = minted > 0n ? Number((total * 10000n) / minted) / 100 : 0;
|
|
1141
|
+
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}%`)}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// y/N gate for the one CairnX write. Non-interactive runs behave like the CLI's other
|
|
1145
|
+
// writes (no prompt — use --dry-run to preview); --yes skips the prompt when interactive.
|
|
1146
|
+
async function confirmSend(q) {
|
|
1147
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1148
|
+
return true;
|
|
1149
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1150
|
+
const ans = await new Promise((res) => rl.question(q, res));
|
|
1151
|
+
rl.close();
|
|
1152
|
+
return /^y(es)?$/i.test(ans.trim());
|
|
1153
|
+
}
|
|
1154
|
+
async function cmdTokenSend(a) {
|
|
1155
|
+
const ticker = String(a.flags.ticker ?? "").toUpperCase();
|
|
1156
|
+
const to = String(a.flags.to ?? "");
|
|
1157
|
+
const amountStr = String(a.flags.amount ?? "");
|
|
1158
|
+
if (!ticker || !a.flags.to || a.flags.amount === undefined) {
|
|
1159
|
+
console.log(warn("usage: ") + c.cyan("cairn token-send --ticker CAIRN --to 0x…40 --amount 1.5 [--dry-run] [--yes]"));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (!TICKER_RE.test(ticker)) {
|
|
1163
|
+
console.log(err(`bad ticker: ${san(ticker)}`));
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(to)) {
|
|
1167
|
+
console.log(err(`bad recipient: ${san(to)}`));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
// CLI-C5-DECIMALS: `decimals` drives the human→base scale but is reported by an UNauthenticated
|
|
1171
|
+
// CairnX read API (no signature/SPV) — a hostile gateway over-reporting it silently inflates the
|
|
1172
|
+
// MAGNITUDE of your transfer. So: (1) when not pinned to a single CAIRNX_API, cross-check decimals
|
|
1173
|
+
// across the default bases and refuse on disagreement (dual-source discipline); (2) always show +
|
|
1174
|
+
// confirm the exact base-unit integer so a wrong scale is visible before signing.
|
|
1175
|
+
const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(san(e.message))); return null; });
|
|
1176
|
+
if (!t)
|
|
1177
|
+
return;
|
|
1178
|
+
const decimals = Number(t.decimals);
|
|
1179
|
+
const bases = defaultBases();
|
|
1180
|
+
if (bases.length > 1) {
|
|
1181
|
+
const seen = await Promise.all(bases.map((b) => cairnxGet(`/token/${encodeURIComponent(ticker)}`, [b]).then((x) => Number(x?.decimals)).catch(() => null)));
|
|
1182
|
+
const consistent = seen.filter((d) => Number.isInteger(d));
|
|
1183
|
+
if (consistent.length >= 2 && new Set(consistent).size > 1) {
|
|
1184
|
+
console.log(err(`CairnX sources DISAGREE on ${ticker} decimals (${[...new Set(consistent)].join(" vs ")}) — refusing to scale the amount against an ambiguous decimals (pin CAIRNX_API to a trusted base).`));
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
let amount;
|
|
1189
|
+
try {
|
|
1190
|
+
amount = humanToBase(amountStr, decimals);
|
|
1191
|
+
}
|
|
1192
|
+
catch (e) {
|
|
1193
|
+
console.log(err(san(e.message)));
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (amount <= 0n) {
|
|
1197
|
+
console.log(err("amount must be > 0"));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const from = await resolveAddr(a);
|
|
1201
|
+
if (!from) {
|
|
1202
|
+
console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
// balance check against the same state the resolver will apply the transfer to
|
|
1206
|
+
const acct = await cairnxGet(`/address/${encodeURIComponent(from.toLowerCase())}`);
|
|
1207
|
+
const avail = big(acct.balances?.[ticker]?.available);
|
|
1208
|
+
if (avail < amount) {
|
|
1209
|
+
console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(), decimals)}, tried to send ${tokAmt(amount.toString(), decimals)}${big(acct.balances?.[ticker]?.locked) > 0n ? ` (${tokAmt(String(acct.balances[ticker].locked), decimals)} more is locked in open offers)` : ""}`));
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
let built;
|
|
1213
|
+
try {
|
|
1214
|
+
built = buildTransferRecord({ ticker, to, amount });
|
|
1215
|
+
}
|
|
1216
|
+
catch (e) {
|
|
1217
|
+
console.log(err(san(e.message)));
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
// clear-print exactly what will be anchored before anything signs — the EXACT base-unit integer is
|
|
1221
|
+
// emphasized on its own line (CLI-C5: a wrong API-reported `decimals` shows up here as a magnitude).
|
|
1222
|
+
console.log(`${kdim("send")} ${c.white(tokAmt(amount.toString(), decimals))} ${c.cyan(ticker)}`);
|
|
1223
|
+
console.log(`${kdim("amount")} ${c.white(c.bold(amount.toString()))} ${c.gray(`base units · ${decimals} decimals (from the CairnX read API — verify this magnitude)`)}`);
|
|
1224
|
+
console.log(`${kdim("to")} ${c.cyan(to.toLowerCase())}`);
|
|
1225
|
+
console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(), decimals)}`)}`);
|
|
1226
|
+
console.log(`${kdim("record")} ${c.white(san(built.uri))}`);
|
|
1227
|
+
console.log(`${kdim("hash")} ${c.magenta(built.payloadHash)}`);
|
|
1228
|
+
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)")}`);
|
|
1229
|
+
if (a.flags["dry-run"]) {
|
|
1230
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (!(await requireCsd()))
|
|
1234
|
+
return;
|
|
1235
|
+
if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), decimals)} ${ticker} (${amount} base units) for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
|
|
1236
|
+
console.log(c.gray("aborted"));
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
if (!(await freshTip(a)))
|
|
1240
|
+
return;
|
|
1241
|
+
const input = await pickAndShow(from, CAIRNX_ANCHOR_FEE, 0, CAIRNX_ANCHOR_FEE);
|
|
1242
|
+
if (!input)
|
|
1243
|
+
return;
|
|
1244
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
1245
|
+
const sp3 = spinner("csd signs → submit");
|
|
1246
|
+
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]);
|
|
1247
|
+
sp3.stop();
|
|
1248
|
+
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"));
|
|
1249
|
+
}
|
|
1250
|
+
async function cmdNames(a) {
|
|
1251
|
+
const addr = await resolveCairnxAddr(a, a._[1]);
|
|
1252
|
+
if (!addr)
|
|
1253
|
+
return;
|
|
1254
|
+
const acct = await cairnxGet(`/address/${encodeURIComponent(addr)}`);
|
|
1255
|
+
const names = acct.names ?? [];
|
|
1256
|
+
if (a.flags.json) {
|
|
1257
|
+
console.log(JSON.stringify(names, null, 2));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
banner();
|
|
1261
|
+
rule(`.csd names · ${addr.slice(0, 10)}…`);
|
|
1262
|
+
if (!names.length) {
|
|
1263
|
+
console.log(c.gray(" no names owned — claim one on " + (activeCairnxBase()?.includes("127.0.0.1") ? "the /trade marketplace" : "https://cairn-substrate.com/trade")));
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
for (const n of names)
|
|
1267
|
+
console.log(` ${c.green(san(n))}`);
|
|
1268
|
+
console.log(c.gray(`\n ${names.length} name${names.length === 1 ? "" : "s"} · cairn name <name> for detail`));
|
|
1269
|
+
}
|
|
1270
|
+
async function cmdName(a) {
|
|
1271
|
+
const n = String(a._[1] ?? "").toLowerCase();
|
|
1272
|
+
if (!n || !NAME_RE.test(n)) {
|
|
1273
|
+
console.log(warn("usage: ") + c.cyan("cairn name <name>") + c.gray(" (lowercase, 3–32 chars [a-z0-9-])"));
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const r = await cairnxGet(`/name/${encodeURIComponent(n)}`).catch((e) => { console.log(e.status === 404 ? err(`unregistered name "${san(n)}"`) + c.gray(" — claimable via commit-reveal on /trade") : err(san(e.message))); return null; });
|
|
1277
|
+
if (!r)
|
|
1278
|
+
return;
|
|
1279
|
+
banner();
|
|
1280
|
+
rule(`name · ${san(r.name ?? n)}`);
|
|
1281
|
+
const row = (k, v) => console.log(` ${kdim(pad(k, 10))} ${v}`);
|
|
1282
|
+
row("owner", c.cyan(san(r.owner)));
|
|
1283
|
+
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)}…`));
|
|
1284
|
+
// lease: the API reports paidThroughEpoch once the v1.5 lease model is live; the ETA is
|
|
1285
|
+
// computed from the chain tip (1 epoch = 30 blocks · 120s target ⇒ ~1h per epoch).
|
|
1286
|
+
if (r.paidThroughEpoch != null) {
|
|
1287
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
1288
|
+
const blocksLeft = (Number(r.paidThroughEpoch) + 1) * 30 - tip;
|
|
1289
|
+
const eta = !tip ? "" : blocksLeft <= 0 ? " · " + "EXPIRED" : ` · expires in ~${blocksLeft >= 720 ? (blocksLeft / 720).toFixed(1) + " days" : Math.max(1, Math.round(blocksLeft / 30)) + "h"}`;
|
|
1290
|
+
row("lease", `${c.white("paid through epoch " + Number(r.paidThroughEpoch))}${blocksLeft <= 0 && tip ? " " + err("EXPIRED") : c.gray(eta)}`);
|
|
1291
|
+
}
|
|
1292
|
+
else
|
|
1293
|
+
row("lease", c.gray("— (no lease data from this API)"));
|
|
1294
|
+
if (r.locked)
|
|
1295
|
+
row("locked", c.gray("yes — a sale/transfer is in flight"));
|
|
1296
|
+
if (r.offer) {
|
|
1297
|
+
const want = r.offer.want ?? {};
|
|
1298
|
+
const price = want.value !== undefined ? `${csdToCoins(Number(want.value))} CSD` : `${san(String(want.amount))} ${san(String(want.ticker))}`;
|
|
1299
|
+
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" : ""}`)}`);
|
|
1300
|
+
}
|
|
1301
|
+
else
|
|
1302
|
+
row("offer", c.gray("no open offer"));
|
|
1303
|
+
}
|
|
1304
|
+
main().catch((e) => { console.error(err(san(String(e?.message ?? e)))); process.exit(1); });
|