@bsv/overlay 0.1.0-alpha.9 → 0.1.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.
Files changed (50) hide show
  1. package/dist/cjs/package.json +2 -1
  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
package/src/Engine.ts CHANGED
@@ -8,9 +8,11 @@ import { STEAK } from './STEAK.js'
8
8
  import { LookupQuestion } from './LookupQuestion.js'
9
9
  import { LookupAnswer } from './LookupAnswer.js'
10
10
  import { LookupFormula } from './LookupFormula.js'
11
- import { Transaction, ChainTracker, MerklePath, Broadcaster } from '@bsv/sdk'
11
+ import { Transaction, ChainTracker, MerklePath, Broadcaster, isBroadcastFailure } from '@bsv/sdk'
12
12
  import { Advertiser } from './Advertiser.js'
13
13
  import { SHIPAdvertisement } from './SHIPAdvertisement.js'
14
+ import { GASP, GASPInitialReply, GASPInitialRequest, GASPInitialResponse, GASPNode, GASPNodeResponse, GASPRemote, GASPStorage } from '@bsv/gasp'
15
+ import { SyncConfiguration } from './SyncConfiguration.js'
14
16
 
15
17
  /**
16
18
  * Am engine for running BSV Overlay Services (topic managers and lookup services).
@@ -26,7 +28,8 @@ export class Engine {
26
28
  * @param {Broadcaster} [Broadcaster] - broadcaster used for broadcasting the incoming transaction
27
29
  * @param {Advertiser} [Advertiser] - handles SHIP and SLAP advertisements for peer-discovery
28
30
  * @param {string} shipTrackers - SHIP domains we know to bootstrap the system
29
- * @param {string} slapTrackers - SAP domains we know to bootstrap the system
31
+ * @param {string} slapTrackers - SLAP domains we know to bootstrap the system
32
+ * @param {Record<string, string[] | 'SHIP'>} syncConfiguration — Configuration object describing historical synchronization of topics.
30
33
  */
31
34
  constructor(
32
35
  public managers: { [key: string]: TopicManager },
@@ -37,19 +40,22 @@ export class Engine {
37
40
  public shipTrackers?: string[],
38
41
  public slapTrackers?: string[],
39
42
  public broadcaster?: Broadcaster,
40
- public advertiser?: Advertiser
41
- ) { }
43
+ public advertiser?: Advertiser,
44
+ public syncConfiguration?: SyncConfiguration
45
+ ) {
46
+ }
42
47
 
43
48
  /**
44
49
  * Submits a transaction for processing by Overlay Services.
45
50
  * @param {TaggedBEEF} taggedBEEF - The transaction to process
46
51
  * @param {function(STEAK): void} [onSTEAKReady] - Optional callback function invoked when the STEAK is ready.
52
+ * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
47
53
  *
48
54
  * The optional callback function should be used to get STEAK when ready, and avoid waiting for broadcast and transaction propagation to complete.
49
55
  *
50
56
  * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
51
57
  */
