@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/dist/tx.js CHANGED
@@ -1,32 +1,159 @@
1
1
  // Canonical transaction encoding. Must stay byte-identical to node/src/types.rs `Tx::signing_bytes`.
2
+ import { blake3 } from "@noble/hashes/blake3.js";
3
+ import { bytesToHex, concatBytes, hexToBytes } from "@noble/hashes/utils.js";
2
4
  export const PUBLIC_KEY_LEN = 1952;
3
5
  export const SIGNATURE_LEN = 3309;
6
+ /** Domain of the unsigned ghost wire form (`private_transfer`, `unshield`). */
7
+ export const GHOST_TX_DOMAIN = "BLOCKULA_GHOST_V1";
8
+ export const GHOST_ENC_BYTES = 1401;
9
+ export const GHOST_OUT_BYTES = 80;
10
+ export const GHOST_ADDRESS_BYTES = 1216;
11
+ /** The Action circuit the node verifies proofs against (blockula-ghost `CIRCUIT_ID`). */
12
+ export const GHOST_CIRCUIT_ID = 1;
4
13
  export const DROPS_PER_FANG = 1000000000n;
5
14
  const utf8 = new TextEncoder();
15
+ const U64_LIMIT = 1n << 64n;
16
+ /**
17
+ * The largest value of a whole-number field a tx carries as a JSON number, not a string (ids,
18
+ * heights, PR numbers, the nonce): 2^53 − 1, what a JavaScript number holds exactly
19
+ * (node/src/types.rs `MAX_JSON_INT`). The chain refuses larger ones, so every stored proposal
20
+ * kind and multisig action reads back exactly here, and its digest can be computed.
21
+ */
22
+ export const MAX_JSON_INT = (1n << 53n) - 1n;
23
+ /** An optional field's JSON default (`#[serde(default)]` in node/src/types.rs): only when it's absent, never for `null`. */
24
+ const orDefault = (v, d) => (v === undefined ? d : v);
25
+ /**
26
+ * An unsigned integer field as the node reads it from JSON (node/src/types.rs: a JSON number,
27
+ * or for amounts a decimal string), checked to be in `0 ..= max`. Anything the node's JSON
28
+ * parser would refuse is refused here, so `signingBytes`/`txBytes` never encode a value other
29
+ * than the one in the tx object (no wrapping of negative or oversized numbers).
30
+ */
31
+ export function uintField(v, max, what = "integer field") {
32
+ let n;
33
+ if (typeof v === "bigint")
34
+ n = v;
35
+ else if (typeof v === "number") {
36
+ if (!Number.isInteger(v))
37
+ throw new RangeError(`${what} must be a whole number, got ${v}`);
38
+ // Above 2^53 − 1 a JavaScript number may not be the value that was meant: pass a string of
39
+ // digits (where the field can be that large).
40
+ if (!Number.isSafeInteger(v)) {
41
+ if (max <= MAX_JSON_INT)
42
+ throw new RangeError(`${what} must be 0 to ${max}, got ${v}${v > Number.MAX_SAFE_INTEGER ? " (past 2^53 − 1: not even exact as a JavaScript number)" : ""}`);
43
+ throw new RangeError(`${what} must be 0 to ${Number.MAX_SAFE_INTEGER} as a number (larger values as a string of digits), got ${v}`);
44
+ }
45
+ n = BigInt(v);
46
+ }
47
+ else if (typeof v === "string") {
48
+ if (!/^\d+$/.test(v))
49
+ throw new RangeError(`${what} must be a decimal string of digits, got ${JSON.stringify(v)}`);
50
+ n = BigInt(v);
51
+ }
52
+ else
53
+ throw new RangeError(`${what} must be a number or a decimal string, got ${typeof v}`);
54
+ if (n < 0n || n > max)
55
+ throw new RangeError(`${what} must be 0 to ${max}, got ${n}`);
56
+ return n;
57
+ }
58
+ /** A u64 field (`uintField` up to 2^64 − 1). */
59
+ export const u64Field = (v, what) => uintField(v, U64_LIMIT - 1n, what);
6
60
  class Writer {
7
61
  parts = [];
8
62
  bytes(b) { for (const x of b)
9
63
  this.parts.push(x); }
10
- u8(n) { this.parts.push(n & 0xff); }
11
- u16(n) { this.u8(n); this.u8(n >> 8); }
12
- u64(n) { for (let i = 0n; i < 8n; i++)
13
- this.u8(Number((n >> (8n * i)) & 0xffn)); }
14
- str(s) { const b = utf8.encode(s); this.u16(b.length); this.bytes(b); }
64
+ /** A byte the encoder chose (tags, bools, lengths' bytes): always in range. */
65
+ byte(n) { this.parts.push(n & 0xff); }
66
+ u8(n, what) { this.byte(Number(uintField(n, 0xffn, what))); }
67
+ u16(n, what) { const v = Number(uintField(n, 0xffffn, what)); this.byte(v); this.byte(v >> 8); }
68
+ u32(n, what) { const v = Number(uintField(n, 0xffffffffn, what)); this.byte(v); this.byte(v >> 8); this.byte(v >> 16); this.byte(v >>> 24); }
69
+ u64(n, what) { this.le64(u64Field(n, what)); }
70
+ /** A u64 the tx carries as a JSON number: at most `MAX_JSON_INT`, as the node's format rules require. */
71
+ int(n, what) { this.le64(uintField(n, MAX_JSON_INT, what)); }
72
+ le64(v) { for (let i = 0n; i < 8n; i++)
73
+ this.byte(Number((v >> (8n * i)) & 0xffn)); }
74
+ bool(b, what = "flag") {
75
+ if (typeof b !== "boolean")
76
+ throw new TypeError(`${what} must be true or false`);
77
+ this.byte(b ? 1 : 0);
78
+ }
79
+ str(s, what = "string field") {
80
+ if (typeof s !== "string")
81
+ throw new TypeError(`${what} must be a string`);
82
+ const b = utf8.encode(s);
83
+ if (b.length > 0xffff)
84
+ throw new Error(`${what} too long to encode`);
85
+ this.u16(b.length);
86
+ this.bytes(b);
87
+ }
88
+ ids(xs, what = "id list") {
89
+ if (!Array.isArray(xs))
90
+ throw new TypeError(`${what} must be an array`);
91
+ this.u16(xs.length, `${what} length`);
92
+ for (const x of xs)
93
+ this.int(x, what);
94
+ }
95
+ /** Raw bytes of exactly `len` bytes, from hex. */
96
+ fixed(hex, len) {
97
+ const b = hexToBytes(hex);
98
+ if (b.length !== len)
99
+ throw new Error(`expected ${len} bytes, got ${b.length}`);
100
+ this.bytes(b);
101
+ }
102
+ strs(xs, what = "string list") {
103
+ if (!Array.isArray(xs))
104
+ throw new TypeError(`${what} must be an array`);
105
+ this.u16(xs.length, `${what} length`);
106
+ for (const x of xs)
107
+ this.str(x, what);
108
+ }
15
109
  done() { return Uint8Array.from(this.parts); }
16
110
  }
