@blockula/sdk 0.3.0 → 0.5.1

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 CHANGED
@@ -7,17 +7,25 @@ npm install @blockula/sdk
7
7
  ```
8
8
 
9
9
  ```ts
10
- import { Client, Wallet } from "@blockula/sdk";
10
+ import { Client, Wallet, encodeKeyGrant } from "@blockula/sdk";
11
11
 
12
12
  const wallet = Wallet.generate(new Client("http://localhost:7070"));
13
13
  await wallet.faucet(); // test chains: 100 FANG
14
14
  await wallet.registerName("vlad"); // vlad.boo
15
15
  await wallet.send("alice.boo", "1.5", { memo: "trick or treat" });
16
16
 
17
- // Let an agent spend up to 10 FANG/day from this account (enforced on-chain)
18
- await wallet.addSessionKey(agentKeyId, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 });
17
+ // Let an agent spend up to 10 FANG/day from this account (enforced on-chain). The agent's key
18
+ // consents to joining this account first: its grant code (the MCP's use_account answers with one).
19
+ const agentGrantCode = encodeKeyGrant(await agent.keyGrant(wallet.account));
20
+ await wallet.addSessionKey(agentGrantCode, "claude", { limit: "10", periodBlocks: 86_400, expiresInBlocks: 30 * 86_400 });
19
21
  ```
20
22
 
23
+ Adding a key needs the key's consent (audit gap-7-2): `AddKey` carries the key's public key and its signature over the chain, the account and its key id, so nobody can put your key on their account (and pad `client.keyAccounts(keyId)`, which pages with `{ after }`). The key's holder makes it with `keyGrant(account)`; `encodeKeyGrant`/`decodeKeyGrant` turn it into a pasteable `bkgrant1.…` code, `verifyKeyGrant` checks one, and `addOwnerKey`/`addSessionKey` take either and refuse a grant made for another account or chain.
24
+
25
+ Votes, multisig approvals and sealed Séance judgments bind what they approve (audit gap-7-6): `vote(id, aye, amount, conviction, { proposal })` signs `proposalDigest(kind, title, url)` of the proposal you showed, `multisigApprove(id, { action })` signs `actionDigest(action)`, and `sealVotes(…, { pairs })` signs `seancePairsDigest` of the pairs and PRs the juror compared (each read from the client when not given). The chain refuses a digest that doesn't match what's stored, so a node that misreports what's being approved makes it fail instead of landing elsewhere.
26
+
27
+ Show the voter or co-signer what the digest covers, rendered by the client, never the node's `summary` text (no digest covers it, so a node can make it say anything): `describeKind(kind)` and `describeAction(action)` say what a proposal or a multisig action does in plain English, every amount spelled out (`"Pay 10,000,000 FANG from the treasury to boo1…"`) and every recipient in full, and `proposalSummary(raw)` adds `does` (that description) and `digest` (what a vote binds; `null`, with `digest_error`, when the client can't read the proposal exactly, e.g. a number past 2^53: don't vote on it from JS). They never throw on content they can't read; they say so. Pass `{ freeText: false }` to leave reasons and labels out (to show them apart, marked as the proposer's words). If you show a digest and vote later, compare it with the one you sign. A multisig's pending `vote` binds its proposal by digest, not by id: `reviewAction(action, { proposal: await client.proposal(action.proposal_id) })` describes the proposal only when its content has the digest the vote binds, and says otherwise (`readable: false`, as for anything it describes as unreadable): don't offer to approve such an action. `multisigApprove(id, { action, proposal })` checks the same before signing: a vote is approved only if the proposal shown (else one read then) has exactly the content it binds.
28
+
21
29
  In the browser, a passkey unlocks the wallet. There's no seed phrase, and the passkey's (non-quantum-safe) signature is never used on-chain. Its PRF output derives the ML-DSA key:
22
30
 
23
31
  ```ts
@@ -25,12 +33,104 @@ import { createPasskeyWallet, unlockPasskeyWallet } from "@blockula/sdk/passkey"
25
33
  const wallet = await createPasskeyWallet({ username: "vlad" });