52
- async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void): Promise<STEAK> {
58
+ async submit(taggedBEEF: TaggedBEEF, onSteakReady?: (steak: STEAK) => void, mode: 'historical-tx' | 'current-tx' = 'current-tx'): Promise<STEAK> {
53
59
  for (const t of taggedBEEF.topics) {
54
60
  if (this.managers[t] === undefined || this.managers[t] === null) {
55
61
  throw new Error(`This server does not support this topic: ${t}`)
@@ -58,9 +64,17 @@ export class Engine {
58
64
  // Validate the transaction SPV information
59
65
  const tx = Transaction.fromBEEF(taggedBEEF.beef)
60
66
  const txid = tx.id('hex')
61
- const txValid = await tx.verify(this.chainTracker)
67
+ const txValid = await tx.verify(this.chainTracker) // Note: also verifying historical-tx with SPV. Needed?
62
68
  if (!txValid) throw new Error('Unable to verify SPV information.')
63
69
 
70
+ // Broadcast the transaction if not historical and broadcaster is configured
71
+ if (mode !== 'historical-tx' && this.broadcaster !== undefined) {
72
+ const response = await this.broadcaster.broadcast(tx)
73
+ if (isBroadcastFailure(response)) {
74
+ throw new Error(`Failed to broadcast transaction! Error: ${response.description}`)
75
+ }
76
+ }
77
+
64
78
  // Find UTXOs belonging to a particular topic
65
79
  const steak: STEAK = {}
66
80
  for (const topic of taggedBEEF.topics) {
@@ -218,17 +232,13 @@ export class Engine {
218
232
  }
219
233
 
220
234
  // Call the callback function if it is provided
221
- if (onSteakReady) {
235
+ // 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.
236
+ if (onSteakReady !== undefined) {
222
237
  onSteakReady(steak)
223
238
  }
224
239
 
225
- // Broadcast the transaction
226
- if (Object.keys(steak).length > 0 && this.broadcaster !== undefined) {
227
- await this.broadcaster.broadcast(tx)
228
- }
229
-
230
- // If we don't have an advertiser, just return the steak
231
- if (this.advertiser === undefined) {
240
+ // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
241
+ if (this.advertiser === undefined || mode === 'historical-tx') {
232
242
  return steak
233
243
  }
234
244
 
@@ -460,6 +470,120 @@ export class Engine {
460
470
  }
461
471
  }
462
472
 
473
+ /**
474
+ * This method goes through each topic that we support syncing and attempts to sync with each endpoint
475
+ * associated with that topic. If the sync configuration is 'SHIP', it will sync to all peers that support
476
+ * the topic.
477
+ *
478
+ * @throws Error if the overlay service engine is not configured for topical synchronization.
479
+ */
480
+ async startGASPSync(): Promise<void> {
481
+ if (this.syncConfiguration === undefined) {
482
+ throw new Error('Overlay Service Engine not configured for topical synchronization!')
483
+ }
484
+
485
+ for (const topic of Object.keys(this.syncConfiguration)) {
486
+ // Make sure syncEndpoints is an array or SHIP
487
+ let syncEndpoints: string[] | string = this.syncConfiguration[topic]
488
+
489
+ if (syncEndpoints === 'SHIP') {
490
+ // Perform lookup and find ship advertisements to set syncEndpoints for topic
491
+ const lookupAnswer = await this.lookup({
492
+ service: 'ls_ship',
493
+ query: {
494
+ topic
495
+ }
496
+ })
497
+
498
+ // Lookup will currently always return type output-list
499
+ if (lookupAnswer.type === 'output-list') {
500
+ const endpointSet = new Set<string>()
501
+
502
+ lookupAnswer.outputs.forEach(output => {
503
+ try {
504
+ // Parse out the advertisements using the provided parser
505
+ const tx = Transaction.fromBEEF(output.beef)
506
+ const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript)
507
+ if (advertisement !== undefined && advertisement !== null && advertisement.protocol === 'SHIP') {
508
+ endpointSet.add(advertisement.domain)
509
+ }
510
+ } catch (error) {
511
+ console.error('Failed to parse advertisement output:', error)
512
+ }
513
+ })
514
+
515
+ syncEndpoints = Array.from(endpointSet)
516
+ }
517
+ }
518
+
519
+ // Now syncEndpoints is guaranteed to be an array of strings without duplicates
520
+ // Note: Consider MySQL DB locking implications when running synchronization in parallel
521
+ if (Array.isArray(syncEndpoints)) {
522
+ await Promise.all(syncEndpoints.map(async endpoint => {
523
+ // Sync to each host that is associated with this topic
524
+ const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), 0, `[GASP Sync of ${topic} with ${endpoint}] `, true)
525
+ await gasp.sync()
526
+ }))
527
+ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Given a GASP request, create an initial response.
533
+ *
534
+ * This method processes an initial synchronization request by finding the relevant UTXOs for the given topic
535
+ * 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
536
+ * and the (timestamp or block height, TODO...) from the initial request.
537
+ *
538
+ * @param initialRequest - The GASP initial request containing the version and the (timestamp or block height, TODO...) since the last sync.
539
+ * @param topic - The topic for which UTXOs are being requested.
540
+ * @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided timestamp.
541
+ */
542
+ async provideForeignSyncResponse(initialRequest: GASPInitialRequest, topic: string): Promise<GASPInitialResponse> {
543
+ const UTXOs = await this.storage.findUTXOsForTopic(topic, initialRequest.since)
544
+
545
+ return {
546
+ UTXOList: UTXOs.map(output => {
547
+ return {
548
+ txid: output.txid,
549
+ outputIndex: output.outputIndex
550
+ }
551
+ }),
552
+ since: initialRequest.since
553
+ }
554
+ }
555
+
556
+ /**
557
+ * Provides a GASPNode for the given graphID, transaction ID, and output index.
558
+ *
559
+ * @param graphID - The identifier for the graph to which this node belongs (in the format txid.outputIndex).
560
+ * @param txid - The transaction ID for the requested output from somewhere within the graph's history.
561
+ * @param outputIndex - The index of the output in the transaction.
562
+ * @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
563
+ * @throws An error if no output is found for the given transaction ID and output index.
564
+ */
565
+ async provideForeignGASPNode(graphID: string, txid: string, outputIndex: number): Promise<GASPNode> {
566
+ const output = await this.storage.findOutput(txid, outputIndex)
567
+
568
+ if (output === undefined || output === null) {
569
+ throw new Error('No matching output found!')
570
+ }
571
+
572
+ const tx = Transaction.fromBEEF(output.beef)
573
+ const rawTx = tx.toHex()
574
+
575
+ const node: GASPNode = {
576
+ rawTx,
577
+ graphID,
578
+ outputIndex
579
+ }
580
+ if (tx.merklePath !== undefined) {
581
+ node.proof = tx.merklePath.toHex()
582
+ }
583
+
584
+ return node
585
+ }
586
+
463
587
  /**
464
588
  * Traverse and return the history of a UTXO.
465
589
  *
@@ -597,42 +721,49 @@ export class Engine {
597
721
  }
598
722
 
599
723
  /**
600
- * Recursively updates the Merkle proof for the given output and its consumedBy outputs.
601
- * If the output matches the source transaction ID, its Merkle proof is updated directly.
602
- * Otherwise, the Merkle proof is updated for the corresponding input in each transaction.
603
- *
604
- * @param output - The output to update with the new Merkle proof.
605
- * @param proof - The Merkle proof to be applied to the output or its inputs.
606
- * @param sourceTxid - The transaction ID of the source output whose Merkle proof is being updated.
724
+ * Given a new transaction proof (txid, proof),
725
+ *
726
+ * update tx.merklePath if appropriate,
727
+ *
728
+ * and if not, recurse through all input sourceTransactions.
729
+ *
730
+ * @param tx transaction which may benefit from new proof.
731
+ * @param txid BE hex string double hash of transaction proven by proof.
732
+ * @param proof for txid
607
733
  */
608
- private async updateMerkleProof(output: Output, proof: MerklePath, recursionPath: Array<{ txid: string, outputIndex: number }>): Promise<void> {
609
- // Add current output to recursionPath
610
- recursionPath.push({ txid: output.txid, outputIndex: output.outputIndex })
611
-
612
- const tx = Transaction.fromBEEF(output.beef)
734
+ private updateInputProofs(tx: Transaction, txid: string, proof: MerklePath) {
735
+ if (tx.merklePath)
736
+ // transaction already has a proof
737
+ return
613
738
 
614
- // Handle the base case
615
- if (output.txid === recursionPath[0].txid) {
739
+ if (tx.id('hex') === txid) {
616
740
  tx.merklePath = proof
617
741
  } else {
618
- // Traverse inputs to update the Merkle proof according to the recursionPath
619
- let currentInputs = tx.inputs
620
-
621
- for (let i = recursionPath.length - 1; i >= 0; i--) {
622
- const crumb = recursionPath[i]
623
-
624
- for (const input of currentInputs) {
625
- if (input.sourceTXID === crumb.txid && input.sourceOutputIndex === crumb.outputIndex) {
626
- if (i === 0 && input.sourceTransaction !== undefined) {
627
- input.sourceTransaction.merklePath = proof
628
- } else if (input.sourceTransaction !== undefined) {
629
- currentInputs = input.sourceTransaction.inputs
630
- break
631
- }
632
- }
633
- }
742
+ for (const input of tx.inputs) {
743
+ // All inputs must have sourceTransactions
744
+ const stx = input.sourceTransaction!
745
+ this.updateInputProofs(stx, txid, proof)
634
746
  }
635
747
  }
748
+ }
749
+
750
+ /**
751
+ * Recursively updates beefs (merkle proofs) of this output and its consumedBy lineage.
752
+ *
753
+ * @param output - An output derived from txid which may benefit from new proof.
754
+ * @param txid - The txid for which proof is a valid merkle path.
755
+ * @param proof - The merklePath proving txid is a mined transaction hash
756
+ */
757
+ private async updateMerkleProof(output: Output, txid: string, proof: MerklePath): Promise<void> {
758
+
759
+ const tx = Transaction.fromBEEF(output.beef)
760
+
761
+ if (tx.merklePath)
762
+ // Already have a proof for this output's transaction.
763
+ return
764
+
765
+ // recursively update all sourceTransactions proven by (txid,proof)
766
+ this.updateInputProofs(tx, txid, proof)
636
767
 
637
768
  // Update the output's BEEF in the storage DB
638
769
  await this.storage.updateOutputBeef(output.txid, output.outputIndex, output.topic, tx.toBEEF())
@@ -641,7 +772,7 @@ export class Engine {
641
772
  for (const consumingOutput of output.consumedBy) {
642
773
  const consumedOutputs = await this.storage.findOutputsForTransaction(consumingOutput.txid)
643
774
  for (const consumedOutput of consumedOutputs) {
644
- await this.updateMerkleProof(consumedOutput, proof, [])
775
+ await this.updateMerkleProof(consumedOutput, txid, proof)
645
776
  }
646
777
  }
647
778
  }
@@ -660,7 +791,7 @@ export class Engine {
660
791
  }
661
792
 
662
793
  for (const output of outputs) {
663
- await this.updateMerkleProof(output, proof, [])
794
+ await this.updateMerkleProof(output, txid, proof)
664
795
  }
665
796
  }
666
797
 
@@ -702,3 +833,462 @@ export class Engine {
702
833
  return documentation !== undefined ? documentation : 'No documentation found!'
703
834
  }
704
835
  }
836
+
837
+ //////////
838
+ // OTHER FILES
839
+ //////////
840
+
841
+ /*
842
+ 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.
843
+ Thus, all non-type exports have been moved to Engine.
844
+ */
845
+
846
+ // TODO: fix bug with imports that break tests. -----[GASP/OverlayGASPRemote.ts]-----
847
+
848
+ export class OverlayGASPRemote implements GASPRemote {
849
+ constructor(public endpointURL: string, public topic: string) { }
850
+
851
+ /**
852
+ * Given an outgoing initial request, sends the request to the foreign instance and obtains their initial response.
853
+ * @param request
854
+ * @returns
855
+ */
856
+ async getInitialResponse(request: GASPInitialRequest): Promise<GASPInitialResponse> {
857
+ // Send out an HTTP request to the URL (current host for topic)
858
+ // Include the topic in the request
859
+ // Parse out response and return correct format
860
+ const url = `${this.endpointURL}/requestSyncResponse`
861
+ const response = await fetch(url, {
862
+ method: 'POST',
863
+ headers: {
864
+ 'Content-Type': 'application/json',
865
+ 'X-BSV-Topic': this.topic
866
+ },
867
+ body: JSON.stringify(request)
868
+ })
869
+
870
+ if (!response.ok) {
871
+ throw new Error(`HTTP error! Status: ${response.status}`)
872
+ }
873
+
874
+ const result: GASPInitialResponse = await response.json()
875
+
876
+ // Validate and return the response in the correct format
877
+ if (!Array.isArray(result.UTXOList) || typeof result.since !== 'number') {
878
+ throw new Error('Invalid response format')
879
+ }
880
+
881
+ return {
882
+ UTXOList: result.UTXOList.map((utxo: any) => ({
883
+ txid: utxo.txid,
884
+ outputIndex: utxo.outputIndex
885
+ })),
886
+ since: result.since
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Given an outgoing txid, outputIndex and optional metadata, request the associated GASP node from the foreign instance.
892
+ * @param graphID
893
+ * @param txid
894
+ * @param outputIndex
895
+ * @param metadata
896
+ * @returns
897
+ */
898
+ async requestNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise<GASPNode> {
899
+ // Send an HTTP request with the provided info and get back a gaspNode
900
+ const url = `${this.endpointURL}/requestForeignGASPNode`
901
+ const body = {
902
+ graphID,
903
+ txid,
904
+ outputIndex,
905
+ metadata
906
+ }
907
+
908
+ const response = await fetch(url, {
909
+ method: 'POST',
910
+ headers: {
911
+ 'Content-Type': 'application/json'
912
+ },
913
+ body: JSON.stringify(body)
914
+ })
915
+
916
+ if (!response.ok) {
917
+ throw new Error(`HTTP error! Status: ${response.status}`)
918
+ }
919
+
920
+ const result = await response.json()
921
+
922
+ // Validate and return the response in the correct format
923
+ if (typeof result.graphID !== 'string' || typeof result.rawTx !== 'string' || typeof result.outputIndex !== 'number') {
924
+ throw new Error('Invalid response format')
925
+ }
926
+
927
+ const gaspNode: GASPNode = {
928
+ graphID: result.graphID,
929
+ rawTx: result.rawTx,
930
+ outputIndex: result.outputIndex,
931
+ proof: result.proof,
932
+ txMetadata: result.txMetadata,
933
+ outputMetadata: result.outputMetadata,
934
+ inputs: result.inputs
935
+ }
936
+
937
+ return gaspNode
938
+ }
939
+
940
+ // ---- Now optional methods ----
941
+
942
+ // When are only syncing to them
943
+ async getInitialReply(response: GASPInitialResponse): Promise<GASPInitialReply> {
944
+ throw new Error('Function not supported!')
945
+ }
946
+
947
+ // Only used when supporting bidirectional sync.
948
+ // Overlay services does not support this.
949
+ async submitNode(node: GASPNode): Promise<void | GASPNodeResponse> {
950
+ throw new Error('Node submission not supported!')
951
+ }
952
+ }
953
+
954
+ // TODO: fix bug with imports that break tests. -----[GASP/OverlayGASPStorage.ts]-----
955
+
956
+ /**
957
+ * Represents a node in the temporary graph.
958
+ */
959
+ export interface GraphNode {
960
+ txid: string
961
+ time: number
962
+ graphID: string
963
+ rawTx: string
964
+ outputIndex: number
965
+ spentBy?: string
966
+ proof?: string
967
+ txMetadata?: string
968
+ outputMetadata?: string
969
+ inputs?: Record<string, { hash: string }> | undefined
970
+ children: GraphNode[]
971
+ parent?: GraphNode
972
+ }
973
+
974
+ export class OverlayGASPStorage implements GASPStorage {
975
+ readonly temporaryGraphNodeRefs: Record<string, GraphNode> = {}
976
+
977
+ constructor(public topic: string, public engine: Engine, public maxNodesInGraph?: number) { }
978
+
979
+ /**
980
+ *
981
+ * @param since
982
+ * @returns
983
+ */
984
+ async findKnownUTXOs(since: number): Promise<Array<{ txid: string, outputIndex: number }>> {
985
+ const UTXOs = await this.engine.storage.findUTXOsForTopic(this.topic, since)
986
+ return UTXOs.map(output => ({
987
+ txid: output.txid,
988
+ outputIndex: output.outputIndex
989
+ }))
990
+ }
991
+
992
+ // TODO: Consider optionality on interface
993
+ async hydrateGASPNode(graphID: string, txid: string, outputIndex: number, metadata: boolean): Promise<GASPNode> {
994
+ throw new Error('GASP node hydration Not supported!')
995
+ }
996
+
997
+ /**
998
+ * For a given node, returns the inputs needed to complete the graph, including whether updated metadata is requested for those inputs.
999
+ * @param tx The node for which needed inputs should be found.
1000
+ * @returns A promise for a mapping of requested input transactions and whether metadata should be provided for each.
1001
+ */
1002
+ async findNeededInputs(tx: GASPNode): Promise<GASPNodeResponse | undefined> {
1003
+ // If there is no Merkle proof, we always need the inputs
1004
+ const response: GASPNodeResponse = {
1005
+ requestedInputs: {}
1006
+ }
1007
+ const parsedTx = Transaction.fromHex(tx.rawTx)
1008
+ if (tx.proof === undefined) {
1009
+ for (const input of parsedTx.inputs) {
1010
+ response.requestedInputs[`${input.sourceTXID}.${input.sourceOutputIndex}`] = {
1011
+ metadata: false
1012
+ }
1013
+ }
1014
+
1015
+ return await this.stripAlreadyKnownInputs(response)
1016
+ }
1017
+
1018
+ // Attempt to check if the current transaction is admissible
1019
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof)
1020
+ const admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTx.toBEEF(), [])
1021
+
1022
+ if (admittanceResult.outputsToAdmit.includes(tx.outputIndex)) {
1023
+ // The transaction is admissible, no further inputs are needed
1024
+ return
1025
+ } else {
1026
+ // The transaction is not admissible, get inputs needed for further verification
1027
+ // TopicManagers should implement a function to identify which inputs are needed.
1028
+ if (this.engine.managers[this.topic] !== undefined && typeof this.engine.managers[this.topic].identifyNeededInputs === 'function') {
1029
+ try {
1030
+ const neededInputs = await this.engine.managers[this.topic].identifyNeededInputs?.(parsedTx.toBEEF()) ?? []
1031
+ for (const input of neededInputs) {
1032
+ response.requestedInputs[`${input.txid}.${input.outputIndex}`] = {
1033
+ metadata: false
1034
+ }
1035
+ }
1036
+ return await this.stripAlreadyKnownInputs(response)
1037
+ } catch (e) {
1038
+ console.error(`An error occurred when identifying needed inputs for transaction: ${parsedTx.id('hex')}.${tx.outputIndex}!`)
1039
+ // Cut off the graph in case of an error here.
1040
+ return
1041
+ }
1042
+ } else {
1043
+ // 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.
1044
+ // However, it's dubious that we sometimes don't know — QUESTION: Should we require all topic managers to support this functionality?
1045
+ // The alternative to requiring ALL inputs by default is to require NO inputs by default, cutting off the historical graph at this point
1046
+ // (e.g. `return undefined`).
1047
+ for (const input of parsedTx.inputs) {
1048
+ response.requestedInputs[`${input.sourceTXID}.${input.sourceOutputIndex}`] = {
1049
+ metadata: false
1050
+ }
1051
+ }
1052
+ return await this.stripAlreadyKnownInputs(response)
1053
+ }
1054
+ }
1055
+ // Everything else falls through to returning undefined/void, which will terminate the synchronization at this point.
1056
+ }
1057
+
1058
+ /**
1059
+ * Ensures that no inputs are requested from foreign nodes before sending any GASP response
1060
+ * Also terminates graphs if the response would be empty.
1061
+ */
1062
+ private async stripAlreadyKnownInputs(response: GASPNodeResponse | undefined): Promise<GASPNodeResponse | undefined> {
1063
+ if (typeof response === 'undefined') {
1064
+ return response
1065
+ }
1066
+ for (const inputNodeId of Object.keys(response.requestedInputs)) {
1067
+ const [txid, outputIndex] = inputNodeId.split('.')
1068
+ const found = await this.engine.storage.findOutput(txid, Number(outputIndex), this.topic)
1069
+ if (found) {
1070
+ delete response.requestedInputs[inputNodeId]
1071
+ }
1072
+ }
1073
+ if (Object.keys(response.requestedInputs).length === 0) {
1074
+ return undefined
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * Appends a new node to a temporary graph.
1080
+ * @param tx The node to append to this graph.
1081
+ * @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.
1082
+ * @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.
1083
+ */
1084
+ async appendToGraph(tx: GASPNode, spentBy?: string | undefined): Promise<void> {
1085
+ if (this.maxNodesInGraph !== undefined && Object.keys(this.temporaryGraphNodeRefs).length >= this.maxNodesInGraph) {
1086
+ throw new Error('The max number of nodes in transaction graph has been reached!')
1087
+ }
1088
+
1089
+ const parsedTx = Transaction.fromHex(tx.rawTx)
1090
+ const txid = parsedTx.id('hex')
1091
+ if (tx.proof !== undefined) {
1092
+ parsedTx.merklePath = MerklePath.fromHex(tx.proof)
1093
+ }
1094
+
1095
+ // Given the passed in node, append to the temp graph
1096
+ // Use the spentBy param which should be a txid.inputIndex for the node which spent this one in 36-byte format
1097
+ const newGraphNode: GraphNode = {
1098
+ txid,
1099
+ time: 0, // TODO: Determine required format for Time (either block height or timestamp, undefined / Infinity for unconfirmed transactions
1100
+ graphID: tx.graphID,
1101
+ rawTx: tx.rawTx,
1102
+ outputIndex: tx.outputIndex,
1103
+ proof: tx.proof,
1104
+ txMetadata: tx.txMetadata,
1105
+ outputMetadata: tx.outputMetadata,
1106
+ inputs: tx.inputs,
1107
+ children: []
1108
+ }
1109
+
1110
+ // If spentBy is undefined, then we know it's the root node.
1111
+ if (spentBy === undefined) {
1112
+ this.temporaryGraphNodeRefs[tx.graphID] = newGraphNode
1113
+ } else {
1114
+ // Find the parent node based on spentBy
1115
+ const parentNode = this.temporaryGraphNodeRefs[spentBy]
1116
+
1117
+ if (parentNode !== undefined) {
1118
+ // Set parent-child relationship
1119
+ parentNode.children.push(newGraphNode)
1120
+ newGraphNode.parent = parentNode
1121
+ this.temporaryGraphNodeRefs[`${newGraphNode.txid}.${newGraphNode.outputIndex}`] = newGraphNode
1122
+ } else {
1123
+ throw new Error(`Parent node with GraphID ${spentBy} not found`)
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * 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.
1130
+ * 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,
1131
+ * while considering any coins which the Manager had previously indicated were either valid or invalid.
1132
+ *
1133
+ * 1. Confirm that we are well anchored (according to the rules of SPV)
1134
+ * 2. Is there some sequence of nodes that will result in the admittance of the root node into the topic
1135
+ * 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.
1136
+ * 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).
1137
+ * b) Once previous nodes are validated, they are taken into account as previous coins to their direct spenders.
1138
+ *
1139
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
1140
+ * @throws If the graph is not well-anchored, according to the rules of Bitcoin or the rules of the Overlay Topic Manager.
1141
+ */
1142
+ async validateGraphAnchor(graphID: string): Promise<void> {
1143
+ const rootNode = this.temporaryGraphNodeRefs[graphID]
1144
+ if (rootNode === undefined) {
1145
+ throw new Error(`Graph node with ID ${graphID} not found`)
1146
+ }
1147
+
1148
+ // Check that the root node is Bitcoin-valid.
1149
+ const beef = this.getBEEFForNode(rootNode)
1150
+ const spvTx = Transaction.fromBEEF(beef)
1151
+ const isBitcoinValid = await spvTx.verify(this.engine.chainTracker)
1152
+ if (!isBitcoinValid) {
1153
+ throw new Error('The graph is not well-anchored according to the rules of Bitcoin.')
1154
+ }
1155
+
1156
+ // Then, ensure the node is Overlay-valid.
1157
+ const validationMap = new Map<string, boolean>() // Tracks historical node validity, avoiding duplication of work
1158
+ const ensureTopicalAnchor = async (graphNode: GraphNode): Promise<boolean> => {
1159
+ const dupeCheckNode = validationMap.get(`${graphNode.txid}.${graphNode.outputIndex}`)
1160
+ if (typeof dupeCheckNode !== 'undefined') {
1161
+ return dupeCheckNode
1162
+ }
1163
+
1164
+ // Parse the transaction, adding a proof if we have one
1165
+ const parsedTX = Transaction.fromHex(graphNode.rawTx)
1166
+ if (graphNode.proof !== undefined) {
1167
+ parsedTX.merklePath = MerklePath.fromHex(graphNode.proof)
1168
+ }
1169
+
1170
+ const validatedChildren: GraphNode[] = []
1171
+ for (const child of graphNode.children) {
1172
+ // If the child has not been validated, check it.
1173
+ if (!validationMap.has(`${child.txid}.${child.outputIndex}`)) {
1174
+ const isValidChild = await ensureTopicalAnchor(child)
1175
+ if (isValidChild) {
1176
+ validatedChildren.push(child)
1177
+ }
1178
+ } else {
1179
+ // If the child has been validated already, just add it to the list.
1180
+ validatedChildren.push(child)
1181
+ }
1182
+ }
1183
+
1184
+ let admittanceResult
1185
+ try {
1186
+ // Previous coins are input indices corresponding to outputs redeemed.
1187
+ const previousCoins = validatedChildren.map(child =>
1188
+ parsedTX.inputs.findIndex(i => i.sourceOutputIndex === child.outputIndex && i.sourceTXID === child.txid)
1189
+ )
1190
+ admittanceResult = await this.engine.managers[this.topic].identifyAdmissibleOutputs(parsedTX.toBEEF(), previousCoins)
1191
+ } catch (error) {
1192
+ console.error('Error in admittance check:', error)
1193
+ validationMap.set(`${graphNode.txid}.${graphNode.outputIndex}`, false)
1194
+ return false
1195
+ }
1196
+
1197
+ const isValid = admittanceResult.outputsToAdmit.includes(graphNode.outputIndex)
1198
+ validationMap.set(`${graphNode.txid}.${graphNode.outputIndex}`, isValid)
1199
+
1200
+ if (isValid === false) {
1201
+ return false
1202
+ }
1203
+
1204
+ // Reached the root successfully
1205
+ if (graphNode.parent === undefined) {
1206
+ return true
1207
+ }
1208
+
1209
+ return await ensureTopicalAnchor(graphNode.parent)
1210
+ }
1211
+ const isOverlayValid = await ensureTopicalAnchor(rootNode)
1212
+ if (!isOverlayValid) {
1213
+ throw new Error('The graph is not well-anchored according to the rules of this overlay topic.')
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Deletes all data associated with a temporary graph that has failed to sync, if the graph exists.
1219
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the tip of this graph.
1220
+ */
1221
+ async discardGraph(graphID: string): Promise<void> {
1222
+ for (const [nodeId, graphRef] of Object.entries(this.temporaryGraphNodeRefs)) {
1223
+ if (graphRef.graphID === graphID) {
1224
+ // Delete child node
1225
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1226
+ delete this.temporaryGraphNodeRefs[nodeId]
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ /**
1232
+ * Finalizes a graph, solidifying the new UTXO and its ancestors so that it will appear in the list of known UTXOs.
1233
+ * @param graphID The TXID and output index (in 36-byte format) for the UTXO at the root of this graph.
1234
+ */
1235
+ async finalizeGraph(graphID: string): Promise<void> {
1236
+ // Construct an ordered set of BEEFs for the graph
1237
+ const beefs: number[][] = []
1238
+ const hydrator = (node: GraphNode): void => {
1239
+ const currentBEEF = this.getBEEFForNode(node)
1240
+ if (beefs.indexOf(currentBEEF) === -1) {
1241
+ beefs.unshift(currentBEEF)
1242
+ }
1243
+
1244
+ for (const child of node.children) {
1245
+ // Continue backwards to the earliest nodes, adding them onto the beginning
1246
+ hydrator(child)
1247
+ }
1248
+ }
1249
+
1250
+ // Start the hydrator with the root node
1251
+ const foundRoot = this.temporaryGraphNodeRefs[graphID]
1252
+ if (!foundRoot) {
1253
+ throw new Error('Unable to find root node in graph for finalization!')
1254
+ }
1255
+ hydrator(foundRoot)
1256
+
1257
+ // Submit all historical BEEFs in order, finalizing the graph for the current UTXO
1258
+ for (const beef of beefs) {
1259
+ await this.engine.submit({
1260
+ beef,
1261
+ topics: [this.topic]
1262
+ }, () => { }, 'historical-tx')
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Computes a full BEEF for a given graph node, based on the temporary graph store.
1268
+ * @param node Graph node for which BEEF is needed.
1269
+ * @returns BEEF array, including all proofs on inputs.
1270
+ */
1271
+ private getBEEFForNode(node: GraphNode): number[] {
1272
+ // Given a node, hydrate its merkle proof or all inputs, returning a reference to the hydrated node's Transaction object
1273
+ const hydrator = (node: GraphNode): Transaction => {
1274
+ const tx = Transaction.fromHex(node.rawTx)
1275
+ if (node.proof) {
1276
+ tx.merklePath = MerklePath.fromHex(node.proof)
1277
+ return tx // Transaction with proof, end of the line.
1278
+ }
1279
+ // For each input, look it up and recurse.
1280
+ for (const inputIndex in tx.inputs) {
1281
+ const input = tx.inputs[inputIndex]
1282
+ const foundNode = this.temporaryGraphNodeRefs[`${input.sourceTXID}.${input.sourceOutputIndex}`]
1283
+ if (!foundNode) {
1284
+ 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.')
1285
+ }
1286
+ tx.inputs[inputIndex].sourceTransaction = hydrator(foundNode)
1287
+ }
1288
+ return tx
1289
+ }
1290
+
1291
+ const finalTX = hydrator(node)
1292
+ return finalTX.toBEEF()
1293
+ }
1294
+ }