@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.
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/src/Engine.js +560 -41
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPRemote.js +96 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +221 -0
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -0
- package/dist/cjs/src/SyncConfiguration.js +3 -0
- package/dist/cjs/src/SyncConfiguration.js.map +1 -0
- package/dist/cjs/src/storage/knex/KnexStorage.js +20 -0
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/Engine.js +562 -41
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPRemote.js +93 -0
- package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -0
- package/dist/esm/src/GASP/OverlayGASPStorage.js +219 -0
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -0
- package/dist/esm/src/SyncConfiguration.js +2 -0
- package/dist/esm/src/SyncConfiguration.js.map +1 -0
- package/dist/esm/src/storage/knex/KnexStorage.js +20 -0
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/mod.d.ts +1 -1
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/Engine.d.ts +161 -9
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts +23 -0
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -0
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts +68 -0
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -0
- package/dist/types/src/SyncConfiguration.d.ts +20 -0
- package/dist/types/src/SyncConfiguration.d.ts.map +1 -0
- package/dist/types/src/TopicManager.d.ts +8 -0
- package/dist/types/src/TopicManager.d.ts.map +1 -1
- package/dist/types/src/storage/Storage.d.ts +7 -0
- package/dist/types/src/storage/Storage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/KnexStorage.d.ts +1 -0
- package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/mod.ts +2 -2
- package/package.json +2 -1
- package/src/Engine.ts +636 -46
- package/src/SyncConfiguration.ts +19 -0
- package/src/TopicManager.ts +6 -0
- package/src/__tests/Engine.test.ts +151 -25
- package/src/__tests/OverlayGASPRemote.test.ts +132 -0
- package/src/__tests/OverlayGASPStorage.test.ts +166 -0
- package/src/storage/Storage.ts +8 -0
- package/src/storage/knex/KnexStorage.ts +26 -0
package/dist/esm/src/Engine.js
CHANGED
|
@@ -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 -
|
|
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
|
-
|
|
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
|
-
//
|
|
193
|
-
if (
|
|
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
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
635
|
+
* Given a new transaction proof (txid, proof),
|
|
636
|
+
*
|
|
637
|
+
* update tx.merklePath if appropriate,
|
|
528
638
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
* @param
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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,
|
|
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,
|
|
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
|