@inversealtruism/cairn-cli 0.3.2 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -9
- package/dist/cli.js +490 -66
- package/dist/lib/api.js +84 -0
- package/dist/lib/cairnx.js +108 -0
- package/dist/lib/config.js +7 -4
- package/dist/lib/csd.js +124 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# cairn-cli
|
|
2
2
|
|
|
3
|
-
A command-line client for **Compute Substrate / Cairn**
|
|
3
|
+
A command-line client for **Compute Substrate / Cairn** - browse the board, and **send CSD,
|
|
4
4
|
propose, attest, and place stones on the Wall** straight from your terminal.
|
|
5
5
|
|
|
6
6
|
For the people who'd rather not put a key in a browser extension: **cairn-cli never holds your
|
|
7
|
-
key.** Reads are plain HTTP. For writes it drives your **own installed `csd` wallet**
|
|
8
|
-
signs with your key (CSD_SIG_V1)
|
|
7
|
+
key.** Reads are plain HTTP. For writes it drives your **own installed `csd` wallet** - `csd`
|
|
8
|
+
signs with your key (CSD_SIG_V1) -- https://github.com/compute-substrate/compute-substrate
|
|
9
|
+
|
|
10
|
+
cairn-cli adds the Cairn layer on top: it computes the
|
|
9
11
|
canonical payload hash, fetches a spendable input from the Cairn proxy (so you don't need a
|
|
10
12
|
synced local node), registers your off-chain content, and gives you the board / wall / network
|
|
11
13
|
views the raw `csd` CLI doesn't have. Browsing needs no `csd` binary and no keys.
|
|
@@ -44,15 +46,16 @@ cairn watch # live auto-refreshing board
|
|
|
44
46
|
cairn recent # recent proposals and support
|
|
45
47
|
cairn show <id> # item detail and integrity check
|
|
46
48
|
cairn verify <id> # recompute the content hash and check it
|
|
47
|
-
cairn wall # the Wall
|
|
49
|
+
cairn wall # the Wall - top stones + the reigning King
|
|
48
50
|
cairn network # live network telemetry (alias: cairn stats)
|
|
49
51
|
cairn quests # open quests
|
|
50
52
|
cairn profile <addr> # identity + on-chain reputation
|
|
51
53
|
cairn leaderboard # top builders by reputation
|
|
54
|
+
cairn tokens [address] # CairnX token balances + .csd names (see below)
|
|
52
55
|
cairn ls --json # machine-readable output
|
|
53
56
|
```
|
|
54
57
|
|
|
55
|
-
## Wallet (transacting
|
|
58
|
+
## Wallet (transacting - uses your own `csd` wallet)
|
|
56
59
|
|
|
57
60
|
One-time: install Compute Substrate's `csd` CLI and create/import your key.
|
|
58
61
|
|
|
@@ -61,7 +64,7 @@ csd wallet new # or: csd wallet init --privkey <your ke
|
|
|
61
64
|
cairn setup # checks csd + wallet, shows your address + balance
|
|
62
65
|
```
|
|
63
66
|
|
|
64
|
-
Then transact
|
|
67
|
+
Then transact - cairn-cli builds the request, `csd` signs with your key, and the tx is submitted
|
|
65
68
|
through the Cairn proxy (no local node required):
|
|
66
69
|
|
|
67
70
|
```bash
|
|
@@ -75,11 +78,33 @@ cairn wall place "gm, Compute Substrate"
|
|
|
75
78
|
Fees and amounts are in **CSD** (e.g. `--amount 1.5`, `--fee 0.05`). Minimums: 0.25 CSD to propose,
|
|
76
79
|
0.05 CSD to attest. Support is a paid demand signal, not a payment to the author; fees go to miners.
|
|
77
80
|
|
|
81
|
+
## CairnX (tokens + .csd names)
|
|
82
|
+
|
|
83
|
+
CairnX is the token / DeFi layer that lives entirely in `cairnx:v1` records on-chain
|
|
84
|
+
(traded at [cairn-substrate.com/trade](https://cairn-substrate.com/trade)). Reads hit the
|
|
85
|
+
CairnX state API (`CAIRNX_API` → local service → public gateway); the one write —
|
|
86
|
+
`token-send` — anchors a canonical transfer record signed by your own `csd` wallet.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cairn tokens [address] # token balances (locked shown) + owned .csd names
|
|
90
|
+
cairn token-info CAIRN # supply · minted · decimals · mint mode · top-10 holders
|
|
91
|
+
cairn token-send --ticker CAIRN --to 0x… --amount 1.5 # send tokens (--dry-run to preview, --yes to skip the prompt)
|
|
92
|
+
cairn names [address] # owned .csd names
|
|
93
|
+
cairn name inverse # one name: owner · lease · open offer
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`token-send` amounts are **human units converted exactly** (string math, never floats):
|
|
97
|
+
`--amount 1.5` on an 8-decimals token sends `150000000` base units, and `1.5` of a
|
|
98
|
+
0-decimals token is an **error**, never a truncation. The transfer itself is free at the
|
|
99
|
+
token layer; anchoring it on-chain costs the 0.25 CSD propose fee (printed before anything
|
|
100
|
+
signs, and `--dry-run` shows the exact canonical record + hash without signing).
|
|
101
|
+
|
|
78
102
|
## Configuration (environment variables)
|
|
79
103
|
|
|
80
104
|
| Variable | Default | Purpose |
|
|
81
105
|
|---|---|---|
|
|
82
106
|
| `CAIRN_API` | `https://cairn-substrate.com` | the board / proxy to talk to (use your own, e.g. `http://127.0.0.1:7777`) |
|
|
107
|
+
| `CAIRNX_API` | local service, then the public gateway | the CairnX (token/name) state API; set it to pin one base |
|
|
83
108
|
| `CAIRN_CSD` | `csd` | path to your installed `csd` binary (signs your transactions) |
|
|
84
109
|
| `CAIRN_ADDR` | – | your public addr20; skips deriving it from the csd wallet |
|
|
85
110
|
| `CAIRN_RPC` | – | optional csd node RPC; enables fully trustless `verify` (recompute the hash + confirm on-chain) |
|
|
@@ -91,13 +116,18 @@ Fees and amounts are in **CSD** (e.g. `--amount 1.5`, `--fee 0.05`). Minimums: 0
|
|
|
91
116
|
- `verify` fetches an item, recomputes `sha256(canonical content)` locally, and if `CAIRN_RPC` is set,
|
|
92
117
|
confirms that hash is the one committed on-chain. You trust the math, not the server.
|
|
93
118
|
- `send` / `propose` / `support` / `wall place`: cairn-cli fetches a spendable input from the Cairn
|
|
94
|
-
proxy, hands it to **your** `csd` (which signs with your wallet key
|
|
119
|
+
proxy, hands it to **your** `csd` (which signs with your wallet key - for these commands the key
|
|
95
120
|
stays inside `csd` and never enters the cairn-cli process), then submits the signed transaction
|
|
96
121
|
through the proxy and (for proposals) registers the off-chain content. Sealed claims and
|
|
97
122
|
Sign-in-with-CSD live in the Cairn Wallet.
|
|
98
|
-
- **
|
|
123
|
+
- **CairnX commands** (`tokens`, `token-info`, `token-send`, `names`, `name`) read the CairnX
|
|
124
|
+
state API. `token-send` builds the canonical transfer record locally (`sha256(uri)` is the
|
|
125
|
+
on-chain commitment, byte-exact-tested against the resolver's own builder), checks your
|
|
126
|
+
balance, prints the record + the 0.25 CSD anchor cost, then has `csd` sign the anchoring
|
|
127
|
+
Propose — the same no-key-in-process path as `propose`/`send`.
|
|
128
|
+
- **registry commands** (`gateway register`, `peer announce`, `identity claim`) are the one
|
|
99
129
|
exception: they sign a registry *binding* with `@inversealtruism/csd-registry`, so cairn-cli reads
|
|
100
|
-
your private key from `csd wallet config` and signs **in-process** (the key is never networked
|
|
130
|
+
your private key from `csd wallet config` and signs **in-process** (the key is never networked - only
|
|
101
131
|
the signed canonical content is published). Because these load key material into the Node process,
|
|
102
132
|
the `csd-registry` / `csd-codec` dependencies are **pinned to exact versions** (no caret ranges) to
|
|
103
133
|
shrink the supply-chain surface. If you only ever `send`/`propose`/`support`, your key never leaves `csd`.
|
package/dist/cli.js
CHANGED
|
@@ -7,29 +7,128 @@ 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
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 * 4) {
|
|
57
|
+
console.log(warn(`heads up: change (${csdToCoins(change)} CSD) is much smaller than the fee (${csdToCoins(fee)} CSD).`) +
|
|
58
|
+
c.gray(" If your proxy under-reports this input, extra value is burned as fee — verify with your own node, or use ") + c.cyan("--max-fee") + c.gray("."));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Pick a spendable input from the proxy, INDEPENDENTLY verify its value against CAIRN_RPC
|
|
62
|
+
// (UTXO-VALUE-1 cross-source cure), and ALWAYS display the input/change so the implied fee is
|
|
63
|
+
// visible on EVERY write (not just `send`). `txValue` = value moved to recipients (0 for a fee-
|
|
64
|
+
// only propose/attest); `fee` = the fee. Returns the csd input triple, or null (reason printed).
|
|
65
|
+
// Defense layering: (1) if an independent node is configured we cryptographically catch an
|
|
66
|
+
// under-report and REFUSE; (2) otherwise the value is proxy-trusted and the only backstop is the
|
|
67
|
+
// feeSanity cap + the visible change line — the user is told to set CAIRN_RPC for a real check.
|
|
68
|
+
async function pickAndShow(addr, need, txValue, fee) {
|
|
69
|
+
const picked = await api.pickInput(addr, need).catch(() => null);
|
|
70
|
+
if (!picked) {
|
|
71
|
+
console.log(err("no single confirmed UTXO covers this spend") + c.gray(" — fund this address, or consolidate (a node + `csd … --auto-input` can combine inputs)."));
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const [ptxid, pvoutS] = picked.input.split(":");
|
|
75
|
+
const v = await api.verifyInputValue(addr, ptxid, Number(pvoutS), picked.value).catch(() => ({ checked: false }));
|
|
76
|
+
if (v.checked && v.ok === false) {
|
|
77
|
+
if (v.missing)
|
|
78
|
+
console.log(err("the picked input is NOT in your independent node's UTXO set (CAIRN_RPC).") + c.gray(" The proxy may be misreporting your coins — refusing to spend. Check your node is synced, or unset CAIRN_RPC to override."));
|
|
79
|
+
else
|
|
80
|
+
console.log(err(`input value MISMATCH — proxy says ${csdToCoins(picked.value)} CSD, your node says ${csdToCoins(v.value ?? 0)} CSD.`) + c.gray(" A proxy under-reporting your input would burn the difference as fee — refusing to spend."));
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const verified = v.checked && v.ok === true;
|
|
84
|
+
const inputValue = verified ? Number(v.value) : picked.value;
|
|
85
|
+
const tag = verified ? c.green(" ✓ verified vs your node")
|
|
86
|
+
: (CAIRN_RPC ? c.gray(" (independent value check unavailable)") : c.gray(" (set CAIRN_RPC to your own node to independently verify this value)"));
|
|
87
|
+
console.log(`${kdim("input")} ${csdToCoins(inputValue)} CSD ${c.gray("(one UTXO)")}${tag} ${kdim("change")} ${csdToCoins(Math.max(0, inputValue - txValue - fee))} CSD ${c.gray("back to you")}`);
|
|
88
|
+
warnIfChangeCollapsed(inputValue, txValue, fee);
|
|
89
|
+
return picked.input;
|
|
90
|
+
}
|
|
13
91
|
// Resolve the user's PUBLIC address (to fetch inputs from the proxy). Never reads the key
|
|
14
92
|
// 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
|
|
93
|
+
// only the public address). Order: --address → CAIRN_ADDR (both EXPLICIT user intent for this
|
|
94
|
+
// invocation) → the csd wallet's real change address → cached config.address (LAST, and never
|
|
95
|
+
// above the wallet — see below).
|
|
96
|
+
//
|
|
97
|
+
// F13/R18: the cached config.address is attacker-tamperable (a poisoned
|
|
98
|
+
// ~/.config/cairn-cli/config.json would otherwise redirect `cairn address` output, so a funder
|
|
99
|
+
// piping it pays the attacker). So when csd is available we re-derive the wallet's REAL change
|
|
100
|
+
// address and treat THAT as authoritative; if it disagrees with the cached value we refuse and
|
|
101
|
+
// rewrite the cache. The cache is only used as a fallback when csd can't tell us the truth.
|
|
16
102
|
async function resolveAddr(a) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
103
|
+
// explicit, per-invocation choice — the user can deliberately target any address
|
|
104
|
+
const explicit = a.flags.address ? String(a.flags.address) : CAIRN_ADDR;
|
|
105
|
+
if (explicit && /^0x[0-9a-fA-F]{40}$/.test(explicit))
|
|
106
|
+
return explicit;
|
|
107
|
+
const cached = loadLocalConfig().address;
|
|
20
108
|
const cfg = await csd.walletConfig();
|
|
21
|
-
//
|
|
22
|
-
//
|
|
109
|
+
// Best case: the wallet exposes its change address directly — authoritative AND needs no key
|
|
110
|
+
// (no argv exposure). The cache is then only an anti-poison cross-check (F13/R18): if a tampered
|
|
111
|
+
// config disagrees, say so loudly and use the wallet's address.
|
|
23
112
|
if (cfg?.default_change_addr20 && /^0x[0-9a-fA-F]{40}$/.test(String(cfg.default_change_addr20))) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
113
|
+
const real = String(cfg.default_change_addr20);
|
|
114
|
+
if (cached && cached.toLowerCase() !== real.toLowerCase())
|
|
115
|
+
console.log(warn(`cached address ${c.cyan(san(cached))} does NOT match your csd wallet ${c.cyan(real)}`) + c.gray(" — using the wallet's address and refreshing the cache (a tampered config can't redirect you)."));
|
|
116
|
+
saveLocalConfig({ address: real });
|
|
117
|
+
return real;
|
|
118
|
+
}
|
|
119
|
+
// No change address configured. Audit H-2: re-deriving from the privkey on EVERY call runs
|
|
120
|
+
// `csd wallet recover --privkey <KEY>`, putting the key on the argv (readable via /proc on a
|
|
121
|
+
// shared host). Avoid that — prefer a previously-cached address; only DERIVE when we have
|
|
122
|
+
// nothing else (then cache it + warn once, and nudge the user to set a change address so the
|
|
123
|
+
// key is never needed again, which also restores the F13 anti-poison cross-check above).
|
|
124
|
+
if (cached && /^0x[0-9a-fA-F]{40}$/.test(cached))
|
|
125
|
+
return cached;
|
|
28
126
|
if (cfg?.default_privkey) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
127
|
+
const real = await csd.deriveAddr(cfg.default_privkey);
|
|
128
|
+
if (real && /^0x[0-9a-fA-F]{40}$/.test(real)) {
|
|
129
|
+
console.log(warn("derived your address from the wallet key once.") + c.gray(" " + csd.keyExposureWarning));
|
|
130
|
+
saveLocalConfig({ address: real });
|
|
131
|
+
return real;
|
|
33
132
|
}
|
|
34
133
|
}
|
|
35
134
|
return null;
|
|
@@ -39,9 +138,13 @@ async function resolveAddr(a) {
|
|
|
39
138
|
// proxy ourselves. We do NOT trust csd's own auto-submit: it targets csd's configured node,
|
|
40
139
|
// which may be a different node than the one the Cairn board (and its miner) read — so a tx
|
|
41
140
|
// 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
|
-
//
|
|
141
|
+
// the proxy (the board's miner-connected node).
|
|
142
|
+
//
|
|
143
|
+
// Success is EVIDENCE-BASED, never message-based: a hostile proxy (or a real double-spend)
|
|
144
|
+
// can return an "already present / conflict" string for a tx that is NOT actually ours, so we
|
|
145
|
+
// never treat any error message as success. A clean submit ack is good; otherwise we ask the
|
|
146
|
+
// node directly whether OUR exact txid is on-chain (api.confirmTxMined), and only claim success
|
|
147
|
+
// when the node confirms it. If we can't confirm, we surface the real node message.
|
|
45
148
|
async function signAndSubmit(csdArgs) {
|
|
46
149
|
const r = await csd.run(csdArgs);
|
|
47
150
|
if (!r.ok)
|
|
@@ -57,17 +160,40 @@ async function signAndSubmit(csdArgs) {
|
|
|
57
160
|
const sub = await api.submitTx(out.tx).catch((e) => ({ ok: false, err: e.message }));
|
|
58
161
|
if (sub.ok)
|
|
59
162
|
return { ok: true, txid: sub.txid || txid };
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
// — is on the user.) The node can't be queried for mempool membership (its /tx indexes only
|
|
66
|
-
// mined txs), so we rely on the matching txid + benign message.
|
|
67
|
-
if (txid && /already|present|known|in mempool|conflict/i.test(String(sub.err ?? "")))
|
|
163
|
+
// Submit was NOT acked. Don't trust the error STRING — a forged "already present" can hide a
|
|
164
|
+
// rejected/conflicting tx. Confirm against the chain: only if the node reports OUR exact txid
|
|
165
|
+
// as mined is this our own prior submit (a genuine "already in"); otherwise the conflict is a
|
|
166
|
+
// DIFFERENT spend (a real double-spend / hostile reply) and we surface it as a failure.
|
|
167
|
+
if (txid && await api.confirmTxMined(txid))
|
|
68
168
|
return { ok: true, txid };
|
|
69
169
|
return { ok: false, error: sub.err || "submit rejected by node", txid };
|
|
70
170
|
}
|
|
171
|
+
// Freshness gate (R12): before building any value tx, consult the proxy's chain-view status so
|
|
172
|
+
// we never sign against a FROZEN or forked tip (the proxy may be failing over to an honest-but-
|
|
173
|
+
// stale or wedged node). Fail CLOSED on a stale tip; fail OPEN (warn only) if the freshness
|
|
174
|
+
// surface is unreachable — that matches the rest of the CLI's 'cannot reach' UX and an old node
|
|
175
|
+
// without /api/rpc/status must still be usable. `--force-stale` lets an operator override.
|
|
176
|
+
async function freshTip(a) {
|
|
177
|
+
let s;
|
|
178
|
+
try {
|
|
179
|
+
s = await api.rpcStatus();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
console.log(warn("could not check chain freshness (status surface unreachable) — proceeding"));
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const secs = Number(s?.secondsSinceAdvance ?? 0);
|
|
186
|
+
const threshold = Number(s?.staleSecsThreshold ?? 600);
|
|
187
|
+
const stale = s?.stale === true || (Number.isFinite(secs) && Number.isFinite(threshold) && threshold > 0 && secs > threshold);
|
|
188
|
+
if (!stale)
|
|
189
|
+
return true;
|
|
190
|
+
if (a.flags["force-stale"]) {
|
|
191
|
+
console.log(warn(`chain tip looks STALE (${secs}s since last advance) — proceeding anyway (--force-stale)`));
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
console.log(err(`chain tip looks STALE — last advanced ${secs}s ago (threshold ${threshold}s).`) + c.gray(" The node may be frozen or failing over to a stale view; refusing to build a tx. Retry shortly, or override with ") + c.cyan("--force-stale") + c.gray("."));
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
71
197
|
// Guard: a write needs `csd` installed + a configured wallet (or an explicit --address + csd key).
|
|
72
198
|
async function requireCsd() {
|
|
73
199
|
if (!(await csd.available())) {
|
|
@@ -103,11 +229,19 @@ function parse(argv) {
|
|
|
103
229
|
}
|
|
104
230
|
return { _, flags, multi };
|
|
105
231
|
}
|
|
106
|
-
// Do two URLs point at the same
|
|
107
|
-
// node RPC and the board API are the same operator).
|
|
232
|
+
// Do two URLs point at the same MACHINE? (used to refuse a "trustless" verify claim when the
|
|
233
|
+
// node RPC and the board API are the same operator). Compares hostname only — NOT host — so a
|
|
234
|
+
// same-box node:8789 + board:7777 are correctly judged same-machine (a port difference does not
|
|
235
|
+
// make two endpoints independent), and canonicalizes the loopback aliases (127.0.0.1 / localhost
|
|
236
|
+
// / ::1) so they all compare equal. Unparseable → treat as same (the SAFE default: never claim
|
|
237
|
+
// trustless independence we can't establish).
|
|
238
|
+
function canonHost(h) {
|
|
239
|
+
const x = h.toLowerCase().replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
240
|
+
return (x === "localhost" || x === "127.0.0.1" || x === "::1" || x === "0.0.0.0") ? "localhost" : x;
|
|
241
|
+
}
|
|
108
242
|
function sameHost(a, b) {
|
|
109
243
|
try {
|
|
110
|
-
return new URL(a).
|
|
244
|
+
return canonHost(new URL(a).hostname) === canonHost(new URL(b).hostname);
|
|
111
245
|
}
|
|
112
246
|
catch {
|
|
113
247
|
return true;
|
|
@@ -271,7 +405,12 @@ async function cmdSetup() {
|
|
|
271
405
|
banner();
|
|
272
406
|
rule("setup — cairn over your csd wallet");
|
|
273
407
|
const has = await csd.available();
|
|
274
|
-
|
|
408
|
+
// H-1: show the RESOLVED absolute path of the binary that will SIGN, so the user can verify it
|
|
409
|
+
// (and see the refusal if it was resolved from an untrusted location).
|
|
410
|
+
const bin = csd.csdPathInfo();
|
|
411
|
+
console.log(` ${kdim("csd binary")} ${has ? ok("found") + c.gray(" " + (bin.path ?? "")) + (bin.explicit ? c.gray(" (CAIRN_CSD)") : c.gray(" (resolved)")) : err(bin.error || "not found — install Compute Substrate's csd CLI, or set CAIRN_CSD to its absolute path")}`);
|
|
412
|
+
if (bin.warning)
|
|
413
|
+
console.log(` ${kdim("")} ${warn(bin.warning)}`);
|
|
275
414
|
if (!has) {
|
|
276
415
|
console.log(c.gray("\n cairn signs nothing itself — it drives your csd wallet. Install csd, then re-run ") + c.cyan("cairn setup") + c.gray("."));
|
|
277
416
|
return;
|
|
@@ -346,30 +485,61 @@ async function cmdSend(a) {
|
|
|
346
485
|
for (const o of outs)
|
|
347
486
|
console.log(`${kdim("to")} ${c.cyan(o.to)} ${c.gray("→ " + csdToCoins(o.value) + " CSD")}`);
|
|
348
487
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("total")} ${csdToCoins(total + fee)} CSD`);
|
|
488
|
+
// max-fee sanity: an absurd fee (typo, or a hostile proxy under-reporting the input → burned
|
|
489
|
+
// change) is refused BEFORE we build/sign. --dry-run still SHOWS the abnormal-fee warning (so the
|
|
490
|
+
// user sees it without spending) but never aborts the preview. --max-fee overrides a deliberate fee.
|
|
491
|
+
const feeOk = feeSanity(fee, total, a);
|
|
349
492
|
if (a.flags["dry-run"]) {
|
|
350
493
|
console.log(c.gray("\n[dry-run] not sent"));
|
|
351
494
|
return;
|
|
352
495
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (!
|
|
356
|
-
|
|
357
|
-
|
|
496
|
+
if (!feeOk)
|
|
497
|
+
return;
|
|
498
|
+
if (!(await freshTip(a)))
|
|
499
|
+
return;
|
|
500
|
+
const input = await pickAndShow(addr, total + fee, total, fee);
|
|
501
|
+
if (!input)
|
|
358
502
|
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
503
|
const sp2 = spinner("csd signs → submit");
|
|
366
504
|
const args = ["spend"];
|
|
367
505
|
for (const o of outs)
|
|
368
506
|
args.push("--output", `${o.to}:${o.value}`);
|
|
369
|
-
args.push("--change", addr, "--fee", String(fee), "--input",
|
|
507
|
+
args.push("--change", addr, "--fee", String(fee), "--input", input);
|
|
370
508
|
const r = await signAndSubmit(args);
|
|
371
509
|
sp2.stop();
|
|
372
|
-
|
|
510
|
+
if (!r.ok) {
|
|
511
|
+
console.log(err(r.error || "failed"));
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
console.log(ok(`sent ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
515
|
+
await confirmMined(r.txid, "transfer", !!a.flags.wait);
|
|
516
|
+
}
|
|
517
|
+
// Post-submit feedback for value writes (send/support). The txid is already proven submitted by the
|
|
518
|
+
// evidence-based signAndSubmit; mining is a separate ~120s event, so by DEFAULT we DON'T block on it —
|
|
519
|
+
// we report submitted + how to track it (matching token-send). Pass `--wait` to block until OUR exact
|
|
520
|
+
// txid is mined (the old behavior; useful in scripts that chain on confirmation). Non-fatal either way.
|
|
521
|
+
async function confirmMined(txid, label, wait) {
|
|
522
|
+
if (!wait) {
|
|
523
|
+
console.log(c.gray(` ${label} submitted — usually mines within ~2 min; track with `) + c.cyan(`cairn show ${txid.slice(0, 10)}…`));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const sp = spinner("waiting for the tx to mine (--wait)");
|
|
527
|
+
const mined = await api.confirmTxMined(txid).catch(() => false);
|
|
528
|
+
// H-7: the proxy can string-echo a "mined" reply. Only assert "confirmed on-chain" when an
|
|
529
|
+
// INDEPENDENT node (CAIRN_RPC) agrees; otherwise soften/flag so the user isn't given a false
|
|
530
|
+
// settlement guarantee they might release goods against.
|
|
531
|
+
const indep = mined ? await api.chainTxMined(txid).catch(() => null) : null;
|
|
532
|
+
sp.stop();
|
|
533
|
+
if (!mined) {
|
|
534
|
+
console.log(warn(`${label} submitted — not mined yet; re-check with `) + c.cyan(`cairn show`) + warn(" once a block lands"));
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (indep === true)
|
|
538
|
+
console.log(ok(`${label} confirmed on-chain`) + c.gray(" (verified against your independent node)"));
|
|
539
|
+
else if (indep === false)
|
|
540
|
+
console.log(err(`${label}: the proxy reports it mined but your node (CAIRN_RPC) does NOT — treat as UNCONFIRMED.`) + c.gray(" A hostile proxy can forge a 'mined' reply; trust your own node."));
|
|
541
|
+
else
|
|
542
|
+
console.log(warn(`${label}: proxy reports mined`) + c.gray(" — set CAIRN_RPC to your own node to confirm independently (a proxy can forge this signal)."));
|
|
373
543
|
}
|
|
374
544
|
async function cmdPropose(a) {
|
|
375
545
|
const domain = String(a.flags.domain ?? "");
|
|
@@ -411,19 +581,21 @@ async function cmdPropose(a) {
|
|
|
411
581
|
console.log(`${kdim("title")} ${c.white(title)}`);
|
|
412
582
|
console.log(`${kdim("hash")} ${c.magenta(payloadHash)} ${c.gray("· uri " + uri)}`);
|
|
413
583
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${kdim("from")} ${c.cyan(addr)}`);
|
|
584
|
+
feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (non-aborting on dry-run)
|
|
414
585
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
415
586
|
return;
|
|
416
587
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (!
|
|
420
|
-
|
|
421
|
-
|
|
588
|
+
if (!feeSanity(fee, fee, a))
|
|
589
|
+
return;
|
|
590
|
+
if (!(await freshTip(a)))
|
|
591
|
+
return;
|
|
592
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
593
|
+
if (!input)
|
|
422
594
|
return;
|
|
423
|
-
}
|
|
424
595
|
const tip = await api.tipHeight().catch(() => 0);
|
|
425
596
|
const days = Math.max(1, parseInt(String(a.flags["expires-days"] ?? 30)) || 30);
|
|
426
|
-
const
|
|
597
|
+
const sp = spinner("csd signs → submit");
|
|
598
|
+
const r = await signAndSubmit(["propose", "--domain", domain, "--payload-hash", payloadHash, "--uri", uri, "--expires-epoch", String(Math.floor(tip / 30) + days * 24), "--fee", String(fee), "--change", addr, "--input", input]);
|
|
427
599
|
sp.stop();
|
|
428
600
|
if (!r.ok) {
|
|
429
601
|
console.log(err(r.error || "failed"));
|
|
@@ -472,19 +644,27 @@ async function cmdSupport(a) {
|
|
|
472
644
|
if (a.flags["dry-run"]) {
|
|
473
645
|
console.log(`${kdim("support")} ${c.cyan(id)}`);
|
|
474
646
|
console.log(`${kdim("fee")} ${csdToCoins(fee)} CSD ${c.gray("· score " + score + " · confidence " + confidence)} ${kdim("from")} ${c.cyan(addr)}`);
|
|
647
|
+
feeSanity(fee, fee, a); // show the abnormal-fee warning in the preview (the attest weight IS the fee)
|
|
475
648
|
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
476
649
|
return;
|
|
477
650
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (!picked) {
|
|
481
|
-
sp.stop();
|
|
482
|
-
console.log(err("no confirmed UTXO above the fee") + c.gray(" — fund " + addr));
|
|
651
|
+
// an attest's fee IS the deliberate weight/stake; only guard against an absurd typo above the cap.
|
|
652
|
+
if (!feeSanity(fee, fee, a))
|
|
483
653
|
return;
|
|
484
|
-
|
|
485
|
-
|
|
654
|
+
if (!(await freshTip(a)))
|
|
655
|
+
return;
|
|
656
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
657
|
+
if (!input)
|
|
658
|
+
return;
|
|
659
|
+
const sp = spinner("csd signs → submit");
|
|
660
|
+
const r = await signAndSubmit(["attest", "--proposal-id", id, "--score", String(score), "--confidence", String(confidence), "--fee", String(fee), "--change", addr, "--input", input]);
|
|
486
661
|
sp.stop();
|
|
487
|
-
|
|
662
|
+
if (!r.ok) {
|
|
663
|
+
console.log(err(r.error || "failed"));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
console.log(ok(`supported ${c.cyan(r.txid)}`) + c.gray(" (signed by your csd wallet)"));
|
|
667
|
+
await confirmMined(r.txid, "support", !!a.flags.wait);
|
|
488
668
|
}
|
|
489
669
|
async function cmdWall(a) {
|
|
490
670
|
if (a._[1] === "place") {
|
|
@@ -631,12 +811,19 @@ async function help() {
|
|
|
631
811
|
console.log(c.bold(" wallet") + c.gray(" (signs with your installed csd wallet — cairn never holds your key)"));
|
|
632
812
|
cmd("setup", "", "check csd + wallet, show your address (alias: doctor)");
|
|
633
813
|
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)");
|
|
814
|
+
cmd("send", "--to <0x…40> --amount <CSD>", "transfer CSD (+ --output <a>:<CSD> ×N, --fee <CSD>, --max-fee <CSD>, --dry-run)");
|
|
635
815
|
cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (alias: post; + --fee, --expires-days, --dry-run)");
|
|
636
816
|
cmd("support", "<id> --fee <CSD>", "back an item (+ --score, --confidence, --dry-run)");
|
|
817
|
+
console.log("");
|
|
818
|
+
console.log(c.bold(" cairnx") + c.gray(" (tokens + .csd names on the CairnX layer)"));
|
|
819
|
+
cmd("tokens", "[address]", "token balances + .csd names (default: your address)");
|
|
820
|
+
cmd("token-info", "<TICKER>", "supply · minted · mint mode · top-10 holders (alias: token)");
|
|
821
|
+
cmd("token-send", "--ticker T --to 0x…40 --amount <n>", "send tokens (anchors a 0.25 CSD transfer record; --dry-run, --yes)");
|
|
822
|
+
cmd("names", "[address]", "owned .csd names");
|
|
823
|
+
cmd("name", "<name>", "one name: owner · lease · open offer");
|
|
637
824
|
console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
|
|
638
825
|
console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${csdToCoins(MIN_FEE_PROPOSE)} · attest ≥ ${csdToCoins(MIN_FEE_ATTEST)} CSD`));
|
|
639
|
-
console.log(c.gray(" config: CAIRN_API (board) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
|
|
826
|
+
console.log(c.gray(" config: CAIRN_API (board) · CAIRNX_API (token layer) · CAIRN_CSD (csd binary) · CAIRN_ADDR (your addr) · CAIRN_RPC (trustless verify) · CAIRN_TOKEN (operator)"));
|
|
640
827
|
console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
|
|
641
828
|
console.log(c.gray(" writes are signed by your own ") + c.cyan("csd") + c.gray(" wallet (csd wallet new / init); cairn supplies the input + Cairn content. Sealed claims + Sign-in: use the Cairn Wallet."));
|
|
642
829
|
}
|
|
@@ -667,6 +854,12 @@ async function main() {
|
|
|
667
854
|
case "propose":
|
|
668
855
|
case "post": return cmdPropose(a);
|
|
669
856
|
case "support": return cmdSupport(a);
|
|
857
|
+
case "tokens": return cmdTokens(a);
|
|
858
|
+
case "token-info":
|
|
859
|
+
case "token": return cmdTokenInfo(a);
|
|
860
|
+
case "token-send": return cmdTokenSend(a);
|
|
861
|
+
case "names": return cmdNames(a);
|
|
862
|
+
case "name": return cmdName(a);
|
|
670
863
|
case "gateway": return cmdGateway(a);
|
|
671
864
|
case "peer": return cmdPeer(a);
|
|
672
865
|
case "identity": return cmdIdentity(a);
|
|
@@ -677,16 +870,18 @@ async function main() {
|
|
|
677
870
|
// Anchor a built registry record: Propose{domain, payloadHash} signed by the csd wallet,
|
|
678
871
|
// then publish the EXACT canonical bytes to the content origin (self-certified on arrival).
|
|
679
872
|
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));
|
|
873
|
+
// max-fee sanity: a fixed-floor record anchor should never burn more than the 1 CSD abs cap.
|
|
874
|
+
if (fee > MAX_FEE_ABS) {
|
|
875
|
+
console.log(err(`fee ${csdToCoins(fee)} CSD looks abnormally high for a ${label} record (cap ${csdToCoins(MAX_FEE_ABS)} CSD).`) + c.gray(" Lower ") + c.cyan("--fee") + c.gray("."));
|
|
686
876
|
return false;
|
|
687
877
|
}
|
|
878
|
+
const uri = "csd:" + rec.domain.replace(/[^a-z]/gi, "").slice(0, 6) + ":v1:" + rec.payloadHash.slice(2, 14);
|
|
879
|
+
const input = await pickAndShow(addr, fee, 0, fee);
|
|
880
|
+
if (!input)
|
|
881
|
+
return false;
|
|
688
882
|
const tip = await api.tipHeight().catch(() => 0);
|
|
689
|
-
const
|
|
883
|
+
const sp = spinner("csd signs → submit");
|
|
884
|
+
const r = await signAndSubmit(["propose", "--domain", rec.domain, "--payload-hash", rec.payloadHash, "--uri", uri, "--expires-epoch", String(Math.floor(tip / 30) + days * 24), "--fee", String(fee), "--change", addr, "--input", input]);
|
|
690
885
|
sp.stop();
|
|
691
886
|
if (!r.ok) {
|
|
692
887
|
console.log(err(r.error || "failed"));
|
|
@@ -806,4 +1001,233 @@ async function cmdIdentity(a) {
|
|
|
806
1001
|
if (okc)
|
|
807
1002
|
console.log(c.gray("\n save this salt — reveal NEXT epoch (~1h): ") + c.cyan(`cairn identity claim ${handle} --reveal --salt ${salt}`));
|
|
808
1003
|
}
|
|
1004
|
+
// ── CairnX: tokens + .csd names (reads via the CairnX state API; the one write —
|
|
1005
|
+
// token-send — anchors a canonical transfer record as a cairnx:v1 Propose) ──
|
|
1006
|
+
// Display: base units → human, with thousands grouping. decimals===undefined (a ticker the
|
|
1007
|
+
// API doesn't know) falls back to raw base units rather than guessing a scale.
|
|
1008
|
+
const group = (s) => s.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
1009
|
+
function tokAmt(base, decimals) {
|
|
1010
|
+
if (decimals === undefined)
|
|
1011
|
+
return `${group(String(base))} (base units)`;
|
|
1012
|
+
const [i, f] = baseToHuman(String(base), decimals).split(".");
|
|
1013
|
+
return group(i) + (f ? "." + f : "");
|
|
1014
|
+
}
|
|
1015
|
+
// The address a CairnX read targets: positional arg → --address/CAIRN_ADDR/csd wallet.
|
|
1016
|
+
async function resolveCairnxAddr(a, positional) {
|
|
1017
|
+
if (positional !== undefined) {
|
|
1018
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(positional)) {
|
|
1019
|
+
console.log(err(`bad address: ${san(positional)}`));
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
return positional.toLowerCase();
|
|
1023
|
+
}
|
|
1024
|
+
const addr = await resolveAddr(a);
|
|
1025
|
+
if (!addr)
|
|
1026
|
+
console.log(err("no address — pass one (cairn tokens 0x…), or --address, or run ") + c.cyan("cairn setup"));
|
|
1027
|
+
return addr ? addr.toLowerCase() : null;
|
|
1028
|
+
}
|
|
1029
|
+
// ticker → decimals map from /tokens (best-effort: an unreachable list degrades to raw units).
|
|
1030
|
+
async function tokenDecimals() {
|
|
1031
|
+
const list = await cairnxGet("/tokens").catch(() => []);
|
|
1032
|
+
const map = {};
|
|
1033
|
+
for (const t of Array.isArray(list) ? list : [])
|
|
1034
|
+
if (typeof t?.ticker === "string" && Number.isInteger(t?.decimals))
|
|
1035
|
+
map[t.ticker] = t.decimals;
|
|
1036
|
+
return map;
|
|
1037
|
+
}
|
|
1038
|
+
async function cmdTokens(a) {
|
|
1039
|
+
const addr = await resolveCairnxAddr(a, a._[1]);
|
|
1040
|
+
if (!addr)
|
|
1041
|
+
return;
|
|
1042
|
+
const [acct, dec] = await Promise.all([cairnxGet(`/address/${encodeURIComponent(addr)}`), tokenDecimals()]);
|
|
1043
|
+
if (a.flags.json) {
|
|
1044
|
+
console.log(JSON.stringify(acct, null, 2));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
banner();
|
|
1048
|
+
rule(`cairnx · ${addr.slice(0, 10)}… · ${String(activeCairnxBase() ?? "").replace(/^https?:\/\//, "")}`);
|
|
1049
|
+
const bals = Object.entries(acct.balances ?? {});
|
|
1050
|
+
if (!bals.length)
|
|
1051
|
+
console.log(c.gray(" no token balances"));
|
|
1052
|
+
for (const [ticker, b] of bals) {
|
|
1053
|
+
const locked = BigInt(String(b.locked ?? "0"));
|
|
1054
|
+
console.log(` ${c.cyan(pad(san(ticker), 14))} ${c.white(tokAmt(String(b.available ?? "0"), dec[ticker]))}${locked > 0n ? c.gray(` · ${tokAmt(String(b.locked), dec[ticker])} locked in open offers`) : ""}`);
|
|
1055
|
+
}
|
|
1056
|
+
const names = acct.names ?? [];
|
|
1057
|
+
console.log(`\n ${kdim(".csd names")} ${names.length ? names.map((n) => c.green(san(n))).join(c.gray(" · ")) : c.gray("none")}`);
|
|
1058
|
+
}
|
|
1059
|
+
async function cmdTokenInfo(a) {
|
|
1060
|
+
const ticker = String(a._[1] ?? "").toUpperCase();
|
|
1061
|
+
if (!TICKER_RE.test(ticker)) {
|
|
1062
|
+
console.log(warn("usage: ") + c.cyan("cairn token-info <TICKER>"));
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
|
|
1066
|
+
if (!t)
|
|
1067
|
+
return;
|
|
1068
|
+
banner();
|
|
1069
|
+
rule(`token · ${san(t.ticker)}`);
|
|
1070
|
+
const row = (k, v) => console.log(` ${kdim(pad(k, 11))} ${v}`);
|
|
1071
|
+
row("name", c.white(san(t.name ?? t.ticker)));
|
|
1072
|
+
row("decimals", c.white(String(t.decimals)));
|
|
1073
|
+
row("supply", `${c.white(tokAmt(String(t.supply), t.decimals))} ${c.gray("max")}`);
|
|
1074
|
+
const minted = BigInt(String(t.minted ?? "0")), supply = BigInt(String(t.supply ?? "0"));
|
|
1075
|
+
row("minted", `${c.white(tokAmt(String(t.minted), t.decimals))}${supply > 0n ? c.gray(` · ${Number((minted * 10000n) / supply) / 100}% of supply`) : ""}`);
|
|
1076
|
+
row("mint", t.mint === "open" ? c.green("open") + c.gray(` · up to ${tokAmt(String(t.mintLimit ?? "0"), t.decimals)} per mint`) : c.gray("issuer-only"));
|
|
1077
|
+
row("deployer", c.gray(san(t.deployer)));
|
|
1078
|
+
row("deployed", c.gray(`height ${Number(t.height)} · id ${san(String(t.deployId ?? "")).slice(0, 22)}…`));
|
|
1079
|
+
// top-10 holders by total (available + locked) — the same reading the explorer shows
|
|
1080
|
+
const holders = Object.entries(t.holders ?? {}).map(([h, b]) => ({ h, total: BigInt(String(b.available ?? "0")) + BigInt(String(b.locked ?? "0")) }))
|
|
1081
|
+
.filter((x) => x.total > 0n).sort((x, y) => (y.total > x.total ? 1 : y.total < x.total ? -1 : 0));
|
|
1082
|
+
console.log(`\n ${kdim("holders")} ${c.white(String(holders.length))}${holders.length > 10 ? c.gray(" · top 10") : ""}`);
|
|
1083
|
+
const max = holders[0]?.total ?? 1n;
|
|
1084
|
+
for (const { h, total } of holders.slice(0, 10)) {
|
|
1085
|
+
const pct = minted > 0n ? Number((total * 10000n) / minted) / 100 : 0;
|
|
1086
|
+
console.log(` ${bar(Number((total * 1000n) / max), 1000)} ${c.gray(san(h).slice(0, 12) + "…")} ${c.white(tokAmt(total.toString(), t.decimals))} ${c.gray(`· ${pct}%`)}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// y/N gate for the one CairnX write. Non-interactive runs behave like the CLI's other
|
|
1090
|
+
// writes (no prompt — use --dry-run to preview); --yes skips the prompt when interactive.
|
|
1091
|
+
async function confirmSend(q) {
|
|
1092
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1093
|
+
return true;
|
|
1094
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1095
|
+
const ans = await new Promise((res) => rl.question(q, res));
|
|
1096
|
+
rl.close();
|
|
1097
|
+
return /^y(es)?$/i.test(ans.trim());
|
|
1098
|
+
}
|
|
1099
|
+
async function cmdTokenSend(a) {
|
|
1100
|
+
const ticker = String(a.flags.ticker ?? "").toUpperCase();
|
|
1101
|
+
const to = String(a.flags.to ?? "");
|
|
1102
|
+
const amountStr = String(a.flags.amount ?? "");
|
|
1103
|
+
if (!ticker || !a.flags.to || a.flags.amount === undefined) {
|
|
1104
|
+
console.log(warn("usage: ") + c.cyan("cairn token-send --ticker CAIRN --to 0x…40 --amount 1.5 [--dry-run] [--yes]"));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (!TICKER_RE.test(ticker)) {
|
|
1108
|
+
console.log(err(`bad ticker: ${san(ticker)}`));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(to)) {
|
|
1112
|
+
console.log(err(`bad recipient: ${san(to)}`));
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
// decimals are AUTHORITATIVE from the token's deploy record — never guessed
|
|
1116
|
+
const t = await cairnxGet(`/token/${encodeURIComponent(ticker)}`).catch((e) => { console.log(e.status === 404 ? err(`unknown token ${ticker}`) : err(e.message)); return null; });
|
|
1117
|
+
if (!t)
|
|
1118
|
+
return;
|
|
1119
|
+
let amount;
|
|
1120
|
+
try {
|
|
1121
|
+
amount = humanToBase(amountStr, Number(t.decimals));
|
|
1122
|
+
}
|
|
1123
|
+
catch (e) {
|
|
1124
|
+
console.log(err(e.message));
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (amount <= 0n) {
|
|
1128
|
+
console.log(err("amount must be > 0"));
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const from = await resolveAddr(a);
|
|
1132
|
+
if (!from) {
|
|
1133
|
+
console.log(err("could not resolve your address — pass --address or run ") + c.cyan("cairn setup"));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
// balance check against the same state the resolver will apply the transfer to
|
|
1137
|
+
const acct = await cairnxGet(`/address/${encodeURIComponent(from.toLowerCase())}`);
|
|
1138
|
+
const avail = BigInt(String(acct.balances?.[ticker]?.available ?? "0"));
|
|
1139
|
+
if (avail < amount) {
|
|
1140
|
+
console.log(err(`insufficient ${ticker}: balance ${tokAmt(avail.toString(), t.decimals)}, tried to send ${tokAmt(amount.toString(), t.decimals)}${BigInt(String(acct.balances?.[ticker]?.locked ?? "0")) > 0n ? ` (${tokAmt(String(acct.balances[ticker].locked), t.decimals)} more is locked in open offers)` : ""}`));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
let built;
|
|
1144
|
+
try {
|
|
1145
|
+
built = buildTransferRecord({ ticker, to, amount });
|
|
1146
|
+
}
|
|
1147
|
+
catch (e) {
|
|
1148
|
+
console.log(err(e.message));
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
// clear-print exactly what will be anchored before anything signs
|
|
1152
|
+
console.log(`${kdim("send")} ${c.white(tokAmt(amount.toString(), t.decimals))} ${c.cyan(ticker)} ${c.gray(`(${amount} base units · ${t.decimals} decimals)`)}`);
|
|
1153
|
+
console.log(`${kdim("to")} ${c.cyan(to.toLowerCase())}`);
|
|
1154
|
+
console.log(`${kdim("from")} ${c.cyan(from.toLowerCase())} ${c.gray(`· ${ticker} balance ${tokAmt(avail.toString(), t.decimals)}`)}`);
|
|
1155
|
+
console.log(`${kdim("record")} ${c.white(built.uri)}`);
|
|
1156
|
+
console.log(`${kdim("hash")} ${c.magenta(built.payloadHash)}`);
|
|
1157
|
+
console.log(`${kdim("anchor")} ${c.gray(`Propose on ${CAIRNX_DOMAIN} — costs `)}${c.white(csdToCoins(CAIRNX_ANCHOR_FEE) + " CSD")}${c.gray(" (the chain fee; the tokens themselves move by record)")}`);
|
|
1158
|
+
if (a.flags["dry-run"]) {
|
|
1159
|
+
console.log(c.gray("\n[dry-run] not signed or submitted"));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (!(await requireCsd()))
|
|
1163
|
+
return;
|
|
1164
|
+
if (!a.flags.yes && !(await confirmSend(`\nsend ${tokAmt(amount.toString(), t.decimals)} ${ticker} for ${csdToCoins(CAIRNX_ANCHOR_FEE)} CSD? [y/N] `))) {
|
|
1165
|
+
console.log(c.gray("aborted"));
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (!(await freshTip(a)))
|
|
1169
|
+
return;
|
|
1170
|
+
const input = await pickAndShow(from, CAIRNX_ANCHOR_FEE, 0, CAIRNX_ANCHOR_FEE);
|
|
1171
|
+
if (!input)
|
|
1172
|
+
return;
|
|
1173
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
1174
|
+
const sp3 = spinner("csd signs → submit");
|
|
1175
|
+
const r = await signAndSubmit(["propose", "--domain", CAIRNX_DOMAIN, "--payload-hash", built.payloadHash, "--uri", built.uri, "--expires-epoch", String(Math.floor(tip / 30) + 24), "--fee", String(CAIRNX_ANCHOR_FEE), "--change", from, "--input", input]);
|
|
1176
|
+
sp3.stop();
|
|
1177
|
+
console.log(r.ok ? ok(`transfer anchored ${c.cyan(r.txid)}`) + c.gray(" (tokens move when it mines — check `cairn tokens`)") : err(r.error || "failed"));
|
|
1178
|
+
}
|
|
1179
|
+
async function cmdNames(a) {
|
|
1180
|
+
const addr = await resolveCairnxAddr(a, a._[1]);
|
|
1181
|
+
if (!addr)
|
|
1182
|
+
return;
|
|
1183
|
+
const acct = await cairnxGet(`/address/${encodeURIComponent(addr)}`);
|
|
1184
|
+
const names = acct.names ?? [];
|
|
1185
|
+
if (a.flags.json) {
|
|
1186
|
+
console.log(JSON.stringify(names, null, 2));
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
banner();
|
|
1190
|
+
rule(`.csd names · ${addr.slice(0, 10)}…`);
|
|
1191
|
+
if (!names.length) {
|
|
1192
|
+
console.log(c.gray(" no names owned — claim one on " + (activeCairnxBase()?.includes("127.0.0.1") ? "the /trade marketplace" : "https://cairn-substrate.com/trade")));
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
for (const n of names)
|
|
1196
|
+
console.log(` ${c.green(san(n))}`);
|
|
1197
|
+
console.log(c.gray(`\n ${names.length} name${names.length === 1 ? "" : "s"} · cairn name <name> for detail`));
|
|
1198
|
+
}
|
|
1199
|
+
async function cmdName(a) {
|
|
1200
|
+
const n = String(a._[1] ?? "").toLowerCase();
|
|
1201
|
+
if (!n || !NAME_RE.test(n)) {
|
|
1202
|
+
console.log(warn("usage: ") + c.cyan("cairn name <name>") + c.gray(" (lowercase, 3–32 chars [a-z0-9-])"));
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const r = await cairnxGet(`/name/${encodeURIComponent(n)}`).catch((e) => { console.log(e.status === 404 ? err(`unregistered name "${n}"`) + c.gray(" — claimable via commit-reveal on /trade") : err(e.message)); return null; });
|
|
1206
|
+
if (!r)
|
|
1207
|
+
return;
|
|
1208
|
+
banner();
|
|
1209
|
+
rule(`name · ${san(r.name ?? n)}`);
|
|
1210
|
+
const row = (k, v) => console.log(` ${kdim(pad(k, 10))} ${v}`);
|
|
1211
|
+
row("owner", c.cyan(san(r.owner)));
|
|
1212
|
+
row("claimed", c.gray(`height ${Number(r.height)}${Number(r.effectiveHeight) !== Number(r.height) ? ` · effective ${Number(r.effectiveHeight)}` : ""} · claim ${san(String(r.claimId ?? "")).slice(0, 22)}…`));
|
|
1213
|
+
// lease: the API reports paidThroughEpoch once the v1.5 lease model is live; the ETA is
|
|
1214
|
+
// computed from the chain tip (1 epoch = 30 blocks · 120s target ⇒ ~1h per epoch).
|
|
1215
|
+
if (r.paidThroughEpoch != null) {
|
|
1216
|
+
const tip = await api.tipHeight().catch(() => 0);
|
|
1217
|
+
const blocksLeft = (Number(r.paidThroughEpoch) + 1) * 30 - tip;
|
|
1218
|
+
const eta = !tip ? "" : blocksLeft <= 0 ? " · " + "EXPIRED" : ` · expires in ~${blocksLeft >= 720 ? (blocksLeft / 720).toFixed(1) + " days" : Math.max(1, Math.round(blocksLeft / 30)) + "h"}`;
|
|
1219
|
+
row("lease", `${c.white("paid through epoch " + Number(r.paidThroughEpoch))}${blocksLeft <= 0 && tip ? " " + err("EXPIRED") : c.gray(eta)}`);
|
|
1220
|
+
}
|
|
1221
|
+
else
|
|
1222
|
+
row("lease", c.gray("— (no lease data from this API)"));
|
|
1223
|
+
if (r.locked)
|
|
1224
|
+
row("locked", c.gray("yes — a sale/transfer is in flight"));
|
|
1225
|
+
if (r.offer) {
|
|
1226
|
+
const want = r.offer.want ?? {};
|
|
1227
|
+
const price = want.value !== undefined ? `${csdToCoins(Number(want.value))} CSD` : `${san(String(want.amount))} ${san(String(want.ticker))}`;
|
|
1228
|
+
row("offer", `${c.green("FOR SALE")} ${c.white("· " + price)} ${c.gray(`· seller ${san(String(r.offer.seller ?? r.owner)).slice(0, 12)}… · offer ${san(String(r.offer.id ?? "")).slice(0, 18)}…${r.offer.taker ? " · reserved for a taker" : ""}`)}`);
|
|
1229
|
+
}
|
|
1230
|
+
else
|
|
1231
|
+
row("offer", c.gray("no open offer"));
|
|
1232
|
+
}
|
|
809
1233
|
main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
|
package/dist/lib/api.js
CHANGED
|
@@ -64,11 +64,73 @@ export async function pickInput(addr, minValue) {
|
|
|
64
64
|
const x = cand[0] ?? (j.utxos ?? []).find(ok);
|
|
65
65
|
return x ? { input: `${x.txid}:${Number(x.vout)}:${Number(x.value)}`, value: Number(x.value) } : null;
|
|
66
66
|
}
|
|
67
|
+
// UTXO-VALUE-1 cross-source check. cairn-cli has no codec to recompute an input's REAL value, so
|
|
68
|
+
// a hostile/MITM'd CAIRN_API that UNDER-reports a UTXO's value would make `csd` compute too-small
|
|
69
|
+
// a change and silently burn the difference as fee. If the user has configured an INDEPENDENT node
|
|
70
|
+
// (CAIRN_RPC, a different host than the proxy), confirm the picked outpoint's value against its
|
|
71
|
+
// authoritative UTXO set and refuse on a mismatch. Returns:
|
|
72
|
+
// { checked:false } — no independent RPC configured / unreachable (caller warns + relies on the fee cap)
|
|
73
|
+
// { checked:true, ok:true, value } — independent node agrees (display the verified value)
|
|
74
|
+
// { checked:true, ok:false, value } — DISAGREEMENT (hostile proxy) → caller refuses
|
|
75
|
+
// { checked:true, ok:false, missing:true } — outpoint absent on the independent node → suspicious, caller refuses
|
|
76
|
+
export async function verifyInputValue(addr, txid, vout, claimedValue) {
|
|
77
|
+
if (!CAIRN_RPC)
|
|
78
|
+
return { checked: false };
|
|
79
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(addr) || !HEX64.test(txid))
|
|
80
|
+
return { checked: false };
|
|
81
|
+
let j;
|
|
82
|
+
try {
|
|
83
|
+
const r = await fetch(`${CAIRN_RPC}/utxos/${encodeURIComponent(addr)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
|
|
84
|
+
if (!r.ok)
|
|
85
|
+
return { checked: false };
|
|
86
|
+
j = await r.json();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { checked: false };
|
|
90
|
+
}
|
|
91
|
+
const list = j.utxos ?? j.outputs ?? [];
|
|
92
|
+
const norm = (t) => String(t).toLowerCase().replace(/^0x/, "");
|
|
93
|
+
const hit = list.find((u) => norm(u.txid) === norm(txid) && Number(u.vout) === vout);
|
|
94
|
+
if (!hit)
|
|
95
|
+
return { checked: true, ok: false, missing: true };
|
|
96
|
+
const v = Number(hit.value);
|
|
97
|
+
return { checked: true, ok: Number.isSafeInteger(v) && v === claimedValue, value: v };
|
|
98
|
+
}
|
|
67
99
|
export async function confirmedBalance(addr) {
|
|
68
100
|
const j = await req(`/api/rpc/utxos-all/${encodeURIComponent(addr)}`);
|
|
69
101
|
return { balance: Number(j.confirmed_balance ?? 0), utxos: (j.utxos ?? []).length };
|
|
70
102
|
}
|
|
71
103
|
export async function tipHeight() { return Number((await req("/api/rpc/tip")).height ?? 0); }
|
|
104
|
+
// Chain-view freshness of the proxy's backend node: { stale, secondsSinceAdvance, staleSecsThreshold, height }.
|
|
105
|
+
// Used to refuse building a tx against a frozen/forked tip. Throws if the surface is unreachable
|
|
106
|
+
// (caller treats that as a soft warning, never a hard block — matches the 'cannot reach' UX).
|
|
107
|
+
export async function rpcStatus() {
|
|
108
|
+
return req("/api/rpc/status");
|
|
109
|
+
}
|
|
110
|
+
// Look up a tx by id on the node. Mined txs resolve to { ok:true, txid, block_hash, height };
|
|
111
|
+
// an unknown id resolves to { ok:false, err:"not found" }. (The node's /tx indexes MINED txs;
|
|
112
|
+
// a mempool-only tx returns not-found, so a not-found is "no proof yet", never "rejected".)
|
|
113
|
+
export async function txStatus(txid) {
|
|
114
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txid))
|
|
115
|
+
return { ok: false }; // never splice an unshaped id into the URL
|
|
116
|
+
const j = await req(`/api/rpc/tx/${encodeURIComponent(txid)}`).catch(() => null);
|
|
117
|
+
return j ?? { ok: false };
|
|
118
|
+
}
|
|
119
|
+
// Has the node confirmed OUR exact txid (mined into the chain)? Polls a few times because a
|
|
120
|
+
// freshly-submitted tx is mempool-only until a block lands. Returns true ONLY on an exact
|
|
121
|
+
// txid match the node reports as known — evidence the user's own tx (not a different conflict)
|
|
122
|
+
// is on-chain. (Non-fatal on an unreachable node: simply never confirms.) The poll budget is
|
|
123
|
+
// overridable via CAIRN_CONFIRM_ATTEMPTS / CAIRN_CONFIRM_INTERVAL_MS (used by the test suite).
|
|
124
|
+
export async function confirmTxMined(txid, attempts = Number(process.env.CAIRN_CONFIRM_ATTEMPTS) || 4, intervalMs = Number(process.env.CAIRN_CONFIRM_INTERVAL_MS) || 7000) {
|
|
125
|
+
for (let i = 0; i < attempts; i++) {
|
|
126
|
+
const s = await txStatus(txid).catch(() => null);
|
|
127
|
+
if (s?.ok && typeof s.txid === "string" && s.txid.toLowerCase() === txid.toLowerCase())
|
|
128
|
+
return true;
|
|
129
|
+
if (i < attempts - 1)
|
|
130
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
72
134
|
// Submit a node-JSON tx through the proxy (for `csd spend`, which builds+signs but doesn't
|
|
73
135
|
// reliably submit to a proxy URL). Returns the node's response.
|
|
74
136
|
export async function submitTx(txNodeJson) { return req("/api/rpc/tx/submit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tx: txNodeJson }) }); }
|
|
@@ -99,6 +161,28 @@ export async function registerRawContent(bytes, txid, attempts = 20) {
|
|
|
99
161
|
}
|
|
100
162
|
return false;
|
|
101
163
|
}
|
|
164
|
+
// Independent confirmation (audit H-7): is this txid mined according to CAIRN_RPC, a node on a
|
|
165
|
+
// DIFFERENT host than the proxy? `confirmTxMined` above polls the proxy's own /api/rpc/tx, which a
|
|
166
|
+
// hostile/MITM'd CAIRN_API can string-echo to forge a "mined" reply. This cross-checks an
|
|
167
|
+
// independent node. Returns true (independent node confirms OUR exact txid mined), false (it does
|
|
168
|
+
// not), or null (no independent RPC configured / unreachable → "unverifiable", never a hard claim).
|
|
169
|
+
export async function chainTxMined(txid) {
|
|
170
|
+
if (!CAIRN_RPC)
|
|
171
|
+
return null;
|
|
172
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txid))
|
|
173
|
+
return null;
|
|
174
|
+
try {
|
|
175
|
+
const r = await fetch(`${CAIRN_RPC}/tx/${encodeURIComponent(txid)}`, { redirect: "error", signal: AbortSignal.timeout(6000) });
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return null;
|
|
178
|
+
const j = await r.json();
|
|
179
|
+
const t = String(j.txid ?? j.tx?.txid ?? "").toLowerCase().replace(/^0x/, "");
|
|
180
|
+
return j.ok === true && t === txid.toLowerCase().replace(/^0x/, "");
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
102
186
|
// optional: query a raw csd node RPC (for trustless verify)
|
|
103
187
|
export async function chainProposal(id) {
|
|
104
188
|
if (!CAIRN_RPC)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// CairnX — the token + .csd-name layer that lives entirely in CSD Propose records on the
|
|
2
|
+
// `cairnx:v1` domain. This file is the CLI's complete CairnX surface:
|
|
3
|
+
// • a READ client for the CairnX state API (resolution order: $CAIRNX_API → the local
|
|
4
|
+
// service → the public gateway, GET-only) with automatic fallback on network failure
|
|
5
|
+
// • the canonical TRANSFER record builder — hand-rolled on the repo's own
|
|
6
|
+
// stableStringify/sha256 (byte-exact-tested against cairnx-core's ground truth) so the
|
|
7
|
+
// CLI takes NO dependency on the private cairnx repo.
|
|
8
|
+
// TODO: swap to @inversealtruism/cairnx-core once it is published to npm.
|
|
9
|
+
// • exact human↔base-unit amount math as STRING/BigInt arithmetic — floats never touch
|
|
10
|
+
// token amounts (no "1.1 * 1e8 = 110000000.00000001" class of bug, no silent truncation).
|
|
11
|
+
import { stableStringify, sha256Hex } from "./item.js";
|
|
12
|
+
export const CAIRNX_DOMAIN = "cairnx:v1";
|
|
13
|
+
export const CAIRNX_ANCHOR_FEE = 25_000_000; // 0.25 CSD — the consensus min Propose fee that anchors a record
|
|
14
|
+
export const MAX_AMOUNT = (1n << 96n) - 1n; // CONVENTION.md: token amounts are ≤ 96-bit
|
|
15
|
+
const MAX_RECORD_BYTES = 512; // consensus MAX_URI_BYTES — the record must fit in `uri`
|
|
16
|
+
// Validation shapes (mirrors CONVENTION.md §4 — kept in sync by the byte-exact fixtures).
|
|
17
|
+
export const TICKER_RE = /^[A-Z][A-Z0-9]{2,11}$/;
|
|
18
|
+
export const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; // a claimable .csd name
|
|
19
|
+
const ADDR_RE = /^0x[0-9a-f]{40}$/; // records carry LOWERCASE addresses
|
|
20
|
+
export function buildTransferRecord(p) {
|
|
21
|
+
const to = String(p.to).toLowerCase();
|
|
22
|
+
if (!TICKER_RE.test(p.ticker))
|
|
23
|
+
throw new Error(`bad ticker "${p.ticker}" — want 3–12 chars [A-Z0-9], starting with a letter`);
|
|
24
|
+
if (!ADDR_RE.test(to))
|
|
25
|
+
throw new Error(`bad recipient "${p.to}" — want a 0x… 20-byte address`);
|
|
26
|
+
if (p.amount <= 0n)
|
|
27
|
+
throw new Error("amount must be > 0");
|
|
28
|
+
if (p.amount > MAX_AMOUNT)
|
|
29
|
+
throw new Error("amount exceeds the 96-bit token-amount limit");
|
|
30
|
+
const record = { amount: p.amount.toString(), t: "transfer", ticker: p.ticker, to, v: 1 };
|
|
31
|
+
const uri = stableStringify(record);
|
|
32
|
+
if (Buffer.byteLength(uri, "utf8") > MAX_RECORD_BYTES)
|
|
33
|
+
throw new Error("record exceeds 512 bytes"); // unreachable for a transfer, kept as a guard
|
|
34
|
+
return { record, uri, payloadHash: sha256Hex(uri) };
|
|
35
|
+
}
|
|
36
|
+
// ── exact amount math (strings + BigInt only) ─────────────────────────────────────────────
|
|
37
|
+
// "1.5" with decimals 8 → 150000000n. Fails LOUDLY instead of truncating: "1.5" on a
|
|
38
|
+
// 0-decimals token is an error, not 1. Trailing fractional zeros are exact and accepted
|
|
39
|
+
// ("1.50" @ 1 decimal → 15n).
|
|
40
|
+
export function humanToBase(human, decimals) {
|
|
41
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8)
|
|
42
|
+
throw new Error(`bad token decimals ${decimals}`);
|
|
43
|
+
const s = String(human).trim();
|
|
44
|
+
if (s.length === 0 || s.length > 40)
|
|
45
|
+
throw new Error(`bad amount "${human}"`); // MAX_AMOUNT is 29 digits; cap before BigInt
|
|
46
|
+
const m = /^([0-9]+)?(?:\.([0-9]+))?$/.exec(s);
|
|
47
|
+
if (!m || (m[1] === undefined && m[2] === undefined))
|
|
48
|
+
throw new Error(`bad amount "${human}" — use plain digits like 1 or 1.25`);
|
|
49
|
+
const frac = (m[2] ?? "").replace(/0+$/, ""); // trailing zeros are exactly representable — drop them
|
|
50
|
+
if (frac.length > decimals)
|
|
51
|
+
throw new Error(`amount "${human}" has more decimal places than this token allows (${decimals})`);
|
|
52
|
+
const base = BigInt(m[1] ?? "0") * 10n ** BigInt(decimals) + BigInt(frac.padEnd(decimals, "0") || "0");
|
|
53
|
+
if (base > MAX_AMOUNT)
|
|
54
|
+
throw new Error(`amount "${human}" exceeds the 96-bit token-amount limit`);
|
|
55
|
+
return base;
|
|
56
|
+
}
|
|
57
|
+
// 150000000n @ 8 → "1.5" (exact inverse of humanToBase; no grouping — display adds that).
|
|
58
|
+
export function baseToHuman(base, decimals) {
|
|
59
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 8)
|
|
60
|
+
throw new Error(`bad token decimals ${decimals}`);
|
|
61
|
+
const v = typeof base === "bigint" ? base : BigInt(String(base));
|
|
62
|
+
if (v < 0n)
|
|
63
|
+
throw new Error("negative token amount");
|
|
64
|
+
const s = v.toString().padStart(decimals + 1, "0");
|
|
65
|
+
const int = decimals ? s.slice(0, -decimals) : s;
|
|
66
|
+
const fr = decimals ? s.slice(-decimals).replace(/0+$/, "") : "";
|
|
67
|
+
return fr ? `${int}.${fr}` : int;
|
|
68
|
+
}
|
|
69
|
+
// ── read API client (GET-only; never sees a key) ──────────────────────────────────────────
|
|
70
|
+
// Base resolution order: $CAIRNX_API (explicit choice — used exclusively), else the local
|
|
71
|
+
// CairnX service, else the public gateway. Read at call time (not import) so tests can vary it.
|
|
72
|
+
export function defaultBases() {
|
|
73
|
+
const env = (process.env.CAIRNX_API ?? "").trim().replace(/\/+$/, "");
|
|
74
|
+
if (env)
|
|
75
|
+
return [env];
|
|
76
|
+
return ["http://127.0.0.1:8794/cairnx", "https://cairn-substrate.com/trade/api/cairnx"];
|
|
77
|
+
}
|
|
78
|
+
// Remember the first base that answered (per base-list) so one command's N requests don't
|
|
79
|
+
// re-probe a dead localhost N times.
|
|
80
|
+
let active = null;
|
|
81
|
+
export const activeCairnxBase = () => active?.base ?? null;
|
|
82
|
+
export async function cairnxGet(path, bases = defaultBases()) {
|
|
83
|
+
const key = bases.join(" ");
|
|
84
|
+
const order = active?.key === key && bases.includes(active.base)
|
|
85
|
+
? [active.base, ...bases.filter((b) => b !== active.base)] : bases;
|
|
86
|
+
let lastErr = null;
|
|
87
|
+
for (const base of order) {
|
|
88
|
+
let res;
|
|
89
|
+
try {
|
|
90
|
+
res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(8000) }); // GET-only, no credentials — redirects are safe to follow
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
lastErr = e;
|
|
94
|
+
continue;
|
|
95
|
+
} // network failure → try the next base
|
|
96
|
+
// A reachable base's answer is authoritative — an HTTP 404 here is a real "not found",
|
|
97
|
+
// never a reason to fall through to a different (possibly divergent) view of state.
|
|
98
|
+
active = { key, base };
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const j = await res.json().catch(() => null);
|
|
101
|
+
const e = new Error(j?.error ? String(j.error) : `CairnX API ${path} → HTTP ${res.status}`);
|
|
102
|
+
e.status = res.status;
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
return res.json();
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`cannot reach a CairnX API (tried ${bases.join(", ")}) — set CAIRNX_API (${lastErr?.message ?? lastErr})`);
|
|
108
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cairn-cli configuration (env-overridable).
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
5
5
|
export const CAIRN_API = (process.env.CAIRN_API ?? "https://cairn-substrate.com").replace(/\/+$/, "");
|
|
6
6
|
export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; // optional operator write token (falls back to local csd wallet)
|
|
7
7
|
export const CAIRN_RPC = process.env.CAIRN_RPC ?? ""; // optional: a csd node RPC, enables trustless verify
|
|
@@ -13,7 +13,9 @@ export const MIN_FEE_ATTEST = 5_000_000; // 0.05 CSD
|
|
|
13
13
|
export function csdToCoins(base) {
|
|
14
14
|
return (base / CSD_PER_COIN).toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
15
15
|
}
|
|
16
|
-
// small local config: caches ONLY the user's public address (never a key).
|
|
16
|
+
// small local config: caches ONLY the user's public address (never a key). Written with
|
|
17
|
+
// owner-only perms (dir 0700, file 0600) so another local user can't poison the cached address
|
|
18
|
+
// to redirect `cairn address` output (F13/R18 — the wallet is still the source of truth).
|
|
17
19
|
const CFG_PATH = process.env.CAIRN_CLI_CONFIG ?? join(homedir(), ".config", "cairn-cli", "config.json");
|
|
18
20
|
export function loadLocalConfig() { try {
|
|
19
21
|
return JSON.parse(readFileSync(CFG_PATH, "utf8"));
|
|
@@ -23,8 +25,9 @@ catch {
|
|
|
23
25
|
} }
|
|
24
26
|
export function saveLocalConfig(patch) {
|
|
25
27
|
try {
|
|
26
|
-
mkdirSync(dirname(CFG_PATH), { recursive: true });
|
|
27
|
-
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n");
|
|
28
|
+
mkdirSync(dirname(CFG_PATH), { recursive: true, mode: 0o700 });
|
|
29
|
+
writeFileSync(CFG_PATH, JSON.stringify({ ...loadLocalConfig(), ...patch }, null, 2) + "\n", { mode: 0o600 });
|
|
30
|
+
chmodSync(CFG_PATH, 0o600); // tighten even if the file pre-existed (writeFileSync mode is create-only)
|
|
28
31
|
}
|
|
29
32
|
catch { /* best-effort */ }
|
|
30
33
|
}
|
package/dist/lib/csd.js
CHANGED
|
@@ -1,19 +1,130 @@
|
|
|
1
1
|
// Thin wrapper around the user's INSTALLED `csd` CLI. cairn-cli never holds a private
|
|
2
2
|
// key: for any write it shells out to `csd`, which signs with the user's own csd wallet
|
|
3
3
|
// config key (CSD_SIG_V1). We only orchestrate — supply the input (fetched from the Cairn
|
|
4
|
-
// proxy, so no local node is required) + the Cairn payload — and `csd` does the signing
|
|
5
|
-
//
|
|
4
|
+
// proxy, so no local node is required) + the Cairn payload — and `csd` does the signing.
|
|
5
|
+
//
|
|
6
|
+
// SECURITY (audit H-1): which `csd` binary signs is a trust decision. A bare `csd` resolved
|
|
7
|
+
// by $PATH order lets a malicious binary planted earlier on PATH (dev env, npm postinstall,
|
|
8
|
+
// shared host) capture the wallet key the instant cairn shells out. So:
|
|
9
|
+
// • CAIRN_CSD, if set, is the user's EXPLICIT choice — honored, but MUST be absolute.
|
|
10
|
+
// • Otherwise we resolve `csd` from a list of CANONICAL absolute locations FIRST (not PATH
|
|
11
|
+
// order), fall back to a PATH search only if none exists, REFUSE a binary in a world-
|
|
12
|
+
// writable / transient / cwd location, and SURFACE the resolved absolute path so the user
|
|
13
|
+
// can see which binary signs.
|
|
6
14
|
import { execFile } from "node:child_process";
|
|
7
15
|
import { promisify } from "node:util";
|
|
16
|
+
import { statSync, realpathSync } from "node:fs";
|
|
17
|
+
import { isAbsolute, join, dirname, delimiter } from "node:path";
|
|
8
18
|
const pexec = promisify(execFile);
|
|
19
|
+
// Back-compat hint (the configured name/path); real resolution is resolveCsdBin().
|
|
9
20
|
export const CSD_BIN = process.env.CAIRN_CSD || "csd";
|
|
21
|
+
// Canonical absolute install locations, checked in order BEFORE any $PATH search so a binary
|
|
22
|
+
// planted earlier on PATH cannot win.
|
|
23
|
+
const HOME = process.env.HOME || "";
|
|
24
|
+
const CANONICAL = [
|
|
25
|
+
"/usr/local/bin/csd", "/usr/bin/csd", "/opt/substrate_miner/bin/csd",
|
|
26
|
+
...(HOME ? [join(HOME, ".cargo/bin/csd"), join(HOME, ".local/bin/csd")] : []),
|
|
27
|
+
];
|
|
28
|
+
// Is this resolved path attacker-plantable? Returns a human reason, or null if it looks safe.
|
|
29
|
+
function insecureReason(abs) {
|
|
30
|
+
let st;
|
|
31
|
+
try {
|
|
32
|
+
st = statSync(abs);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return "does not exist";
|
|
36
|
+
}
|
|
37
|
+
if (!st.isFile())
|
|
38
|
+
return "is not a regular file";
|
|
39
|
+
if (st.mode & 0o002)
|
|
40
|
+
return "is world-writable";
|
|
41
|
+
const dir = dirname(abs);
|
|
42
|
+
try {
|
|
43
|
+
const dst = statSync(dir);
|
|
44
|
+
if ((dst.mode & 0o002) && !(dst.mode & 0o1000))
|
|
45
|
+
return `is in a world-writable directory (${dir})`;
|
|
46
|
+
}
|
|
47
|
+
catch { /* dir unreadable — fall through */ }
|
|
48
|
+
if (dir === process.cwd())
|
|
49
|
+
return "is in the current working directory (a cwd hijack vector)";
|
|
50
|
+
if (dir.startsWith("/tmp") || dir.startsWith("/var/tmp") || dir.startsWith("/dev/shm"))
|
|
51
|
+
return `is in a transient directory (${dir})`;
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function pathSearch(name) {
|
|
55
|
+
for (const d of (process.env.PATH || "").split(delimiter).filter(Boolean)) {
|
|
56
|
+
const p = join(d, name);
|
|
57
|
+
try {
|
|
58
|
+
const st = statSync(p);
|
|
59
|
+
if (st.isFile() && (st.mode & 0o111))
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
catch { /* keep looking */ }
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
let _resolved = null;
|
|
67
|
+
// Resolve (and cache) the trusted `csd` path. See header for the policy.
|
|
68
|
+
export function resolveCsdBin() {
|
|
69
|
+
if (_resolved)
|
|
70
|
+
return _resolved;
|
|
71
|
+
const env = process.env.CAIRN_CSD;
|
|
72
|
+
if (env) {
|
|
73
|
+
// Explicit user choice. Require absolute (a relative explicit path is still PATH/cwd-hijackable);
|
|
74
|
+
// honor it even in an unusual location (the user told us exactly which binary to trust), but warn
|
|
75
|
+
// if world-writable. We do NOT refuse here — the test harness and power users point CAIRN_CSD at
|
|
76
|
+
// bespoke absolute paths deliberately.
|
|
77
|
+
if (!isAbsolute(env))
|
|
78
|
+
return (_resolved = { path: null, explicit: true, error: `CAIRN_CSD must be an ABSOLUTE path (got "${env}") — a relative csd binary is a key-theft risk` });
|
|
79
|
+
let abs = env;
|
|
80
|
+
try {
|
|
81
|
+
abs = realpathSync(env);
|
|
82
|
+
}
|
|
83
|
+
catch { /* may legitimately not exist yet — surfaced at run/available */ }
|
|
84
|
+
let warning;
|
|
85
|
+
try {
|
|
86
|
+
if (statSync(abs).mode & 0o002)
|
|
87
|
+
warning = `CAIRN_CSD (${abs}) is world-writable — anyone could replace it with a key-stealing binary`;
|
|
88
|
+
}
|
|
89
|
+
catch { /* */ }
|
|
90
|
+
return (_resolved = { path: abs, explicit: true, warning });
|
|
91
|
+
}
|
|
92
|
+
// Implicit resolution: canonical locations first (defeats PATH-order hijack), then PATH.
|
|
93
|
+
for (const cand of CANONICAL) {
|
|
94
|
+
try {
|
|
95
|
+
const st = statSync(cand);
|
|
96
|
+
if (st.isFile() && (st.mode & 0o111) && !insecureReason(cand))
|
|
97
|
+
return (_resolved = { path: realpathSync(cand), explicit: false });
|
|
98
|
+
}
|
|
99
|
+
catch { /* next */ }
|
|
100
|
+
}
|
|
101
|
+
const found = pathSearch("csd");
|
|
102
|
+
if (!found)
|
|
103
|
+
return (_resolved = { path: null, explicit: false, error: "`csd` not found in any trusted location or on PATH — install it, or set CAIRN_CSD to its absolute path" });
|
|
104
|
+
let abs;
|
|
105
|
+
try {
|
|
106
|
+
abs = realpathSync(found);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return (_resolved = { path: null, explicit: false, error: `csd at ${found} could not be resolved` });
|
|
110
|
+
}
|
|
111
|
+
const bad = insecureReason(abs);
|
|
112
|
+
if (bad)
|
|
113
|
+
return (_resolved = { path: null, explicit: false, error: `refusing to run csd at ${abs} — it ${bad}. A malicious csd there could steal your wallet key. Move it to a trusted location (e.g. /usr/local/bin) or set CAIRN_CSD to a trusted absolute path.` });
|
|
114
|
+
return (_resolved = { path: abs, explicit: false });
|
|
115
|
+
}
|
|
116
|
+
// For display (the `setup` command shows the user exactly which binary will sign).
|
|
117
|
+
export function csdPathInfo() { return resolveCsdBin(); }
|
|
10
118
|
function extractTxid(s) {
|
|
11
119
|
const m = s.match(/txid["':\s]+0x([0-9a-fA-F]{64})/) || s.match(/0x[0-9a-fA-F]{64}/);
|
|
12
120
|
return m ? (m[1] ? "0x" + m[1] : m[0]) : undefined;
|
|
13
121
|
}
|
|
14
122
|
export async function run(args) {
|
|
123
|
+
const bin = resolveCsdBin();
|
|
124
|
+
if (!bin.path)
|
|
125
|
+
return { ok: false, stdout: "", stderr: bin.error || "csd unavailable" };
|
|
15
126
|
try {
|
|
16
|
-
const { stdout, stderr } = await pexec(
|
|
127
|
+
const { stdout, stderr } = await pexec(bin.path, args, { timeout: 30000 });
|
|
17
128
|
return { ok: true, stdout, stderr, txid: extractTxid(stdout + stderr) };
|
|
18
129
|
}
|
|
19
130
|
catch (e) {
|
|
@@ -21,9 +132,10 @@ export async function run(args) {
|
|
|
21
132
|
return { ok: false, stdout: e.stdout ?? "", stderr: e.stderr ?? String(e.message ?? e), txid: extractTxid(out) };
|
|
22
133
|
}
|
|
23
134
|
}
|
|
24
|
-
// Is `csd` installed + runnable?
|
|
25
|
-
export async function available() {
|
|
26
|
-
|
|
135
|
+
// Is `csd` installed + runnable (at the trusted path)?
|
|
136
|
+
export async function available() { const bin = resolveCsdBin(); if (!bin.path)
|
|
137
|
+
return false; try {
|
|
138
|
+
await pexec(bin.path, ["--version"], { timeout: 5000 });
|
|
27
139
|
return true;
|
|
28
140
|
}
|
|
29
141
|
catch {
|
|
@@ -37,5 +149,10 @@ export async function walletConfig() { const r = await run(["wallet", "config"])
|
|
|
37
149
|
catch {
|
|
38
150
|
return null;
|
|
39
151
|
} }
|
|
40
|
-
// Derive the public addr20 from a privkey via `csd wallet recover
|
|
152
|
+
// Derive the public addr20 from a privkey via `csd wallet recover`.
|
|
153
|
+
// SECURITY (audit H-2): this puts --privkey on the csd argv, briefly readable via /proc on a
|
|
154
|
+
// shared host. It is a LAST resort — resolveAddr() only calls it when the wallet has no
|
|
155
|
+
// default_change_addr20 AND we have no cached address, and the result is cached so it happens at
|
|
156
|
+
// most once. Callers surface keyExposureWarning and recommend setting a change address.
|
|
41
157
|
export async function deriveAddr(priv) { const r = await run(["wallet", "recover", "--privkey", priv]); const m = r.stdout.match(/addr20:\s*(0x[0-9a-fA-F]{40})/i); return m ? m[1] : null; }
|
|
158
|
+
export const keyExposureWarning = "deriving your address from the wallet key briefly exposes it on the `csd` command line (readable via /proc on a shared host). Set a change address once — `csd wallet init --privkey <key>` — so cairn never needs the key again.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inversealtruism/cairn-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
26
26
|
"build": "npm run clean && tsc",
|
|
27
27
|
"dev": "tsx src/cli.ts",
|
|
28
|
-
"test": "npm run build && node test/security.mjs && node test/e2e.mjs",
|
|
28
|
+
"test": "npm run build && node test/security.mjs && node test/cairnx.mjs && node test/e2e.mjs",
|
|
29
29
|
"prepare": "npm run clean && tsc",
|
|
30
30
|
"prepublishOnly": "npm run clean && tsc"
|
|
31
31
|
},
|