111
+ /** A chain's genesis hash as its 32 bytes (`/api/status` `genesis_hash`, lowercase hex). */
112
+ export function genesisHashBytes(genesisHash) {
113
+ if (typeof genesisHash !== "string" || !/^[0-9a-f]{64}$/.test(genesisHash)) {
114
+ throw new Error("genesis_hash must be the chain's genesis hash: 32 bytes of lowercase hex (genesis_hash in /api/status)");
115
+ }
116
+ return hexToBytes(genesisHash);
117
+ }
118
+ /** Unsigned ghost txs (`private_transfer`, `unshield`): no key signs them, their proof authorizes them. */
119
+ export function isUnsignedAction(a) {
120
+ return a.type === "private_transfer" || a.type === "unshield";
121
+ }
122
+ /**
123
+ * The bytes a key signs (node/src/types.rs `Tx::signing_bytes`). For an unsigned ghost tx this is
124
+ * its whole wire form instead: `BLOCKULA_GHOST_V1 ‖ chain_id ‖ genesis hash ‖ fee ‖ tip ‖ valid_until ‖ action`.
125
+ */
17
126
  export function signingBytes(tx) {
18
127
  const w = new Writer();
19
- w.bytes(utf8.encode("BLOCKULA_TX_V2"));
128
+ if (isUnsignedAction(tx.action)) {
129
+ w.bytes(utf8.encode(GHOST_TX_DOMAIN));
130
+ w.str(tx.chain_id);
131
+ w.bytes(genesisHashBytes(tx.genesis_hash));
132
+ w.u64(tx.fee, "fee");
133
+ w.u64(orDefault(tx.tip, "0"), "tip");
134
+ w.int(tx.valid_until, "valid_until");
135
+ encodeAction(w, tx.action);
136
+ return w.done();
137
+ }
138
+ w.bytes(utf8.encode("BLOCKULA_TX_V3"));
20
139
  w.str(tx.chain_id);
140
+ w.bytes(genesisHashBytes(tx.genesis_hash));
21
141
  w.str(tx.from);
22
- w.u64(BigInt(tx.nonce));
23
- w.u64(BigInt(tx.fee));
24
- const a = tx.action;
142
+ w.int(tx.nonce, "nonce");
143
+ w.u64(tx.fee, "fee");
144
+ w.u64(orDefault(tx.tip, "0"), "tip");
145
+ w.int(tx.valid_until, "valid_until");
146
+ encodeAction(w, tx.action);
147
+ w.str(orDefault(tx.memo, ""), "memo");
148
+ return w.done();
149
+ }
150
+ /** An action's tag and fields, as inside the signing bytes (node/src/types.rs `Action::encode`). */
151
+ function encodeAction(w, a) {
25
152
  switch (a.type) {
26
153
  case "transfer":
27
154
  w.u8(0);
28
155
  w.str(a.to);
29
- w.u64(BigInt(a.amount));
156
+ w.u64(a.amount);
30
157
  break;
31
158
  case "register_name":
32
159
  w.u8(1);
@@ -40,10 +167,12 @@ export function signingBytes(tx) {
40
167
  w.u8(0);
41
168
  else {
42
169
  w.u8(1);
43
- w.u64(BigInt(a.role.limit));
44
- w.u64(BigInt(a.role.period_blocks));
45
- w.u64(BigInt(a.role.expires_at));
170
+ w.u64(a.role.limit);
171
+ w.int(a.role.period_blocks, "period_blocks");
172
+ w.int(a.role.expires_at, "expires_at");
46
173
  }
174
+ w.str(a.public_key);
175
+ w.str(a.pop);
47
176
  break;
48
177
  case "remove_key":
49
178
  w.u8(3);
@@ -54,7 +183,7 @@ export function signingBytes(tx) {
54
183
  w.str(a.consensus_key);
55
184
  w.str(a.p2p_addr);
56
185
  w.str(a.http_addr);
57
- w.u64(BigInt(a.bond));
186
+ w.u64(a.bond);
58
187
  w.str(a.pop);
59
188
  break;
60
189
  case "approve_validator":
@@ -81,34 +210,485 @@ export function signingBytes(tx) {
81
210
  case "create_hunt":
82
211
  w.u8(10);
83
212
  w.str(a.repo);
84
- w.u64(BigInt(a.issue));
85
- w.u64(BigInt(a.amount));
86
- w.u64(BigInt(a.expires_at));
213
+ w.int(a.issue, "issue");
214
+ w.u64(a.amount);
215
+ w.int(a.expires_at, "expires_at");
87
216
  w.str(a.title);
88
217
  break;
89
218
  case "fund_hunt":
90
219
  w.u8(11);
91
- w.u64(BigInt(a.hunt_id));
92
- w.u64(BigInt(a.amount));
220
+ w.int(a.hunt_id, "hunt_id");
221
+ w.u64(a.amount);
93
222
  break;
94
223
  case "award_hunt":
95
224
  w.u8(12);
96
- w.u64(BigInt(a.hunt_id));
225
+ w.int(a.hunt_id, "hunt_id");
97
226
  w.str(a.to);
98
- w.u64(BigInt(a.pr));
227
+ w.int(a.pr, "pr");
99
228
  w.str(a.familiar_key ?? "");
100
229
  break;
101
230
  case "cancel_hunt":
102
231
  w.u8(13);
103
- w.u64(BigInt(a.hunt_id));
232
+ w.int(a.hunt_id, "hunt_id");
104
233
  break;
105
234
  case "register_familiar":
106
235
  w.u8(14);
107
236
  w.str(a.key_id);
108
237
  w.str(a.label);
109
238
  break;
239
+ case "set_repo":
240
+ w.u8(15);
241
+ w.str(a.repo);
242
+ w.int(a.weight, "weight");
243
+ break;
244
+ case "grant_soul":
245
+ w.u8(16);
246
+ w.str(a.to);
247
+ w.int(a.amount, "amount");
248
+ w.str(a.reason);
249
+ break;
250
+ case "vouch":
251
+ w.u8(17);
252
+ w.str(a.account);
253
+ break;
254
+ case "unvouch":
255
+ w.u8(18);
256
+ w.str(a.account);
257
+ break;
258
+ case "flag_sybil":
259
+ w.u8(19);
260
+ w.str(a.account);
261
+ w.str(a.reason);
262
+ break;
263
+ case "submit_claim":
264
+ w.u8(20);
265
+ w.str(a.repo);
266
+ w.int(a.pr, "pr");
267
+ w.str(a.familiar_key ?? "");
268
+ break;
269
+ case "attest_claim":
270
+ w.u8(21);
271
+ w.int(a.claim_id, "claim_id");
272
+ w.bool(a.ok, "ok");
273
+ w.str(a.merge_sha);
274
+ w.str(orDefault(a.note, ""));
275
+ break;
276
+ case "reject_claim":
277
+ w.u8(22);
278
+ w.int(a.claim_id, "claim_id");
279
+ w.str(a.reason);
280
+ break;
281
+ case "join_jury":
282
+ w.u8(23);
283
+ w.u64(a.bond);
284
+ break;
285
+ case "leave_jury":
286
+ w.u8(24);
287
+ break;
288
+ case "commit_votes":
289
+ w.u8(25);
290
+ w.int(a.seance_id, "seance_id");
291
+ w.str(a.commitment);
292
+ w.str(a.pairs_digest);
293
+ break;
294
+ case "reveal_votes":
295
+ w.u8(26);
296
+ w.int(a.seance_id, "seance_id");
297
+ w.str(a.votes);
298
+ w.ids(a.spam, "spam");
299
+ w.str(a.salt);
300
+ break;
301
+ case "fund_pool":
302
+ w.u8(27);
303
+ w.u64(a.amount);
304
+ break;
305
+ case "withdraw_vested":
306
+ w.u8(28);
307
+ break;
308
+ case "clawback":
309
+ w.u8(29);
310
+ w.str(a.account);
311
+ w.str(a.reason);
312
+ break;
313
+ case "delegate":
314
+ w.u8(30);
315
+ w.str(a.validator);
316
+ w.u64(a.amount);
317
+ break;
318
+ case "undelegate":
319
+ w.u8(31);
320
+ w.str(a.validator);
321
+ w.u64(a.shares);
322
+ w.u64(orDefault(a.amount, "0"));
323
+ break;
324
+ case "redelegate":
325
+ w.u8(32);
326
+ w.str(a.from_validator);
327
+ w.str(a.to_validator);
328
+ w.u64(a.shares);
329
+ break;
330
+ case "withdraw_unbonded":
331
+ w.u8(33);
332
+ break;
333
+ case "update_validator":
334
+ w.u8(34);
335
+ w.int(a.commission_bps, "commission_bps");
336
+ w.str(a.p2p_addr);
337
+ w.str(a.http_addr);
338
+ break;
339
+ case "unjail":
340
+ w.u8(35);
341
+ w.str(a.validator);
342
+ break;
343
+ case "report_equivocation":
344
+ w.u8(36);
345
+ w.str(a.evidence);
346
+ break;
347
+ case "beacon_commit":
348
+ w.u8(37);
349
+ w.int(a.epoch, "epoch");
350
+ w.str(a.commitment);
351
+ break;
352
+ case "beacon_reveal":
353
+ w.u8(38);
354
+ w.int(a.epoch, "epoch");
355
+ w.str(a.secret);
356
+ break;
357
+ case "propose":
358
+ w.u8(39);
359
+ encodeProposalKind(w, a.kind);
360
+ w.u64(a.deposit);
361
+ w.str(a.title);
362
+ w.str(orDefault(a.url, ""));
363
+ break;
364
+ case "vote":
365
+ w.u8(40);
366
+ w.int(a.proposal_id, "proposal_id");
367
+ w.bool(a.aye, "aye");
368
+ w.u64(a.amount);
369
+ w.u8(a.conviction, "conviction");
370
+ w.str(a.proposal_digest);
371
+ break;
372
+ case "unlock":
373
+ w.u8(41);
374
+ break;
375
+ case "multisig_create":
376
+ w.u8(42);
377
+ w.strs(a.owners);
378
+ w.u8(a.threshold, "threshold");
379
+ break;
380
+ case "multisig_propose":
381
+ if (a.action.type === "multisig_propose")
382
+ throw new Error("a multisig proposal can't contain another");
383
+ w.u8(43);
384
+ encodeAction(w, a.action);
385
+ break;
386
+ case "multisig_approve":
387
+ w.u8(44);
388
+ w.int(a.id, "id");
389
+ w.str(a.action_digest);
390
+ break;
391
+ case "renew_name":
392
+ w.u8(45);
393
+ w.str(a.name);
394
+ w.u8(a.years, "years");
395
+ break;
396
+ case "transfer_name":
397
+ w.u8(46);
398
+ w.str(a.name);
399
+ w.str(a.to);
400
+ break;
401
+ case "release_name":
402
+ w.u8(47);
403
+ w.str(a.name);
404
+ break;
405
+ case "claim_distribution":
406
+ w.u8(48);
407
+ w.int(a.distribution_id, "distribution_id");
408
+ w.u64(a.amount);
409
+ w.strs(a.proof);
410
+ break;
411
+ case "attest_revert":
412
+ w.u8(49);
413
+ w.int(a.claim_id, "claim_id");
414
+ w.str(a.revert_sha);
415
+ break;
416
+ case "shield":
417
+ w.u8(50);
418
+ w.u64(a.amount);
419
+ encodeBundle(w, a.bundle);
420
+ break;
421
+ case "private_transfer":
422
+ w.u8(51);
423
+ encodeBundle(w, a.bundle);
424
+ break;
425
+ case "unshield":
426
+ w.u8(52);
427
+ w.str(a.to);
428
+ w.u64(a.amount);
429
+ encodeBundle(w, a.bundle);
430
+ break;
431
+ case "set_ghost_address":
432
+ w.u8(53);
433
+ w.fixed(a.address, GHOST_ADDRESS_BYTES);
434
+ break;
435
+ case "ghost_pause":
436
+ w.u8(54);
437
+ break;
438
+ case "validator_ready":
439
+ w.u8(60);
440
+ w.int(a.epoch, "epoch");
441
+ break;
442
+ case "admit_validator":
443
+ w.u8(61);
444
+ w.str(a.validator);
445
+ w.int(a.epoch, "epoch");
446
+ break;
447
+ default:
448
+ throw new Error(`unknown action type ${a.type}`);
449
+ }
450
+ }
451
+ /** A bundle without its proof, raw and in wire order: what the sighash covers. */
452
+ function encodeBundlePublic(w, b) {
453
+ w.fixed(b.instance, 32);
454
+ w.fixed(b.anchor, 32);
455
+ for (const x of [...b.nullifiers, ...b.commitments])
456
+ w.fixed(x, 32);
457
+ for (const x of b.enc)
458
+ w.fixed(x, GHOST_ENC_BYTES);
459
+ for (const x of b.out)
460
+ w.fixed(x, GHOST_OUT_BYTES);
461
+ }
462
+ /** node/src/types.rs `GhostBundle::encode`: the fixed fields, then the proof with a u32 length. */
463
+ function encodeBundle(w, b) {
464
+ encodeBundlePublic(w, b);
465
+ const proof = hexToBytes(b.proof);
466
+ w.u32(proof.length);
467
+ w.bytes(proof);
468
+ }
469
+ /**
470
+ * What a ghost tx's Action proof is bound to (node/src/types.rs `Tx::ghost_sighash`):
471
+ * BLAKE3-KDF("blockula ghost v1 tx sighash", chain_id ‖ genesis hash ‖ tag ‖ valid_until ‖ fee ‖
472
+ * tip ‖ the tag's own fields (shield: from, nonce, amount; unshield: to, amount) ‖ the bundle
473
+ * without its proof ‖ circuit id). A wallet proves with this as the witness's sighash, then fills
474
+ * in the proof.
475
+ */
476
+ export function ghostSighash(tx) {
477
+ const a = tx.action;
478
+ if (a.type !== "shield" && a.type !== "private_transfer" && a.type !== "unshield")
479
+ throw new Error("not a ghost transfer");
480
+ const w = new Writer();
481
+ w.str(tx.chain_id);
482
+ w.bytes(genesisHashBytes(tx.genesis_hash));
483
+ w.u8(a.type === "shield" ? 50 : a.type === "private_transfer" ? 51 : 52);
484
+ w.int(tx.valid_until, "valid_until");
485
+ w.u64(tx.fee, "fee");
486
+ w.u64(orDefault(tx.tip, "0"), "tip");
487
+ if (a.type === "shield") {
488
+ w.str(tx.from);
489
+ w.int(tx.nonce, "nonce");
490
+ w.u64(a.amount);
110
491
  }
111
- w.str(tx.memo);
492
+ if (a.type === "unshield") {
493
+ w.str(a.to);
494
+ w.u64(a.amount);
495
+ }
496
+ encodeBundlePublic(w, a.bundle);
497
+ w.u32(GHOST_CIRCUIT_ID);
498
+ return blake3(w.done(), { context: utf8.encode("blockula ghost v1 tx sighash") });
499
+ }
500
+ /** A proposal kind's own tag and fields (node/src/types.rs `ProposalKind::encode`). */
501
+ function encodeProposalKind(w, k) {
502
+ switch (k.type) {
503
+ case "param_change":
504
+ w.u8(0);
505
+ w.str(k.key);
506
+ w.u64(k.value);
507
+ break;
508
+ case "treasury_spend":
509
+ w.u8(1);
510
+ w.str(k.to);
511
+ w.u64(k.amount);
512
+ break;
513
+ case "distribution":
514
+ w.u8(2);
515
+ w.int(k.distribution_id, "distribution_id");
516
+ w.str(k.merkle_root);
517
+ w.u64(k.total);
518
+ w.int(k.claim_until, "claim_until");
519
+ break;
520
+ case "set_repo":
521
+ w.u8(3);
522
+ w.str(k.repo);
523
+ w.int(k.weight, "weight");
524
+ break;
525
+ case "grant_soul":
526
+ w.u8(4);
527
+ w.str(k.to);
528
+ w.int(k.amount, "amount");
529
+ w.str(k.reason);
530
+ break;
531
+ case "flag_sybil":
532
+ w.u8(5);
533
+ w.str(k.account);
534
+ w.str(k.reason);
535
+ break;
536
+ case "reject_claim":
537
+ w.u8(6);
538
+ w.int(k.claim_id, "claim_id");
539
+ w.str(k.reason);
540
+ break;
541
+ case "clawback":
542
+ w.u8(7);
543
+ w.str(k.account);
544
+ w.str(k.reason);
545
+ break;
546
+ case "unlink_github":
547
+ w.u8(8);
548
+ w.str(k.handle);
549
+ break;
550
+ case "refund_hunt":
551
+ w.u8(9);
552
+ w.int(k.hunt_id, "hunt_id");
553
+ break;
554
+ case "schedule_upgrade":
555
+ w.u8(10);
556
+ w.u32(k.version, "version");
557
+ w.int(k.activation_height, "activation_height");
558
+ break;
559
+ case "activate_feature":
560
+ w.u8(11);
561
+ w.str(k.feature);
562
+ break;
563
+ case "deactivate_feature":
564
+ w.u8(12);
565
+ w.str(k.feature);
566
+ break;
567
+ case "text":
568
+ w.u8(13);
569
+ break;
570
+ case "unflag_sybil":
571
+ w.u8(14);
572
+ w.str(k.account);
573
+ w.str(k.reason);
574
+ break;
575
+ case "cancel_upgrade":
576
+ w.u8(15);
577
+ w.u32(k.version, "version");
578
+ w.int(k.activation_height, "activation_height");
579
+ break;
580
+ default:
581
+ throw new Error(`unknown proposal kind ${k.type}`);
582
+ }
583
+ }
584
+ /**
585
+ * What a Séance juror seals before revealing (node/src/types.rs `seance_commitment`): hex
586
+ * BLAKE3 of the Séance id, the juror's account, the votes, spam flags and a secret salt.
587
+ */
588
+ export function seanceCommitment(seanceId, juror, votes, spam, salt) {
589
+ const w = new Writer();
590
+ w.bytes(utf8.encode("BLOCKULA_SEANCE_V1"));
591
+ w.int(seanceId, "seance_id");
592
+ w.str(juror);
593
+ w.str(votes);
594
+ w.ids(spam, "spam");
595
+ w.str(salt);
596
+ return bytesToHex(blake3(w.done()));
597
+ }
598
+ /**
599
+ * A validator's randomness-beacon commitment (node/src/beacon.rs `commitment`): hex BLAKE3 in
600
+ * derive-key mode ("blockula beacon commitment v1") over the chain id, the target epoch, the
601
+ * validator's address and its 32-byte hex secret. Strings are u64-length-prefixed, integers
602
+ * u64 little-endian.
603
+ */
604
+ export function beaconCommitment(chainId, epoch, validator, secret) {
605
+ const u64 = (n) => {
606
+ const b = new Uint8Array(8);
607
+ // setBigUint64 would wrap a negative or oversized value: check it first.
608
+ new DataView(b.buffer).setBigUint64(0, u64Field(n, "epoch"), true);
609
+ return b;
610
+ };
611
+ const str = (s) => {
612
+ const b = utf8.encode(s);
613
+ return concatBytes(u64(b.length), b);
614
+ };
615
+ const key = hexToBytes(secret);
616
+ if (key.length !== 32)
617
+ throw new Error("a beacon secret is 32 bytes");
618
+ return bytesToHex(blake3(concatBytes(str(chainId), u64(epoch), str(validator), key), { context: utf8.encode("blockula beacon commitment v1") }));
619
+ }
620
+ /** "2.5" Soul → 2500 milli-Soul. */
621
+ export function parseSoul(soul) {
622
+ const s = String(soul).trim();
623
+ if (!/^\d+(\.\d{1,3})?$/.test(s))
624
+ throw new Error(`invalid Soul amount: ${s}`);
625
+ const [whole, frac = ""] = s.split(".");
626
+ return Number(whole) * 1000 + Number(frac.padEnd(3, "0"));
627
+ }
628
+ /**
629
+ * What a `multisig_approve` binds (node/src/types.rs `action_digest`): BLAKE3 (derive-key mode,
630
+ * context "blockula multisig action v1") of the action's encoding, as inside the signing bytes.
631
+ * Compute it from the action you show the approver (`client.multisig(account)` `pending[].action`).
632
+ */
633
+ export function actionDigest(action) {
634
+ return digestOf("multisig action", "blockula multisig action v1", (w) => encodeAction(w, action));
635
+ }
636
+ /**
637
+ * What a `vote` binds (node/src/types.rs `proposal_digest`): the proposal's kind (accounts as
638
+ * stored, resolved), title and link. Compute it from the proposal you show the voter.
639
+ */
640
+ export function proposalDigest(kind, title, url = "") {
641
+ return digestOf("proposal", "blockula proposal v1", (w) => {
642
+ encodeProposalKind(w, kind);
643
+ w.str(title);
644
+ w.str(url);
645
+ });
646
+ }
647
+ /**
648
+ * BLAKE3 (derive-key mode, `context`) of what `encode` writes. A field out of range (a JSON
649
+ * number past 2^53 − 1 arrives rounded, and can't be the value the chain stored) fails with
650
+ * what's wrong and where it likely came from, not a bare encoder error (review-r2-clients-1).
651
+ */
652
+ function digestOf(what, context, encode) {
653
+ const w = new Writer();
654
+ try {
655
+ encode(w);
656
+ }
657
+ catch (e) {
658
+ if (e instanceof RangeError)
659
+ throw new RangeError(`can't compute this ${what}'s digest: ${e.message}. The chain stores no ${what} with a whole number past 2^53 − 1 (the most a JSON number carries exactly), so the node it was read from may be misreporting it: read it from another node`);
660
+ throw e;
661
+ }
662
+ return bytesToHex(blake3(w.done(), { context: utf8.encode(context) }));
663
+ }
664
+ /**
665
+ * What a `commit_votes` binds (node/src/types.rs `seance_pairs_digest`): the Séance, the juror and,
666
+ * per assigned pair in order, both claims' ids, repos and PR numbers.
667
+ */
668
+ export function seancePairsDigest(seanceId, juror, pairs) {
669
+ const w = new Writer();
670
+ w.int(seanceId, "seance_id");
671
+ w.str(juror);
672
+ w.u16(pairs.length);
673
+ for (const pair of pairs)
674
+ for (const c of pair) {
675
+ w.int(c.id, "claim id");
676
+ w.str(c.repo);
677
+ w.int(c.pr, "pr");
678
+ }
679
+ return bytesToHex(blake3(w.done(), { context: utf8.encode("blockula seance pairs v1") }));
680
+ }
681
+ /** FIPS 204 context string for a key's proof of possession in `add_key` (node/src/types.rs `KEY_POP_CONTEXT`). */
682
+ export const KEY_POP_CONTEXT = utf8.encode("blockula/key-pop/v1");
683
+ /**
684
+ * What a key signs to consent to being added to `account` (node/src/types.rs `key_pop_message`):
685
+ * the chain instance, the account and the key's own id (audit gap-7-2).
686
+ */
687
+ export function keyPopMessage(chainId, genesisHash, account, keyId) {
688
+ const w = new Writer();
689
+ w.bytes(utf8.encode("BLOCKULA_KEY_POP_V1"));
690
+ for (const s of [chainId, genesisHash, account, keyId])
691
+ w.str(s);
112
692
  return w.done();
113
693
  }
114
694
  /** FIPS 204 context string for validator proofs of possession (node/src/types.rs `POP_CONTEXT`). */
@@ -121,10 +701,57 @@ export function popMessage(chainId, owner, p2pAddr, httpAddr) {
121
701
  w.str(s);
122
702
  return w.done();
123
703
  }
124
- /** Mirrors `min_fee` in node/src/types.rs: base fee is priced per byte and burned. */
704
+ /** Wire size in bytes (node/src/types.rs `Tx::size`): signing bytes plus the key and signature (none for an unsigned ghost tx). Fees are priced on it. */
705
+ export function txSize(tx) {
706
+ return signingBytes(tx).length + (isUnsignedAction(tx.action) ? 0 : PUBLIC_KEY_LEN + SIGNATURE_LEN);
707
+ }
708
+ /** The base fee at its genesis floor: 10,000 drops + 10 per byte (`min_fee`). */
709
+ export const FLOOR_BASE_FEE = { flat: 10000n, rate: 10000n };
710
+ /** Mirrors `FeeSlot::fee` in node/src/econ.rs: `flat + rate × size / 1000`, rounded down. Burned. */
711
+ export function baseFee(tx, fee = FLOOR_BASE_FEE) {
712
+ return BigInt(fee.flat) + (BigInt(fee.rate) * BigInt(txSize(tx))) / 1000n;
713
+ }
714
+ /** Mirrors `min_fee` in node/src/types.rs: the base fee at its floor (an idle chain's). */
125
715
  export function minFee(tx) {
126
- const size = signingBytes(tx).length + PUBLIC_KEY_LEN + SIGNATURE_LEN;
127
- return 10000n + BigInt(size) * 10n;
716
+ return baseFee(tx, FLOOR_BASE_FEE);
717
+ }
718
+ /** The chain's default `tip_cap_multiple` (node/src/params.rs): a tip may be at most this many times the base fee of the block that includes it. */
719
+ export const DEFAULT_TIP_CAP_MULTIPLE = 20;
720
+ /**
721
+ * The most this tx may tip in a block with this base fee (node/src/econ.rs `max_tip`): `multiple` × its base fee. Nodes refuse
722
+ * a bigger tip. `/api/fees` serves the chain's `tip_cap_multiple` and `lowest`, the base fee a tip fits under in any upcoming block.
723
+ * Tips are also paid only from transferable FANG (locks hold them), and a multisig's owner keys or a validator's consensus key
724
+ * acting for its owner can't tip at all.
725
+ */
726
+ export function maxTip(tx, fee = FLOOR_BASE_FEE, multiple = DEFAULT_TIP_CAP_MULTIPLE) {
727
+ return BigInt(multiple) * baseFee(tx, fee);
728
+ }
729
+ /** What the tx is charged in a block with this base fee (node/src/econ.rs `charge`): base fee + tip. Its `fee` must cover it; nothing more is debited. */
730
+ export function txCharge(tx, fee = FLOOR_BASE_FEE) {
731
+ return baseFee(tx, fee) + u64Field(orDefault(tx.tip, "0"), "tip");
732
+ }
733
+ /**
734
+ * Wire form (node/src/types.rs `Tx::to_bytes`): signing bytes ‖ public key (1,952 B) ‖ signature (3,309 B), or just the
735
+ * unsigned form for an unsigned ghost tx. Blocks and gossip carry this; `POST /api/tx` takes it as `application/octet-stream`.
736
+ */
737
+ export function txBytes(tx) {
738
+ const sb = signingBytes(tx);
739
+ if (isUnsignedAction(tx.action))
740
+ return sb;
741
+ const out = new Uint8Array(sb.length + PUBLIC_KEY_LEN + SIGNATURE_LEN);
742
+ out.set(sb);
743
+ out.set(hexToBytes(tx.public_key), sb.length);
744
+ out.set(hexToBytes(tx.signature), sb.length + PUBLIC_KEY_LEN);
745
+ return out;
746
+ }
747
+ /** A tx's id (node/src/types.rs `Tx::id`): hex BLAKE3 of the signing bytes and the signature. Known before submitting. */
748
+ export function txId(tx) {
749
+ const sb = signingBytes(tx);
750
+ const sig = hexToBytes(tx.signature);
751
+ const buf = new Uint8Array(sb.length + sig.length);
752
+ buf.set(sb);
753
+ buf.set(sig, sb.length);
754
+ return bytesToHex(blake3(buf));
128
755
  }
129
756
  /** "1.5" → 1500000000n drops. Accepts numbers, decimal strings or bigint drops. */
130
757
  export function parseFang(amount) {