@inversealtruism/cairn-cli 0.3.5 → 0.3.7
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 +111 -40
- package/dist/lib/cairnx.js +16 -14
- package/dist/lib/config.js +8 -1
- package/dist/lib/csd.js +18 -8
- package/dist/lib/ui.js +11 -6
- package/package.json +4 -3
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ 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, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
|
|
10
|
+
import { cairnxGet, activeCairnxBase, defaultBases, buildTransferRecord, humanToBase, baseToHuman, CAIRNX_DOMAIN, CAIRNX_ANCHOR_FEE, TICKER_RE, NAME_RE } from "./lib/cairnx.js";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import { createInterface } from "node:readline";
|
|
13
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";
|
|
@@ -53,8 +53,8 @@ function warnIfChangeCollapsed(inputValue, txValue, fee) {
|
|
|
53
53
|
return; // pickInput already ensures coverage; defensive
|
|
54
54
|
// a healthy spend leaves change ≫ fee unless the user genuinely picked a tight UTXO; flag the
|
|
55
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
|
|
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
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
59
|
}
|
|
60
60
|
}
|
|
@@ -83,9 +83,19 @@ async function pickAndShow(addr, need, txValue, fee) {
|
|
|
83
83
|
const verified = v.checked && v.ok === true;
|
|
84
84
|
const inputValue = verified ? Number(v.value) : picked.value;
|
|
85
85
|
const tag = verified ? c.green(" ✓ verified vs your node")
|
|
86
|
-
: (CAIRN_RPC ? c.gray(" (independent value check unavailable)") : c.gray(" (set CAIRN_RPC
|
|
86
|
+
: (CAIRN_RPC ? c.gray(" (independent value check unavailable — CAIRN_RPC unreachable)") : c.gray(" (UNVERIFIED — set CAIRN_RPC)"));
|
|
87
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
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
|
+
}
|
|
89
99
|
return picked.input;
|
|
90
100
|
}
|
|
91
101
|
// Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
|
|
@@ -126,8 +136,15 @@ async function resolveAddr(a) {
|
|
|
126
136
|
if (cfg?.default_privkey) {
|
|
127
137
|
const real = await csd.deriveAddr(cfg.default_privkey);
|
|
128
138
|
if (real && /^0x[0-9a-fA-F]{40}$/.test(real)) {
|
|
139
|
+
const cached2 = saveLocalConfig({ address: real });
|
|
129
140
|
console.log(warn("derived your address from the wallet key once.") + c.gray(" " + csd.keyExposureWarning));
|
|
130
|
-
|
|
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."));
|
|
131
148
|
return real;
|
|
132
149
|
}
|
|
133
150
|
}
|
|
@@ -148,7 +165,7 @@ async function resolveAddr(a) {
|
|
|
148
165
|
async function signAndSubmit(csdArgs) {
|
|
149
166
|
const r = await csd.run(csdArgs);
|
|
150
167
|
if (!r.ok)
|
|
151
|
-
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]) };
|
|
152
169
|
let out = null;
|
|
153
170
|
try {
|
|
154
171
|
out = JSON.parse(r.stdout);
|
|
@@ -158,15 +175,24 @@ async function signAndSubmit(csdArgs) {
|
|
|
158
175
|
return { ok: false, error: "csd produced no signed transaction" };
|
|
159
176
|
const txid = out.txid;
|
|
160
177
|
const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
}
|
|
163
189
|
// Submit was NOT acked. Don't trust the error STRING — a forged "already present" can hide a
|
|
164
190
|
// rejected/conflicting tx. Confirm against the chain: only if the node reports OUR exact txid
|
|
165
191
|
// as mined is this our own prior submit (a genuine "already in"); otherwise the conflict is a
|
|
166
192
|
// DIFFERENT spend (a real double-spend / hostile reply) and we surface it as a failure.
|
|
167
193
|
if (txid && await api.confirmTxMined(txid))
|
|
168
194
|
return { ok: true, txid };
|
|
169
|
-
return { ok: false, error: sub.err || "submit rejected by node", txid };
|
|
195
|
+
return { ok: false, error: san(sub.err || "submit rejected by node"), txid };
|
|
170
196
|
}
|
|
171
197
|
// Freshness gate (R12): before building any value tx, consult the proxy's chain-view status so
|
|
172
198
|
// we never sign against a FROZEN or forked tip (the proxy may be failing over to an honest-but-
|
|
@@ -237,7 +263,13 @@ function parse(argv) {
|
|
|
237
263
|
// trustless independence we can't establish).
|
|
238
264
|
function canonHost(h) {
|
|
239
265
|
const x = h.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
240
|
-
|
|
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;
|
|
241
273
|
}
|
|
242
274
|
function sameHost(a, b) {
|
|
243
275
|
try {
|
|
@@ -534,8 +566,13 @@ async function confirmMined(txid, label, wait) {
|
|
|
534
566
|
console.log(warn(`${label} submitted — not mined yet; re-check with `) + c.cyan(`cairn show`) + warn(" once a block lands"));
|
|
535
567
|
return;
|
|
536
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.
|
|
537
572
|
if (indep === true)
|
|
538
|
-
console.log(ok(`${label} confirmed on-chain`) +
|
|
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)")));
|
|
539
576
|
else if (indep === false)
|
|
540
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."));
|
|
541
578
|
else
|
|
@@ -558,11 +595,11 @@ async function cmdPropose(a) {
|
|
|
558
595
|
try {
|
|
559
596
|
const r = await api.apiPropose({ domain, title, body, links, fee });
|
|
560
597
|
sp.stop();
|
|
561
|
-
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")));
|
|
562
599
|
}
|
|
563
600
|
catch (e) {
|
|
564
601
|
sp.stop();
|
|
565
|
-
console.log(err(e.message));
|
|
602
|
+
console.log(err(san(e.message)));
|
|
566
603
|
}
|
|
567
604
|
return;
|
|
568
605
|
}
|
|
@@ -626,11 +663,11 @@ async function cmdSupport(a) {
|
|
|
626
663
|
try {
|
|
627
664
|
const r = await api.apiSupport({ id, fee, score, confidence });
|
|
628
665
|
sp.stop();
|
|
629
|
-
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")));
|
|
630
667
|
}
|
|
631
668
|
catch (e) {
|
|
632
669
|
sp.stop();
|
|
633
|
-
console.log(err(e.message));
|
|
670
|
+
console.log(err(san(e.message)));
|
|
634
671
|
}
|
|
635
672
|
return;
|
|
636
673
|
}
|
|
@@ -869,12 +906,16 @@ async function main() {
|
|
|
869
906
|
// ── L3 registry publish commands (build a signed record → anchor Propose → serve bytes) ──
|
|
870
907
|
// Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
|
|
871
908
|
// then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
|
|
872
|
-
async function anchorRecord(rec, addr, fee, days, label) {
|
|
909
|
+
async function anchorRecord(a, rec, addr, fee, days, label) {
|
|
873
910
|
// max-fee sanity: a fixed-floor record anchor should never burn more than the 1 CSD abs cap.
|
|
874
911
|
if (fee > MAX_FEE_ABS) {
|
|
875
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("."));
|
|
876
913
|
return false;
|
|
877
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;
|
|
878
919
|
const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
|
|
879
920
|
const input = await pickAndShow(addr, fee, 0, fee);
|
|
880
921
|
if (!input)
|
|
@@ -932,7 +973,7 @@ async function cmdGateway(a) {
|
|
|
932
973
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
933
974
|
return;
|
|
934
975
|
}
|
|
935
|
-
await anchorRecord(rec, p.addr, fee, 10, "gateway");
|
|
976
|
+
await anchorRecord(a, rec, p.addr, fee, 10, "gateway");
|
|
936
977
|
}
|
|
937
978
|
async function cmdPeer(a) {
|
|
938
979
|
if (a._[1] !== "announce") {
|
|
@@ -956,7 +997,7 @@ async function cmdPeer(a) {
|
|
|
956
997
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
957
998
|
return;
|
|
958
999
|
}
|
|
959
|
-
await anchorRecord(rec, p.addr, fee, 10, "peer");
|
|
1000
|
+
await anchorRecord(a, rec, p.addr, fee, 10, "peer");
|
|
960
1001
|
}
|
|
961
1002
|
async function cmdIdentity(a) {
|
|
962
1003
|
const sub = a._[1];
|
|
@@ -986,7 +1027,7 @@ async function cmdIdentity(a) {
|
|
|
986
1027
|
console.log(c.gray("\n[dry-run] not signed"));
|
|
987
1028
|
return;
|
|
988
1029
|
}
|
|
989
|
-
await anchorRecord(rec, p.addr, fee, 90, "identity reveal");
|
|
1030
|
+
await anchorRecord(a, rec, p.addr, fee, 90, "identity reveal");
|
|
990
1031
|
return;
|
|
991
1032
|
}
|
|
992
1033
|
// default / --commit-only: step 1
|
|
@@ -997,7 +1038,7 @@ async function cmdIdentity(a) {
|
|
|
997
1038
|
console.log(c.gray("\n[dry-run] not signed"));
|
|
998
1039
|
return;
|
|
999
1040
|
}
|
|
1000
|
-
const okc = await anchorRecord(rec, p.addr, fee, 90, "identity commit");
|
|
1041
|
+
const okc = await anchorRecord(a, rec, p.addr, fee, 90, "identity commit");
|
|
1001
1042
|
if (okc)
|
|
1002
1043
|
console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
|
|
1003
1044
|
}
|
|
@@ -1006,11 +1047,25 @@ async function cmdIdentity(a) {
|
|
|
1006
1047
|
// Display: base units → human, with thousands grouping. decimals===undefined (a ticker the
|
|
1007
1048
|
// API doesn't know) falls back to raw base units rather than guessing a scale.
|
|
1008
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
|
+
} };
|
|
1009
1059
|
function tokAmt(base, decimals) {
|
|
1010
1060
|
if (decimals === undefined)
|
|
1011
1061
|
return `${group(String(base))} (base units)`;
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
+
}
|
|
1014
1069
|
}
|
|
1015
1070
|
// The address a CairnX read targets: positional arg → --address/CAIRN_ADDR/csd wallet.
|
|
1016
1071
|
async function resolveCairnxAddr(a, positional) {
|
|
@@ -1050,7 +1105,7 @@ async function cmdTokens(a) {
|
|
|
1050
1105
|
if (!bals.length)
|
|
1051
1106
|
console.log(c.gray(" no token balances"));
|
|
1052
1107
|
for (const [ticker, b] of bals) {
|
|
1053
|
-
const locked =
|
|
1108
|
+
const locked = big(b.locked);
|
|
1054
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`) : ""}`);
|
|
1055
1110
|
}
|
|
1056
1111
|
const names = acct.names ?? [];
|
|
@@ -1062,7 +1117,7 @@ async function cmdTokenInfo(a) {
|
|
|
1062
1117
|
console.log(warn("usage: ") + c.cyan("cairn token-info <TICKER>"));
|
|
1063
1118
|
return;
|
|
1064
1119
|
}
|
|
1065
|
-
const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
|
|
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; });
|
|
1066
1121
|
if (!t)
|
|
1067
1122
|
return;
|
|
1068
1123
|
banner();
|
|
@@ -1071,13 +1126,13 @@ async function cmdTokenInfo(a) {
|
|
|
1071
1126
|
row("name", c.white(san(t.name ?? t.ticker)));
|
|
1072
1127
|
row("decimals", c.white(String(t.decimals)));
|
|
1073
1128
|
row("supply", `${c.white(tokAmt(String(t.supply), t.decimals))} ${c.gray("max")}`);
|
|
1074
|
-
const minted =
|
|
1129
|
+
const minted = big(t.minted), supply = big(t.supply);
|
|
1075
1130
|
row("minted", `${c.white(tokAmt(String(t.minted), t.decimals))}${supply > 0n ? c.gray(` · ${Number((minted * 10000n) / supply) / 100}% of supply`) : ""}`);
|
|
1076
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"));
|
|
1077
1132
|
row("deployer", c.gray(san(t.deployer)));
|
|
1078
1133
|
row("deployed", c.gray(`height ${Number(t.height)} · id ${san(String(t.deployId ?? "")).slice(0, 22)}…`));
|
|
1079
1134
|
// 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:
|
|
1135
|
+
const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: big(b.available) + big(b.locked) }))
|
|
1081
1136
|
.filter((x) => x.total > 0n).sort((x, y) => (y.total > x.total ? 1 : y.total < x.total ? -1 : 0));
|
|
1082
1137
|
console.log(`\n ${kdim("holders")} ${c.white(String(holders.length))}${holders.length > 10 ? c.gray(" · top 10") : ""}`);
|
|
1083
1138
|
const max = holders[0]?.total ?? 1n;
|
|
@@ -1112,16 +1167,30 @@ async function cmdTokenSend(a) {
|
|
|
1112
1167
|
console.log(err(`bad recipient: ${san(to)}`));
|
|
1113
1168
|
return;
|
|
1114
1169
|
}
|
|
1115
|
-
// decimals
|
|
1116
|
-
|
|
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; });
|
|
1117
1176
|
if (!t)
|
|
1118
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
|
+
}
|
|
1119
1188
|
let amount;
|
|
1120
1189
|
try {
|
|
1121
|
-
amount = humanToBase(amountStr,
|
|
1190
|
+
amount = humanToBase(amountStr, decimals);
|
|
1122
1191
|
}
|
|
1123
1192
|
catch (e) {
|
|
1124
|
-
console.log(err(e.message));
|
|
1193
|
+
console.log(err(san(e.message)));
|
|
1125
1194
|
return;
|
|
1126
1195
|
}
|
|
1127
1196
|
if (amount <= 0n) {
|
|
@@ -1135,9 +1204,9 @@ async function cmdTokenSend(a) {
|
|
|
1135
1204
|
}
|
|
1136
1205
|
// balance check against the same state the resolver will apply the transfer to
|
|
1137
1206
|
const acct = await cairnxGet(`/address/${encodeURIComponent(from.toLowerCase())}`);
|
|
1138
|
-
const avail =
|
|
1207
|
+
const avail = big(acct.balances?.[ticker]?.available);
|
|
1139
1208
|
if (avail < amount) {
|
|
1140
|
-
console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(),
|
|
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)` : ""}`));
|
|
1141
1210
|
return;
|
|
1142
1211
|
}
|
|
1143
1212
|
let built;
|
|
@@ -1145,14 +1214,16 @@ async function cmdTokenSend(a) {
|
|
|
1145
1214
|
built = buildTransferRecord({ ticker, to, amount });
|
|
1146
1215
|
}
|
|
1147
1216
|
catch (e) {
|
|
1148
|
-
console.log(err(e.message));
|
|
1217
|
+
console.log(err(san(e.message)));
|
|
1149
1218
|
return;
|
|
1150
1219
|
}
|
|
1151
|
-
// clear-print exactly what will be anchored before anything signs
|
|
1152
|
-
|
|
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)`)}`);
|
|
1153
1224
|
console.log(`${kdim("to")} ${c.cyan(to.toLowerCase())}`);
|
|
1154
|
-
console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(),
|
|
1155
|
-
console.log(`${kdim("record")} ${c.white(built.uri)}`);
|
|
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))}`);
|
|
1156
1227
|
console.log(`${kdim("hash")} ${c.magenta(built.payloadHash)}`);
|
|
1157
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)")}`);
|
|
1158
1229
|
if (a.flags["dry-run"]) {
|
|
@@ -1161,7 +1232,7 @@ async function cmdTokenSend(a) {
|
|
|
1161
1232
|
}
|
|
1162
1233
|
if (!(await requireCsd()))
|
|
1163
1234
|
return;
|
|
1164
|
-
if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(),
|
|
1235
|
+
if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), decimals)} ${ticker} (${amount} base units) for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
|
|
1165
1236
|
console.log(c.gray("aborted"));
|
|
1166
1237
|
return;
|
|
1167
1238
|
}
|
|
@@ -1202,7 +1273,7 @@ async function cmdName(a) {
|
|
|
1202
1273
|
console.log(warn("usage: ") + c.cyan("cairn name <name>") + c.gray(" (lowercase, 3–32 chars [a-z0-9-])"));
|
|
1203
1274
|
return;
|
|
1204
1275
|
}
|
|
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; });
|
|
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; });
|
|
1206
1277
|
if (!r)
|
|
1207
1278
|
return;
|
|
1208
1279
|
banner();
|
|
@@ -1230,4 +1301,4 @@ async function cmdName(a) {
|
|
|
1230
1301
|
else
|
|
1231
1302
|
row("offer", c.gray("no open offer"));
|
|
1232
1303
|
}
|
|
1233
|
-
main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
|
|
1304
|
+
main().catch((e) => { console.error(err(san(String(e?.message ?? e)))); process.exit(1); });
|
package/dist/lib/cairnx.js
CHANGED
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
// `cairnx:v1` domain. This file is the CLI's complete CairnX surface:
|
|
3
3
|
// • a READ client for the CairnX state API (resolution order: $CAIRNX_API → the local
|
|
4
4
|
// service → the public gateway, GET-only) with automatic fallback on network failure
|
|
5
|
-
// • the canonical TRANSFER record builder —
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// • the canonical TRANSFER record builder — canonicalised with the SAME @inversealtruism/csd-codec
|
|
6
|
+
// `canonicalJson` the resolver uses (single source of truth), so the CLI's on-chain payload_hash
|
|
7
|
+
// is byte-identical to the resolver's by construction (CLI-C7: was a hand-rolled stableStringify,
|
|
8
|
+
// proven byte-identical to canonicalJson for the transfer-record shape, now consolidated to remove
|
|
9
|
+
// the latent dual-canonicaliser seam).
|
|
9
10
|
// • exact human↔base-unit amount math as STRING/BigInt arithmetic — floats never touch
|
|
10
11
|
// token amounts (no "1.1 * 1e8 = 110000000.00000001" class of bug, no silent truncation).
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const
|
|
18
|
-
export const
|
|
19
|
-
|
|
12
|
+
import { sha256Hex } from "./item.js";
|
|
13
|
+
import { canonicalJson, MIN_FEE_PROPOSE } from "@inversealtruism/csd-codec";
|
|
14
|
+
// ★ Consensus shapes/constants are IMPORTED from cairnx-core, not hand-declared (shared-core de-dup,
|
|
15
|
+
// cairn docs/Plans/46): they validate a record BEFORE the CLI spends the anchor fee, so a drifted regex /
|
|
16
|
+
// limit would build a record the resolver no-ops (a lost fee). One source = the published convention.
|
|
17
|
+
import { DOMAIN, TICKER_RE, NAME_RE, ADDR_RE, MAX_AMOUNT, MAX_RECORD_BYTES } from "@inversealtruism/cairnx-core";
|
|
18
|
+
export const CAIRNX_DOMAIN = DOMAIN; // "cairnx:v1"
|
|
19
|
+
export const CAIRNX_ANCHOR_FEE = MIN_FEE_PROPOSE; // 0.25 CSD — the consensus min Propose fee that anchors a record
|
|
20
|
+
// re-export the §4 shapes the CLI's public surface exposed (now single-sourced from cairnx-core)
|
|
21
|
+
export { TICKER_RE, NAME_RE, MAX_AMOUNT };
|
|
20
22
|
export function buildTransferRecord(p) {
|
|
21
23
|
const to = String(p.to).toLowerCase();
|
|
22
24
|
if (!TICKER_RE.test(p.ticker))
|
|
@@ -28,7 +30,7 @@ export function buildTransferRecord(p) {
|
|
|
28
30
|
if (p.amount > MAX_AMOUNT)
|
|
29
31
|
throw new Error("amount exceeds the 96-bit token-amount limit");
|
|
30
32
|
const record = { amount: p.amount.toString(), t: "transfer", ticker: p.ticker, to, v: 1 };
|
|
31
|
-
const uri =
|
|
33
|
+
const uri = canonicalJson(record);
|
|
32
34
|
if (Buffer.byteLength(uri, "utf8") > MAX_RECORD_BYTES)
|
|
33
35
|
throw new Error("record exceeds 512 bytes"); // unreachable for a transfer, kept as a guard
|
|
34
36
|
return { record, uri, payloadHash: sha256Hex(uri) };
|
package/dist/lib/config.js
CHANGED
|
@@ -23,11 +23,18 @@ export function loadLocalConfig() { try {
|
|
|
23
23
|
catch {
|
|
24
24
|
return {};
|
|
25
25
|
} }
|
|
26
|
+
// Returns true iff the address was durably persisted. CLI-C2: a SILENT failure here breaks the
|
|
27
|
+
// H-2 "derive the key-on-argv address at most once" guarantee — a read-only HOME / unwritable
|
|
28
|
+
// CAIRN_CLI_CONFIG / full disk would make every subsequent call re-derive (re-exposing the key on
|
|
29
|
+
// the csd argv). Callers surface a warning on false so the user can fix the cache (or stop deriving).
|
|
26
30
|
export function saveLocalConfig(patch) {
|
|
27
31
|
try {
|
|
28
32
|
mkdirSync(dirname(CFG_PATH), { recursive: true, mode: 0o700 });
|
|
29
33
|
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n", { mode: 0o600 });
|
|
30
34
|
chmodSync(CFG_PATH, 0o600); // tighten even if the file pre-existed (writeFileSync mode is create-only)
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
31
39
|
}
|
|
32
|
-
catch { /* best-effort */ }
|
|
33
40
|
}
|
package/dist/lib/csd.js
CHANGED
|
@@ -41,7 +41,10 @@ function insecureReason(abs) {
|
|
|
41
41
|
const dir = dirname(abs);
|
|
42
42
|
try {
|
|
43
43
|
const dst = statSync(dir);
|
|
44
|
-
|
|
44
|
+
// CLI-C2-STICKY: a trusted signing binary must never live in a world-writable directory —
|
|
45
|
+
// the sticky bit stops OTHER users deleting your file, but the dir's owner (the attacker, if
|
|
46
|
+
// they own the planted binary) is unaffected, so do NOT exempt sticky 1777 dirs from the check.
|
|
47
|
+
if (dst.mode & 0o002)
|
|
45
48
|
return `is in a world-writable directory (${dir})`;
|
|
46
49
|
}
|
|
47
50
|
catch { /* dir unreadable — fall through */ }
|
|
@@ -81,20 +84,27 @@ export function resolveCsdBin() {
|
|
|
81
84
|
abs = realpathSync(env);
|
|
82
85
|
}
|
|
83
86
|
catch { /* may legitimately not exist yet — surfaced at run/available */ }
|
|
87
|
+
// CLI-C2-EXPLICIT-DIR: warn (don't refuse — it's the user's explicit choice) on ANY insecurity
|
|
88
|
+
// of the resolved path, not just a world-writable FILE: a world-writable / transient / cwd
|
|
89
|
+
// DIRECTORY is just as plantable. "does not exist" is normal (surfaced later), so skip it.
|
|
84
90
|
let warning;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
catch { /* */ }
|
|
91
|
+
const reason = insecureReason(abs);
|
|
92
|
+
if (reason && reason !== "does not exist")
|
|
93
|
+
warning = `CAIRN_CSD (${abs}) ${reason} — anyone could replace it with a key-stealing binary`;
|
|
90
94
|
return (_resolved = { path: abs, explicit: true, warning });
|
|
91
95
|
}
|
|
92
96
|
// Implicit resolution: canonical locations first (defeats PATH-order hijack), then PATH.
|
|
93
97
|
for (const cand of CANONICAL) {
|
|
98
|
+
// CLI-C2-SYMLINK-TOCTOU: resolve the symlink FIRST, then run the security check on the REAL
|
|
99
|
+
// target (insecureReason on a symlink validates the link's parent dir, not the target's) — so a
|
|
100
|
+
// canonical-path symlink can't point at a world-writable binary that slips the check.
|
|
94
101
|
try {
|
|
95
102
|
const st = statSync(cand);
|
|
96
|
-
if (st.isFile()
|
|
97
|
-
|
|
103
|
+
if (!st.isFile() || !(st.mode & 0o111))
|
|
104
|
+
continue;
|
|
105
|
+
const real = realpathSync(cand);
|
|
106
|
+
if (!insecureReason(real))
|
|
107
|
+
return (_resolved = { path: real, explicit: false });
|
|
98
108
|
}
|
|
99
109
|
catch { /* next */ }
|
|
100
110
|
}
|
package/dist/lib/ui.js
CHANGED
|
@@ -66,12 +66,17 @@ export function bar(value, max, width = 16) {
|
|
|
66
66
|
export function csd(base) {
|
|
67
67
|
return c.green(`${(base / 1e8).toLocaleString(undefined, { maximumFractionDigits: 4 })}`) + c.gray(" CSD");
|
|
68
68
|
}
|
|
69
|
-
// Strip
|
|
70
|
-
//
|
|
71
|
-
// can't inject ANSI/OSC escapes to spoof output —
|
|
72
|
-
// "✗ MISMATCH"
|
|
73
|
-
//
|
|
74
|
-
|
|
69
|
+
// Strip dangerous characters from UNTRUSTED strings before printing them to a TTY, so a
|
|
70
|
+
// hostile server/chain field (title, body, message, ERROR string, txid/id, handle, bio,
|
|
71
|
+
// domain…) can't (a) inject ANSI/OSC escapes to spoof output — cursor-up + repaint to
|
|
72
|
+
// overwrite a "✗ MISMATCH" with "✓ VERIFIED", rewrite the window title, OSC-8 link spoof,
|
|
73
|
+
// OSC-52 clipboard write — or (b) use Unicode bidi-overrides / zero-width chars to spoof
|
|
74
|
+
// a displayed address/name/amount (CLI-9). Display-only: NEVER apply to bytes that get
|
|
75
|
+
// hashed/verified (it would change the hash).
|
|
76
|
+
// • C0/C1 control + DEL (incl. ESC 0x1b — the ANSI/OSC lead-in)
|
|
77
|
+
// • bidi controls: LRM/RLM U+200E/F, ALM U+061C, LRE..RLO U+202A-E, LRI..PDI U+2066-9
|
|
78
|
+
// • zero-width / joiners / BOM: ZWSP/ZWNJ/ZWJ U+200B-D, WJ U+2060, BOM U+FEFF
|
|
79
|
+
const CTRL = new RegExp("[\\u0000-\\u001f\\u007f-\\u009f\\u061c\\u200b-\\u200f\\u2060\\u2066-\\u2069\\u202a-\\u202e\\ufeff]", "g");
|
|
75
80
|
export function san(s) { return String(s ?? "").replace(CTRL, ""); }
|
|
76
81
|
export function ok(s) { return c.green("✓ ") + s; }
|
|
77
82
|
export function warn(s) { return c.gray("⚠ ") + s; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inversealtruism/cairn-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "CLI for Compute Substrate / Cairn — browse the board/wall/network, and send CSD, propose, attest, and place stones non-custodially by driving your own installed `csd` wallet (cairn never holds your key; works node-less via the Cairn proxy).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"typescript": "^5.7.2"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@inversealtruism/
|
|
45
|
-
"@inversealtruism/csd-
|
|
44
|
+
"@inversealtruism/cairnx-core": "0.1.20",
|
|
45
|
+
"@inversealtruism/csd-codec": "0.1.14",
|
|
46
|
+
"@inversealtruism/csd-registry": "0.1.14"
|
|
46
47
|
}
|
|
47
48
|
}
|