@bsv/overlay 0.1.0-alpha.9 → 0.1.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.
Files changed (50) hide show
  1. package/dist/cjs/package.json +1 -0
  2. package/dist/cjs/src/Engine.js +560 -41
  3. package/dist/cjs/src/Engine.js.map +1 -1
  4. package/dist/cjs/src/GASP/OverlayGASPRemote.js +96 -0
  5. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -0
  6. package/dist/cjs/src/GASP/OverlayGASPStorage.js +221 -0
  7. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -0
  8. package/dist/cjs/src/SyncConfiguration.js +3 -0
  9. package/dist/cjs/src/SyncConfiguration.js.map +1 -0
  10. package/dist/cjs/src/storage/knex/KnexStorage.js +20 -0
  11. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/Engine.js +562 -41
  14. package/dist/esm/src/Engine.js.map +1 -1
  15. package/dist/esm/src/GASP/OverlayGASPRemote.js +93 -0
  16. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -0
  17. package/dist/esm/src/GASP/OverlayGASPStorage.js +219 -0
  18. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -0
  19. package/dist/esm/src/SyncConfiguration.js +2 -0
  20. package/dist/esm/src/SyncConfiguration.js.map +1 -0
  21. package/dist/esm/src/storage/knex/KnexStorage.js +20 -0
  22. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/mod.d.ts +1 -1
  25. package/dist/types/mod.d.ts.map +1 -1
  26. package/dist/types/src/Engine.d.ts +161 -9
  27. package/dist/types/src/Engine.d.ts.map +1 -1
  28. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +23 -0
  29. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
  30. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +68 -0
  31. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
  32. package/dist/types/src/SyncConfiguration.d.ts +20 -0
  33. package/dist/types/src/SyncConfiguration.d.ts.map +1 -0
  34. package/dist/types/src/TopicManager.d.ts +8 -0
  35. package/dist/types/src/TopicManager.d.ts.map +1 -1
  36. package/dist/types/src/storage/Storage.d.ts +7 -0
  37. package/dist/types/src/storage/Storage.d.ts.map +1 -1
  38. package/dist/types/src/storage/knex/KnexStorage.d.ts +1 -0
  39. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  40. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  41. package/mod.ts +2 -2
  42. package/package.json +2 -1
  43. package/src/Engine.ts +636 -46
  44. package/src/SyncConfiguration.ts +19 -0
  45. package/src/TopicManager.ts +6 -0
  46. package/src/__tests/Engine.test.ts +151 -25
  47. package/src/__tests/OverlayGASPRemote.test.ts +132 -0
  48. package/src/__tests/OverlayGASPStorage.test.ts +166 -0
  49. package/src/storage/Storage.ts +8 -0
  50. package/src/storage/knex/KnexStorage.ts +26 -0
@@ -1,4 +1,5 @@
1
- import { Transaction } from '@bsv/sdk';
1
+ import { Transaction, MerklePath, isBroadcastFailure } from '@bsv/sdk';
2
+ import { GASP } from '@bsv/gasp';
2
3
  /**
3
4
  * Am engine for running BSV Overlay Services (topic managers and lookup services).
4
5
  */
