@inversealtruism/cairn-cli 0.3.12 → 0.3.15

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
@@ -11,7 +11,28 @@ import { cairnxGet, activeCairnxBase, defaultBases, buildTransferRecord, humanTo
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";
14
- const CSD = (n) => Number.isFinite(n) ? Math.round(n * CSD_PER_COIN) : NaN; // CSD base units
14
+ // CSD base units via EXACT decimal-string parsing (Plan 57 B5a; Plan 56 A.4 finding 3). The
15
+ // old `Math.round(n * 1e8)` float path silently lost precision above ~9e7 CSD; this reuses the
16
+ // token path's exact converter (humanToBase, 8 dp), so both money paths speak the same input
17
+ // language (plain decimals; no exponent/hex forms). Unparseable or beyond-2^53 amounts return
18
+ // NaN, which every call site's isSafeInteger guard treats as invalid (fail-loud, never silently
19
+ // wrong; the three registry fee sites gained that guard in this same change, closing an old
20
+ // Math.max(MIN, NaN) fee-poison path that could reach the signer as a literal "NaN").
21
+ const CSD = (v) => {
22
+ try {
23
+ const b = humanToBase(String(v), 8);
24
+ return b <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(b) : NaN;
25
+ }
26
+ catch {
27
+ return NaN;
28
+ }
29
+ };
30
+ // P8 (Plan 57 B8a review): an unparseable --fee falls back to the default/minimum (safe
31
+ // direction, fee only falls DOWN), but silently: say so, or a typo like `--fee 2e-2` looks
32
+ // accepted while a different fee is used.
33
+ const warnBadFee = (v, usedBase) => {
34
+ console.log(warn(`unparseable --fee ${String(v)}: using ${csdToCoins(usedBase)} CSD (pass a plain decimal like 0.25)`));
35
+ };
15
36
  // ── max-fee sanity guard (UTXO-VALUE-1) ──────────────────────────────────────────────────────
16
37
  // A CSD fee is implicit (Σin − Σout) and the chain enforces NO maximum, so a hostile proxy that
17
38
  // UNDER-reports the picked UTXO's value would make `csd` compute too-small a change and the
@@ -25,7 +46,7 @@ const MAX_FEE_ABS = 100_000_000; // 1 CSD absolute floor — every honest fee is
25
46
  const MAX_FEE_VALUE_FRACTION = 0.25; // …and ≤ 25% of the value moved
26
47
  function feeCap(txValue, a) {
27
48
  if (a.flags["max-fee"] !== undefined) {
28
- const m = CSD(Number(a.flags["max-fee"]));
49
+ const m = CSD(a.flags["max-fee"]);
29
50
  if (Number.isSafeInteger(m) && m >= 0)
30
51
  return m;
31
52
  }
@@ -485,10 +506,10 @@ function gatherOutputs(a) {
485
506
  const i = String(spec).lastIndexOf(":");
486
507
  if (i < 0)
487
508
  return `bad --output (want <addr>:<CSD>): ${spec}`;
488
- outs.push({ to: String(spec).slice(0, i), value: CSD(Number(String(spec).slice(i + 1))) });
509
+ outs.push({ to: String(spec).slice(0, i), value: CSD(String(spec).slice(i + 1)) });
489
510
  }
490
511
  if (a.flags.to !== undefined || a.flags.amount !== undefined)
491
- outs.push({ to: String(a.flags.to ?? ""), value: CSD(Number(a.flags.amount ?? 0)) });
512
+ outs.push({ to: String(a.flags.to ?? ""), value: CSD(a.flags.amount ?? 0) });
492
513
  for (const o of outs) {
493
514
  if (!/^0x[0-9a-fA-F]{40}$/.test(o.to))
494
515
  return `bad recipient: ${o.to}`;
@@ -510,8 +531,10 @@ async function cmdSend(a) {
510
531
  console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
511
532
  return;
512
533
  }
513
- const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.01;
514
- const fee = (Number.isFinite(feeCsd) && feeCsd >= 0) ? CSD(feeCsd) : 1_000_000;
534
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : 1_000_000;
535
+ const fee = (Number.isSafeInteger(feeFlag) && feeFlag >= 0) ? feeFlag : 1_000_000;
536
+ if (a.flags.fee !== undefined && fee !== feeFlag)
537
+ warnBadFee(a.flags.fee, fee);
515
538
  const total = outs.reduce((s, o) => s + o.value, 0);
516
539
  console.log(`${kdim("from")} ${c.cyan(addr)}`);
517
540
  for (const o of outs)
@@ -587,8 +610,10 @@ async function cmdPropose(a) {
587
610
  console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <CSD>] [--expires-days N]"));
588
611
  return;
589
612
  }
590
- const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.25;
591
- const fee = Math.max(MIN_FEE_PROPOSE, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_PROPOSE);
613
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : MIN_FEE_PROPOSE;
614
+ const fee = Math.max(MIN_FEE_PROPOSE, Number.isSafeInteger(feeFlag) ? feeFlag : MIN_FEE_PROPOSE);
615
+ if (a.flags.fee !== undefined && !Number.isSafeInteger(feeFlag))
616
+ warnBadFee(a.flags.fee, fee);
592
617
  // operator-token path stays available for the instance operator
593
618
  if (CAIRN_TOKEN && !(await csd.available())) {
594
619
  const sp = spinner("posting via operator token");
@@ -654,8 +679,10 @@ async function cmdSupport(a) {
654
679
  console.log(err("proposal id must be 0x…64-hex"));
655
680
  return;
656
681
  }
657
- const feeCsd = a.flags.fee !== undefined ? Number(a.flags.fee) : 0.05;
658
- const fee = Math.max(MIN_FEE_ATTEST, Number.isFinite(feeCsd) ? CSD(feeCsd) : MIN_FEE_ATTEST);
682
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : MIN_FEE_ATTEST;
683
+ const fee = Math.max(MIN_FEE_ATTEST, Number.isSafeInteger(feeFlag) ? feeFlag : MIN_FEE_ATTEST);
684
+ if (a.flags.fee !== undefined && !Number.isSafeInteger(feeFlag))
685
+ warnBadFee(a.flags.fee, fee);
659
686
  const score = Math.max(0, Math.min(100, parseInt(String(a.flags.score ?? 75)) || 0));
660
687
  const confidence = Math.max(0, Math.min(100, parseInt(String(a.flags.confidence ?? 60)) || 0));
661
688
  if (CAIRN_TOKEN && !(await csd.available())) {
@@ -967,7 +994,10 @@ async function cmdGateway(a) {
967
994
  if (!p)
968
995
  return;
969
996
  const rec = buildGatewayRecord({ priv: p.priv, url, kind: a.flags.pin ? "pin" : "gateway", address: p.addr });
970
- const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
997
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : MIN_FEE_PROPOSE;
998
+ const fee = Math.max(MIN_FEE_PROPOSE, Number.isSafeInteger(feeFlag) ? feeFlag : MIN_FEE_PROPOSE);
999
+ if (a.flags.fee !== undefined && !Number.isSafeInteger(feeFlag))
1000
+ warnBadFee(a.flags.fee, fee); // NaN-guard: the old Math.max(MIN, NaN) poisoned the fee on a garbage --fee
971
1001
  if (a.flags["dry-run"]) {
972
1002
  console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("url")} ${c.white(url)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
973
1003
  console.log(c.gray("\n[dry-run] not signed or submitted"));
@@ -991,7 +1021,10 @@ async function cmdPeer(a) {
991
1021
  return;
992
1022
  const caps = (a.multi.cap ?? (a.flags.cap ? [String(a.flags.cap)] : [])).filter(Boolean);
993
1023
  const rec = buildPeerRecord({ priv: p.priv, peer_id: peerId, multiaddrs, caps: caps.length ? caps : undefined, address: p.addr });
994
- const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
1024
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : MIN_FEE_PROPOSE;
1025
+ const fee = Math.max(MIN_FEE_PROPOSE, Number.isSafeInteger(feeFlag) ? feeFlag : MIN_FEE_PROPOSE);
1026
+ if (a.flags.fee !== undefined && !Number.isSafeInteger(feeFlag))
1027
+ warnBadFee(a.flags.fee, fee); // NaN-guard: the old Math.max(MIN, NaN) poisoned the fee on a garbage --fee
995
1028
  if (a.flags["dry-run"]) {
996
1029
  console.log(`${kdim("domain")} ${c.cyan(rec.domain)}\n${kdim("peer")} ${c.white(peerId)}\n${kdim("hash")} ${c.magenta(rec.payloadHash)}`);
997
1030
  console.log(c.gray("\n[dry-run] not signed or submitted"));
@@ -1014,7 +1047,10 @@ async function cmdIdentity(a) {
1014
1047
  const p = await registryPrep(a);
1015
1048
  if (!p)
1016
1049
  return;
1017
- const fee = Math.max(MIN_FEE_PROPOSE, a.flags.fee !== undefined ? CSD(Number(a.flags.fee)) : MIN_FEE_PROPOSE);
1050
+ const feeFlag = a.flags.fee !== undefined ? CSD(a.flags.fee) : MIN_FEE_PROPOSE;
1051
+ const fee = Math.max(MIN_FEE_PROPOSE, Number.isSafeInteger(feeFlag) ? feeFlag : MIN_FEE_PROPOSE);
1052
+ if (a.flags.fee !== undefined && !Number.isSafeInteger(feeFlag))
1053
+ warnBadFee(a.flags.fee, fee); // NaN-guard: the old Math.max(MIN, NaN) poisoned the fee on a garbage --fee
1018
1054
  if (a.flags.reveal) {
1019
1055
  const salt = String(a.flags.salt ?? "");
1020
1056
  if (!/^[0-9a-f]{16,}$/i.test(salt)) {
@@ -1090,6 +1126,13 @@ async function tokenDecimals() {
1090
1126
  map[t.ticker] = t.decimals;
1091
1127
  return map;
1092
1128
  }
1129
+ // A name in `acct.nameDetails` with pending:true is a v2.5/v2.6 reservation (revealed, not yet finalized):
1130
+ // the resolver gives it no send address and no owner actions until nfinalize. Mark it so a reservation is
1131
+ // never shown as an owned name. Defensive — an older resolver that omits nameDetails yields an empty set.
1132
+ function pendingNames(acct) {
1133
+ return new Set((acct?.nameDetails ?? []).filter((d) => d?.pending).map((d) => String(d.name)));
1134
+ }
1135
+ const nameCell = (n, pend) => pend.has(n) ? c.cyan(san(n)) + c.gray(" (finalizing)") : c.green(san(n));
1093
1136
  async function cmdTokens(a) {
1094
1137
  const addr = await resolveCairnxAddr(a, a._[1]);
1095
1138
  if (!addr)
@@ -1109,7 +1152,8 @@ async function cmdTokens(a) {
1109
1152
  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
1153
  }
1111
1154
  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")}`);
1155
+ const pend = pendingNames(acct);
1156
+ console.log(`\n ${kdim(".csd names")} ${names.length ? names.map((n) => nameCell(n, pend)).join(c.gray(" · ")) : c.gray("none")}`);
1113
1157
  }
1114
1158
  async function cmdTokenInfo(a) {
1115
1159
  const ticker = String(a._[1] ?? "").toUpperCase();
@@ -1263,8 +1307,9 @@ async function cmdNames(a) {
1263
1307
  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
1308
  return;
1265
1309
  }
1310
+ const pend = pendingNames(acct);
1266
1311
  for (const n of names)
1267
- console.log(` ${c.green(san(n))}`);
1312
+ console.log(` ${nameCell(n, pend)}${pend.has(n) ? c.gray(" · complete on /trade") : ""}`);
1268
1313
  console.log(c.gray(`\n ${names.length} name${names.length === 1 ? "" : "s"} · cairn name <name> for detail`));
1269
1314
  }
1270
1315
  async function cmdName(a) {
@@ -9,18 +9,22 @@
9
9
  // the latent dual-canonicaliser seam).
10
10
  // • exact human↔base-unit amount math as STRING/BigInt arithmetic — floats never touch
11
11
  // token amounts (no "1.1 * 1e8 = 110000000.00000001" class of bug, no silent truncation).
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";
12
+ import { MIN_FEE_PROPOSE } from "@inversealtruism/csd-codec";
13
+ // Consensus shapes/constants AND the record builder are IMPORTED from cairnx-core, not
14
+ // hand-declared (shared-core de-dup, cairn docs/Plans/46 + 57 B5a): they validate a record BEFORE
15
+ // the CLI spends the anchor fee, so a drifted regex / limit would build a record the resolver
16
+ // no-ops (a lost fee). One source = the published convention.
17
+ import { DOMAIN, TICKER_RE, NAME_RE, ADDR_RE, MAX_AMOUNT, transfer } from "@inversealtruism/cairnx-core";
18
18
  export const CAIRNX_DOMAIN = DOMAIN; // "cairnx:v1"
19
19
  export const CAIRNX_ANCHOR_FEE = MIN_FEE_PROPOSE; // 0.25 CSD — the consensus min Propose fee that anchors a record
20
20
  // re-export the §4 shapes the CLI's public surface exposed (now single-sourced from cairnx-core)
21
21
  export { TICKER_RE, NAME_RE, MAX_AMOUNT };
22
22
  export function buildTransferRecord(p) {
23
23
  const to = String(p.to).toLowerCase();
24
+ // Friendly pre-checks first (CLI-grade error messages), then the CANONICAL builder does the
25
+ // authoritative work (Plan 57 B5a): cairnx-core transfer() round-trips through parseRecord, so
26
+ // what the CLI anchors is valid-by-construction against CONVENTION.md, not a hand-kept mirror.
27
+ // Output is byte-identical to the old hand-assembly (same record keys; canonicalJson sorts).
24
28
  if (!TICKER_RE.test(p.ticker))
25
29
  throw new Error(`bad ticker "${p.ticker}" — want 3–12 chars [A-Z0-9], starting with a letter`);
26
30
  if (!ADDR_RE.test(to))
@@ -29,11 +33,8 @@ export function buildTransferRecord(p) {
29
33
  throw new Error("amount must be > 0");
30
34
  if (p.amount > MAX_AMOUNT)
31
35
  throw new Error("amount exceeds the 96-bit token-amount limit");
32
- const record = { amount: p.amount.toString(), t: "transfer", ticker: p.ticker, to, v: 1 };
33
- const uri = canonicalJson(record);
34
- if (Buffer.byteLength(uri, "utf8") > MAX_RECORD_BYTES)
35
- throw new Error("record exceeds 512 bytes"); // unreachable for a transfer, kept as a guard
36
- return { record, uri, payloadHash: sha256Hex(uri) };
36
+ const built = transfer({ ticker: p.ticker, to, amount: p.amount.toString() });
37
+ return { record: built.record, uri: built.uri, payloadHash: built.payloadHash };
37
38
  }
38
39
  // ── exact amount math (strings + BigInt only) ─────────────────────────────────────────────
39
40
  // "1.5" with decimals 8 → 150000000n. Fails LOUDLY instead of truncating: "1.5" on a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inversealtruism/cairn-cli",
3
- "version": "0.3.12",
3
+ "version": "0.3.15",
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": {
@@ -21,14 +21,6 @@
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
- "scripts": {
25
- "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
26
- "build": "npm run clean && tsc",
27
- "dev": "tsx src/cli.ts",
28
- "test": "npm run build && node test/security.mjs && node test/cairnx.mjs && node test/e2e.mjs",
29
- "prepare": "npm run clean && tsc",
30
- "prepublishOnly": "npm run clean && tsc"
31
- },
32
24
  "keywords": [
33
25
  "cairn",
34
26
  "compute-substrate",
@@ -41,8 +33,14 @@
41
33
  "typescript": "^5.7.2"
42
34
  },
43
35
  "dependencies": {
44
- "@inversealtruism/cairnx-core": "0.1.28",
45
- "@inversealtruism/csd-codec": "0.1.14",
46
- "@inversealtruism/csd-registry": "0.1.14"
36
+ "@inversealtruism/cairnx-core": "0.1.31",
37
+ "@inversealtruism/csd-codec": "0.1.15",
38
+ "@inversealtruism/csd-registry": "0.1.15"
39
+ },
40
+ "scripts": {
41
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
42
+ "build": "npm run clean && tsc",
43
+ "dev": "tsx src/cli.ts",
44
+ "test": "npm run build && node test/security.mjs && node test/cairnx.mjs && node test/e2e.mjs"
47
45
  }
48
- }
46
+ }