26
34
  ```
27
35
 
36
+ Contribution pool (Proof of Contribution):
37
+
38
+ ```ts
39
+ await wallet.submitClaim("jackdishman/blockula", 34); // a merged PR by your linked GitHub handle
40
+ const s = await client.seance((await client.contributions()).open_seance.id); // jurors: your pairs
41
+ const sealed = await juror.sealVotes(s.id, "ab=", [17]); // a/b/= per pair, spam flags; keep sealed.salt
42
+ await juror.revealVotes(s.id, sealed.votes, sealed.spam, sealed.salt); // after the sealing phase
43
+ await wallet.withdrawVested();
44
+ ```
45
+
28
46
  Amounts are FANG strings (`"1.5"`) or bigint drops (1 FANG = 10⁹ drops). Every transaction result includes a plain-English `summary`.
29
47
 
30
- - `send("alice.boo", …)` resolves the name first and signs the **address**, so whoever you checked is exactly who gets paid.
31
- - Node errors throw `ApiError` (`.status`: 429 means rate limited). A transaction that fails at execution throws `TxFailedError` from `waitForTx`.
48
+ Fees, tips and expiry (protocol v1):
49
+
50
+ ```ts
51
+ await wallet.send("alice.boo", "1.5", { tip: "0.001", validForBlocks: 120 });
52
+ wallet.tip = 0n; // defaults for every tx this wallet builds
53
+ wallet.validForBlocks = 600;
54
+ ```
55
+
56
+ - A transaction is charged its block's base fee (`baseFee(tx, slot)`: priced per byte, burned; it rises while blocks are full and is fixed 17 blocks ahead) plus its `tip` (paid to the block's proposer; blocks take the highest tip per byte first). The wallet sets `fee` to the base fee at `client.fees()`'s `suggested` plus the tip; only the actual charge (`txCharge(tx, slot)`) is debited. On an idle chain the base fee is `minFee(tx)`.
57
+ - A tip is at most `tip_cap_multiple` (20) × the base fee of the block that includes it (`maxTip(tx, slot, multiple)`); `wallet.build` refuses a bigger one, priced at `client.fees()`'s `lowest`. It's paid only from FANG the account could transfer (vesting and vote locks hold it), and a multisig's owner keys and a validator's consensus key acting for its owner can't tip at all.
58
+ - Every transaction carries `valid_until`, the last block it may execute in. The wallet sets it to the current height plus `validForBlocks` (default 600 blocks, at most the chain's `max_tx_validity_blocks`, an hour of blocks). A transaction past `valid_until` can never execute, so it's safe to report it as expired.
59
+ - `txBytes(tx)` is the binary wire form nodes gossip and put in blocks, and `txId(tx)` its id, known before you submit.
60
+ - Every transaction names the chain it's for by `chain_id` and `genesis_hash` (the chain instance, from `/api/status`), so it can't replay on a relaunched chain that reuses the id. `wallet.build` fills both in; to sign offline, put the chain's `genesis_hash` in the `UnsignedTx` you pass to `wallet.sign`.
61
+
62
+ Addresses are bech32m with the chain's prefix: `boo1…` on mainnet, `tboo1…` on public testnets. `isAddress(s)` accepts any known prefix and `isAddress(s, "tboo")` exactly one; `addressFromPublicKey(pk, prefix)` renders one. A `Client` learns its chain's prefix from `/api/status` (`await client.addressPrefix()`), and wallets using that client sign with it (`wallet.address`, `wallet.account`). Pin it with `new Client(rpc, { prefix: "boo" })` to refuse a node on another network (see "Which chain?" below). Nodes reject addresses of another network's prefix.
63
+
64
+ - Names in a signature (audit gap-7-1). Where the chain resolves a name itself (`send`, `awardHunt`, `vouch`/`unvouch`, `grantSoul`, `flagSybil`, `clawback`, `transferName`, proposal targets), a client with `witnesses` (below) signs the name's proven owner, and one without signs the **name itself** (`alice.boo`): the chain pays whoever owns it when the tx runs, so a lying node can't point it at another account now. It could still hold the signed tx and release it later in its validity window, which counts from the height that node reported, by when the name may have lapsed and been registered again: pay a name that's about to expire by address. `Wallet.build` refuses a height more than `maxHeadLeadBlocks` (30) ahead of the lowest head the witnesses report (every witness must answer, so one honest witness is enough), or past what a `genesisClock: { createdAtMs, blockTimeMs }` pin allows by this machine's clock (`HeightAheadError`, nothing signed); without either the height is the node's word. `client.recipient(name)` says which (`signed`, `byChain`, and `how` to show before signing); the address it reports for an unproven name is the node's word, for display. Where an address is needed (`delegate`, `undelegate`, `redelegate`, `unjail`, `actAs`, `keyGrant`), a name is resolved (`client.resolution`: `proven` and `how`), and on mainnet nothing is signed with one only a single node vouched for: it throws `UnprovenNameError` (checked against the very status each tx is signed for). Pass the address once its owner has confirmed it, or give the client witnesses.
65
+ - `wallet.address` uses the chain's prefix once the client knows it. Before the first request it shows the `boo` form, so `await client.addressPrefix()` first if you display it on a testnet.
66
+ - Node errors throw `ApiError` (`.status`: 429 means rate limited). A transaction the node reports as failed throws `TxFailedError` from `waitForTx` (`dropped` says whether it ran: see "Timeouts and pending transactions").
32
67
  - Pass `rpId` to `createPasskeyWallet`/`unlockPasskeyWallet` to pin wallets to your production domain. Passkeys are bound to that hostname for good.
33
68
 
69
+ ### Staking, governance and names (protocol v1)
70
+
71
+ ```ts
72
+ import { Client, Wallet, proposal, stakeSummary } from "@blockula/sdk";
73
+
74
+ await wallet.delegate(validatorAddress, "100"); // stake in a validator's pool
75
+ const stake = stakeSummary(await client.staking(wallet.account), (await client.status()).height);
76
+ await wallet.undelegate(validatorAddress, { amount: "10" }); // the chain converts it to shares; or "all", or a share count
77
+ await wallet.withdrawUnbonded(); // once the unbonding period is over
78
+
79
+ await wallet.propose(proposal.paramChange("max_validators", 40), { title: "More validators", url: "https://forum.example/p/1" });
80
+ await wallet.vote(7, true, "10", 2); // aye, 10 FANG locked at 2× conviction
81
+ await wallet.unlock(); // release vote locks that ended
82
+
83
+ await wallet.renewName("vlad", 2); // names are registered a year at a time
84
+ await wallet.transferName("vlad", "alice.boo"); // an offer: alice.boo accepts with acceptName("vlad")
85
+ await wallet.multisigCreate([phoneKeyId, laptopKeyId, kitKeyId], 2);
86
+ ```
87
+
88
+ - Every tag 30–49 has a helper: `delegate`, `undelegate`, `redelegate`, `withdrawUnbonded`, `updateValidator`, `unjail`, `propose`, `vote`, `unlock`, `multisigCreate` / `multisigPropose` / `multisigApprove`, `renewName`, `transferName` (an offer the recipient takes with `acceptName`; `withdrawNameOffer`), `releaseName`, `claimDistribution`, and the low-level validator ones (`reportEquivocation`, `beaconCommit`, `beaconReveal`, `attestRevert`); tags 60–61 are a set of one or two's `admitValidator` (a member's consensus key admits a candidate) and `validatorReady` (candidate nodes send it). They check their arguments before signing, resolve `.boo` names to the addresses they sign, and wait for the block.
89
+ - `proposal.*` builds every `ProposalKind`: `paramChange`, `treasurySpend`, `distribution`, `setRepo`, `grantSoul`, `flagSybil`, `unflagSybil` (its appeal), `rejectClaim`, `clawback`, `unlinkGithub`, `refundHunt`, `scheduleUpgrade`, `cancelUpgrade`, `activateFeature`, `deactivateFeature`, `text`. `propose` bonds the chain's `proposal_deposit` unless you pass `deposit`.
90
+ - `convictionWeight(amount, conviction)` is a vote's weight: 0.1× with no lock, then 1×–6× for 1, 2, 4, 8, 16 or 32 lock periods (`CONVICTION_PERIODS`).
91
+ - Reads: `client.staking(account)`, `client.proposals()`, `client.proposal(id)`, `client.params()`, `client.fees()`, `client.upgrade()`, `client.distribution(id)`, `client.multisig(account)`. `stakeSummary`, `proposalSummary` and `paramsOf` turn them into stable shapes, tolerating renamed or missing fields.
92
+
93
+ ### Which chain? Pinning and proofs
94
+
95
+ ```ts
96
+ const client = new Client("https://rpc.example", { chainId: "blockula-1", genesisHash: "…", prefix: "boo" });
97
+ ```
98
+
99
+ A pinned client checks every `/api/status` it reads, including the one each signature is made against, and throws `ChainMismatchError` instead of signing for another chain.
100
+
101
+ A node can still lie about the state of the right chain. Give a client witnesses and the names it resolves for signatures are proven (without them, see "Names in a signature" above) (`new Client(rpc, { …pins, witnesses: [new Client(rpc2, pins), …], requireProvenNames: true })`; `requireProvenNames` refuses any name it can't prove). `Wallet.propose`'s default deposit must be the same on the node and every witness, and at most `maxDeposit` (default `DEFAULT_MAX_PROPOSAL_DEPOSIT`, 1,000 FANG): a node can report any deposit, and all of it burns if the vote misses quorum (audit gap-7-5). Session-key and hunt expiries count from the same status read as `valid_until`, so a node lying about the height can stretch them by at most `max_tx_validity_blocks` (audit gap-7-3).
102
+
103
+ Proven reads check the node's Merkle proof (`/api/proof`) against its state root, ask independent nodes (`witnesses`) for that block's root, and require the proof to be fresh:
104
+
105
+ ```ts
106
+ const others = [new Client("https://rpc-2.example"), new Client("https://rpc-3.example")];
107
+ const acct = await client.provenAccount("boo1…", { witnesses: others }); // { balance, nonce, keys, height, root, head } or null
108
+ const name = await client.provenName("vlad.boo", { witnesses: others }); // { owner, registered_at, expires_at, active, … } or null
109
+ ```
110
+
111
+ - **Fresh, not just genuine.** Witnesses confirm any root the chain ever had, so a lying node could answer with a real but old record: a name's previous owner, a balance before a spend, a key since removed. The proof's block may be at most `maxLagBlocks` (default 10) behind the newest head the node and its witnesses report, or it throws `StaleProofError`. Without witnesses only the node's own head is compared, which a lying node controls.
112
+ - **Expired names don't resolve.** `provenName` returns `null` once a name's `expires_at` has passed (its grace period included), like `client.resolve`. `{ includeExpired: true }` returns the record with `active: false`; never pay its `owner`.
113
+
114
+ `verifyProof(root, namespace, key, value, proof)` is the bare check (a port of `node/src/state/tree.rs`, tested against the node's own vectors), and `provenRecord(namespace, key)` works for any record (check its `height` against `head` if you call it with your own options).
115
+
116
+ ### Timeouts and pending transactions
117
+
118
+ Every request has a timeout (`new Client(rpc, { timeoutMs })`, default 15 s, at most `MAX_TIMEOUT_MS` = 2^31 − 1 ms, since a longer timer fires at once): a node that never answers fails with `RequestTimeoutError` instead of hanging. `submit` (and every helper that sends) tells you what a failure means:
119
+
120
+ - `ApiError`: the node refused the tx. It wasn't admitted, so nothing was spent.
121
+ - `TxFailedError` with `dropped` false: it executed in a block and failed there (its fee was charged). It can't execute again.
122
+ - `TxFailedError` with `dropped` true: this node dropped it from its mempool without running it (evicted by a higher tip, replaced, not included, or an earlier tx from the account was dropped). Nothing was charged, but another node may still hold it and include it until its `valid_until`: check `client.tx(id)` before sending again, or the payment can go out twice.
123
+ - `TxPendingError`: it was admitted but isn't final yet, and it can still execute until its `valid_until`. Check `client.tx(err.id)` before sending again, or the payment can go out twice.
124
+ - `RequestTimeoutError` or a network error while submitting: unknown, the tx may have arrived. Same advice.
125
+
126
+ ### Encoding
127
+
128
+ `signingBytes` and `txBytes` encode a tx exactly as the node does (`node/src/types.rs`, checked offline against the node's vectors in `test/tx-vectors.json`). They take integers as the node's JSON parser does, as JSON numbers or strings of digits within the field's width, and refuse anything else (a negative or oversized amount, `"0x10"`, `" 1"`, a fraction) instead of wrapping it into a different tx.
129
+
130
+ `verifyReleaseSignature(sig, checksumsFile, trustedKeyId)` checks a release's offline ML-DSA-65 signature (docs/OPERATIONS.md "Releases").
131
+
34
132
  The public testnet is `https://blockula-testnet.fly.dev`, and the wallet is https://blockula-wallet.vercel.app.
