@dusk-network/app-core 0.3.2 → 0.4.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.
package/src/shielded.js CHANGED
@@ -38,6 +38,17 @@ export const HEIGHTS_PER_REQUEST = 15;
38
38
  // (`convert`, `mint`, `withdraw`), where a `phoenix` event lists whole notes.
39
39
  const RECEIVER_START = 328;
40
40
  const RECEIVER_END = 392;
41
+ // Such an event makes one transparent note, and carries the note's whole
42
+ // shielded address with its value right after it; the note holds that
43
+ // value in the 8 bytes after its position.
44
+ const ADDRESS_END = 648;
45
+ const VALUE_START = 656;
46
+ const VALUE_END = 664;
47
+ const MAKES_NOTE = new Set(["convert", "mint", "withdraw"]);
48
+ // A `convert` event: its sender, then where its value went, a shielded
49
+ // address (0) or a public account (1).
50
+ const CONVERT_BYTES = 544;
51
+ const CONVERT_RECEIVER = 208;
41
52
  // A request for 15 blocks and their events may take a while.
42
53
  const BLOCKS_TIMEOUT_MS = 30_000;
43
54
  const MAX_U64 = (1n << 64n) - 1n;
@@ -328,6 +339,24 @@ function randomIndex(n, random) {
328
339
 
329
340
  const cryptoRandom = () => crypto.getRandomValues(new Uint32Array(1))[0];
330
341
 
342
+ /**
343
+ * The heights in a random order.
344
+ *
345
+ * @param {ReadonlyArray<number>} list
346
+ * @param {() => number} random
347
+ */
348
+ function shuffled(list, random) {
349
+ const heights = [...list];
350
+
351
+ for (let i = heights.length - 1; i > 0; i -= 1) {
352
+ const j = randomIndex(i + 1, random);
353
+
354
+ [heights[i], heights[j]] = [heights[j], heights[i]];
355
+ }
356
+
357
+ return heights;
358
+ }
359
+
331
360
  /**
332
361
  * @param {ReadonlyArray<number>} pool
333
362
  * @param {ReadonlyArray<number>} real
@@ -348,15 +377,7 @@ export function pickHeights(pool, real, { keep = [], perHeight = DECOYS_PER_HEIG
348
377
  free.pop();
349
378
  }
350
379
 
351
- const heights = [...own, ...decoys];
352
-
353
- for (let i = heights.length - 1; i > 0; i -= 1) {
354
- const j = randomIndex(i + 1, random);
355
-
356
- [heights[i], heights[j]] = [heights[j], heights[i]];
357
- }
358
-
359
- return { decoys, heights };
380
+ return { decoys, heights: shuffled([...own, ...decoys], random) };
360
381
  }
361
382
 
362
383
  // ---------------------------------------------------------------------------
@@ -430,11 +451,12 @@ export async function fetchShieldedBlocks(archive, heights, { isCurrent = always
430
451
  // ---------------------------------------------------------------------------
431
452
 
432
453
  /**
433
- * What one transaction did to the profile's notes.
454
+ * What one transaction did to the profile's notes, and what its `convert`
455
+ * event says it was.
434
456
  *
435
- * @typedef {{ hash: string, height: number, made: bigint, spent: bigint, topics: Set<string> }} Tally
457
+ * @typedef {{ hash: string, height: number, made: bigint, spent: bigint, topics: Set<string>, conversion: EntryKind | null }} Tally
436
458
  *
437
- * @typedef {{ matched: boolean, part: string, value: bigint, whole: string }} MadeNote
459
+ * @typedef {{ matched: boolean, part: string, tail: string, value: bigint, whole: string }} MadeNote
438
460
  */
439
461
 
440
462
  /**
@@ -451,35 +473,78 @@ function hexHas(hay, needle) {
451
473
  return false;
452
474
  }
453
475
 
476
+ /**
477
+ * What a `convert` event did: a shield, into a note, or an unshield, to a
478
+ * public account; null for an event of another shape.
479
+ *
480
+ * @param {string} data - its data, as lower-case hex
481
+ * @returns {EntryKind | null}
482
+ */
483
+ function conversionOf(data) {
484
+ const tag = data.length === 2 * CONVERT_BYTES ? data.slice(2 * CONVERT_RECEIVER, 2 * CONVERT_RECEIVER + 2) : "";
485
+
486
+ return tag === "00" ? ENTRY_KIND.SHIELD : tag === "01" ? ENTRY_KIND.UNSHIELD : null;
487
+ }
488
+
454
489
  /**
455
490
  * Adds one transfer contract event to its transaction's tally. A `phoenix`
456
491
  * event names the nullifiers its transaction spent and the notes it made
457
- * (change and the fee's refund among them); an event paying a shielded
458
- * receiver from a public account or a contract names part of the note.
492
+ * (change and the fee's refund among them), whole; an event paying a
493
+ * shielded receiver from a public account or a contract made one note, and
494
+ * is kept in `makers` until every whole note of the block is matched.
459
495
  *
460
496
  * @param {Tally} tally
461
497
  * @param {any} event
462
498
  * @param {MadeNote[]} made - the profile's notes at this height
463
499
  * @param {{ nullifier: string, value: bigint }[]} spent - the profile's spent notes
500
+ * @param {{ tally: Tally, data: string }[]} makers
464
501
  */
465
- function addTransferEvent(tally, event, made, spent) {
502
+ function addTransferEvent(tally, event, made, spent, makers) {
466
503
  const data = String(event.data).toLowerCase();
467
- const phoenix = event.topic === "phoenix";
504
+ const topic = String(event.topic);
468
505
 
469
- tally.topics.add(String(event.topic));
506
+ tally.topics.add(topic);
470
507
 
471
- if (phoenix) {
472
- for (const n of spent) if (hexHas(data, n.nullifier)) tally.spent += n.value;
473
- }
508
+ if (topic === "convert") tally.conversion ??= conversionOf(data);
509
+ if (MAKES_NOTE.has(topic)) makers.push({ data, tally });
510
+ if (topic !== "phoenix") return;
511
+
512
+ for (const n of spent) if (hexHas(data, n.nullifier)) tally.spent += n.value;
474
513
 
475
514
  for (const n of made) {
476
- if (!n.matched && hexHas(data, phoenix ? n.whole : n.part)) {
515
+ if (!n.matched && hexHas(data, n.whole)) {
477
516
  tally.made += n.value;
478
517
  n.matched = true;
479
518
  }
480
519
  }
481
520
  }
482
521
 
522
+ /**
523
+ * Gives each event that made one note the note it made: first the one
524
+ * whose shielded address and value it carries, then one whose address it
525
+ * names, so two notes to the same address in one block go to an event
526
+ * each.
527
+ *
528
+ * @param {{ tally: Tally, data: string }[]} makers
529
+ * @param {MadeNote[]} made
530
+ */
531
+ function matchMakers(makers, made) {
532
+ /** @type {Set<{ tally: Tally, data: string }>} */
533
+ const done = new Set();
534
+
535
+ for (const key of /** @type {const} */ (["tail", "part"])) {
536
+ for (const maker of makers) {
537
+ const note = done.has(maker) ? undefined : made.find((n) => !n.matched && hexHas(maker.data, n[key]));
538
+
539
+ if (!note) continue;
540
+
541
+ maker.tally.made += note.value;
542
+ note.matched = true;
543
+ done.add(maker);
544
+ }
545
+ }
546
+ }
547
+
483
548
  /**
484
549
  * @param {Tally} t
485
550
  * @returns {EntryKind}
@@ -488,7 +553,7 @@ function kindOf(t) {
488
553
  if (t.topics.has("stake:stake")) return ENTRY_KIND.STAKE;
489
554
  if (t.topics.has("stake:unstake")) return ENTRY_KIND.UNSTAKE;
490
555
  if (t.topics.has("stake:withdraw")) return ENTRY_KIND.WITHDRAW_REWARD;
491
- if (t.topics.has("convert")) return t.spent > 0n ? ENTRY_KIND.UNSHIELD : ENTRY_KIND.SHIELD;
556
+ if (t.topics.has("convert")) return t.conversion ?? (t.spent > 0n ? ENTRY_KIND.UNSHIELD : ENTRY_KIND.SHIELD);
492
557
 
493
558
  return t.topics.has("deposit") ? ENTRY_KIND.CONTRACT_CALL : ENTRY_KIND.TRANSFER;
494
559
  }
@@ -559,7 +624,7 @@ function tallyNotes(notes, blocks) {
559
624
  if (known) return known;
560
625
 
561
626
  /** @type {Tally} */
562
- const tally = { hash, height, made: 0n, spent: 0n, topics: new Set() };
627
+ const tally = { conversion: null, hash, height, made: 0n, spent: 0n, topics: new Set() };
563
628
 
564
629
  txs.set(hash, tally);
565
630
 
@@ -575,18 +640,23 @@ function tallyNotes(notes, blocks) {
575
640
  const made = own.map((n) => ({
576
641
  matched: false,
577
642
  part: hex(n.leaf.subarray(RECEIVER_START, RECEIVER_END)),
643
+ tail: hex(n.leaf.subarray(RECEIVER_START, ADDRESS_END)) + hex(n.leaf.subarray(VALUE_START, VALUE_END)),
578
644
  value: BigInt(n.value),
579
645
  whole: hex(n.leaf.subarray(0, NOTE_BYTES)),
580
646
  }));
647
+ /** @type {{ tally: Tally, data: string }[]} */
648
+ const makers = [];
581
649
 
582
650
  for (const event of /** @type {any[]} */ (events)) {
583
651
  const source = String(event.source ?? event.target ?? "").toLowerCase();
584
652
  const hash = String(event.origin).toLowerCase();
585
653
 
586
654
  if (source === STAKE_CONTRACT) tallyOf(hash, height).topics.add(`stake:${event.topic}`);
587
- else if (source === TRANSFER_CONTRACT) addTransferEvent(tallyOf(hash, height), event, made, spent);
655
+ else if (source === TRANSFER_CONTRACT) addTransferEvent(tallyOf(hash, height), event, made, spent, makers);
588
656
  }
589
657
 
658
+ matchMakers(makers, made);
659
+
590
660
  if (height > 0) unnamed += made.filter((n) => !n.matched).length;
591
661
  }
592
662
 
@@ -626,26 +696,41 @@ const fingerprintOf = (notes) =>
626
696
  .join(",");
627
697
 
628
698
  /**
629
- * The kept history, as far as it reads as one; null for none. A store that
630
- * can't be read rejects: taking it for none would draw new decoys for the
631
- * same notes, and a node could compare the two sets.
699
+ * A kept history, as far as it reads as one; null for none.
632
700
  *
633
- * @param {ShieldedStore} store
634
- * @returns {Promise<ShieldedRecord | null>}
701
+ * @param {unknown} kept
702
+ * @returns {ShieldedRecord | null}
635
703
  */
636
- async function keptRecord(store) {
637
- /** @type {any} */
638
- const kept = await store.get();
639
-
704
+ function recordOf(kept) {
640
705
  if (!kept || typeof kept !== "object") return null;
641
706
 
707
+ const { decoys, entries, fingerprint } = /** @type {any} */ (kept);
708
+
642
709
  return {
643
- decoys: Array.isArray(kept.decoys) ? kept.decoys.filter(isHeight) : [],
644
- entries: Array.isArray(kept.entries) ? kept.entries : [],
645
- fingerprint: typeof kept.fingerprint === "string" ? kept.fingerprint : "",
710
+ decoys: Array.isArray(decoys) ? decoys.filter(isHeight) : [],
711
+ entries: Array.isArray(entries) ? entries : [],
712
+ fingerprint: typeof fingerprint === "string" ? fingerprint : "",
646
713
  };
647
714
  }
648
715
 
716
+ /**
717
+ * The kept history; null for none. A store that can't be read rejects:
718
+ * taking it for none would draw new decoys for the same notes, and a node
719
+ * could compare the two sets.
720
+ *
721
+ * @param {ShieldedStore} store
722
+ * @returns {Promise<ShieldedRecord | null>}
723
+ */
724
+ const keptRecord = async (store) => recordOf(await store.get());
725
+
726
+ /**
727
+ * `decoys`, with those of `more` it lacks after them: kept decoys only grow.
728
+ *
729
+ * @param {ReadonlyArray<number>} decoys
730
+ * @param {ReadonlyArray<number>} more
731
+ */
732
+ const grown = (decoys, more) => [...decoys, ...more.filter((h) => !decoys.includes(h))];
733
+
649
734
  /**
650
735
  * @param {ShieldedHistoryOptions} options
651
736
  * @returns {Promise<ShieldedHistory>}
@@ -704,7 +789,7 @@ export async function shieldedHistory({ archive, notes, valueOf, tree, store, tr
704
789
  // Not for a height the tree as read doesn't have: no decoy could be one.
705
790
  if (!pool || real.some((h) => !inTree.has(h))) return unfinished;
706
791
 
707
- const { decoys, heights } = pickHeights(pool, real, { keep: kept?.decoys ?? [], random });
792
+ let { decoys, heights } = pickHeights(pool, real, { keep: kept?.decoys ?? [], random });
708
793
 
709
794
  // Without another height to ask among, asking would name the profile's own.
710
795
  if (!decoys.length) return unfinished;
@@ -712,13 +797,36 @@ export async function shieldedHistory({ archive, notes, valueOf, tree, store, tr
712
797
  // The decoys are kept before any block is asked for, beside what was kept
713
798
  // before: a read that fails partway asks among the same ones next time,
714
799
  // so no two requests differ in their decoys alone. They are read back:
715
- // nothing is asked unless they are kept.
800
+ // nothing is asked unless they are kept. A store with `update` draws and
801
+ // keeps them in one transaction, from the decoys kept then: two reads at
802
+ // once ask among the same ones, the first kept.
716
803
  if (!sameHeights(decoys, kept?.decoys ?? [])) {
717
- await store.put({ decoys, entries: kept?.entries ?? [], fingerprint: kept?.fingerprint ?? "" });
804
+ /** @type {ShieldedRecord | null} */
805
+ let drawn = null;
718
806
 
719
- const back = await keptRecord(store);
807
+ if (typeof store.update === "function") {
808
+ const back = recordOf(
809
+ await store.update((now) => {
810
+ const record = recordOf(now);
811
+ const more = pickHeights(pool, real, { keep: record?.decoys ?? [], random }).decoys;
720
812
 
721
- if (!back || !sameHeights(back.decoys, decoys)) throw new Error("The decoys weren't kept, so no block was asked for");
813
+ return (drawn = { decoys: more, entries: record?.entries ?? [], fingerprint: record?.fingerprint ?? "" });
814
+ })
815
+ ) ?? (await keptRecord(store));
816
+
817
+ if (!back || !drawn || !sameHeights(back.decoys, /** @type {ShieldedRecord} */ (drawn).decoys)) {
818
+ throw new Error("The decoys weren't kept, so no block was asked for");
819
+ }
820
+
821
+ decoys = back.decoys;
822
+ heights = shuffled([...real, ...decoys], random ?? cryptoRandom);
823
+ } else {
824
+ await store.put({ decoys, entries: kept?.entries ?? [], fingerprint: kept?.fingerprint ?? "" });
825
+
826
+ const back = await keptRecord(store);
827
+
828
+ if (!back || !sameHeights(back.decoys, decoys)) throw new Error("The decoys weren't kept, so no block was asked for");
829
+ }
722
830
 
723
831
  current();
724
832
  }
@@ -737,7 +845,12 @@ export async function shieldedHistory({ archive, notes, valueOf, tree, store, tr
737
845
 
738
846
  const entries = entriesOf(tallies, blocks);
739
847
 
740
- await store.put({ decoys, entries, fingerprint });
848
+ // Kept over the decoys kept now, which only grow.
849
+ if (typeof store.update === "function") {
850
+ await store.update((now) => ({ decoys: grown(recordOf(now)?.decoys ?? [], decoys), entries, fingerprint }));
851
+ } else {
852
+ await store.put({ decoys, entries, fingerprint });
853
+ }
741
854
 
742
855
  // A wallet that changed while its history was kept gets none of it.
743
856
  current();