@@ -12,6 +13,7 @@ export class Engine {
12
13
  slapTrackers;
13
14
  broadcaster;
14
15
  advertiser;
16
+ syncConfiguration;
15
17
  /**
16
18
  * Creates a new Overlay Services Engine
17
19
  * @param {[key: string]: TopicManager} managers - manages topic admittance
@@ -22,9 +24,10 @@ export class Engine {
22
24
  * @param {Broadcaster} [Broadcaster] - broadcaster used for broadcasting the incoming transaction
23
25
  * @param {Advertiser} [Advertiser] - handles SHIP and SLAP advertisements for peer-discovery
24
26
  * @param {string} shipTrackers - SHIP domains we know to bootstrap the system
25
- * @param {string} slapTrackers - SAP domains we know to bootstrap the system
27
+ * @param {string} slapTrackers - SLAP domains we know to bootstrap the system
28
+ * @param {Record<string, string[] | 'SHIP'>} syncConfiguration — Configuration object describing historical synchronization of topics.
26
29
  */
27
- constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser) {
30
+ constructor(managers, lookupServices, storage, chainTracker, hostingURL, shipTrackers, slapTrackers, broadcaster, advertiser, syncConfiguration) {
28
31
  this.managers = managers;
29
32
  this.lookupServices = lookupServices;
30
33
  this.storage = storage;
@@ -34,17 +37,19 @@ export class Engine {
34
37
  this.slapTrackers = slapTrackers;
35
38
  this.broadcaster = broadcaster;
36
39
  this.advertiser = advertiser;
40
+ this.syncConfiguration = syncConfiguration;
37
41
  }
38
42
  /**
39
43
  * Submits a transaction for processing by Overlay Services.
40
44
  * @param {TaggedBEEF} taggedBEEF - The transaction to process
41
45
  * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
46
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
42
47
  *
43
48
  * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
44
49
  *
45
50
  * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
46
51
  */
47
- async submit(taggedBEEF, onSteakReady) {
52
+ async submit(taggedBEEF, onSteakReady, mode = 'current-tx') {
48
53
  for (const t of taggedBEEF.topics) {
49
54
  if (this.managers[t] === undefined || this.managers[t] === null) {
50
55
  throw new Error(`This server does not support this topic: ${t}`);
@@ -53,9 +58,16 @@ export class Engine {
53
58
  // Validate the transaction SPV information
54
59
  const tx = Transaction.fromBEEF(taggedBEEF.beef);
55
60
  const txid = tx.id('hex');
56
- const txValid = await tx.verify(this.chainTracker);
61
+ const txValid = await tx.verify(this.chainTracker); // Note: also verifying historical-tx with SPV. Needed?
57
62
  if (!txValid)
58
63
  throw new Error('Unable to verify SPV information.');
64
+ // Broadcast the transaction if not historical and broadcaster is configured
65
+ if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
66
+ const response = await this.broadcaster.broadcast(tx);
67
+ if (isBroadcastFailure(response)) {
68
+ throw new Error(`Failed to broadcast transaction! Error: ${response.description}`);
69
+ }
70
+ }
59
71
  // Find UTXOs belonging to a particular topic
60
72
  const steak = {};
61
73
  for (const topic of taggedBEEF.topics) {
@@ -186,15 +198,12 @@ export class Engine {
186
198
  steak[topic] = admissableOutputs;
187
199
  }
188
200
  // Call the callback function if it is provided
189
- if (onSteakReady) {
201
+ // TODO: To call `onSteakReady` sooner, we could have two loops. First we figure out topical admittance only, then we call `onSteakReeady` and do everything else after the first loop.
202
+ if (onSteakReady !== undefined) {
190
203
  onSteakReady(steak);
191
204
  }
192
- // Broadcast the transaction
193
- if (Object.keys(steak).length > 0 && this.broadcaster !== undefined) {
194
- await this.broadcaster.broadcast(tx);
195
- }
196
- // If we don't have an advertiser, just return the steak
197
- if (this.advertiser === undefined) {
205
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
206
+ if (this.advertiser === undefined || mode === 'historical-tx') {
198
207
  return steak;
199
208
  }
200
209
  // Propagate transaction to other nodes according to synchronization agreements
@@ -404,6 +413,107 @@ export class Engine {
404
413
  }
405
414
  }
406
415
  }
416
+ /**
417
+ * This method goes through each topic that we support syncing and attempts to sync with each endpoint
418
+ * associated with that topic. If the sync configuration is 'SHIP', it will sync to all peers that support
419
+ * the topic.
420
+ *
421
+ * @throws Error if the overlay service engine is not configured for topical synchronization.
422
+ */
423
+ async startGASPSync() {
424
+ if (this.syncConfiguration === undefined) {
425
+ throw new Error('Overlay Service Engine not configured for topical synchronization!');
426
+ }
427
+ for (const topic of Object.keys(this.syncConfiguration)) {
428
+ // Make sure syncEndpoints is an array or SHIP
429
+ let syncEndpoints = this.syncConfiguration[topic];
430
+ if (syncEndpoints === 'SHIP') {
431
+ // Perform lookup and find ship advertisements to set syncEndpoints for topic
432
+ const lookupAnswer = await this.lookup({
433
+ service: 'ls_ship',
434
+ query: {
435
+ topic
436
+ }
437
+ });
438
+ // Lookup will currently always return type output-list
439
+ if (lookupAnswer.type === 'output-list') {
440
+ const endpointSet = new Set();
441
+ lookupAnswer.outputs.forEach(output => {
442
+ try {
443
+ // Parse out the advertisements using the provided parser
444
+ const tx = Transaction.fromBEEF(output.beef);
445
+ const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
446
+ if (advertisement !== undefined && advertisement !== null && advertisement.protocol === 'SHIP') {
447
+ endpointSet.add(advertisement.domain);
448
+ }
449
+ }
450
+ catch (error) {
451
+ console.error('Failed to parse advertisement output:', error);
452
+ }
453
+ });
454
+ syncEndpoints = Array.from(endpointSet);
455
+ }
456
+ }
457
+ // Now syncEndpoints is guaranteed to be an array of strings without duplicates
458
+ // Note: Consider MySQL DB locking implications when running synchronization in parallel
459
+ if (Array.isArray(syncEndpoints)) {
460
+ await Promise.all(syncEndpoints.map(async (endpoint) => {
461
+ // Sync to each host that is associated with this topic
462
+ const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), 0, `[GASP Sync of ${topic} with ${endpoint}] `, true);
463
+ await gasp.sync();
464
+ }));
465
+ }
466
+ }
467
+ }
468
+ /**
469
+ * Given a GASP request, create an initial response.
470
+ *
471
+ * This method processes an initial synchronization request by finding the relevant UTXOs for the given topic
472
+ * since the provided (TODO: timestamp or block height, we need to decide on sync timing semantics) in the request. It constructs a response that includes a list of these UTXOs
473
+ * and the (timestamp or block height, TODO...) from the initial request.
474
+ *
475
+ * @param initialRequest - The GASP initial request containing the version and the (timestamp or block height, TODO...) since the last sync.
476
+ * @param topic - The topic for which UTXOs are being requested.
477
+ * @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided timestamp.
478
+ */
479
+ async provideForeignSyncResponse(initialRequest, topic) {
480
+ const UTXOs = await this.storage.findUTXOsForTopic(topic, initialRequest.since);
481
+ return {
482
+ UTXOList: UTXOs.map(output => {
483
+ return {
484
+ txid: output.txid,
485
+ outputIndex: output.outputIndex
486
+ };
487
+ }),
488
+ since: initialRequest.since
489
+ };
490
+ }
491
+ /**
492
+ * Provides a GASPNode for the given graphID, transaction ID, and output index.
493
+ *
494
+ * @param graphID - The identifier for the graph to which this node belongs (in the format txid.outputIndex).
495
+ * @param txid - The transaction ID for the requested output from somewhere within the graph's history.
496
+ * @param outputIndex - The index of the output in the transaction.
497
+ * @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
498
+ * @throws An error if no output is found for the given transaction ID and output index.
499
+ */
500
+ async provideForeignGASPNode(graphID, txid, outputIndex) {
501
+ const output = await this.storage.findOutput(txid, outputIndex);
502
+ if (output === undefined || output === null) {
503
+ throw new Error('No matching output found!');
504
+ }
505
+ const tx = Transaction.fromBEEF(output.beef);
506
+ const rawTx = tx.toHex();
507
+ const node = {
508
+ rawTx,
509
+ graphID,
510
+ outputIndex
511
+ };
512
+ if (tx.merklePath !== undefined) {
513
+ node.proof = tx.merklePath.toHex();
514
+ }
515
+ return node;
516
+ }
407
517
  /**
408
518
  * Traverse and return the history of a UTXO.
409
519
  *
@@ -522,47 +632,52 @@ export class Engine {
522
632
  }
523
633
  }
524
634
  /**
525
- * Recursively updates the Merkle proof for the given output and its consumedBy outputs.
526
- * If the output matches the source transaction ID, its Merkle proof is updated directly.
527
- * Otherwise, the Merkle proof is updated for the corresponding input in each transaction.
635
+ * Given a new transaction proof (txid, proof),
636
+ *
637
+ * update tx.merklePath if appropriate,
528
638
  *
529
- * @param output - The output to update with the new Merkle proof.
530
- * @param proof - The Merkle proof to be applied to the output or its inputs.
531
- * @param sourceTxid - The transaction ID of the source output whose Merkle proof is being updated.
639
+ * and if not, recurse through all input sourceTransactions.
640
+ *
641
+ * @param tx transaction which may benefit from new proof.
642
+ * @param txid BE hex string double hash of transaction proven by proof.
643
+ * @param proof for txid
532
644
  */
533
- async updateMerkleProof(output, proof, recursionPath) {
534
- // Add current output to recursionPath
535
- recursionPath.push({ txid: output.txid, outputIndex: output.outputIndex });
536
- const tx = Transaction.fromBEEF(output.beef);
537
- // Handle the base case
538
- if (output.txid === recursionPath[0].txid) {
645
+ updateInputProofs(tx, txid, proof) {
646
+ if (tx.merklePath)
647
+ // transaction already has a proof
648
+ return;
649
+ if (tx.id('hex') === txid) {
539
650
  tx.merklePath = proof;
540
651
  }
541
652
  else {
542
- // Traverse inputs to update the Merkle proof according to the recursionPath
543
- let currentInputs = tx.inputs;
544
- for (let i = recursionPath.length - 1; i >= 0; i--) {
545
- const crumb = recursionPath[i];
546
- for (const input of currentInputs) {
547
- if (input.sourceTXID === crumb.txid && input.sourceOutputIndex === crumb.outputIndex) {
548
- if (i === 0 && input.sourceTransaction !== undefined) {
549
- input.sourceTransaction.merklePath = proof;
550
- }
551
- else if (input.sourceTransaction !== undefined) {
552
- currentInputs = input.sourceTransaction.inputs;
553
- break;
554
- }
555
- }
556
- }
653
+ for (const input of tx.inputs) {
654
+ // All inputs must have sourceTransactions
655
+ const stx = input.sourceTransaction;
656
+ this.updateInputProofs(stx, txid, proof);
557
657
  }
558
658
  }
659
+ }
660
+ /**
661
+ * Recursively updates beefs (merkle proofs) of this output and its consumedBy lineage.
662
+ *
663
+ * @param output - An output derived from txid which may benefit from new proof.
664
+ * @param txid - The txid for which proof is a valid merkle path.
665
+ * @param proof - The merklePath proving txid is a mined transaction hash
666
+ */
667
+ async updateMerkleProof(output, txid, proof) {
668
+ const tx = Transaction.fromBEEF(output.beef);
669
+ if (tx.merklePath)
670
+ // Already have a proof for this output's transaction.
671
+ return;
672
+ // recursively update all sourceTransactions proven by (txid,proof)
673
+ this.updateInputProofs(tx, txid, proof);
559
674
  // Update the output's BEEF in the storage DB
560
675
  await this.storage.updateOutputBeef(output.txid, output.outputIndex, output.topic, tx.toBEEF());
561
676
  // Recursively update the consumedBy outputs
562
677
  for (const consumingOutput of output.consumedBy) {
563
678
  const consumedOutputs = await this.storage.findOutputsForTransaction(consumingOutput.txid);
564
679
  for (const consumedOutput of consumedOutputs) {
565
- await this.updateMerkleProof(consumedOutput, proof, []);
680
+ await this.updateMerkleProof(consumedOutput, txid, proof);
566
681
  }
567
682
  }
568
683
  }
@@ -578,7 +693,7 @@ export class Engine {
578
693
  throw new Error('Could not find matching transaction outputs for proof ingest!');
579
694
  }
580
695
  for (const output of outputs) {
581
- await this.updateMerkleProof(output, proof, []);
696
+ await this.updateMerkleProof(output, txid, proof);
582
697
  }
583
698
  }
584
699
  /**
@@ -616,4 +731,410 @@ export class Engine {
616
731
  return documentation !== undefined ? documentation : 'No documentation found!';
617
732
  }
618
733
  }
734
+ //////////
735
+ // OTHER FILES
736
+ //////////
737
+ /*
738
+ There is currently a bug with the test runner that prevents importing and using files that export variables other than type definitions within implementation files that are not directly imported themselves.
739
+ Thus, all non-type exports have been moved to Engine.
740
+ */
741
+ // TODO: fix bug with imports that break tests. -----[GASP/OverlayGASPRemote.ts]-----
742
+ export class OverlayGASPRemote {
743
+ endpointURL;
744
+ topic;
745
+ constructor(endpointURL, topic) {
746
+ this.endpointURL = endpointURL;
747
+ this.topic = topic;
748
+ }
749
+ /**
750
+ * Given an outgoing initial request, sends the request to the foreign instance and obtains their initial response.
751
+ * @param request
752
+ * @returns
753
+ */
754
+ async getInitialResponse(request) {
755
+ // Send out an HTTP request to the URL (current host for topic)
756
+ // Include the topic in the request
757
+ // Parse out response and return correct format
758
+ const url = `${this.endpointURL}/requestSyncResponse`;
759
+ const response = await fetch(url, {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ 'X-BSV-Topic': this.topic
764
+ },
765
+ body: JSON.stringify(request)
766
+ });
767
+ if (!response.ok) {
768
+ throw new Error(`HTTP error! Status: ${response.status}`);
769
+ }
770
+ const result = await response.json();
771
+ // Validate and return the response in the correct format
772
+ if (!Array.isArray(result.UTXOList) || typeof result.since !== 'number') {
773
+ throw new Error('Invalid response format');
774
+ }
775
+ return {
776
+ UTXOList: result.UTXOList.map((utxo) => ({
777
+ txid: utxo.txid,
778
+ outputIndex: utxo.outputIndex
779
+ })),
780
+ since: result.since
781
+ };
782
+ }
783
+ /**
784
+ * Given an outgoing txid, outputIndex and optional metadata, request the associated GASP node from the foreign instance.
785
+ * @param graphID
786
+ * @param txid
787
+ * @param outputIndex
788
+ * @param metadata
789
+ * @returns
790
+ */
791
+ async requestNode(graphID, txid, outputIndex, metadata) {
792
+ // Send an HTTP request with the provided info and get back a gaspNode
793
+ const url = `${this.endpointURL}/requestForeignGASPNode`;
794
+ const body = {
795
+ graphID,
796
+ txid,
797
+ outputIndex,
798
+ metadata
799
+ };
800
+ const response = await fetch(url, {
801
+ method: 'POST',
802
+ headers: {
803
+ 'Content-Type': 'application/json'
804
+ },
805
+ body: JSON.stringify(body)
806
+ });
807
+ if (!response.ok) {
808
+ throw new Error(`HTTP error! Status: ${response.status}`);
809
+ }
810
+ const result = await response.json();
811
+ // Validate and return the response in the correct format
812
+ if (typeof result.graphID !== 'string' || typeof result.rawTx !== 'string' || typeof result.outputIndex !== 'number') {
813
+ throw new Error('Invalid response format');
814
+ }
815
+ const gaspNode = {
816
+ graphID: result.graphID,
817
+ rawTx: result.rawTx,
818
+ outputIndex: result.outputIndex,
819
+ proof: result.proof,
820
+ txMetadata: result.txMetadata,
821
+ outputMetadata: result.outputMetadata,
822
+ inputs: result.inputs
823
+ };
824
+ return gaspNode;
825
+ }
826
+ // ---- Now optional methods ----
827
+ // When are only syncing to them
828
+ async getInitialReply(response) {
829
+ throw new Error('Function not supported!');
830
+ }
831
+ // Only used when supporting bidirectional sync.
832
+ // Overlay services does not support this.
833
+ async submitNode(node) {
834
+ throw new Error('Node submission not supported!');
835
+ }
836
+ }
837
+ export class OverlayGASPStorage {
838
+ topic;
839
+ engine;
840
+ maxNodesInGraph;
841
+ temporaryGraphNodeRefs = {};
842
+ constructor(topic, engine, maxNodesInGraph) {
843
+ this.topic = topic;
844
+ this.engine = engine;
845
+ this.maxNodesInGraph = maxNodesInGraph;
846
+ }
847
+ /**
848
+ *
849
+ * @param since
850
+ * @returns
851
+ */
852
+ async findKnownUTXOs(since) {
853
+ const UTXOs = await this.engine.storage.findUTXOsForTopic(this.topic, since);
854
+ return UTXOs.map(output => ({
855
+ txid: output.txid,
856
+ outputIndex: output.outputIndex
857
+ }));
858
+ }
859
+ // TODO: Consider optionality on interface
860
+ async hydrateGASPNode(graphID, txid, outputIndex, metadata) {
861
+ throw new Error('GASP node hydration Not supported!');
862
+ }
863
+ /**
864
+ * For a given node, returns the inputs needed to complete the graph, including whether updated metadata is requested for those inputs.
865
+ * @param tx The node for which needed inputs should be found.
866
+ * @returns A promise for a mapping of requested input transactions and whether metadata should be provided for each.
867
+ */
868
+ async findNeededInputs(tx) {
869
+ // If there is no Merkle proof, we always need the inputs
870
+ const response = {
871
+ requestedInputs: {}
872
+ };
873
+ const parsedTx = Transaction.fromHex(tx.rawTx);
874
+ if (tx.proof === undefined) {
875
+ for (const input of parsedTx.inputs) {
876
+ response.requestedInputs[`${input.sourceTXID}.${input.sourceOutputIndex}`] = {
877
+ metadata: false
878
+ };
879
+ }
880
+ return await this.stripAlreadyKnownInputs(response);
881
+ }
882
+ // Attempt to check if the current transaction is admissible
883
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof);
884
+ const admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTx.toBEEF(), []);
885
+ if (admittanceResult.outputsToAdmit.includes(tx.outputIndex)) {
886
+ // The transaction is admissible, no further inputs are needed
887
+ return;
888
+ }
889
+ else {
890
+ // The transaction is not admissible, get inputs needed for further verification
891
+ // TopicManagers should implement a function to identify which inputs are needed.
892
+ if (this.engine.managers[this.topic] !== undefined && typeof this.engine.managers[this.topic].identifyNeededInputs === 'function') {
893
+ try {
894
+ const neededInputs = await this.engine.managers[this.topic].identifyNeededInputs?.(parsedTx.toBEEF()) ?? [];
895
+ for (const input of neededInputs) {
896
+ response.requestedInputs[`${input.txid}.${input.outputIndex}`] = {
897
+ metadata: false
898
+ };
899
+ }
900
+ return await this.stripAlreadyKnownInputs(response);
901
+ }
902
+ catch (e) {
903
+ console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}!`);
904
+ // Cut off the graph in case of an error here.
905
+ return;
906
+ }
907
+ }
908
+ else {
909
+ // In case the topic manager isn't able to stipulate needed inputs, we need to request all inputs as if we had no merkle proof.
910
+ // However, it's dubious that we sometimes don't know — QUESTION: Should we require all topic managers to support this functionality?
911
+ // The alternative to requiring ALL inputs by default is to require NO inputs by default, cutting off the historical graph at this point
912
+ // (e.g. `return undefined`).
913
+ for (const input of parsedTx.inputs) {
914
+ response.requestedInputs[`${input.sourceTXID}.${input.sourceOutputIndex}`] = {
915
+ metadata: false
916
+ };
917
+ }
918
+ return await this.stripAlreadyKnownInputs(response);
919
+ }
920
+ }
921
+ // Everything else falls through to returning undefined/void, which will terminate the synchronization at this point.
922
+ }
923
+ /**
924
+ * Ensures that no inputs are requested from foreign nodes before sending any GASP response
925
+ * Also terminates graphs if the response would be empty.
926
+ */
927
+ async stripAlreadyKnownInputs(response) {
928
+ if (typeof response === 'undefined') {
929
+ return response;
930
+ }
931
+ for (const inputNodeId of Object.keys(response.requestedInputs)) {
932
+ const [txid, outputIndex] = inputNodeId.split('.');
933
+ const found = await this.engine.storage.findOutput(txid, Number(outputIndex), this.topic);
934
+ if (found) {
935
+ delete response.requestedInputs[inputNodeId];
936
+ }
937
+ }
938
+ if (Object.keys(response.requestedInputs).length === 0) {
939
+ return undefined;
940
+ }
941
+ }
942
+ /**
943
+ * Appends a new node to a temporary graph.
944
+ * @param tx The node to append to this graph.
945
+ * @param spentBy Unless this is the same node identified by the graph ID, denotes the TXID and input index for the node which spent this one, in 36-byte format.
946
+ * @throws If the node cannot be appended to the graph, either because the graph ID is for a graph the recipient does not want or because the graph has grown to be too large before being finalized.
947
+ */
948
+ async appendToGraph(tx, spentBy) {
949
+ if (this.maxNodesInGraph !== undefined && Object.keys(this.temporaryGraphNodeRefs).length >= this.maxNodesInGraph) {
950
+ throw new Error('The max number of nodes in transaction graph has been reached!');
951
+ }
952
+ const parsedTx = Transaction.fromHex(tx.rawTx);
953
+ const txid = parsedTx.id('hex');
954
+ if (tx.proof !== undefined) {
955
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof);
956
+ }
957
+ // Given the passed in node, append to the temp graph
958
+ // Use the spentBy param which should be a txid.inputIndex for the node which spent this one in 36-byte format
959
+ const newGraphNode = {
960
+ txid,
961
+ time: 0, // TODO: Determine required format for Time (either block height or timestamp, undefined / Infinity for unconfirmed transactions
962
+ graphID: tx.graphID,
963
+ rawTx: tx.rawTx,
964
+ outputIndex: tx.outputIndex,
965
+ proof: tx.proof,
966
+ txMetadata: tx.txMetadata,
967
+ outputMetadata: tx.outputMetadata,
968
+ inputs: tx.inputs,
969
+ children: []
970
+ };
971
+ // If spentBy is undefined, then we know it's the root node.
972
+ if (spentBy === undefined) {
973
+ this.temporaryGraphNodeRefs[tx.graphID] = newGraphNode;
974
+ }
975
+ else {
976
+ // Find the parent node based on spentBy
977
+ const parentNode = this.temporaryGraphNodeRefs[spentBy];
978
+ if (parentNode !== undefined) {
979
+ // Set parent-child relationship
980
+ parentNode.children.push(newGraphNode);
981
+ newGraphNode.parent = parentNode;
982
+ this.temporaryGraphNodeRefs[`${newGraphNode.txid}.${newGraphNode.outputIndex}`] = newGraphNode;
983
+ }
984
+ else {
985
+ throw new Error(`Parent node with GraphID ${spentBy} not found`);
986
+ }
987
+ }
988
+ }
989
+ /**
990
+ * Checks whether the given graph, in its current state, makes reference only to transactions that are proven in the blockchain, or already known by the recipient to be valid.
991
+ * Additionally, in a breadth-first manner (ensuring that all inputs for any given node are processed before nodes that spend them), it ensures that the root node remains valid according to the rules of the overlay's topic manager,
992
+ * while considering any coins which the Manager had previously indicated were either valid or invalid.
993
+ *
994
+ * 1. Confirm that we are well anchored (according to the rules of SPV)
995
+ * 2. Is there some sequence of nodes that will result in the admittance of the root node into the topic
996
+ * 3. If there is some spend chain that, when executed in order to the root node, leads to a valid admittance, we are good to go.
997
+ * a) For each node in the chain we need to check topical admittance (such that all relevant inputs to any given node are executed before the node in question).
998
+ * b) Once previous nodes are validated, they are taken into account as previous coins to their direct spenders.
999
+ *
1000
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
1001
+ * @throws If the graph is not well-anchored, according to the rules of Bitcoin or the rules of the Overlay Topic Manager.
1002
+ */
1003
+ async validateGraphAnchor(graphID) {
1004
+ const rootNode = this.temporaryGraphNodeRefs[graphID];
1005
+ if (rootNode === undefined) {
1006
+ throw new Error(`Graph node with ID ${graphID} not found`);
1007
+ }
1008
+ // Check that the root node is Bitcoin-valid.
1009
+ const beef = this.getBEEFForNode(rootNode);
1010
+ const spvTx = Transaction.fromBEEF(beef);
1011
+ const isBitcoinValid = await spvTx.verify(this.engine.chainTracker);
1012
+ if (!isBitcoinValid) {
1013
+ throw new Error('The graph is not well-anchored according to the rules of Bitcoin.');
1014
+ }
1015
+ // Then, ensure the node is Overlay-valid.
1016
+ const validationMap = new Map(); // Tracks historical node validity, avoiding duplication of work
1017
+ const ensureTopicalAnchor = async (graphNode) => {
1018
+ const dupeCheckNode = validationMap.get(`${graphNode.txid}.${graphNode.outputIndex}`);
1019
+ if (typeof dupeCheckNode !== 'undefined') {
1020
+ return dupeCheckNode;
1021
+ }
1022
+ // Parse the transaction, adding a proof if we have one
1023
+ const parsedTX = Transaction.fromHex(graphNode.rawTx);
1024
+ if (graphNode.proof !== undefined) {
1025
+ parsedTX.merklePath = MerklePath.fromHex(graphNode.proof);
1026
+ }
1027
+ const validatedChildren = [];
1028
+ for (const child of graphNode.children) {
1029
+ // If the child has not been validated, check it.
1030
+ if (!validationMap.has(`${child.txid}.${child.outputIndex}`)) {
1031
+ const isValidChild = await ensureTopicalAnchor(child);
1032
+ if (isValidChild) {
1033
+ validatedChildren.push(child);
1034
+ }
1035
+ }
1036
+ else {
1037
+ // If the child has been validated already, just add it to the list.
1038
+ validatedChildren.push(child);
1039
+ }
1040
+ }
1041
+ let admittanceResult;
1042
+ try {
1043
+ // Previous coins are input indices corresponding to outputs redeemed.
1044
+ const previousCoins = validatedChildren.map(child => parsedTX.inputs.findIndex(i => i.sourceOutputIndex === child.outputIndex && i.sourceTXID === child.txid));
1045
+ admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTX.toBEEF(), previousCoins);
1046
+ }
1047
+ catch (error) {
1048
+ console.error('Error in admittance check:', error);
1049
+ validationMap.set(`${graphNode.txid}.${graphNode.outputIndex}`, false);
1050
+ return false;
1051
+ }
1052
+ const isValid = admittanceResult.outputsToAdmit.includes(graphNode.outputIndex);
1053
+ validationMap.set(`${graphNode.txid}.${graphNode.outputIndex}`, isValid);
1054
+ if (isValid === false) {
1055
+ return false;
1056
+ }
1057
+ // Reached the root successfully
1058
+ if (graphNode.parent === undefined) {
1059
+ return true;
1060
+ }
1061
+ return await ensureTopicalAnchor(graphNode.parent);
1062
+ };
1063
+ const isOverlayValid = await ensureTopicalAnchor(rootNode);
1064
+ if (!isOverlayValid) {
1065
+ throw new Error('The graph is not well-anchored according to the rules of this overlay topic.');
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Deletes all data associated with a temporary graph that has failed to sync, if the graph exists.
1070
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
1071
+ */
1072
+ async discardGraph(graphID) {
1073
+ for (const [nodeId, graphRef] of Object.entries(this.temporaryGraphNodeRefs)) {
1074
+ if (graphRef.graphID === graphID) {
1075
+ // Delete child node
1076
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1077
+ delete this.temporaryGraphNodeRefs[nodeId];
1078
+ }
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Finalizes a graph, solidifying the new UTXO and its ancestors so that it will appear in the list of known UTXOs.
1083
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the root of this graph.
1084
+ */
1085
+ async finalizeGraph(graphID) {
1086
+ // Construct an ordered set of BEEFs for the graph
1087
+ const beefs = [];
1088
+ const hydrator = (node) => {
1089
+ const currentBEEF = this.getBEEFForNode(node);
1090
+ if (beefs.indexOf(currentBEEF) === -1) {
1091
+ beefs.unshift(currentBEEF);
1092
+ }
1093
+ for (const child of node.children) {
1094
+ // Continue backwards to the earliest nodes, adding them onto the beginning
1095
+ hydrator(child);
1096
+ }
1097
+ };
1098
+ // Start the hydrator with the root node
1099
+ const foundRoot = this.temporaryGraphNodeRefs[graphID];
1100
+ if (!foundRoot) {
1101
+ throw new Error('Unable to find root node in graph for finalization!');
1102
+ }
1103
+ hydrator(foundRoot);
1104
+ // Submit all historical BEEFs in order, finalizing the graph for the current UTXO
1105
+ for (const beef of beefs) {
1106
+ await this.engine.submit({
1107
+ beef,
1108
+ topics: [this.topic]
1109
+ }, () => { }, 'historical-tx');
1110
+ }
1111
+ }
1112
+ /**
1113
+ * Computes a full BEEF for a given graph node, based on the temporary graph store.
1114
+ * @param node Graph node for which BEEF is needed.
1115
+ * @returns BEEF array, including all proofs on inputs.
1116
+ */
1117
+ getBEEFForNode(node) {
1118
+ // Given a node, hydrate its merkle proof or all inputs, returning a reference to the hydrated node's Transaction object
1119
+ const hydrator = (node) => {
1120
+ const tx = Transaction.fromHex(node.rawTx);
1121
+ if (node.proof) {
1122
+ tx.merklePath = MerklePath.fromHex(node.proof);
1123
+ return tx; // Transaction with proof, end of the line.
1124
+ }
1125
+ // For each input, look it up and recurse.
1126
+ for (const inputIndex in tx.inputs) {
1127
+ const input = tx.inputs[inputIndex];
1128
+ const foundNode = this.temporaryGraphNodeRefs[`${input.sourceTXID}.${input.sourceOutputIndex}`];
1129
+ if (!foundNode) {
1130
+ throw new Error('Required input node for unproven parent not found in temporary graph store. Ensure, for every parent of any given already-proven node (kept for Overlay-specific historical reasons), that a proof is also provided on those inputs. While implicitly they are valid by virtue of their descendents being proven in the blockchain, BEEF serialization will still fail when winding forward the topical UTXO set histories during sync.');
1131
+ }
1132
+ tx.inputs[inputIndex].sourceTransaction = hydrator(foundNode);
1133
+ }
1134
+ return tx;
1135
+ };
1136
+ const finalTX = hydrator(node);
1137
+ return finalTX.toBEEF();
1138
+ }
1139
+ }
619
1140
  //# sourceMappingURL=Engine.js.map