35
133
 
134
+ Tests: `npm run test:vectors` (offline: passkey derivation, state-proof and tx-encoding vectors from the node, every protocol v1 helper against a fake node, pinning, proof freshness, timeouts, release signatures), `npm run test:kat` (ML-DSA known answers), `npm test` (end to end against `blockula dev`).
135
+
36
136
  ⚠️ Early and unaudited: test chains only.
@@ -0,0 +1,50 @@
1
+ /** Options of `describeKind` / `describeAction`. */
2
+ export interface DescribeOptions {
3
+ /**
4
+ * Quote the text a stranger wrote (reasons, labels, titles, memos) inside the description.
5
+ * Default true. Pass false where it must be shown apart, marked untrusted (an agent's tools do).
6
+ */
7
+ freeText?: boolean;
8
+ /**
9
+ * For a `vote` action (a multisig's ballot): the node's read of the proposal it names
10
+ * (`client.proposal(id)`). A vote binds that proposal's content by digest, not by id (audit
11
+ * gap-7-6), so the proposal is described only if its kind, title and link hash to the digest
12
+ * the vote binds; otherwise the description says the node's proposal isn't what it backs.
13
+ * `reviewAction` says whether that makes the action fit to approve.
14
+ */
15
+ proposal?: unknown;
16
+ }
17
+ /** An amount of drops, spelled out in FANG with every digit: "10,000,000 FANG", "0.5 FANG". */
18
+ export declare function fangText(drops: unknown): string;
19
+ /**
20
+ * What a governance proposal's `kind` does, in plain English, from the kind itself (as stored on
21
+ * chain: accounts are addresses there). Every amount is spelled out in FANG and every recipient
22
+ * in full. Show this to a voter, not the node's `summary`: `proposalDigest(kind, title, url)` is
23
+ * what the vote binds, and this is rendered from the same `kind`.
24
+ */
25
+ export declare function describeKind(kind: unknown, opts?: DescribeOptions): string;
26
+ /**
27
+ * What a transaction action does (a multisig's pending action, before an owner approves it), in
28
+ * plain English, from the action itself. Show this, not the node's summary:
29
+ * `actionDigest(action)` is what the approval binds, and this is rendered from the same action.
30
+ */
31
+ export declare function describeAction(action: unknown, opts?: DescribeOptions): string;
32
+ /** Whether an approver can act on a description (`reviewAction`). */
33
+ export interface ActionReview {
34
+ /** `describeAction(action, opts)`. */
35
+ does: string;
36
+ /**
37
+ * Whether this client could read all of it: false for anything it described as unreadable
38
+ * (an unknown type, a malformed field, a number JSON rounded, a key id the chain couldn't store)
39
+ * and for a `vote` whose proposal (`opts.proposal`) wasn't given or doesn't hash to the digest
40
+ * it binds. Don't offer to approve an action that isn't `readable`.
41
+ */
42
+ readable: boolean;
43
+ }
44
+ /**
45
+ * `describeAction`, and whether the description is complete enough to approve on (a multisig
46
+ * co-signer's check). For a `vote`, pass the node's read of the proposal it names as
47
+ * `opts.proposal` (`client.proposal(action.proposal_id)`): it's described, and approvable, only
48
+ * when its content hashes to the digest the vote binds (audit gap-7-6).
49
+ */
50
+ export declare function reviewAction(action: unknown, opts?: DescribeOptions): ActionReview;
@@ -0,0 +1,350 @@
1
+ // What a proposal kind or a multisig action does, in plain English, rendered by the client from
2
+ // the exact content a vote or an approval binds (audit gap-7-6). The node's own `summary` text
3
+ // isn't covered by any digest, so a lying node can rewrite it at will: show these instead.
4
+ //
5
+ // They never throw. Content a client can't read exactly (an unknown type, a number past 2^53 that
6
+ // JSON rounded, a malformed amount or account) is described as such, so a UI can say "don't vote
7
+ // on this" instead of rendering something it guessed.
8
+ import { blake3 } from "@noble/hashes/blake3.js";
9
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
10
+ import { bech32m } from "@scure/base";
11
+ import { DROPS_PER_FANG, PUBLIC_KEY_LEN, proposalDigest } from "./tx.js";
12
+ const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
13
+ /** Whether `s` is a canonical address (any known prefix). Kept local so this module has no import cycle. */
14
+ function address(s) {
15
+ if (typeof s !== "string" || s !== s.toLowerCase())
16
+ return false;
17
+ try {
18
+ const { prefix, words } = bech32m.decode(s);
19
+ return (prefix === "boo" || prefix === "tboo") && bech32m.fromWords(words).length === 32;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ /** Groups a string of digits in thousands: 10000000 → 10,000,000. */
26
+ const grouped = (digits) => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
27
+ /** A JSON number the client can read exactly, or a decimal string, as a bigint; `undefined` otherwise. */
28
+ function whole(v) {
29
+ if (typeof v === "bigint")
30
+ return v >= 0n ? v : undefined;
31
+ if (typeof v === "number")
32
+ return Number.isSafeInteger(v) && v >= 0 ? BigInt(v) : undefined;
33
+ if (typeof v === "string" && /^\d{1,20}$/.test(v) && BigInt(v) < 1n << 64n)
34
+ return BigInt(v);
35
+ return undefined;
36
+ }
37
+ /** Why a number can't be shown exactly. */
38
+ const unreadable = (v) => typeof v === "number" && Number.isFinite(v) && Number.isInteger(v) && v > Number.MAX_SAFE_INTEGER
39
+ ? "a number too large for this client to read exactly (it may have been rounded)"
40
+ : "an unreadable number";
41
+ /** An amount of drops, spelled out in FANG with every digit: "10,000,000 FANG", "0.5 FANG". */
42
+ export function fangText(drops) {
43
+ const d = whole(drops);
44
+ if (d === undefined)
45
+ return typeof drops === "string" || typeof drops === "number" ? `${unreadable(drops)} of FANG` : "an unreadable amount of FANG";
46
+ const frac = (d % DROPS_PER_FANG).toString().padStart(9, "0").replace(/0+$/, "");
47
+ return `${grouped((d / DROPS_PER_FANG).toString())}${frac ? "." + frac : ""} FANG`;
48
+ }
49
+ /** A whole number (ids, heights, counts), or what's wrong with it. */
50
+ function n(v) {
51
+ const d = whole(v);
52
+ return d === undefined ? unreadable(v) : d.toString();
53
+ }
54
+ /** milli-Soul as Soul ("2.5 Soul"). */
55
+ function soul(v) {
56
+ const d = whole(v);
57
+ if (d === undefined)
58
+ return `${unreadable(v)} of Soul`;
59
+ const frac = (d % 1000n).toString().padStart(3, "0").replace(/0+$/, "");
60
+ return `${grouped((d / 1000n).toString())}${frac ? "." + frac : ""} Soul`;
61
+ }
62
+ const NAME = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])$/;
63
+ /**
64
+ * An account field: an address in full (check it), or a `.boo` name, which the chain resolves to
65
+ * its owner when the action runs.
66
+ */
67
+ function account(v) {
68
+ if (address(v))
69
+ return v;
70
+ if (typeof v === "string") {
71
+ // Exactly as the chain reads it (node/src/types.rs `normalize_name`): ASCII, any case, no spaces.
72
+ const bare = v.toLowerCase().replace(/\.boo$/, "");
73
+ if (NAME.test(bare))
74
+ return `${bare}.boo (a name, not an address: the chain resolves it to whoever owns it when this runs)`;
75
+ }
76
+ return "an unreadable account";
77
+ }
78
+ /** A `.boo` name field (registered, renewed, offered, released). */
79
+ function nameField(v) {
80
+ if (typeof v !== "string")
81
+ return "an unreadable name";
82
+ const bare = v.toLowerCase().replace(/\.boo$/, "");
83
+ return NAME.test(bare) ? `${bare}.boo` : "an unreadable name";
84
+ }
85
+ /** A plain token (parameter keys, feature names, repos, handles), or nothing it can show safely. */
86
+ function token(v, re, what) {
87
+ return typeof v === "string" && re.test(v) ? v : `an unreadable ${what}`;
88
+ }
89
+ /** Hex of an exact length, shortened to its ends. */
90
+ function hex(v, bytes, what) {
91
+ return typeof v === "string" && new RegExp(`^[0-9a-f]{${bytes * 2}}$`).test(v) ? `${v.slice(0, 12)}…${v.slice(-8)}` : `an unreadable ${what}`;
92
+ }
93
+ /** Text a stranger wrote, quoted (one line, at most 200 characters), or left out. */
94
+ function quoted(v, label, opts) {
95
+ if (opts.freeText === false || typeof v !== "string" || !v.trim())
96
+ return "";
97
+ // One line, nothing invisible, and no curly quotes of its own, so it can't close the quote early.
98
+ const t = [...v.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, " ").replace(/[“”]/g, '"').replace(/ {2,}/g, " ").trim()];
99
+ return ` (${label}, as written: “${t.length > 200 ? t.slice(0, 200).join("") + "…" : t.join("")}”)`;
100
+ }
101
+ /** A network address field (host:port or a URL) as written, quoted on one line, or an unreadable one. */
102
+ function netAddr(v) {
103
+ if (typeof v !== "string" || v.length > 256)
104
+ return "an unreadable address";
105
+ const t = [...v.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\s]+/gu, " ").replace(/[“”]/g, '"').trim()];
106
+ return `“${t.length > 120 ? t.slice(0, 120).join("") + "…" : t.join("")}”`;
107
+ }
108
+ /** A consensus key (hex ML-DSA-65 public key) by its key id, shortened like the other key ids. */
109
+ function consensusKey(v) {
110
+ if (typeof v !== "string" || !new RegExp(`^[0-9a-f]{${PUBLIC_KEY_LEN * 2}}$`).test(v))
111
+ return "an unreadable consensus key";
112
+ return `consensus key ${hex(bytesToHex(blake3(hexToBytes(v))), 32, "key id")}`;
113
+ }
114
+ /**
115
+ * What a `vote` action backs, given the node's read of the proposal it names (`opts.proposal`):
116
+ * `ok` only when that proposal's content hashes to the digest the vote binds (no node can make
117
+ * other content do that), and the text to append to the vote's description.
118
+ */
119
+ function voteTarget(a, opts) {
120
+ const id = n(a.proposal_id);
121
+ if (opts.proposal === undefined)
122
+ return { ok: false, text: `: read proposal #${id} and check its digest before approving` };
123
+ const p = obj(opts.proposal);
124
+ let digest = null;
125
+ try {
126
+ digest = p.kind && typeof p.kind === "object" ? proposalDigest(p.kind, String(p.title ?? ""), typeof p.url === "string" ? p.url : "") : null;
127
+ }
128
+ catch {
129
+ digest = null;
130
+ }
131
+ if (digest === null)
132
+ return { ok: false, text: `; this client can't read the node's proposal #${id} exactly: don't approve it` };
133
+ if (digest !== a.proposal_digest)
134
+ return { ok: false, text: `; but that's NOT what the node shows as proposal #${id} (${describeKind(p.kind, { freeText: false })}, digest ${digest}): the node is showing another proposal, or the vote was made for content #${id} doesn't have. Don't approve it` };
135
+ return { ok: true, text: `: proposal #${id} (its content has exactly that digest), which does this: ${describeKind(p.kind, opts)}${quoted(p.title, "title", opts)}` };
136
+ }
137
+ const REPO = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
138
+ const HANDLE = /^[A-Za-z0-9-]{1,39}$/;
139
+ const KEY = /^[a-z0-9_]{1,64}$/;
140
+ const FEATURE = /^[a-z0-9_-]{1,64}$/;
141
+ /**
142
+ * What a governance proposal's `kind` does, in plain English, from the kind itself (as stored on
143
+ * chain: accounts are addresses there). Every amount is spelled out in FANG and every recipient
144
+ * in full. Show this to a voter, not the node's `summary`: `proposalDigest(kind, title, url)` is
145
+ * what the vote binds, and this is rendered from the same `kind`.
146
+ */
147
+ export function describeKind(kind, opts = {}) {
148
+ const k = obj(kind);
149
+ switch (k.type) {
150
+ case "param_change":
151
+ return `Set the chain parameter ${token(k.key, KEY, "parameter")} to ${n(k.value)} (in that parameter's own units: drops, blocks, basis points…)`;
152
+ case "treasury_spend":
153
+ return `Pay ${fangText(k.amount)} from the treasury to ${account(k.to)}`;
154
+ case "distribution":
155
+ return `Distribute ${fangText(k.total)} from the community account as distribution #${n(k.distribution_id)}, to the accounts in the Merkle tree with root ${hex(k.merkle_root, 32, "root")} (check it against the published tree), claimable until block #${n(k.claim_until)}`;
156
+ case "set_repo":
157
+ return whole(k.weight) === 0n ? `Remove the repo ${token(k.repo, REPO, "repo")} from the contribution pool` : `Put the repo ${token(k.repo, REPO, "repo")} in the contribution pool with weight ${n(k.weight)}`;
158
+ case "grant_soul":
159
+ return `Grant ${soul(k.amount)} to ${account(k.to)}${quoted(k.reason, "reason", opts)}`;
160
+ case "flag_sybil":
161
+ return `Flag ${account(k.account)} as a sybil (its voucher is slashed, its unvested rewards clawed back)${quoted(k.reason, "reason", opts)}`;
162
+ case "unflag_sybil":
163
+ return `Lift the sybil flag on ${account(k.account)} (nothing the flag took comes back)${quoted(k.reason, "reason", opts)}`;
164
+ case "reject_claim":
165
+ return `Reject contribution claim #${n(k.claim_id)}${quoted(k.reason, "reason", opts)}`;
166
+ case "clawback":
167
+ return `Claw back the unvested contribution rewards of ${account(k.account)}${quoted(k.reason, "reason", opts)}`;
168
+ case "unlink_github":
169
+ return `Unlink the GitHub handle @${token(k.handle, HANDLE, "handle")} and hold it for the proposer to link for one voting period`;
170
+ case "refund_hunt":
171
+ return `Refund every funder of hunt #${n(k.hunt_id)} what they put in`;
172
+ case "schedule_upgrade":
173
+ return `Switch the chain to protocol version ${n(k.version)} at block #${n(k.activation_height)} (nodes that can't run it stop there)`;
174
+ case "cancel_upgrade":
175
+ // The same sentence as the CLI's describe_kind and the node's static text (audit
176
+ // review-r3-clients-1): a cancellation also supersedes every upgrade proposal still open.
177
+ return `Cancel the upgrade to protocol v${n(k.version)} at block ${n(k.activation_height)} and any upgrade scheduled after it; upgrade proposals still open when it runs are superseded and can't take effect`;
178
+ case "activate_feature":
179
+ return `Turn on the feature ${token(k.feature, FEATURE, "feature")}`;
180
+ case "deactivate_feature":
181
+ return `Turn off the feature ${token(k.feature, FEATURE, "feature")}`;
182
+ case "text":
183
+ return "Nothing on chain: a text proposal (a signal; read its title and link)";
184
+ default:
185
+ return `Something this client can't read (a proposal of type ${typeof k.type === "string" ? token(k.type, /^[a-z_]{1,40}$/, "type") : "unknown"}): don't vote on it from here`;
186
+ }
187
+ }
188
+ /** A key role (`add_key`). */
189
+ function role(r) {
190
+ const x = obj(r);
191
+ if (x.type === "owner")
192
+ return "an owner key (full control of the account)";
193
+ if (x.type === "session")
194
+ return `a session key that may send up to ${fangText(x.limit)} (fees included) every ${n(x.period_blocks)} blocks, until block #${n(x.expires_at)}, and nothing else`;
195
+ return "an unreadable role";
196
+ }
197
+ /**
198
+ * What a transaction action does (a multisig's pending action, before an owner approves it), in
199
+ * plain English, from the action itself. Show this, not the node's summary:
200
+ * `actionDigest(action)` is what the approval binds, and this is rendered from the same action.
201
+ */
202
+ export function describeAction(action, opts = {}) {
203
+ const a = obj(action);
204
+ switch (a.type) {
205
+ case "transfer":
206
+ return `Send ${fangText(a.amount)} to ${account(a.to)}`;
207
+ case "register_name":
208
+ return `Register the name ${nameField(a.name)}`;
209
+ case "add_key":
210
+ return `Add key ${hex(a.key_id, 32, "key id")} to the account as ${role(a.role)}${quoted(a.label, "label", opts)}`;
211
+ case "remove_key":
212
+ return `Remove key ${hex(a.key_id, 32, "key id")} from the account`;
213
+ case "register_validator":
214
+ // The consensus key decides who signs for (and can be slashed with) the bonded pool: named
215
+ // by its key id, the 32 bytes the validator's address carries (review-r3-clients-5).
216
+ return `Register a validator with ${consensusKey(a.consensus_key)} (it signs for the validator; the validator's address carries the same key id), p2p address ${netAddr(a.p2p_addr)}, HTTP address ${netAddr(a.http_addr)}, bonding ${fangText(a.bond)} from the account`;
217
+ case "approve_validator":
218
+ return `Approve validator ${account(a.validator)} (admin chains only)`;
219
+ case "remove_validator":
220
+ return `Remove validator ${account(a.validator)} (admin chains only)`;
221
+ case "unbond":
222
+ return `Reclaim the bond of removed validator ${account(a.validator)}`;
223
+ case "link_github":
224
+ return `Link the GitHub handle @${token(a.handle, HANDLE, "handle")} to the account`;
225
+ case "unlink_github":
226
+ return `Unlink the GitHub handle @${token(a.handle, HANDLE, "handle")}`;
227
+ case "create_hunt":
228
+ return `Escrow ${fangText(a.amount)} as a bounty on ${token(a.repo, REPO, "repo")} issue #${n(a.issue)}, until block #${n(a.expires_at)}${quoted(a.title, "title", opts)}`;
229
+ case "fund_hunt":
230
+ return `Add ${fangText(a.amount)} to hunt #${n(a.hunt_id)}`;
231
+ case "award_hunt":
232
+ return `Pay hunt #${n(a.hunt_id)}'s bounty to ${account(a.to)} for PR #${n(a.pr)}`;
233
+ case "cancel_hunt":
234
+ return `Cancel hunt #${n(a.hunt_id)} and refund its funders`;
235
+ case "register_familiar":
236
+ return `Register key ${hex(a.key_id, 32, "key id")} as a Familiar${quoted(a.label, "label", opts)}`;
237
+ case "set_repo":
238
+ return `Set the repo ${token(a.repo, REPO, "repo")}'s weight in the contribution pool to ${n(a.weight)} (admin chains only)`;
239
+ case "grant_soul":
240
+ return `Grant ${soul(a.amount)} to ${account(a.to)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
241
+ case "vouch":
242
+ return `Stake 10 FANG vouching for ${account(a.account)}`;
243
+ case "unvouch":
244
+ return `Withdraw the vouch for ${account(a.account)}`;
245
+ case "flag_sybil":
246
+ return `Flag ${account(a.account)} as a sybil (admin chains only)${quoted(a.reason, "reason", opts)}`;
247
+ case "submit_claim":
248
+ return `Claim PR ${token(a.repo, REPO, "repo")}#${n(a.pr)} for the contribution pool`;
249
+ case "attest_claim":
250
+ return `Attest contribution claim #${n(a.claim_id)} as ${a.ok === true ? "merged" : "not merged"}`;
251
+ case "reject_claim":
252
+ return `Reject contribution claim #${n(a.claim_id)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
253
+ case "join_jury":
254
+ return `Join the Séance juror pool, bonding ${fangText(a.bond)}`;
255
+ case "leave_jury":
256
+ return "Leave the Séance juror pool";
257
+ case "commit_votes":
258
+ return `Seal a judgment in Séance #${n(a.seance_id)}`;
259
+ case "reveal_votes":
260
+ return `Reveal a judgment in Séance #${n(a.seance_id)}`;
261
+ case "fund_pool":
262
+ return `Add ${fangText(a.amount)} to the contribution pool`;
263
+ case "withdraw_vested":
264
+ return "Withdraw the contribution rewards that have vested";
265
+ case "clawback":
266
+ return `Claw back the unvested rewards of ${account(a.account)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
267
+ case "delegate":
268
+ return `Stake ${fangText(a.amount)} with validator ${account(a.validator)}`;
269
+ case "undelegate": {
270
+ // Amount mode (audit gap-7-5): `shares` 0 and an amount the chain converts at its own price.
271
+ const amount = whole(a.amount);
272
+ if (amount !== undefined && amount > 0n)
273
+ return `Start unbonding ${fangText(a.amount)} of stake from validator ${account(a.validator)} (the chain converts it to pool shares at its own price when this runs)`;
274
+ if (a.amount !== undefined && amount === undefined)
275
+ return `Start unbonding ${fangText(a.amount)} of stake from validator ${account(a.validator)}`;
276
+ return `Start unbonding ${n(a.shares)} pool shares from validator ${account(a.validator)}`;
277
+ }
278
+ case "redelegate":
279
+ return `Move ${n(a.shares)} pool shares from validator ${account(a.from_validator)} to validator ${account(a.to_validator)}`;
280
+ case "withdraw_unbonded":
281
+ return "Withdraw the stake that has finished unbonding";
282
+ case "update_validator":
283
+ // Each address separately: on chain an empty one means "keep it" (review-r3-clients-5).
284
+ return `Set the account's validator commission to ${n(a.commission_bps)} basis points${a.p2p_addr === "" ? "" : `, its p2p address to ${netAddr(a.p2p_addr)}`}${a.http_addr === "" ? "" : `, its HTTP address to ${netAddr(a.http_addr)}`}`;
285
+ case "unjail":
286
+ return `Unjail validator ${account(a.validator)}`;
287
+ case "report_equivocation":
288
+ return "Report a validator's equivocation (two conflicting signed votes)";
289
+ case "beacon_commit":
290
+ return `Commit to a randomness-beacon secret for epoch ${n(a.epoch)}`;
291
+ case "beacon_reveal":
292
+ return `Reveal the randomness-beacon secret for epoch ${n(a.epoch)}`;
293
+ case "propose":
294
+ return `Propose, bonding ${fangText(a.deposit)}: ${describeKind(a.kind, opts)}${quoted(a.title, "title", opts)}`;
295
+ case "vote":
296
+ // What #id is comes from the digest the vote binds, never from the id (audit gap-7-6).
297
+ return `Vote ${a.aye === true ? "aye" : a.aye === false ? "nay" : "(unreadable side)"} on proposal #${n(a.proposal_id)} with ${fangText(a.amount)} at conviction ${n(a.conviction)}, binding the proposal whose content has digest ${typeof a.proposal_digest === "string" && /^[0-9a-f]{64}$/.test(a.proposal_digest) ? a.proposal_digest : "(unreadable)"}${voteTarget(a, opts).text}`;
298
+ case "unlock":
299
+ return "Release the account's expired vote locks";
300
+ case "multisig_create": {
301
+ const owners = Array.isArray(a.owners) ? a.owners : [];
302
+ return `Make the account a ${n(a.threshold)}-of-${owners.length} multisig of the owner keys ${owners.map((o) => hex(o, 32, "key id")).join(", ") || "(none)"}`;
303
+ }
304
+ case "multisig_propose":
305
+ return `Propose a multisig action: ${describeAction(a.action, opts)}`;
306
+ case "multisig_approve":
307
+ return `Approve multisig action #${n(a.id)}`;
308
+ case "renew_name":
309
+ return `Renew the name ${nameField(a.name)} for ${n(a.years)} year(s)`;
310
+ case "transfer_name":
311
+ return `Offer the name ${nameField(a.name)} to ${account(a.to)} (theirs once they accept it; offered to the account itself, it accepts or withdraws an offer)`;
312
+ case "release_name":
313
+ return `Release the name ${nameField(a.name)}: anyone can register it afterwards`;
314
+ case "claim_distribution":
315
+ return `Claim ${fangText(a.amount)} from community distribution #${n(a.distribution_id)}`;
316
+ case "attest_revert":
317
+ return `Attest that contribution claim #${n(a.claim_id)}'s PR was reverted`;
318
+ case "shield":
319
+ return `Move ${fangText(a.amount)} into the Ghost Mode pool`;
320
+ case "unshield":
321
+ return `Pay ${fangText(a.amount)} out of the Ghost Mode pool to ${account(a.to)}`;
322
+ case "private_transfer":
323
+ return "A private Ghost Mode transfer";
324
+ case "set_ghost_address":
325
+ return "Publish the account's Ghost Mode address";
326
+ case "ghost_pause":
327
+ return "Vote to pause Ghost Mode (validators)";
328
+ case "validator_ready":
329
+ return `Signal that the account's candidate validator node is running, to join a set of one or two at the end of epoch ${n(a.epoch)} (validators)`;
330
+ case "admit_validator":
331
+ return `Admit candidate validator ${account(a.validator)} into a set of one or two at the end of epoch ${n(a.epoch)} (validators)`;
332
+ default:
333
+ return `Something this client can't read (an action of type ${typeof a.type === "string" ? token(a.type, /^[a-z_]{1,40}$/, "type") : "unknown"}): don't approve it from here`;
334
+ }
335
+ }
336
+ /**
337
+ * `describeAction`, and whether the description is complete enough to approve on (a multisig
338
+ * co-signer's check). For a `vote`, pass the node's read of the proposal it names as
339
+ * `opts.proposal` (`client.proposal(action.proposal_id)`): it's described, and approvable, only
340
+ * when its content hashes to the digest the vote binds (audit gap-7-6).
341
+ */
342
+ export function reviewAction(action, opts = {}) {
343
+ const does = describeAction(action, opts);
344
+ const a = obj(action);
345
+ // Strangers' quoted text is left out of the check: it can say "unreadable" too.
346
+ const ours = does.replace(/ \((?:reason|label|title), as written: “[^”]*”\)/g, "");
347
+ const unreadable = /\b(unreadable|can't read|too large for this client)\b/.test(ours);
348
+ const vote = a.type !== "vote" || voteTarget(a, opts).ok;
349
+ return { does, readable: !unreadable && vote };
350
+ }
@@ -0,0 +1,26 @@
1
+ /** Most hashes a claim's proof may carry (the node's `MAX_PROOF`). */
2
+ export declare const MAX_DISTRIBUTION_PROOF = 64;
3
+ /**
4
+ * The leaf of `address`'s `amount` (drops) in distribution `id`:
5
+ * BLAKE3("BLOCKULA_DISTRIBUTION_LEAF_V1" ‖ id u64 LE ‖ len(address) u16 LE ‖ address ‖ amount u64 LE).
6
+ * `address` is the bech32m string exactly as the claimant's tx spells it.
7
+ */
8
+ export declare function distributionLeaf(id: bigint | number, address: string, amount: bigint): Uint8Array;
9
+ /** Sorted-pair inner node: BLAKE3("BLOCKULA_DISTRIBUTION_NODE_V1" ‖ min(a, b) ‖ max(a, b)). */
10
+ export declare function distributionNode(a: Uint8Array, b: Uint8Array): Uint8Array;
11
+ /**
12
+ * Root and per-entry proofs (hex sibling hashes from the leaf up), entries in input order, in
13
+ * the reference shape: leaves sorted, paired left to right level by level, an odd last node
14
+ * carried up unchanged. Addresses must be distinct and amounts non-zero.
15
+ */
16
+ export declare function buildDistribution(id: bigint | number, entries: {
17
+ address: string;
18
+ amount: bigint;
19
+ }[]): {
20
+ root: string;
21
+ proofs: string[][];
22
+ };
23
+ /** The root a leaf and its proof (hex sibling hashes from the leaf up) lead to (hex). */
24
+ export declare function distributionRoot(id: bigint | number, address: string, amount: bigint, proof: string[]): string;
25
+ /** Whether `proof` leads from `address`'s leaf for `amount` (drops) in distribution `id` to `root` (hex). */
26
+ export declare function verifyDistributionProof(root: string, id: bigint | number, address: string, amount: bigint, proof: string[]): boolean;