@blockula/sdk 0.3.0 → 1.0.0

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.
@@ -0,0 +1,441 @@
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, interfaceHash, isProgramAction, proposalDigest, wasmHash } 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
+ // ---- Program actions (tags 62–64 and 69, docs/PROGRAMS-V0.md §2), described from their structure
115
+ // only, as node/src/programs/describe.rs does: programs and code by address and hash, the FANG
116
+ // that goes in, what the program may become. A method's name, an interface's identifiers and a
117
+ // call's input are the deployer's or the caller's words, and these never quote them (PRG 9.7). ----
118
+ /** An address a program action names (a program, an authority): in full, or unreadable. Never a name: programs are named by address. */
119
+ function addressOnly(v, what) {
120
+ return address(v) ? v : `an unreadable ${what}`;
121
+ }
122
+ /** Uploaded bytes (lowercase hex, as `upload_code` carries them) by the hash a create names them with, and their size. */
123
+ function blob(v, hash, what) {
124
+ if (typeof v !== "string" || !/^(?:[0-9a-f]{2})*$/.test(v))
125
+ return `an unreadable ${what}`;
126
+ return `${what} ${hex(hash(v), 32, "hash")} (${grouped(String(v.length / 2))} bytes)`;
127
+ }
128
+ /** A binding's digest that admits any version of its program (node/vm `exec::ANY_VERSION`). */
129
+ const ANY_VERSION = "0".repeat(64);
130
+ /** The other programs a program action's code may reach (its bindings), or nothing when there are none. */
131
+ function bindings(v) {
132
+ if (!Array.isArray(v))
133
+ return "; its code may also reach an unreadable list of programs";
134
+ if (v.length === 0)
135
+ return "";
136
+ const each = v.map((b) => {
137
+ const x = obj(b);
138
+ const version = x.version_digest === ANY_VERSION ? " (any version)" : typeof x.version_digest === "string" && /^[0-9a-f]{64}$/.test(x.version_digest) ? "" : " (an unreadable version)";
139
+ return `${addressOnly(x.program, "program")}${version}`;
140
+ });
141
+ return `; its code may also reach ${each.join(", ")}`;
142
+ }
143
+ /**
144
+ * What a create's authority may later do to the program, once program upgrades exist (inert in
145
+ * v0): the sentence's subject ("an immutable program" or "a program") and what follows the code.
146
+ */
147
+ function upgrades(a) {
148
+ if (a.authority === undefined || a.authority === null) {
149
+ // The one encoding of an immutable program; the chain refuses any other.
150
+ if (whole(a.upgrade_delay_blocks) === 0n && a.policy === "open")
151
+ return ["an immutable program", ""];
152
+ return ["a program", ", with an unreadable upgrade setting (no authority, but an upgrade delay or policy)"];
153
+ }
154
+ const policy = a.policy === "open" ? "any code" : a.policy === "additive" ? "code that keeps every method" : "an unreadable policy";
155
+ return ["a program", `, upgradable by ${addressOnly(a.authority, "account")} to ${policy} with ${n(a.upgrade_delay_blocks)} blocks' notice once program upgrades exist`];
156
+ }
157
+ /** A create's initializer (the interface's `Init` method, never named here) and the FANG it's sent, or nothing when there's none. */
158
+ function initializer(v) {
159
+ if (v === undefined || v === null)
160
+ return "";
161
+ if (typeof v !== "object" || Array.isArray(v))
162
+ return ", running an unreadable initializer";
163
+ const value = obj(v).value;
164
+ return whole(value) === 0n ? ", running its initializer" : `, running its initializer with ${fangText(value)} attached`;
165
+ }
166
+ /** The sum of the calls' values (drops), or `undefined` if one can't be read exactly. */
167
+ function callValue(calls) {
168
+ let sum = 0n;
169
+ for (const c of calls) {
170
+ const v = whole(c.value);
171
+ if (v === undefined)
172
+ return undefined;
173
+ sum += v;
174
+ }
175
+ return sum;
176
+ }
177
+ /** The storage deposits a program action may bond, at most (`storage_limit`, and what for), or nothing when it's 0. */
178
+ function storage(v, what = "") {
179
+ return whole(v) === 0n ? "" : `, bonding at most ${fangText(v)} in storage deposits${what}`;
180
+ }
181
+ /**
182
+ * What a `vote` action backs, given the node's read of the proposal it names (`opts.proposal`):
183
+ * `ok` only when that proposal's content hashes to the digest the vote binds (no node can make
184
+ * other content do that), and the text to append to the vote's description.
185
+ */
186
+ function voteTarget(a, opts) {
187
+ const id = n(a.proposal_id);
188
+ if (opts.proposal === undefined)
189
+ return { ok: false, text: `: read proposal #${id} and check its digest before approving` };
190
+ const p = obj(opts.proposal);
191
+ let digest = null;
192
+ try {
193
+ digest = p.kind && typeof p.kind === "object" ? proposalDigest(p.kind, String(p.title ?? ""), typeof p.url === "string" ? p.url : "") : null;
194
+ }
195
+ catch {
196
+ digest = null;
197
+ }
198
+ if (digest === null)
199
+ return { ok: false, text: `; this client can't read the node's proposal #${id} exactly: don't approve it` };
200
+ if (digest !== a.proposal_digest)
201
+ 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` };
202
+ return { ok: true, text: `: proposal #${id} (its content has exactly that digest), which does this: ${describeKind(p.kind, opts)}${quoted(p.title, "title", opts)}` };
203
+ }
204
+ const REPO = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
205
+ const HANDLE = /^[A-Za-z0-9-]{1,39}$/;
206
+ const KEY = /^[a-z0-9_]{1,64}$/;
207
+ const FEATURE = /^[a-z0-9_-]{1,64}$/;
208
+ /**
209
+ * What a governance proposal's `kind` does, in plain English, from the kind itself (as stored on
210
+ * chain: accounts are addresses there). Every amount is spelled out in FANG and every recipient
211
+ * in full. Show this to a voter, not the node's `summary`: `proposalDigest(kind, title, url)` is
212
+ * what the vote binds, and this is rendered from the same `kind`.
213
+ */
214
+ export function describeKind(kind, opts = {}) {
215
+ const k = obj(kind);
216
+ switch (k.type) {
217
+ case "param_change":
218
+ return `Set the chain parameter ${token(k.key, KEY, "parameter")} to ${n(k.value)} (in that parameter's own units: drops, blocks, basis points…)`;
219
+ case "treasury_spend":
220
+ return `Pay ${fangText(k.amount)} from the treasury to ${account(k.to)}`;
221
+ case "distribution":
222
+ 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)}`;
223
+ case "set_repo":
224
+ 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)}`;
225
+ case "grant_soul":
226
+ return `Grant ${soul(k.amount)} to ${account(k.to)}${quoted(k.reason, "reason", opts)}`;
227
+ case "flag_sybil":
228
+ return `Flag ${account(k.account)} as a sybil (its voucher is slashed, its unvested rewards clawed back)${quoted(k.reason, "reason", opts)}`;
229
+ case "unflag_sybil":
230
+ return `Lift the sybil flag on ${account(k.account)} (nothing the flag took comes back)${quoted(k.reason, "reason", opts)}`;
231
+ case "reject_claim":
232
+ return `Reject contribution claim #${n(k.claim_id)}${quoted(k.reason, "reason", opts)}`;
233
+ case "clawback":
234
+ return `Claw back the unvested contribution rewards of ${account(k.account)}${quoted(k.reason, "reason", opts)}`;
235
+ case "unlink_github":
236
+ return `Unlink the GitHub handle @${token(k.handle, HANDLE, "handle")} and hold it for the proposer to link for one voting period`;
237
+ case "refund_hunt":
238
+ return `Refund every funder of hunt #${n(k.hunt_id)} what they put in`;
239
+ case "schedule_upgrade":
240
+ return `Switch the chain to protocol version ${n(k.version)} at block #${n(k.activation_height)} (nodes that can't run it stop there)`;
241
+ case "cancel_upgrade":
242
+ // The same sentence as the CLI's describe_kind and the node's static text (audit
243
+ // review-r3-clients-1): a cancellation also supersedes every upgrade proposal still open.
244
+ 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`;
245
+ case "activate_feature":
246
+ return `Turn on the feature ${token(k.feature, FEATURE, "feature")}`;
247
+ case "deactivate_feature":
248
+ return `Turn off the feature ${token(k.feature, FEATURE, "feature")}`;
249
+ case "text":
250
+ return "Nothing on chain: a text proposal (a signal; read its title and link)";
251
+ default:
252
+ 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`;
253
+ }
254
+ }
255
+ /** A key role (`add_key`). */
256
+ function role(r) {
257
+ const x = obj(r);
258
+ if (x.type === "owner")
259
+ return "an owner key (full control of the account)";
260
+ if (x.type === "session")
261
+ 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`;
262
+ return "an unreadable role";
263
+ }
264
+ /**
265
+ * What a transaction action does (a multisig's pending action, before an owner approves it), in
266
+ * plain English, from the action itself. Show this, not the node's summary:
267
+ * `actionDigest(action)` is what the approval binds, and this is rendered from the same action.
268
+ */
269
+ export function describeAction(action, opts = {}) {
270
+ const a = obj(action);
271
+ switch (a.type) {
272
+ case "transfer":
273
+ return `Send ${fangText(a.amount)} to ${account(a.to)}`;
274
+ case "register_name":
275
+ return `Register the name ${nameField(a.name)}`;
276
+ case "add_key":
277
+ return `Add key ${hex(a.key_id, 32, "key id")} to the account as ${role(a.role)}${quoted(a.label, "label", opts)}`;
278
+ case "remove_key":
279
+ return `Remove key ${hex(a.key_id, 32, "key id")} from the account`;
280
+ case "register_validator":
281
+ // The consensus key decides who signs for (and can be slashed with) the bonded pool: named
282
+ // by its key id, the 32 bytes the validator's address carries (review-r3-clients-5).
283
+ 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`;
284
+ case "approve_validator":
285
+ return `Approve validator ${account(a.validator)} (admin chains only)`;
286
+ case "remove_validator":
287
+ return `Remove validator ${account(a.validator)} (admin chains only)`;
288
+ case "unbond":
289
+ return `Reclaim the bond of removed validator ${account(a.validator)}`;
290
+ case "link_github":
291
+ return `Link the GitHub handle @${token(a.handle, HANDLE, "handle")} to the account`;
292
+ case "unlink_github":
293
+ return `Unlink the GitHub handle @${token(a.handle, HANDLE, "handle")}`;
294
+ case "create_hunt":
295
+ 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)}`;
296
+ case "fund_hunt":
297
+ return `Add ${fangText(a.amount)} to hunt #${n(a.hunt_id)}`;
298
+ case "award_hunt":
299
+ return `Pay hunt #${n(a.hunt_id)}'s bounty to ${account(a.to)} for PR #${n(a.pr)}`;
300
+ case "cancel_hunt":
301
+ return `Cancel hunt #${n(a.hunt_id)} and refund its funders`;
302
+ case "register_familiar":
303
+ return `Register key ${hex(a.key_id, 32, "key id")} as a Familiar${quoted(a.label, "label", opts)}`;
304
+ case "set_repo":
305
+ return `Set the repo ${token(a.repo, REPO, "repo")}'s weight in the contribution pool to ${n(a.weight)} (admin chains only)`;
306
+ case "grant_soul":
307
+ return `Grant ${soul(a.amount)} to ${account(a.to)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
308
+ case "vouch":
309
+ return `Stake 10 FANG vouching for ${account(a.account)}`;
310
+ case "unvouch":
311
+ return `Withdraw the vouch for ${account(a.account)}`;
312
+ case "flag_sybil":
313
+ return `Flag ${account(a.account)} as a sybil (admin chains only)${quoted(a.reason, "reason", opts)}`;
314
+ case "submit_claim":
315
+ return `Claim PR ${token(a.repo, REPO, "repo")}#${n(a.pr)} for the contribution pool`;
316
+ case "attest_claim":
317
+ return `Attest contribution claim #${n(a.claim_id)} as ${a.ok === true ? "merged" : "not merged"}`;
318
+ case "reject_claim":
319
+ return `Reject contribution claim #${n(a.claim_id)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
320
+ case "join_jury":
321
+ return `Join the Séance juror pool, bonding ${fangText(a.bond)}`;
322
+ case "leave_jury":
323
+ return "Leave the Séance juror pool";
324
+ case "commit_votes":
325
+ return `Seal a judgment in Séance #${n(a.seance_id)}`;
326
+ case "reveal_votes":
327
+ return `Reveal a judgment in Séance #${n(a.seance_id)}`;
328
+ case "fund_pool":
329
+ return `Add ${fangText(a.amount)} to the contribution pool`;
330
+ case "withdraw_vested":
331
+ return "Withdraw the contribution rewards that have vested";
332
+ case "clawback":
333
+ return `Claw back the unvested rewards of ${account(a.account)} (admin chains only)${quoted(a.reason, "reason", opts)}`;
334
+ case "delegate":
335
+ return `Stake ${fangText(a.amount)} with validator ${account(a.validator)}`;
336
+ case "undelegate": {
337
+ // Amount mode (audit gap-7-5): `shares` 0 and an amount the chain converts at its own price.
338
+ const amount = whole(a.amount);
339
+ if (amount !== undefined && amount > 0n)
340
+ 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)`;
341
+ if (a.amount !== undefined && amount === undefined)
342
+ return `Start unbonding ${fangText(a.amount)} of stake from validator ${account(a.validator)}`;
343
+ return `Start unbonding ${n(a.shares)} pool shares from validator ${account(a.validator)}`;
344
+ }
345
+ case "redelegate":
346
+ return `Move ${n(a.shares)} pool shares from validator ${account(a.from_validator)} to validator ${account(a.to_validator)}`;
347
+ case "withdraw_unbonded":
348
+ return "Withdraw the stake that has finished unbonding";
349
+ case "update_validator":
350
+ // Each address separately: on chain an empty one means "keep it" (review-r3-clients-5).
351
+ 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)}`}`;
352
+ case "unjail":
353
+ return `Unjail validator ${account(a.validator)}`;
354
+ case "report_equivocation":
355
+ return "Report a validator's equivocation (two conflicting signed votes)";
356
+ case "beacon_commit":
357
+ return `Commit to a randomness-beacon secret for epoch ${n(a.epoch)}`;
358
+ case "beacon_reveal":
359
+ return `Reveal the randomness-beacon secret for epoch ${n(a.epoch)}`;
360
+ case "propose":
361
+ return `Propose, bonding ${fangText(a.deposit)}: ${describeKind(a.kind, opts)}${quoted(a.title, "title", opts)}`;
362
+ case "vote":
363
+ // What #id is comes from the digest the vote binds, never from the id (audit gap-7-6).
364
+ 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}`;
365
+ case "unlock":
366
+ return "Release the account's expired vote locks";
367
+ case "multisig_create": {
368
+ const owners = Array.isArray(a.owners) ? a.owners : [];
369
+ 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)"}`;
370
+ }
371
+ case "multisig_propose":
372
+ return `Propose a multisig action: ${describeAction(a.action, opts)}`;
373
+ case "multisig_approve":
374
+ return `Approve multisig action #${n(a.id)}`;
375
+ case "renew_name":
376
+ return `Renew the name ${nameField(a.name)} for ${n(a.years)} year(s)`;
377
+ case "transfer_name":
378
+ 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)`;
379
+ case "release_name":
380
+ return `Release the name ${nameField(a.name)}: anyone can register it afterwards`;
381
+ case "claim_distribution":
382
+ return `Claim ${fangText(a.amount)} from community distribution #${n(a.distribution_id)}`;
383
+ case "attest_revert":
384
+ return `Attest that contribution claim #${n(a.claim_id)}'s PR was reverted`;
385
+ case "shield":
386
+ return `Move ${fangText(a.amount)} into the Ghost Mode pool`;
387
+ case "unshield":
388
+ return `Pay ${fangText(a.amount)} out of the Ghost Mode pool to ${account(a.to)}`;
389
+ case "private_transfer":
390
+ return "A private Ghost Mode transfer";
391
+ case "set_ghost_address":
392
+ return "Publish the account's Ghost Mode address";
393
+ case "ghost_pause":
394
+ return "Vote to pause Ghost Mode (validators)";
395
+ case "validator_ready":
396
+ 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)`;
397
+ case "admit_validator":
398
+ return `Admit candidate validator ${account(a.validator)} into a set of one or two at the end of epoch ${n(a.epoch)} (validators)`;
399
+ case "upload_code":
400
+ return `Upload ${blob(a.wasm, wasmHash, "program code")} with ${blob(a.interface, interfaceHash, "interface")}${storage(a.storage_limit, " for whichever isn't stored yet")}`;
401
+ case "create_program": {
402
+ const [program, upgradable] = upgrades(a);
403
+ const transfers = a.accepts_transfers === true ? ", accepting transfers" : a.accepts_transfers === false ? "" : ", with an unreadable transfer setting";
404
+ return `Create ${program} from code ${hex(a.wasm_hash, 32, "code hash")} and interface ${hex(a.interface_hash, 32, "interface hash")}${upgradable}${transfers}${initializer(a.init)}${storage(a.storage_limit)}${bindings(a.bindings)}`;
405
+ }
406
+ case "call_program": {
407
+ if (!Array.isArray(a.calls) || a.calls.length === 0)
408
+ return `Call programs: an unreadable list of calls${bindings(a.bindings)}`;
409
+ const calls = a.calls.map(obj);
410
+ // The programs called, each once, in the order the calls first name them.
411
+ const programs = [...new Set(calls.map((c) => addressOnly(c.program, "program")))];
412
+ const what = calls.length === 1 ? `Call program ${programs[0]}`
413
+ : programs.length === 1 ? `Make ${calls.length} calls to program ${programs[0]} (all or nothing)`
414
+ : `Call ${programs.length} programs (${programs.join(", ")}) in ${calls.length} calls (all or nothing)`;
415
+ const value = callValue(calls);
416
+ const attached = value === undefined ? " with an unreadable amount of FANG attached" : value > 0n ? ` with ${fangText(value)} attached` : "";
417
+ return `${what}${attached}${storage(a.storage_limit)}${bindings(a.bindings)}`;
418
+ }
419
+ case "programs_pause":
420
+ return "Vote to pause programs (validators: they pause once f + 1 of the epoch's validators have)";
421
+ default:
422
+ 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`;
423
+ }
424
+ }
425
+ /**
426
+ * `describeAction`, and whether the description is complete enough to approve on (a multisig
427
+ * co-signer's check). For a `vote`, pass the node's read of the proposal it names as
428
+ * `opts.proposal` (`client.proposal(action.proposal_id)`): it's described, and approvable, only
429
+ * when its content hashes to the digest the vote binds (audit gap-7-6).
430
+ */
431
+ export function reviewAction(action, opts = {}) {
432
+ const does = describeAction(action, opts);
433
+ const a = obj(action);
434
+ // Strangers' quoted text is left out of the check: it can say "unreadable" too.
435
+ const ours = does.replace(/ \((?:reason|label|title), as written: “[^”]*”\)/g, "");
436
+ const unreadable = /\b(unreadable|can't read|too large for this client)\b/.test(ours);
437
+ const vote = a.type !== "vote" || voteTarget(a, opts).ok;
438
+ // A multisig holds no program action: the node never decodes one inside a multisig proposal.
439
+ const multisigCanHold = !isProgramAction(a);
440
+ return { does, readable: !unreadable && vote && multisigCanHold };
441
+ }
@@ -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;
@@ -0,0 +1,110 @@
1
+ // Community distribution Merkle trees (docs/GOVERNANCE.md "Merkle tree format"): a port of
2
+ // node/src/governance.rs `distribution_leaf`, `merkle_parent`, `build_distribution` and
3
+ // `root_from_proof`. Claims are checked on chain against the proposal's root, so these must match
4
+ // the node byte for byte; test/unit.ts checks the shared vectors (distribution 7).
5
+ import { blake3 } from "@noble/hashes/blake3.js";
6
+ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
7
+ const LEAF_DOMAIN = new TextEncoder().encode("BLOCKULA_DISTRIBUTION_LEAF_V1");
8
+ const NODE_DOMAIN = new TextEncoder().encode("BLOCKULA_DISTRIBUTION_NODE_V1");
9
+ /** Most hashes a claim's proof may carry (the node's `MAX_PROOF`). */
10
+ export const MAX_DISTRIBUTION_PROOF = 64;
11
+ const U64_MAX = (1n << 64n) - 1n;
12
+ function u64le(v, what) {
13
+ const n = BigInt(v);
14
+ if (n < 0n || n > U64_MAX)
15
+ throw new Error(`${what} must fit in a u64`);
16
+ const out = new Uint8Array(8);
17
+ new DataView(out.buffer).setBigUint64(0, n, true);
18
+ return out;
19
+ }
20
+ function concat(...parts) {
21
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
22
+ let at = 0;
23
+ for (const p of parts) {
24
+ out.set(p, at);
25
+ at += p.length;
26
+ }
27
+ return out;
28
+ }
29
+ function compare(a, b) {
30
+ for (let i = 0; i < Math.min(a.length, b.length); i++)
31
+ if (a[i] !== b[i])
32
+ return a[i] - b[i];
33
+ return a.length - b.length;
34
+ }
35
+ /**
36
+ * The leaf of `address`'s `amount` (drops) in distribution `id`:
37
+ * BLAKE3("BLOCKULA_DISTRIBUTION_LEAF_V1" ‖ id u64 LE ‖ len(address) u16 LE ‖ address ‖ amount u64 LE).
38
+ * `address` is the bech32m string exactly as the claimant's tx spells it.
39
+ */
40
+ export function distributionLeaf(id, address, amount) {
41
+ const addr = new TextEncoder().encode(address);
42
+ if (addr.length > 0xffff)
43
+ throw new Error("address too long");
44
+ const len = new Uint8Array([addr.length & 0xff, addr.length >> 8]);
45
+ return blake3(concat(LEAF_DOMAIN, u64le(id, "distribution id"), len, addr, u64le(amount, "amount")));
46
+ }
47
+ /** Sorted-pair inner node: BLAKE3("BLOCKULA_DISTRIBUTION_NODE_V1" ‖ min(a, b) ‖ max(a, b)). */
48
+ export function distributionNode(a, b) {
49
+ const [lo, hi] = compare(a, b) <= 0 ? [a, b] : [b, a];
50
+ return blake3(concat(NODE_DOMAIN, lo, hi));
51
+ }
52
+ /**
53
+ * Root and per-entry proofs (hex sibling hashes from the leaf up), entries in input order, in
54
+ * the reference shape: leaves sorted, paired left to right level by level, an odd last node
55
+ * carried up unchanged. Addresses must be distinct and amounts non-zero.
56
+ */
57
+ export function buildDistribution(id, entries) {
58
+ if (entries.length === 0)
59
+ throw new Error("a distribution needs at least one entry");
60
+ const seen = new Set();
61
+ for (const e of entries) {
62
+ if (seen.has(e.address))
63
+ throw new Error(`${e.address} is listed twice`);
64
+ seen.add(e.address);
65
+ if (e.amount <= 0n)
66
+ throw new Error(`${e.address} gets 0`);
67
+ }
68
+ const leaves = entries.map((e, i) => ({ hash: distributionLeaf(id, e.address, e.amount), i }));
69
+ // Like the node's sort of (hash, index) pairs: by hash, then input order.
70
+ leaves.sort((x, y) => compare(x.hash, y.hash) || x.i - y.i);
71
+ let level = leaves.map((l) => l.hash);
72
+ const pos = new Array(entries.length);
73
+ leaves.forEach((l, p) => (pos[l.i] = p));
74
+ const proofs = entries.map(() => []);
75
+ while (level.length > 1) {
76
+ for (let i = 0; i < pos.length; i++) {
77
+ const sibling = pos[i] ^ 1;
78
+ if (sibling < level.length)
79
+ proofs[i].push(level[sibling]);
80
+ pos[i] = Math.floor(pos[i] / 2);
81
+ }
82
+ const next = [];
83
+ for (let k = 0; k < level.length; k += 2)
84
+ next.push(k + 1 < level.length ? distributionNode(level[k], level[k + 1]) : level[k]);
85
+ level = next;
86
+ }
87
+ return { root: bytesToHex(level[0]), proofs: proofs.map((p) => p.map(bytesToHex)) };
88
+ }
89
+ /** The root a leaf and its proof (hex sibling hashes from the leaf up) lead to (hex). */
90
+ export function distributionRoot(id, address, amount, proof) {
91
+ if (proof.length > MAX_DISTRIBUTION_PROOF)
92
+ throw new Error(`a proof has at most ${MAX_DISTRIBUTION_PROOF} hashes`);
93
+ let h = distributionLeaf(id, address, amount);
94
+ for (const s of proof) {
95
+ const sibling = hexToBytes(s);
96
+ if (sibling.length !== 32)
97
+ throw new Error("proof hashes are 32 bytes of hex");
98
+ h = distributionNode(h, sibling);
99
+ }
100
+ return bytesToHex(h);
101
+ }
102
+ /** Whether `proof` leads from `address`'s leaf for `amount` (drops) in distribution `id` to `root` (hex). */
103
+ export function verifyDistributionProof(root, id, address, amount, proof) {
104
+ try {
105
+ return distributionRoot(id, address, amount, proof) === root.toLowerCase();
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ }