@bsv/overlay 2.3.1 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/dist/cjs/mod.d.ts +3 -0
- package/dist/cjs/mod.d.ts.map +1 -1
- package/dist/cjs/mod.js +8 -1
- package/dist/cjs/mod.js.map +1 -1
- package/dist/cjs/package.json +10 -4
- package/dist/cjs/src/BASM.d.ts +6 -0
- package/dist/cjs/src/BASM.d.ts.map +1 -1
- package/dist/cjs/src/BASM.js +21 -8
- package/dist/cjs/src/BASM.js.map +1 -1
- package/dist/cjs/src/BASMRemote.d.ts +8 -1
- package/dist/cjs/src/BASMRemote.d.ts.map +1 -1
- package/dist/cjs/src/BASMRemote.js +179 -13
- package/dist/cjs/src/BASMRemote.js.map +1 -1
- package/dist/cjs/src/BASMValidation.d.ts +27 -0
- package/dist/cjs/src/BASMValidation.d.ts.map +1 -0
- package/dist/cjs/src/BASMValidation.js +108 -0
- package/dist/cjs/src/BASMValidation.js.map +1 -0
- package/dist/cjs/src/DiscoveryAdvertisementValidation.d.ts +11 -0
- package/dist/cjs/src/DiscoveryAdvertisementValidation.d.ts.map +1 -0
- package/dist/cjs/src/DiscoveryAdvertisementValidation.js +60 -0
- package/dist/cjs/src/DiscoveryAdvertisementValidation.js.map +1 -0
- package/dist/cjs/src/Engine.d.ts +37 -13
- package/dist/cjs/src/Engine.d.ts.map +1 -1
- package/dist/cjs/src/Engine.js +1713 -336
- package/dist/cjs/src/Engine.js.map +1 -1
- package/dist/cjs/src/EngineAdmission.d.ts +70 -0
- package/dist/cjs/src/EngineAdmission.d.ts.map +1 -0
- package/dist/cjs/src/EngineAdmission.js +297 -0
- package/dist/cjs/src/EngineAdmission.js.map +1 -0
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +4 -3
- package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPRemote.js +27 -43
- package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +22 -16
- package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/cjs/src/GASP/OverlayGASPStorage.js +206 -57
- package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/cjs/src/RemoteSecurity.d.ts +50 -0
- package/dist/cjs/src/RemoteSecurity.d.ts.map +1 -0
- package/dist/cjs/src/RemoteSecurity.js +301 -0
- package/dist/cjs/src/RemoteSecurity.js.map +1 -0
- package/dist/cjs/src/storage/AdmissionStorage.d.ts +185 -0
- package/dist/cjs/src/storage/AdmissionStorage.d.ts.map +1 -0
- package/dist/cjs/src/storage/AdmissionStorage.js +93 -0
- package/dist/cjs/src/storage/AdmissionStorage.js.map +1 -0
- package/dist/cjs/src/storage/RecoveryContract.d.ts +50 -0
- package/dist/cjs/src/storage/RecoveryContract.d.ts.map +1 -0
- package/dist/cjs/src/storage/RecoveryContract.js +35 -0
- package/dist/cjs/src/storage/RecoveryContract.js.map +1 -0
- package/dist/cjs/src/storage/Storage.d.ts +32 -19
- package/dist/cjs/src/storage/Storage.d.ts.map +1 -1
- package/dist/cjs/src/storage/Storage.js +5 -0
- package/dist/cjs/src/storage/Storage.js.map +1 -1
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts +8 -8
- package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/cjs/src/storage/knex/KnexStorage.js +98 -61
- package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -1
- package/dist/cjs/src/storage/knex/all-migrations.js +5 -1
- package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts +4 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js +36 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts +5 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts.map +1 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.js +16 -0
- package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.d.ts +18 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.js +229 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.d.ts +112 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.js +675 -0
- package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoOverlayStorage.d.ts +71 -0
- package/dist/cjs/src/storage/mongo/MongoOverlayStorage.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoOverlayStorage.js +453 -0
- package/dist/cjs/src/storage/mongo/MongoOverlayStorage.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoPayloadStore.d.ts +134 -0
- package/dist/cjs/src/storage/mongo/MongoPayloadStore.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoPayloadStore.js +756 -0
- package/dist/cjs/src/storage/mongo/MongoPayloadStore.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoReadGuards.d.ts +44 -0
- package/dist/cjs/src/storage/mongo/MongoReadGuards.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoReadGuards.js +135 -0
- package/dist/cjs/src/storage/mongo/MongoReadGuards.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoSchema.d.ts +67 -0
- package/dist/cjs/src/storage/mongo/MongoSchema.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoSchema.js +743 -0
- package/dist/cjs/src/storage/mongo/MongoSchema.js.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoTransactionRunner.d.ts +69 -0
- package/dist/cjs/src/storage/mongo/MongoTransactionRunner.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo/MongoTransactionRunner.js +368 -0
- package/dist/cjs/src/storage/mongo/MongoTransactionRunner.js.map +1 -0
- package/dist/cjs/src/storage/mongo.d.ts +9 -0
- package/dist/cjs/src/storage/mongo.d.ts.map +1 -0
- package/dist/cjs/src/storage/mongo.js +25 -0
- package/dist/cjs/src/storage/mongo.js.map +1 -0
- package/dist/esm/mod.js +3 -0
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/src/BASM.js +21 -8
- package/dist/esm/src/BASM.js.map +1 -1
- package/dist/esm/src/BASMRemote.js +176 -12
- package/dist/esm/src/BASMRemote.js.map +1 -1
- package/dist/esm/src/BASMValidation.js +95 -0
- package/dist/esm/src/BASMValidation.js.map +1 -0
- package/dist/esm/src/DiscoveryAdvertisementValidation.js +57 -0
- package/dist/esm/src/DiscoveryAdvertisementValidation.js.map +1 -0
- package/dist/esm/src/Engine.js +1698 -326
- package/dist/esm/src/Engine.js.map +1 -1
- package/dist/esm/src/EngineAdmission.js +280 -0
- package/dist/esm/src/EngineAdmission.js.map +1 -0
- package/dist/esm/src/GASP/OverlayGASPRemote.js +27 -43
- package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
- package/dist/esm/src/GASP/OverlayGASPStorage.js +200 -56
- package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
- package/dist/esm/src/RemoteSecurity.js +279 -0
- package/dist/esm/src/RemoteSecurity.js.map +1 -0
- package/dist/esm/src/storage/AdmissionStorage.js +85 -0
- package/dist/esm/src/storage/AdmissionStorage.js.map +1 -0
- package/dist/esm/src/storage/RecoveryContract.js +31 -0
- package/dist/esm/src/storage/RecoveryContract.js.map +1 -0
- package/dist/esm/src/storage/Storage.js +4 -1
- package/dist/esm/src/storage/Storage.js.map +1 -1
- package/dist/esm/src/storage/knex/KnexStorage.js +98 -61
- package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
- package/dist/esm/src/storage/knex/all-migrations.js +5 -1
- package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
- package/dist/esm/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js +32 -0
- package/dist/esm/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js.map +1 -0
- package/dist/esm/src/storage/knex/migrations/2026-09-20-001-spent-by.js +12 -0
- package/dist/esm/src/storage/knex/migrations/2026-09-20-001-spent-by.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoAdmissionPlan.js +217 -0
- package/dist/esm/src/storage/mongo/MongoAdmissionPlan.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoAdmissionStorage.js +673 -0
- package/dist/esm/src/storage/mongo/MongoAdmissionStorage.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoOverlayStorage.js +451 -0
- package/dist/esm/src/storage/mongo/MongoOverlayStorage.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoPayloadStore.js +747 -0
- package/dist/esm/src/storage/mongo/MongoPayloadStore.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoReadGuards.js +129 -0
- package/dist/esm/src/storage/mongo/MongoReadGuards.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoSchema.js +730 -0
- package/dist/esm/src/storage/mongo/MongoSchema.js.map +1 -0
- package/dist/esm/src/storage/mongo/MongoTransactionRunner.js +371 -0
- package/dist/esm/src/storage/mongo/MongoTransactionRunner.js.map +1 -0
- package/dist/esm/src/storage/mongo.js +9 -0
- package/dist/esm/src/storage/mongo.js.map +1 -0
- package/dist/types/mod.d.ts +3 -0
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/BASM.d.ts +6 -0
- package/dist/types/src/BASM.d.ts.map +1 -1
- package/dist/types/src/BASMRemote.d.ts +8 -1
- package/dist/types/src/BASMRemote.d.ts.map +1 -1
- package/dist/types/src/BASMValidation.d.ts +27 -0
- package/dist/types/src/BASMValidation.d.ts.map +1 -0
- package/dist/types/src/DiscoveryAdvertisementValidation.d.ts +11 -0
- package/dist/types/src/DiscoveryAdvertisementValidation.d.ts.map +1 -0
- package/dist/types/src/Engine.d.ts +37 -13
- package/dist/types/src/Engine.d.ts.map +1 -1
- package/dist/types/src/EngineAdmission.d.ts +70 -0
- package/dist/types/src/EngineAdmission.d.ts.map +1 -0
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts +4 -3
- package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts +22 -16
- package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
- package/dist/types/src/RemoteSecurity.d.ts +50 -0
- package/dist/types/src/RemoteSecurity.d.ts.map +1 -0
- package/dist/types/src/storage/AdmissionStorage.d.ts +185 -0
- package/dist/types/src/storage/AdmissionStorage.d.ts.map +1 -0
- package/dist/types/src/storage/RecoveryContract.d.ts +50 -0
- package/dist/types/src/storage/RecoveryContract.d.ts.map +1 -0
- package/dist/types/src/storage/Storage.d.ts +32 -19
- package/dist/types/src/storage/Storage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/KnexStorage.d.ts +8 -8
- package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
- package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
- package/dist/types/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts +4 -0
- package/dist/types/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts.map +1 -0
- package/dist/types/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts +5 -0
- package/dist/types/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoAdmissionPlan.d.ts +18 -0
- package/dist/types/src/storage/mongo/MongoAdmissionPlan.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoAdmissionStorage.d.ts +112 -0
- package/dist/types/src/storage/mongo/MongoAdmissionStorage.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoOverlayStorage.d.ts +71 -0
- package/dist/types/src/storage/mongo/MongoOverlayStorage.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoPayloadStore.d.ts +134 -0
- package/dist/types/src/storage/mongo/MongoPayloadStore.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoReadGuards.d.ts +44 -0
- package/dist/types/src/storage/mongo/MongoReadGuards.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoSchema.d.ts +67 -0
- package/dist/types/src/storage/mongo/MongoSchema.d.ts.map +1 -0
- package/dist/types/src/storage/mongo/MongoTransactionRunner.d.ts +69 -0
- package/dist/types/src/storage/mongo/MongoTransactionRunner.d.ts.map +1 -0
- package/dist/types/src/storage/mongo.d.ts +9 -0
- package/dist/types/src/storage/mongo.d.ts.map +1 -0
- package/package.json +11 -5
package/dist/esm/src/Engine.js
CHANGED
|
@@ -1,18 +1,185 @@
|
|
|
1
|
-
import { Transaction, MerklePath, isBroadcastFailure, SHIPBroadcaster, HTTPSOverlayBroadcastFacilitator, LookupResolver } from '@bsv/sdk';
|
|
1
|
+
import { Transaction, MerklePath, isBroadcastFailure, isPublicNetworkAddress, SHIPBroadcaster, HTTPSOverlayBroadcastFacilitator, LookupResolver } from '@bsv/sdk';
|
|
2
2
|
import { GASP } from '@bsv/gasp';
|
|
3
3
|
import { OverlayGASPRemote } from './GASP/OverlayGASPRemote.js';
|
|
4
4
|
import { OverlayGASPStorage } from './GASP/OverlayGASPStorage.js';
|
|
5
5
|
import { BASM_ZERO_HASH, computeBasmRoot, computeTac, extractMerkleProofMetadata } from './BASM.js';
|
|
6
6
|
import { BASMRemote } from './BASMRemote.js';
|
|
7
|
+
import { basmHash, basmInteger, requireBASM } from './BASMValidation.js';
|
|
7
8
|
import { serializeErrorForLog, serializeLogValue } from './SafeLog.js';
|
|
9
|
+
import { decodeAndVerifyDiscoveryAdvertisement } from './DiscoveryAdvertisementValidation.js';
|
|
10
|
+
import { assertHash, assertNonnegativeInteger, assertOutpoint, assertOutputIndex, assertRawTransactionMatches, assertRegistryName, assertTopic, assertTxidList, normalizePeerEndpoint, validateAdmittanceInstructions, MAX_GASP_PAGE_SIZE } from './RemoteSecurity.js';
|
|
11
|
+
import { buildOverlayAdmissionPlan, getOverlayAdmissionHost, overlayAdmissionMode, selectNewAdmissionTopics, waitForAdmissionReceipt } from './EngineAdmission.js';
|
|
8
12
|
const DEFAULT_GASP_SYNC_LIMIT = 10000;
|
|
9
13
|
const DEFAULT_BASM_RANGE_LIMIT = 1024;
|
|
14
|
+
// The public Overlay Express transport defaults to 1,000 anchors per request.
|
|
15
|
+
const DEFAULT_BASM_SYNC_PAGE_SIZE = 1000;
|
|
16
|
+
const MAX_SYNC_ENDPOINTS_PER_TOPIC = 128;
|
|
17
|
+
const MAX_SUBMISSION_TOPICS = 128;
|
|
18
|
+
const MAX_SUBMISSION_BEEF_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
const MAX_OFF_CHAIN_VALUES = 100_000;
|
|
20
|
+
const MAX_EVICTION_OUTPUTS = 100_000;
|
|
21
|
+
const MAX_EVICTION_REASON_BYTES = 1024;
|
|
22
|
+
const MAX_UNPROVEN_CANDIDATES = 10_000;
|
|
23
|
+
const MAX_UNPROVEN_THRESHOLD_BLOCKS = 10_000_000;
|
|
24
|
+
const MAX_REORG_ORPHAN_HASHES = 10_000;
|
|
25
|
+
const MAX_REORG_ROWS = 100_000;
|
|
26
|
+
const MAX_REORG_DEPTH = 100_000;
|
|
27
|
+
const MAX_BASM_ADMITTED_PER_BLOCK = 100_000;
|
|
28
|
+
const MAX_HISTORY_PRUNE_OUTPUTS = 100_000;
|
|
29
|
+
const MAX_OUTPUT_RELATIONS = 100_000;
|
|
30
|
+
const MAX_PROOF_UPDATE_OUTPUTS = 100_000;
|
|
31
|
+
const MAX_LOOKUP_FORMULAS = 100_000;
|
|
32
|
+
const MAX_LOOKUP_HISTORY_DEPTH = 2048;
|
|
33
|
+
const MAX_LOOKUP_HYDRATION_NODES = 100_000;
|
|
34
|
+
const MAX_LOOKUP_STORAGE_ROWS = 100_000;
|
|
35
|
+
const MAX_LOOKUP_CONTEXT_BYTES = 1024 * 1024;
|
|
36
|
+
const MAX_LOOKUP_TOTAL_CONTEXT_BYTES = 16 * 1024 * 1024;
|
|
37
|
+
const MAX_LOOKUP_TOTAL_BEEF_BYTES = 128 * 1024 * 1024;
|
|
38
|
+
const MAX_STORED_OUTPUT_SCRIPT_BYTES = 64 * 1024 * 1024;
|
|
39
|
+
const MAX_CURRENT_ADVERTISEMENTS = 10_000;
|
|
40
|
+
const MAX_REGISTERED_COMPONENTS = 10_000;
|
|
41
|
+
const MAX_COMPONENT_NAME_BYTES = 256;
|
|
42
|
+
const MAX_COMPONENT_DESCRIPTION_BYTES = 4096;
|
|
43
|
+
const MAX_COMPONENT_VERSION_BYTES = 128;
|
|
44
|
+
const MAX_COMPONENT_URL_BYTES = 2048;
|
|
45
|
+
const MAX_COMPONENT_DOCUMENTATION_BYTES = 1024 * 1024;
|
|
46
|
+
function assertComponentText(value, label, maximumBytes, allowEmpty = false) {
|
|
47
|
+
if (typeof value !== 'string' ||
|
|
48
|
+
(!allowEmpty && value.length === 0) ||
|
|
49
|
+
new TextEncoder().encode(value).byteLength > maximumBytes ||
|
|
50
|
+
Array.from(value).some(character => {
|
|
51
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
52
|
+
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
53
|
+
})) {
|
|
54
|
+
throw new TypeError(`${label} is invalid`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function readOwnComponentField(value, key, required) {
|
|
58
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
59
|
+
if (descriptor === undefined) {
|
|
60
|
+
if (required)
|
|
61
|
+
throw new TypeError(`Component metadata ${key} is required`);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(descriptor, 'value')) {
|
|
65
|
+
throw new TypeError(`Component metadata ${key} must be a data property`);
|
|
66
|
+
}
|
|
67
|
+
return descriptor.value;
|
|
68
|
+
}
|
|
69
|
+
function normalizeComponentURL(value, label) {
|
|
70
|
+
assertComponentText(value, label, MAX_COMPONENT_URL_BYTES);
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = new URL(value);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new TypeError(`${label} is invalid`);
|
|
77
|
+
}
|
|
78
|
+
if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
|
|
79
|
+
parsed.username !== '' ||
|
|
80
|
+
parsed.password !== '') {
|
|
81
|
+
throw new TypeError(`${label} is invalid`);
|
|
82
|
+
}
|
|
83
|
+
return parsed.toString();
|
|
84
|
+
}
|
|
85
|
+
function validateComponentMetadata(value) {
|
|
86
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
87
|
+
throw new TypeError('Component metadata must be an object');
|
|
88
|
+
}
|
|
89
|
+
const name = readOwnComponentField(value, 'name', true);
|
|
90
|
+
const shortDescription = readOwnComponentField(value, 'shortDescription', true);
|
|
91
|
+
assertComponentText(name, 'Component metadata name', MAX_COMPONENT_NAME_BYTES);
|
|
92
|
+
assertComponentText(shortDescription, 'Component metadata shortDescription', MAX_COMPONENT_DESCRIPTION_BYTES, true);
|
|
93
|
+
const result = { name, shortDescription };
|
|
94
|
+
const iconURL = readOwnComponentField(value, 'iconURL', false);
|
|
95
|
+
if (iconURL !== undefined)
|
|
96
|
+
result.iconURL = normalizeComponentURL(iconURL, 'Component iconURL');
|
|
97
|
+
const version = readOwnComponentField(value, 'version', false);
|
|
98
|
+
if (version !== undefined) {
|
|
99
|
+
assertComponentText(version, 'Component metadata version', MAX_COMPONENT_VERSION_BYTES);
|
|
100
|
+
result.version = version;
|
|
101
|
+
}
|
|
102
|
+
const informationURL = readOwnComponentField(value, 'informationURL', false);
|
|
103
|
+
if (informationURL !== undefined) {
|
|
104
|
+
result.informationURL = normalizeComponentURL(informationURL, 'Component informationURL');
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function validateComponentDocumentation(value) {
|
|
109
|
+
if (typeof value !== 'string' ||
|
|
110
|
+
new TextEncoder().encode(value).byteLength > MAX_COMPONENT_DOCUMENTATION_BYTES) {
|
|
111
|
+
throw new TypeError(`Component documentation must be a string of at most ${MAX_COMPONENT_DOCUMENTATION_BYTES} bytes`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function copyRegistry(value, label) {
|
|
116
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
117
|
+
throw new TypeError(`${label} must be an object`);
|
|
118
|
+
}
|
|
119
|
+
const names = Object.keys(value);
|
|
120
|
+
if (names.length > MAX_REGISTERED_COMPONENTS) {
|
|
121
|
+
throw new RangeError(`${label} cannot contain more than ${MAX_REGISTERED_COMPONENTS} entries`);
|
|
122
|
+
}
|
|
123
|
+
const result = Object.create(null);
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
assertRegistryName(name, `${label} name`);
|
|
126
|
+
result[name] = value[name];
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
function copyBoundedStringList(value, label) {
|
|
131
|
+
if (value === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (!Array.isArray(value) || value.length > MAX_SYNC_ENDPOINTS_PER_TOPIC) {
|
|
134
|
+
throw new TypeError(`${label} must be an array of at most ${MAX_SYNC_ENDPOINTS_PER_TOPIC} strings`);
|
|
135
|
+
}
|
|
136
|
+
return value.map((entry, index) => {
|
|
137
|
+
if (typeof entry !== 'string' ||
|
|
138
|
+
entry.length === 0 ||
|
|
139
|
+
new TextEncoder().encode(entry).byteLength > 2048 ||
|
|
140
|
+
Array.from(entry).some(character => {
|
|
141
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
142
|
+
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
143
|
+
})) {
|
|
144
|
+
throw new TypeError(`${label}[${index}] is invalid`);
|
|
145
|
+
}
|
|
146
|
+
return entry;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function copySyncConfiguration(value) {
|
|
150
|
+
const copied = copyRegistry(value ?? {}, 'Sync configuration');
|
|
151
|
+
const result = Object.create(null);
|
|
152
|
+
for (const name of Object.keys(copied)) {
|
|
153
|
+
const entry = copied[name];
|
|
154
|
+
if (entry === false || entry === 'SHIP') {
|
|
155
|
+
result[name] = entry;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const endpoints = copyBoundedStringList(entry, `Sync configuration ${name}`);
|
|
159
|
+
if (endpoints === undefined)
|
|
160
|
+
throw new TypeError(`Sync configuration ${name} is invalid`);
|
|
161
|
+
result[name] = endpoints;
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
function assertUnprovenThreshold(value) {
|
|
166
|
+
if (!Number.isSafeInteger(value) ||
|
|
167
|
+
value < 1 ||
|
|
168
|
+
value > MAX_UNPROVEN_THRESHOLD_BLOCKS) {
|
|
169
|
+
throw new TypeError(`Unproven thresholdBlocks must be an integer between 1 and ${MAX_UNPROVEN_THRESHOLD_BLOCKS}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
10
172
|
function findSpendingInputIndex(tx, output) {
|
|
11
173
|
return tx.inputs.findIndex(input => {
|
|
12
174
|
const realSource = input.sourceTXID || input.sourceTransaction?.id('hex');
|
|
13
175
|
return realSource === output.txid && input.sourceOutputIndex === output.outputIndex;
|
|
14
176
|
});
|
|
15
177
|
}
|
|
178
|
+
function requireBASMDefined(value, aligned, message) {
|
|
179
|
+
requireBASM(aligned, message);
|
|
180
|
+
requireBASM(value !== undefined, message);
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
16
183
|
/**
|
|
17
184
|
* An engine for running BSV Overlay Services (topic managers and lookup services).
|
|
18
185
|
*/
|
|
@@ -37,6 +204,8 @@ export class Engine {
|
|
|
37
204
|
basmSyncEnabled;
|
|
38
205
|
unprovenEvictionBlocks;
|
|
39
206
|
maxLookupResults;
|
|
207
|
+
submissionTail = Promise.resolve();
|
|
208
|
+
basmFetchImpl;
|
|
40
209
|
/**
|
|
41
210
|
* Creates a new Overlay Services Engine
|
|
42
211
|
* @param {[key: string]: TopicManager} managers - manages topic admittance
|
|
@@ -81,25 +250,43 @@ export class Engine {
|
|
|
81
250
|
this.basmSyncEnabled = basmSyncEnabled;
|
|
82
251
|
this.unprovenEvictionBlocks = unprovenEvictionBlocks;
|
|
83
252
|
this.maxLookupResults = maxLookupResults;
|
|
84
|
-
if (maxLookupResults !== -1 &&
|
|
253
|
+
if (maxLookupResults !== -1 &&
|
|
254
|
+
(!Number.isSafeInteger(maxLookupResults) || maxLookupResults < 1)) {
|
|
85
255
|
throw new TypeError('maxLookupResults must be -1 or a positive safe integer');
|
|
86
256
|
}
|
|
257
|
+
assertUnprovenThreshold(unprovenEvictionBlocks);
|
|
258
|
+
// Registries cross configuration, HTTP dispatch, and JSON response
|
|
259
|
+
// boundaries. Own-property null-prototype copies prevent inherited entries
|
|
260
|
+
// and reserved object names from becoming executable services.
|
|
261
|
+
this.managers = copyRegistry(managers, 'Topic manager registry');
|
|
262
|
+
this.lookupServices = copyRegistry(lookupServices, 'Lookup service registry');
|
|
263
|
+
this.shipTrackers = copyBoundedStringList(this.shipTrackers, 'SHIP trackers');
|
|
264
|
+
this.slapTrackers = copyBoundedStringList(this.slapTrackers, 'SLAP trackers');
|
|
265
|
+
this.syncConfiguration = copySyncConfiguration(this.syncConfiguration);
|
|
87
266
|
// To encourage synchronization of overlay services, the SHIP sync strategy is used by default for all overlay topics, except for 'tm_ship' and 'tm_slap'.
|
|
88
267
|
// For these two topics, any existing trackers are combined with the provided shipTrackers and slapTrackers omitting any duplicates.
|
|
89
268
|
this.syncConfiguration ??= {};
|
|
90
|
-
for (const managerName of Object.keys(managers)) {
|
|
91
|
-
if (managerName === 'tm_ship' &&
|
|
269
|
+
for (const managerName of Object.keys(this.managers)) {
|
|
270
|
+
if (managerName === 'tm_ship' &&
|
|
271
|
+
this.shipTrackers !== undefined &&
|
|
272
|
+
this.syncConfiguration[managerName] !== false) {
|
|
92
273
|
// Combine tm_ship trackers with preexisting entries if any
|
|
93
274
|
const combinedSet = new Set([
|
|
94
|
-
...(Array.isArray(this.syncConfiguration[managerName])
|
|
275
|
+
...(Array.isArray(this.syncConfiguration[managerName])
|
|
276
|
+
? this.syncConfiguration[managerName]
|
|
277
|
+
: []),
|
|
95
278
|
...this.shipTrackers
|
|
96
279
|
]);
|
|
97
280
|
this.syncConfiguration[managerName] = Array.from(combinedSet);
|
|
98
281
|
}
|
|
99
|
-
else if (managerName === 'tm_slap' &&
|
|
282
|
+
else if (managerName === 'tm_slap' &&
|
|
283
|
+
this.slapTrackers !== undefined &&
|
|
284
|
+
this.syncConfiguration[managerName] !== false) {
|
|
100
285
|
// Combine tm_slap trackers with preexisting entries if any
|
|
101
286
|
const combinedSet = new Set([
|
|
102
|
-
...(Array.isArray(this.syncConfiguration[managerName])
|
|
287
|
+
...(Array.isArray(this.syncConfiguration[managerName])
|
|
288
|
+
? this.syncConfiguration[managerName]
|
|
289
|
+
: []),
|
|
103
290
|
...this.slapTrackers
|
|
104
291
|
]);
|
|
105
292
|
this.syncConfiguration[managerName] = Array.from(combinedSet);
|
|
@@ -126,7 +313,9 @@ export class Engine {
|
|
|
126
313
|
return undefined;
|
|
127
314
|
}
|
|
128
315
|
try {
|
|
129
|
-
|
|
316
|
+
const height = await this.chainTracker.currentHeight();
|
|
317
|
+
assertNonnegativeInteger(height, 'Current chain height');
|
|
318
|
+
return height;
|
|
130
319
|
}
|
|
131
320
|
catch (error) {
|
|
132
321
|
this.logger.warn(`Unable to resolve current chain height for overlay metadata: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -134,12 +323,27 @@ export class Engine {
|
|
|
134
323
|
}
|
|
135
324
|
}
|
|
136
325
|
async resolveBlockHash(blockHeight, merkleRoot) {
|
|
326
|
+
assertNonnegativeInteger(blockHeight, 'BASM block height');
|
|
327
|
+
if (merkleRoot !== undefined)
|
|
328
|
+
assertHash(merkleRoot, 'BASM merkle root');
|
|
137
329
|
try {
|
|
138
330
|
const header = await this.topicAnchorHeaderResolver?.(blockHeight);
|
|
139
331
|
if (header === undefined) {
|
|
140
332
|
return undefined;
|
|
141
333
|
}
|
|
142
|
-
if (header
|
|
334
|
+
if (typeof header !== 'object' || header === null) {
|
|
335
|
+
throw new TypeError('Header resolver returned an invalid header');
|
|
336
|
+
}
|
|
337
|
+
assertNonnegativeInteger(header.blockHeight, 'BASM header height');
|
|
338
|
+
assertHash(header.blockHash, 'BASM header block hash');
|
|
339
|
+
if (header.merkleRoot !== undefined)
|
|
340
|
+
assertHash(header.merkleRoot, 'BASM header merkle root');
|
|
341
|
+
if (header.blockHeight !== blockHeight) {
|
|
342
|
+
throw new TypeError('Header resolver returned a different block height');
|
|
343
|
+
}
|
|
344
|
+
if (header.merkleRoot !== undefined &&
|
|
345
|
+
merkleRoot !== undefined &&
|
|
346
|
+
header.merkleRoot.toLowerCase() !== merkleRoot.toLowerCase()) {
|
|
143
347
|
throw new Error(`Header merkle root ${header.merkleRoot} does not match proof root ${merkleRoot} at height ${blockHeight}`);
|
|
144
348
|
}
|
|
145
349
|
return header.blockHash;
|
|
@@ -174,7 +378,9 @@ export class Engine {
|
|
|
174
378
|
const metadata = extractMerkleProofMetadata(txid, tx.merklePath);
|
|
175
379
|
const [firstSeenHeight, blockHash] = await Promise.all([
|
|
176
380
|
this.currentHeightOrUndefined(),
|
|
177
|
-
metadata === undefined
|
|
381
|
+
metadata === undefined
|
|
382
|
+
? undefined
|
|
383
|
+
: this.resolveBlockHash(metadata.blockHeight, metadata.merkleRoot)
|
|
178
384
|
]);
|
|
179
385
|
return {
|
|
180
386
|
blockHeight: metadata?.blockHeight,
|
|
@@ -186,12 +392,20 @@ export class Engine {
|
|
|
186
392
|
};
|
|
187
393
|
}
|
|
188
394
|
async recomputeTopicBlockAnchor(topic, blockHeight, blockHash) {
|
|
395
|
+
assertTopic(topic, 'BASM topic');
|
|
396
|
+
assertNonnegativeInteger(blockHeight, 'BASM block height');
|
|
397
|
+
if (blockHash !== undefined)
|
|
398
|
+
assertHash(blockHash, 'BASM block hash');
|
|
189
399
|
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
|
|
190
400
|
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
|
|
191
401
|
typeof this.storage.findTopicBlockAnchor !== 'function') {
|
|
192
402
|
return undefined;
|
|
193
403
|
}
|
|
194
|
-
const
|
|
404
|
+
const storedAnchor = await this.storage.findTopicBlockAnchor(topic, blockHeight);
|
|
405
|
+
if (storedAnchor !== undefined) {
|
|
406
|
+
this.assertTopicBlockAnchor(storedAnchor, topic, 'Stored BASM anchor', blockHeight);
|
|
407
|
+
}
|
|
408
|
+
const anchorBlockHash = blockHash ?? storedAnchor?.blockHash;
|
|
195
409
|
if (anchorBlockHash === undefined) {
|
|
196
410
|
return undefined;
|
|
197
411
|
}
|
|
@@ -202,11 +416,17 @@ export class Engine {
|
|
|
202
416
|
// an out-of-order proof (older height arriving after a newer one) can never
|
|
203
417
|
// leave a gap that silently breaks the chain.
|
|
204
418
|
const tip = await this.storage.findTopicAnchorTip?.(topic);
|
|
419
|
+
if (tip !== undefined)
|
|
420
|
+
this.assertTopicAnchorTip(tip, topic, 'Stored BASM tip');
|
|
205
421
|
const tipHeight = tip !== undefined && tip.blockHeight >= 0 ? tip.blockHeight : undefined;
|
|
206
422
|
const fromHeight = tipHeight === undefined ? blockHeight : Math.min(blockHeight, tipHeight + 1);
|
|
207
423
|
const toHeight = tipHeight === undefined ? blockHeight : Math.max(blockHeight, tipHeight);
|
|
208
424
|
await this.rebuildTopicAnchorChain(topic, fromHeight, toHeight, new Map([[blockHeight, anchorBlockHash]]));
|
|
209
|
-
|
|
425
|
+
const rebuilt = await this.storage.findTopicBlockAnchor(topic, blockHeight);
|
|
426
|
+
if (rebuilt !== undefined) {
|
|
427
|
+
this.assertTopicBlockAnchor(rebuilt, topic, 'Rebuilt BASM anchor', blockHeight);
|
|
428
|
+
}
|
|
429
|
+
return rebuilt;
|
|
210
430
|
}
|
|
211
431
|
/**
|
|
212
432
|
* Extends every configured topic's anchor chain forward with empty Topic Block
|
|
@@ -217,16 +437,21 @@ export class Engine {
|
|
|
217
437
|
* unstarted (genesis is the topic's first admitted height).
|
|
218
438
|
*/
|
|
219
439
|
async advanceTopicAnchorChains(toHeight) {
|
|
440
|
+
if (toHeight !== undefined)
|
|
441
|
+
assertNonnegativeInteger(toHeight, 'BASM target height');
|
|
220
442
|
if (typeof this.storage.findTopicAnchorTip !== 'function' ||
|
|
221
443
|
typeof this.storage.upsertTopicBlockAnchor !== 'function') {
|
|
222
444
|
return;
|
|
223
445
|
}
|
|
224
|
-
const targetHeight = toHeight ?? await this.currentHeightOrUndefined();
|
|
446
|
+
const targetHeight = toHeight ?? (await this.currentHeightOrUndefined());
|
|
225
447
|
if (targetHeight === undefined) {
|
|
226
448
|
return;
|
|
227
449
|
}
|
|
450
|
+
assertNonnegativeInteger(targetHeight, 'BASM target height');
|
|
228
451
|
for (const topic of Object.keys(this.managers)) {
|
|
229
452
|
const tip = await this.storage.findTopicAnchorTip(topic);
|
|
453
|
+
if (tip !== undefined)
|
|
454
|
+
this.assertTopicAnchorTip(tip, topic, 'Stored BASM tip');
|
|
230
455
|
if (tip === undefined || tip.blockHeight < 0 || tip.blockHeight >= targetHeight) {
|
|
231
456
|
continue;
|
|
232
457
|
}
|
|
@@ -242,6 +467,20 @@ export class Engine {
|
|
|
242
467
|
* contiguity instead of leaving a hole.
|
|
243
468
|
*/
|
|
244
469
|
async rebuildTopicAnchorChain(topic, fromHeight, toHeight, blockHashHints = new Map(), forceResolve = false) {
|
|
470
|
+
assertTopic(topic, 'BASM topic');
|
|
471
|
+
assertNonnegativeInteger(fromHeight, 'BASM range start');
|
|
472
|
+
assertNonnegativeInteger(toHeight, 'BASM range end');
|
|
473
|
+
if (!(blockHashHints instanceof Map))
|
|
474
|
+
throw new TypeError('BASM block hash hints must be a Map');
|
|
475
|
+
if (typeof forceResolve !== 'boolean')
|
|
476
|
+
throw new TypeError('BASM forceResolve must be a boolean');
|
|
477
|
+
for (const [height, hash] of blockHashHints) {
|
|
478
|
+
assertNonnegativeInteger(height, 'BASM block hash hint height');
|
|
479
|
+
assertHash(hash, 'BASM block hash hint');
|
|
480
|
+
if (height < fromHeight || height > toHeight) {
|
|
481
|
+
throw new TypeError('BASM block hash hint is outside the rebuild range');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
245
484
|
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function' ||
|
|
246
485
|
typeof this.storage.upsertTopicBlockAnchor !== 'function' ||
|
|
247
486
|
typeof this.storage.findTopicBlockAnchor !== 'function' ||
|
|
@@ -253,16 +492,22 @@ export class Engine {
|
|
|
253
492
|
this.logger.warn(`[BASM] capping anchor chain extension: topic=${serializeLogValue(topic)} limit=${serializeLogValue(DEFAULT_BASM_RANGE_LIMIT)} requestedFrom=${serializeLogValue(fromHeight)} requestedTo=${serializeLogValue(toHeight)}; will continue on the next pass`);
|
|
254
493
|
toHeight = fromHeight + DEFAULT_BASM_RANGE_LIMIT - 1;
|
|
255
494
|
}
|
|
256
|
-
const previousAnchor = fromHeight > 0
|
|
257
|
-
|
|
258
|
-
|
|
495
|
+
const previousAnchor = fromHeight > 0 ? await this.storage.findTopicBlockAnchor(topic, fromHeight - 1) : undefined;
|
|
496
|
+
if (previousAnchor !== undefined) {
|
|
497
|
+
this.assertTopicBlockAnchor(previousAnchor, topic, 'Previous BASM anchor', fromHeight - 1);
|
|
498
|
+
}
|
|
259
499
|
let prevTac = previousAnchor?.tac ?? BASM_ZERO_HASH;
|
|
260
500
|
for (let height = fromHeight; height <= toHeight; height++) {
|
|
261
|
-
const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height);
|
|
501
|
+
const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, height, undefined, MAX_BASM_ADMITTED_PER_BLOCK + 1);
|
|
262
502
|
const existing = await this.storage.findTopicBlockAnchor(topic, height);
|
|
503
|
+
if (existing !== undefined) {
|
|
504
|
+
this.assertTopicBlockAnchor(existing, topic, 'Stored BASM anchor', height);
|
|
505
|
+
}
|
|
263
506
|
// On a reorg rebuild the existing anchor's block hash is stale, so force
|
|
264
507
|
// canonical re-resolution from the header resolver instead of reusing it.
|
|
265
|
-
const blockHash = blockHashHints.get(height) ??
|
|
508
|
+
const blockHash = blockHashHints.get(height) ??
|
|
509
|
+
(forceResolve ? undefined : existing?.blockHash) ??
|
|
510
|
+
(await this.resolveBlockHash(height));
|
|
266
511
|
if (blockHash === undefined) {
|
|
267
512
|
this.logger.warn(`[BASM] unable to resolve block hash: topic=${serializeLogValue(topic)} height=${serializeLogValue(height)}; halting chain extension`);
|
|
268
513
|
return;
|
|
@@ -292,6 +537,29 @@ export class Engine {
|
|
|
292
537
|
* SSE reconnect, and poll.
|
|
293
538
|
*/
|
|
294
539
|
async handleReorg(input) {
|
|
540
|
+
return await this.reconcileReorg(input, new Set());
|
|
541
|
+
}
|
|
542
|
+
async reconcileReorg(input, independentlyInvalidProofRows) {
|
|
543
|
+
if (typeof input !== 'object' ||
|
|
544
|
+
input === null ||
|
|
545
|
+
!Array.isArray(input.orphanedBlockHashes) ||
|
|
546
|
+
input.orphanedBlockHashes.length > MAX_REORG_ORPHAN_HASHES) {
|
|
547
|
+
throw new TypeError('Invalid or oversized reorg input');
|
|
548
|
+
}
|
|
549
|
+
assertNonnegativeInteger(input.rebuildFromHeight, 'Reorg rebuild start');
|
|
550
|
+
assertNonnegativeInteger(input.newTipHeight, 'Reorg new tip height');
|
|
551
|
+
if (input.rebuildFromHeight > input.newTipHeight ||
|
|
552
|
+
input.newTipHeight - input.rebuildFromHeight + 1 > MAX_REORG_DEPTH) {
|
|
553
|
+
throw new TypeError(`Reorg rebuild ranges are capped at ${MAX_REORG_DEPTH} heights`);
|
|
554
|
+
}
|
|
555
|
+
const orphanedHashes = new Set();
|
|
556
|
+
for (const hash of input.orphanedBlockHashes) {
|
|
557
|
+
assertHash(hash, 'Reorg orphaned block hash');
|
|
558
|
+
const canonical = hash.toLowerCase();
|
|
559
|
+
if (orphanedHashes.has(canonical))
|
|
560
|
+
throw new TypeError('Reorg block hashes must be unique');
|
|
561
|
+
orphanedHashes.add(canonical);
|
|
562
|
+
}
|
|
295
563
|
const report = { perTopic: [] };
|
|
296
564
|
if (typeof this.storage.findProvenAppliedTransactionsByBlockHash !== 'function' ||
|
|
297
565
|
typeof this.storage.demoteAppliedTransactionToUnproven !== 'function' ||
|
|
@@ -302,25 +570,94 @@ export class Engine {
|
|
|
302
570
|
// 1) Demote proven admissions whose block was orphaned. Hashes are
|
|
303
571
|
// normalized to lower-case display hex to match stored block hashes
|
|
304
572
|
// (go-sdk chainhash.Hash marshals as reversed display hex).
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
573
|
+
const rowsToDemote = [];
|
|
574
|
+
const seenRows = new Set();
|
|
575
|
+
for (const blockHash of orphanedHashes) {
|
|
576
|
+
const remaining = MAX_REORG_ROWS - rowsToDemote.length;
|
|
577
|
+
const rows = await this.storage.findProvenAppliedTransactionsByBlockHash(blockHash, remaining + 1);
|
|
578
|
+
if (!Array.isArray(rows) || rows.length > remaining) {
|
|
579
|
+
throw new TypeError('Storage returned an invalid or oversized reorg transaction set');
|
|
580
|
+
}
|
|
581
|
+
for (const [index, row] of rows.entries()) {
|
|
582
|
+
if (typeof row !== 'object' || row === null) {
|
|
583
|
+
throw new TypeError(`Storage returned an invalid reorg transaction at index ${index}`);
|
|
584
|
+
}
|
|
585
|
+
assertHash(row.txid, `Reorg transaction[${index}] txid`);
|
|
586
|
+
assertTopic(row.topic, `Reorg transaction[${index}] topic`);
|
|
587
|
+
assertNonnegativeInteger(row.blockHeight, `Reorg transaction[${index}] height`);
|
|
588
|
+
if (row.blockHeight < input.rebuildFromHeight || row.blockHeight > input.newTipHeight) {
|
|
589
|
+
throw new TypeError('Storage returned a reorg transaction outside the rebuild range');
|
|
590
|
+
}
|
|
591
|
+
const key = `${row.txid.toLowerCase()}.${row.topic}`;
|
|
592
|
+
if (seenRows.has(key))
|
|
593
|
+
throw new TypeError('Storage returned duplicate reorg transactions');
|
|
594
|
+
seenRows.add(key);
|
|
595
|
+
rowsToDemote.push({ ...row, reportedBlockHash: blockHash });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// The event stream is an acceleration hint, not independent chain-state
|
|
599
|
+
// authority. Before any durable mutation, corroborate every claimed
|
|
600
|
+
// orphaned block against the configured canonical header resolver. A
|
|
601
|
+
// missing answer fails closed; a hash that is still canonical proves the
|
|
602
|
+
// stream claim is stale or hostile.
|
|
603
|
+
const canonicalByHeight = new Map();
|
|
604
|
+
const authorizedRowsToDemote = [];
|
|
605
|
+
for (const row of rowsToDemote) {
|
|
606
|
+
let canonical = canonicalByHeight.get(row.blockHeight);
|
|
607
|
+
if (canonical === undefined) {
|
|
608
|
+
canonical = (await this.resolveBlockHash(row.blockHeight))?.toLowerCase() ?? null;
|
|
609
|
+
canonicalByHeight.set(row.blockHeight, canonical);
|
|
610
|
+
}
|
|
611
|
+
const rowKey = `${row.txid.toLowerCase()}\0${row.topic}`;
|
|
612
|
+
const independentlyInvalid = independentlyInvalidProofRows.has(rowKey);
|
|
613
|
+
if (canonical === null && !independentlyInvalid) {
|
|
614
|
+
throw new Error('Unable to corroborate a reported reorg before mutating state');
|
|
615
|
+
}
|
|
616
|
+
if (canonical === row.reportedBlockHash.toLowerCase() && !independentlyInvalid) {
|
|
617
|
+
if (independentlyInvalidProofRows.size > 0)
|
|
618
|
+
continue;
|
|
619
|
+
throw new Error('Reported orphaned block is still canonical');
|
|
314
620
|
}
|
|
621
|
+
authorizedRowsToDemote.push(row);
|
|
622
|
+
}
|
|
623
|
+
const demotedByTopic = new Map();
|
|
624
|
+
for (const row of authorizedRowsToDemote) {
|
|
625
|
+
await this.storage.demoteAppliedTransactionToUnproven(row.txid, row.topic);
|
|
626
|
+
const list = demotedByTopic.get(row.topic) ?? [];
|
|
627
|
+
list.push(row.txid);
|
|
628
|
+
demotedByTopic.set(row.topic, list);
|
|
315
629
|
}
|
|
316
630
|
// 2) Rebuild every topic anchor chain that intersects the reorged range,
|
|
317
631
|
// forcing canonical block-hash re-resolution so stale hashes are replaced.
|
|
318
632
|
for (const topic of Object.keys(this.managers)) {
|
|
319
|
-
const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight);
|
|
633
|
+
const existing = await this.storage.findTopicBlockAnchors(topic, input.rebuildFromHeight, input.newTipHeight, MAX_REORG_DEPTH + 1);
|
|
634
|
+
if (!Array.isArray(existing) || existing.length > MAX_REORG_DEPTH) {
|
|
635
|
+
throw new TypeError('Storage returned an invalid or oversized reorg anchor set');
|
|
636
|
+
}
|
|
320
637
|
if (existing.length === 0) {
|
|
321
638
|
continue;
|
|
322
639
|
}
|
|
323
|
-
|
|
640
|
+
let startHeight = input.newTipHeight;
|
|
641
|
+
const seenHeights = new Set();
|
|
642
|
+
for (const [index, anchor] of existing.entries()) {
|
|
643
|
+
if (typeof anchor !== 'object' || anchor === null) {
|
|
644
|
+
throw new TypeError(`Storage returned an invalid reorg anchor at index ${index}`);
|
|
645
|
+
}
|
|
646
|
+
assertTopic(anchor.topic, `Reorg anchor[${index}] topic`);
|
|
647
|
+
assertNonnegativeInteger(anchor.blockHeight, `Reorg anchor[${index}] height`);
|
|
648
|
+
if (anchor.topic !== topic ||
|
|
649
|
+
anchor.blockHeight < input.rebuildFromHeight ||
|
|
650
|
+
anchor.blockHeight > input.newTipHeight ||
|
|
651
|
+
seenHeights.has(anchor.blockHeight)) {
|
|
652
|
+
throw new TypeError('Storage returned an unbound or duplicate reorg anchor');
|
|
653
|
+
}
|
|
654
|
+
assertHash(anchor.blockHash, `Reorg anchor[${index}] block hash`);
|
|
655
|
+
assertHash(anchor.basmRoot, `Reorg anchor[${index}] BASM root`);
|
|
656
|
+
assertHash(anchor.tac, `Reorg anchor[${index}] TAC`);
|
|
657
|
+
assertNonnegativeInteger(anchor.admittedCount, `Reorg anchor[${index}] admitted count`);
|
|
658
|
+
seenHeights.add(anchor.blockHeight);
|
|
659
|
+
startHeight = Math.min(startHeight, anchor.blockHeight);
|
|
660
|
+
}
|
|
324
661
|
await this.rebuildTopicAnchorChain(topic, startHeight, input.newTipHeight, new Map(), true);
|
|
325
662
|
report.perTopic.push({
|
|
326
663
|
topic,
|
|
@@ -341,10 +678,11 @@ export class Engine {
|
|
|
341
678
|
* treated as orphaned and reconciled via {@link handleReorg}.
|
|
342
679
|
*/
|
|
343
680
|
async isProvenAnchorStale(row, chainTracker) {
|
|
681
|
+
let rootInvalid = false;
|
|
344
682
|
if (row.merkleRoot !== undefined) {
|
|
345
683
|
try {
|
|
346
|
-
if (
|
|
347
|
-
|
|
684
|
+
if ((await chainTracker.isValidRootForHeight(row.merkleRoot, row.blockHeight)) !== true) {
|
|
685
|
+
rootInvalid = true;
|
|
348
686
|
}
|
|
349
687
|
}
|
|
350
688
|
catch (error) {
|
|
@@ -353,9 +691,15 @@ export class Engine {
|
|
|
353
691
|
}
|
|
354
692
|
}
|
|
355
693
|
const canonical = await this.resolveBlockHash(row.blockHeight);
|
|
356
|
-
|
|
694
|
+
if (canonical !== undefined && canonical.toLowerCase() !== row.blockHash?.toLowerCase()) {
|
|
695
|
+
return 'stale-block';
|
|
696
|
+
}
|
|
697
|
+
return rootInvalid ? 'invalid-proof' : false;
|
|
357
698
|
}
|
|
358
699
|
async revalidateRecentAnchors(depth = 3) {
|
|
700
|
+
if (!Number.isSafeInteger(depth) || depth < 1 || depth > MAX_REORG_DEPTH) {
|
|
701
|
+
throw new TypeError(`Reorg revalidation depth must be between 1 and ${MAX_REORG_DEPTH}`);
|
|
702
|
+
}
|
|
359
703
|
const chainTracker = this.chainTracker;
|
|
360
704
|
if (chainTracker === 'scripts only') {
|
|
361
705
|
this.logger.warn('[BASM] revalidation sweep requires a ChainTracker; skipping');
|
|
@@ -368,28 +712,58 @@ export class Engine {
|
|
|
368
712
|
if (tip === undefined) {
|
|
369
713
|
return undefined;
|
|
370
714
|
}
|
|
715
|
+
assertNonnegativeInteger(tip, 'Current chain height');
|
|
371
716
|
const fromHeight = Math.max(0, tip - depth + 1);
|
|
372
|
-
const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip);
|
|
717
|
+
const rows = await this.storage.findProvenAppliedTransactionsInRange(fromHeight, tip, undefined, MAX_REORG_ROWS + 1);
|
|
718
|
+
if (!Array.isArray(rows) || rows.length > MAX_REORG_ROWS) {
|
|
719
|
+
throw new TypeError('Storage returned an invalid or oversized revalidation set');
|
|
720
|
+
}
|
|
373
721
|
const orphaned = new Set();
|
|
722
|
+
const independentlyInvalidProofRows = new Set();
|
|
374
723
|
let minAffected = Number.POSITIVE_INFINITY;
|
|
375
|
-
|
|
724
|
+
const seenRows = new Set();
|
|
725
|
+
for (const [index, row] of rows.entries()) {
|
|
726
|
+
if (typeof row !== 'object' || row === null) {
|
|
727
|
+
throw new TypeError(`Storage returned an invalid revalidation row at index ${index}`);
|
|
728
|
+
}
|
|
729
|
+
assertHash(row.txid, `Revalidation row[${index}] txid`);
|
|
730
|
+
assertTopic(row.topic, `Revalidation row[${index}] topic`);
|
|
731
|
+
assertNonnegativeInteger(row.blockHeight, `Revalidation row[${index}] height`);
|
|
732
|
+
if (row.blockHeight < fromHeight || row.blockHeight > tip) {
|
|
733
|
+
throw new TypeError('Storage returned a revalidation row outside the requested range');
|
|
734
|
+
}
|
|
735
|
+
if (row.blockHash !== undefined)
|
|
736
|
+
assertHash(row.blockHash, `Revalidation row[${index}] block hash`);
|
|
737
|
+
if (row.merkleRoot !== undefined)
|
|
738
|
+
assertHash(row.merkleRoot, `Revalidation row[${index}] Merkle root`);
|
|
739
|
+
const key = `${row.txid.toLowerCase()}.${row.topic}`;
|
|
740
|
+
if (seenRows.has(key))
|
|
741
|
+
throw new TypeError('Storage returned duplicate revalidation rows');
|
|
742
|
+
seenRows.add(key);
|
|
376
743
|
if (row.blockHash === undefined) {
|
|
377
744
|
continue;
|
|
378
745
|
}
|
|
379
746
|
const stale = await this.isProvenAnchorStale(row, chainTracker);
|
|
380
|
-
if (stale
|
|
381
|
-
|
|
747
|
+
if (stale !== false && stale !== undefined) {
|
|
748
|
+
const blockHash = row.blockHash.toLowerCase();
|
|
749
|
+
orphaned.add(blockHash);
|
|
750
|
+
if (stale === 'invalid-proof') {
|
|
751
|
+
independentlyInvalidProofRows.add(`${row.txid.toLowerCase()}\0${row.topic}`);
|
|
752
|
+
}
|
|
753
|
+
if (orphaned.size > MAX_REORG_ORPHAN_HASHES) {
|
|
754
|
+
throw new TypeError('Revalidation produced too many orphaned block hashes');
|
|
755
|
+
}
|
|
382
756
|
minAffected = Math.min(minAffected, row.blockHeight);
|
|
383
757
|
}
|
|
384
758
|
}
|
|
385
759
|
if (orphaned.size === 0) {
|
|
386
760
|
return { perTopic: [] };
|
|
387
761
|
}
|
|
388
|
-
return await this.
|
|
762
|
+
return await this.reconcileReorg({
|
|
389
763
|
orphanedBlockHashes: Array.from(orphaned),
|
|
390
764
|
rebuildFromHeight: minAffected,
|
|
391
765
|
newTipHeight: tip
|
|
392
|
-
});
|
|
766
|
+
}, independentlyInvalidProofRows);
|
|
393
767
|
}
|
|
394
768
|
async validateTopicSubmission(topic, context) {
|
|
395
769
|
const { tx, txid, beef, offChainValues, mode, dupeTopics, failedTopics } = context;
|
|
@@ -400,6 +774,9 @@ export class Engine {
|
|
|
400
774
|
this.startTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
401
775
|
const isDupe = await this.storage.doesAppliedTransactionExist({ txid, topic });
|
|
402
776
|
this.endTime(`dupCheck_${txid.substring(0, 10)}`);
|
|
777
|
+
if (typeof isDupe !== 'boolean') {
|
|
778
|
+
throw new TypeError('Storage returned an invalid applied-transaction verdict');
|
|
779
|
+
}
|
|
403
780
|
if (isDupe) {
|
|
404
781
|
dupeTopics.add(topic);
|
|
405
782
|
return {
|
|
@@ -415,16 +792,22 @@ export class Engine {
|
|
|
415
792
|
const previousTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
416
793
|
if (previousTXID === undefined)
|
|
417
794
|
return null;
|
|
418
|
-
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic);
|
|
419
|
-
if (output !== undefined && output !== null)
|
|
795
|
+
const output = await this.storage.findOutput(previousTXID, input.sourceOutputIndex, topic, false);
|
|
796
|
+
if (output !== undefined && output !== null) {
|
|
797
|
+
this.assertStoredOutputMatches(output, { txid: previousTXID, outputIndex: input.sourceOutputIndex, topic }, 'Previous topical output');
|
|
798
|
+
if (output.spent !== false) {
|
|
799
|
+
throw new TypeError('Storage returned a spent output for an unspent topical query');
|
|
800
|
+
}
|
|
420
801
|
previousCoins.push(inputIndex);
|
|
802
|
+
}
|
|
421
803
|
return output ?? null;
|
|
422
804
|
});
|
|
423
805
|
this.startTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
424
806
|
const previousOutputs = await Promise.all(outputPromises);
|
|
807
|
+
previousCoins.sort((left, right) => left - right);
|
|
425
808
|
this.endTime(`previousOutputQuery_${txid.substring(0, 10)}`);
|
|
426
809
|
this.startTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
427
|
-
const admissibleOutputs = await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode);
|
|
810
|
+
const admissibleOutputs = validateAdmittanceInstructions(await this.managers[topic].identifyAdmissibleOutputs(beef, previousCoins, offChainValues, mode), tx, previousCoins);
|
|
428
811
|
this.endTime(`identifyAdmissibleOutputs_${txid.substring(0, 10)}`);
|
|
429
812
|
return {
|
|
430
813
|
topic,
|
|
@@ -447,10 +830,11 @@ export class Engine {
|
|
|
447
830
|
}
|
|
448
831
|
}
|
|
449
832
|
isTopicSubmissionAccepted(validation, failedTopics) {
|
|
450
|
-
return !failedTopics.has(validation.topic) &&
|
|
451
|
-
validation.
|
|
452
|
-
|
|
453
|
-
|
|
833
|
+
return (!failedTopics.has(validation.topic) &&
|
|
834
|
+
(validation.isDupe ||
|
|
835
|
+
validation.admissibleOutputs.outputsToAdmit.length > 0 ||
|
|
836
|
+
validation.admissibleOutputs.coinsToRetain.length > 0 ||
|
|
837
|
+
validation.previousCoins.length > 0));
|
|
454
838
|
}
|
|
455
839
|
async broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted) {
|
|
456
840
|
this.startTime(`broadcast_${txid.substring(0, 10)}`);
|
|
@@ -550,29 +934,24 @@ export class Engine {
|
|
|
550
934
|
async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
|
|
551
935
|
if (output === null)
|
|
552
936
|
return;
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}));
|
|
563
|
-
}
|
|
564
|
-
catch (error) {
|
|
565
|
-
this.logger.error('Error marking UTXO as spent:', error);
|
|
566
|
-
}
|
|
937
|
+
await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic, txid);
|
|
938
|
+
await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
|
|
939
|
+
try {
|
|
940
|
+
await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
this.logger.error('Error in lookup service for outputSpent:', error);
|
|
944
|
+
}
|
|
945
|
+
}));
|
|
567
946
|
}
|
|
568
947
|
async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
|
|
569
|
-
|
|
948
|
+
for (const validation of validations) {
|
|
570
949
|
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
571
|
-
|
|
572
|
-
|
|
950
|
+
continue;
|
|
951
|
+
for (const output of validation.previousOutputs) {
|
|
573
952
|
await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
576
955
|
}
|
|
577
956
|
classifyPreviousCoins(tx, validation) {
|
|
578
957
|
const outputsConsumed = [];
|
|
@@ -602,8 +981,10 @@ export class Engine {
|
|
|
602
981
|
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
603
982
|
await Promise.all(outputs.map(async (coin) => {
|
|
604
983
|
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
|
|
605
|
-
if (output !== undefined && output !== null)
|
|
984
|
+
if (output !== undefined && output !== null) {
|
|
985
|
+
this.assertStoredOutputMatches(output, { txid: coin.txid, outputIndex: coin.previousOutputIndex, topic }, 'Stale topical output');
|
|
606
986
|
await this.deleteUTXODeep(output);
|
|
987
|
+
}
|
|
607
988
|
}));
|
|
608
989
|
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
609
990
|
}
|
|
@@ -666,7 +1047,17 @@ export class Engine {
|
|
|
666
1047
|
const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
|
|
667
1048
|
if (storedOutput === undefined || storedOutput === null)
|
|
668
1049
|
return;
|
|
669
|
-
|
|
1050
|
+
this.assertStoredOutputMatches(storedOutput, { ...output, topic }, 'Consumed topical output');
|
|
1051
|
+
const consumedByMap = new Map();
|
|
1052
|
+
for (const [index, relation] of [...storedOutput.consumedBy, ...newUTXOs].entries()) {
|
|
1053
|
+
assertHash(relation.txid, `Consumed topical output relation[${index}] txid`);
|
|
1054
|
+
assertOutputIndex(relation.outputIndex, `Consumed topical output relation[${index}] output index`);
|
|
1055
|
+
consumedByMap.set(this.toOutputCacheKey(relation.txid, relation.outputIndex), relation);
|
|
1056
|
+
}
|
|
1057
|
+
if (consumedByMap.size > MAX_OUTPUT_RELATIONS) {
|
|
1058
|
+
throw new RangeError('Consumed topical output relations exceeded their work budget');
|
|
1059
|
+
}
|
|
1060
|
+
const consumedBy = Array.from(consumedByMap.values());
|
|
670
1061
|
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
|
|
671
1062
|
}
|
|
672
1063
|
async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
|
|
@@ -675,7 +1066,7 @@ export class Engine {
|
|
|
675
1066
|
await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
|
|
676
1067
|
steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
|
|
677
1068
|
const newUTXOs = [];
|
|
678
|
-
|
|
1069
|
+
for (const outputIndex of validation.admissibleOutputs.outputsToAdmit) {
|
|
679
1070
|
await this.admitOutput(outputIndex, {
|
|
680
1071
|
tx,
|
|
681
1072
|
txid,
|
|
@@ -685,7 +1076,7 @@ export class Engine {
|
|
|
685
1076
|
newUTXOs,
|
|
686
1077
|
offChainValues
|
|
687
1078
|
});
|
|
688
|
-
}
|
|
1079
|
+
}
|
|
689
1080
|
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
690
1081
|
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
|
|
691
1082
|
await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
|
|
@@ -706,20 +1097,14 @@ export class Engine {
|
|
|
706
1097
|
const topic = validation.topic;
|
|
707
1098
|
if (dupeTopics.has(topic) || failedTopics.has(topic))
|
|
708
1099
|
continue;
|
|
709
|
-
|
|
710
|
-
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
711
|
-
}
|
|
712
|
-
catch (error) {
|
|
713
|
-
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
|
|
714
|
-
}
|
|
1100
|
+
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
715
1101
|
}
|
|
716
1102
|
}
|
|
717
1103
|
async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
|
|
718
1104
|
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
719
1105
|
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
720
1106
|
!dupeTopics.has(topic) &&
|
|
721
|
-
(steak[topic].outputsToAdmit.length !== 0 ||
|
|
722
|
-
steak[topic].coinsRemoved?.length !== 0));
|
|
1107
|
+
(steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0));
|
|
723
1108
|
if (relevantTopics.length === 0) {
|
|
724
1109
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
725
1110
|
return;
|
|
@@ -733,8 +1118,8 @@ export class Engine {
|
|
|
733
1118
|
resolver: new LookupResolver(resolverConfig)
|
|
734
1119
|
};
|
|
735
1120
|
}
|
|
736
|
-
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
|
|
737
1121
|
try {
|
|
1122
|
+
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
|
|
738
1123
|
await shipBroadcaster.broadcast(tx);
|
|
739
1124
|
}
|
|
740
1125
|
catch (error) {
|
|
@@ -742,6 +1127,68 @@ export class Engine {
|
|
|
742
1127
|
}
|
|
743
1128
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
744
1129
|
}
|
|
1130
|
+
assertSupportedTopics(topics) {
|
|
1131
|
+
for (const topic of topics) {
|
|
1132
|
+
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
1133
|
+
throw new Error(`This server does not support this topic: ${topic}`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
shouldSkipPropagation(mode) {
|
|
1138
|
+
return (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv');
|
|
1139
|
+
}
|
|
1140
|
+
notifySteakReady(callback, steak) {
|
|
1141
|
+
if (callback === undefined)
|
|
1142
|
+
return;
|
|
1143
|
+
try {
|
|
1144
|
+
callback(steak);
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
this.logger.error('Error in onSteakReady callback:', error);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async acknowledgeOverlayAdmission(context) {
|
|
1151
|
+
const admissionHost = getOverlayAdmissionHost(this.storage);
|
|
1152
|
+
if (admissionHost === undefined)
|
|
1153
|
+
return undefined;
|
|
1154
|
+
const { taggedBEEF, steak, tx, txid, mode, offChainValues, validations, failedTopics, anyTopicAccepted, onSteakReady } = context;
|
|
1155
|
+
if (!anyTopicAccepted) {
|
|
1156
|
+
this.notifySteakReady(onSteakReady, steak);
|
|
1157
|
+
return steak;
|
|
1158
|
+
}
|
|
1159
|
+
// A topic counts toward `anyTopicAccepted` when it is a dupe (so the
|
|
1160
|
+
// caller still gets broadcast/propagation for a retried submission), but
|
|
1161
|
+
// a dupe must never be resubmitted for admission: it was already
|
|
1162
|
+
// committed under a (possibly different) operation, and re-including it
|
|
1163
|
+
// here would make commitAdmission reject the whole plan. When nothing
|
|
1164
|
+
// left over is a genuinely new admission, skip the plan/commit entirely
|
|
1165
|
+
// and hand back the STEAK already computed from validation — it reports
|
|
1166
|
+
// each dupe topic as accepted-with-nothing-new, same as the classic
|
|
1167
|
+
// (non-admission) storage path does.
|
|
1168
|
+
if (selectNewAdmissionTopics(validations, failedTopics).length === 0) {
|
|
1169
|
+
this.notifySteakReady(onSteakReady, steak);
|
|
1170
|
+
return steak;
|
|
1171
|
+
}
|
|
1172
|
+
const applied = await this.buildAppliedTransactionRecord(tx);
|
|
1173
|
+
const buildPlan = async () => await buildOverlayAdmissionPlan({
|
|
1174
|
+
host: admissionHost,
|
|
1175
|
+
tx,
|
|
1176
|
+
txid,
|
|
1177
|
+
beef: taggedBEEF.beef,
|
|
1178
|
+
topics: taggedBEEF.topics,
|
|
1179
|
+
mode: overlayAdmissionMode(mode),
|
|
1180
|
+
offChainValues,
|
|
1181
|
+
validations,
|
|
1182
|
+
failedTopics,
|
|
1183
|
+
lookupServices: this.lookupServices,
|
|
1184
|
+
includePropagation: !this.shouldSkipPropagation(mode),
|
|
1185
|
+
applied
|
|
1186
|
+
});
|
|
1187
|
+
const committed = await waitForAdmissionReceipt(admissionHost.admission, await buildPlan(), buildPlan);
|
|
1188
|
+
const acknowledged = JSON.parse(committed.receipt.steak);
|
|
1189
|
+
this.notifySteakReady(onSteakReady, acknowledged);
|
|
1190
|
+
return acknowledged;
|
|
1191
|
+
}
|
|
745
1192
|
/**
|
|
746
1193
|
* Submits a transaction for processing by Overlay Services.
|
|
747
1194
|
* @param {TaggedBEEF} taggedBEEF - The transaction to process
|
|
@@ -749,20 +1196,68 @@ export class Engine {
|
|
|
749
1196
|
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
|
|
750
1197
|
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
|
|
751
1198
|
*
|
|
752
|
-
* The optional callback
|
|
1199
|
+
* The optional callback is invoked after any required transaction broadcast and
|
|
1200
|
+
* local storage mutations have completed, but before peer-to-peer propagation.
|
|
753
1201
|
*
|
|
754
1202
|
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
|
|
755
1203
|
*/
|
|
756
1204
|
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1205
|
+
const previous = this.submissionTail;
|
|
1206
|
+
let release;
|
|
1207
|
+
this.submissionTail = new Promise(resolve => {
|
|
1208
|
+
release = resolve;
|
|
1209
|
+
});
|
|
1210
|
+
await previous;
|
|
1211
|
+
try {
|
|
1212
|
+
return await this.submitUnlocked(taggedBEEF, onSteakReady, mode, offChainValues);
|
|
1213
|
+
}
|
|
1214
|
+
finally {
|
|
1215
|
+
release();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
async submitUnlocked(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
1219
|
+
if (typeof taggedBEEF !== 'object' ||
|
|
1220
|
+
taggedBEEF === null ||
|
|
1221
|
+
!Array.isArray(taggedBEEF.beef) ||
|
|
1222
|
+
!Array.isArray(taggedBEEF.topics)) {
|
|
1223
|
+
throw new TypeError('Tagged BEEF must contain byte and topic arrays');
|
|
1224
|
+
}
|
|
1225
|
+
if (taggedBEEF.beef.length === 0 ||
|
|
1226
|
+
taggedBEEF.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
|
|
1227
|
+
taggedBEEF.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
1228
|
+
throw new TypeError(`Tagged BEEF must contain between 1 and ${MAX_SUBMISSION_BEEF_BYTES} bytes`);
|
|
1229
|
+
}
|
|
1230
|
+
if (taggedBEEF.topics.length === 0 || taggedBEEF.topics.length > MAX_SUBMISSION_TOPICS) {
|
|
1231
|
+
throw new TypeError(`Tagged BEEF must contain between 1 and ${MAX_SUBMISSION_TOPICS} topics`);
|
|
761
1232
|
}
|
|
1233
|
+
if (!['historical-tx', 'current-tx', 'historical-tx-no-spv'].includes(mode)) {
|
|
1234
|
+
throw new TypeError('Invalid overlay submission mode');
|
|
1235
|
+
}
|
|
1236
|
+
if (onSteakReady !== undefined && typeof onSteakReady !== 'function') {
|
|
1237
|
+
throw new TypeError('onSteakReady must be a function');
|
|
1238
|
+
}
|
|
1239
|
+
if (offChainValues !== undefined &&
|
|
1240
|
+
(!Array.isArray(offChainValues) ||
|
|
1241
|
+
offChainValues.length > MAX_OFF_CHAIN_VALUES ||
|
|
1242
|
+
offChainValues.some(value => !Number.isSafeInteger(value)))) {
|
|
1243
|
+
throw new TypeError('offChainValues must be a bounded array of safe integers');
|
|
1244
|
+
}
|
|
1245
|
+
const uniqueTopics = new Set();
|
|
1246
|
+
for (const topic of taggedBEEF.topics) {
|
|
1247
|
+
assertTopic(topic, 'Tagged BEEF topic');
|
|
1248
|
+
if (uniqueTopics.has(topic))
|
|
1249
|
+
throw new TypeError('Tagged BEEF contains duplicate topics');
|
|
1250
|
+
uniqueTopics.add(topic);
|
|
1251
|
+
}
|
|
1252
|
+
this.assertSupportedTopics(taggedBEEF.topics);
|
|
762
1253
|
// Validate the transaction SPV information
|
|
763
1254
|
const tx = Transaction.fromBEEF(taggedBEEF.beef);
|
|
764
1255
|
const txid = tx.id('hex');
|
|
765
1256
|
this.startTime(`submit_${txid}`);
|
|
1257
|
+
// Every submission is SPV-verified except 'historical-tx-no-spv', which is
|
|
1258
|
+
// reserved for callers that have already proven inclusion independently
|
|
1259
|
+
// (GASP graph finalization and BASM reconciliation). 'historical-tx' is a
|
|
1260
|
+
// public submission mode, so it keeps the full SPV check.
|
|
766
1261
|
if (mode !== 'historical-tx-no-spv') {
|
|
767
1262
|
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
768
1263
|
const txValid = await tx.verify(this.chainTracker);
|
|
@@ -811,30 +1306,49 @@ export class Engine {
|
|
|
811
1306
|
await this.abortProvisionalAdmissions(validations, failedTopics, taggedBEEF.beef);
|
|
812
1307
|
throw error;
|
|
813
1308
|
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
onSteakReady(steak);
|
|
817
|
-
}
|
|
818
|
-
// ===================================================================
|
|
819
|
-
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
820
|
-
// ===================================================================
|
|
821
|
-
// Mark previous outputs as spent and notify lookup services
|
|
822
|
-
await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
|
|
823
|
-
await this.applyStorageMutations(validations, {
|
|
824
|
-
dupeTopics,
|
|
825
|
-
failedTopics,
|
|
1309
|
+
const admissionSteak = await this.acknowledgeOverlayAdmission({
|
|
1310
|
+
taggedBEEF,
|
|
826
1311
|
steak,
|
|
827
1312
|
tx,
|
|
828
1313
|
txid,
|
|
829
|
-
|
|
830
|
-
offChainValues
|
|
1314
|
+
mode,
|
|
1315
|
+
offChainValues,
|
|
1316
|
+
validations,
|
|
1317
|
+
failedTopics,
|
|
1318
|
+
anyTopicAccepted,
|
|
1319
|
+
onSteakReady
|
|
831
1320
|
});
|
|
1321
|
+
if (admissionSteak !== undefined)
|
|
1322
|
+
return admissionSteak;
|
|
1323
|
+
// ===================================================================
|
|
1324
|
+
// PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
|
|
1325
|
+
// ===================================================================
|
|
1326
|
+
// Mark previous outputs as spent and notify lookup services
|
|
1327
|
+
try {
|
|
1328
|
+
await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
|
|
1329
|
+
await this.applyStorageMutations(validations, {
|
|
1330
|
+
dupeTopics,
|
|
1331
|
+
failedTopics,
|
|
1332
|
+
steak,
|
|
1333
|
+
tx,
|
|
1334
|
+
txid,
|
|
1335
|
+
beef: taggedBEEF.beef,
|
|
1336
|
+
offChainValues
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
catch (error) {
|
|
1340
|
+
await this.abortProvisionalAdmissions(validations, failedTopics, taggedBEEF.beef);
|
|
1341
|
+
throw error;
|
|
1342
|
+
}
|
|
1343
|
+
// A STEAK is only ready after the corresponding local state is durable.
|
|
1344
|
+
// This prevents a callback from reporting success for a storage mutation
|
|
1345
|
+
// that later fails.
|
|
1346
|
+
this.notifySteakReady(onSteakReady, steak);
|
|
832
1347
|
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
|
|
833
|
-
if (this.
|
|
1348
|
+
if (this.shouldSkipPropagation(mode)) {
|
|
834
1349
|
return steak;
|
|
835
1350
|
}
|
|
836
1351
|
await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
|
|
837
|
-
// Immediately return from the function without waiting for the promises to resolve.
|
|
838
1352
|
return steak;
|
|
839
1353
|
}
|
|
840
1354
|
/**
|
|
@@ -843,13 +1357,23 @@ export class Engine {
|
|
|
843
1357
|
* @returns The answer to the question
|
|
844
1358
|
*/
|
|
845
1359
|
async lookup(lookupQuestion) {
|
|
1360
|
+
if (typeof lookupQuestion !== 'object' || lookupQuestion === null) {
|
|
1361
|
+
throw new TypeError('Lookup question must be an object');
|
|
1362
|
+
}
|
|
1363
|
+
assertRegistryName(lookupQuestion.service, 'Lookup service name');
|
|
846
1364
|
// Validate a lookup service for the provider is found
|
|
847
|
-
const lookupService = this.lookupServices
|
|
1365
|
+
const lookupService = Object.prototype.hasOwnProperty.call(this.lookupServices, lookupQuestion.service)
|
|
1366
|
+
? this.lookupServices[lookupQuestion.service]
|
|
1367
|
+
: undefined;
|
|
848
1368
|
if (lookupService === undefined || lookupService === null)
|
|
849
1369
|
throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
|
|
850
1370
|
const lookupResult = await lookupService.lookup(lookupQuestion);
|
|
851
|
-
|
|
852
|
-
|
|
1371
|
+
this.assertLookupFormula(lookupResult);
|
|
1372
|
+
const configuredLimit = this.maxLookupResults === -1
|
|
1373
|
+
? MAX_LOOKUP_FORMULAS
|
|
1374
|
+
: Math.min(this.maxLookupResults, MAX_LOOKUP_FORMULAS);
|
|
1375
|
+
if (lookupResult.length > configuredLimit) {
|
|
1376
|
+
throw new RangeError(`Lookup returned ${lookupResult.length} results; maximum is ${configuredLimit}`);
|
|
853
1377
|
}
|
|
854
1378
|
const hydrationContext = this.createUTXOHistoryHydrationContext();
|
|
855
1379
|
await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
|
|
@@ -870,9 +1394,7 @@ export class Engine {
|
|
|
870
1394
|
};
|
|
871
1395
|
})))
|
|
872
1396
|
.filter((output) => output !== null)
|
|
873
|
-
.map(({ beef, outputIndex, context }) =>
|
|
874
|
-
? { beef, outputIndex }
|
|
875
|
-
: { beef, outputIndex, context }));
|
|
1397
|
+
.map(({ beef, outputIndex, context }) => context === undefined ? { beef, outputIndex } : { beef, outputIndex, context });
|
|
876
1398
|
return {
|
|
877
1399
|
type: 'output-list',
|
|
878
1400
|
outputs: hydratedOutputs
|
|
@@ -880,11 +1402,88 @@ export class Engine {
|
|
|
880
1402
|
}
|
|
881
1403
|
createUTXOHistoryHydrationContext() {
|
|
882
1404
|
return {
|
|
883
|
-
outputCache: new Map()
|
|
1405
|
+
outputCache: new Map(),
|
|
1406
|
+
budgetedOutputs: new Set(),
|
|
1407
|
+
hydratedNodes: 0,
|
|
1408
|
+
relationCount: 0,
|
|
1409
|
+
totalBeefBytes: 0
|
|
884
1410
|
};
|
|
885
1411
|
}
|
|
886
1412
|
toOutputCacheKey(txid, outputIndex) {
|
|
887
|
-
return `${txid}:${outputIndex}`;
|
|
1413
|
+
return `${txid.toLowerCase()}:${outputIndex}`;
|
|
1414
|
+
}
|
|
1415
|
+
assertLookupFormula(value) {
|
|
1416
|
+
if (!Array.isArray(value))
|
|
1417
|
+
throw new TypeError('Lookup service must return an array');
|
|
1418
|
+
if (value.length > MAX_LOOKUP_FORMULAS) {
|
|
1419
|
+
throw new RangeError(`Lookup returned more than ${MAX_LOOKUP_FORMULAS} results`);
|
|
1420
|
+
}
|
|
1421
|
+
let totalContextBytes = 0;
|
|
1422
|
+
for (const [index, entry] of value.entries()) {
|
|
1423
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
1424
|
+
throw new TypeError(`Lookup result[${index}] is invalid`);
|
|
1425
|
+
}
|
|
1426
|
+
assertHash(entry.txid, `Lookup result[${index}] txid`);
|
|
1427
|
+
assertOutputIndex(entry.outputIndex, `Lookup result[${index}] output index`);
|
|
1428
|
+
if (entry.history !== undefined &&
|
|
1429
|
+
typeof entry.history !== 'function' &&
|
|
1430
|
+
(!Number.isSafeInteger(entry.history) ||
|
|
1431
|
+
entry.history < -1 ||
|
|
1432
|
+
entry.history > MAX_LOOKUP_HISTORY_DEPTH)) {
|
|
1433
|
+
throw new TypeError(`Lookup result[${index}] history must be -1 through ${MAX_LOOKUP_HISTORY_DEPTH}, or a function`);
|
|
1434
|
+
}
|
|
1435
|
+
if (entry.context !== undefined) {
|
|
1436
|
+
if (!Array.isArray(entry.context) || entry.context.length > MAX_LOOKUP_CONTEXT_BYTES) {
|
|
1437
|
+
throw new TypeError(`Lookup result[${index}] context must be bounded bytes`);
|
|
1438
|
+
}
|
|
1439
|
+
for (let byteIndex = 0; byteIndex < entry.context.length; byteIndex++) {
|
|
1440
|
+
const byte = entry.context[byteIndex];
|
|
1441
|
+
if (!Object.prototype.hasOwnProperty.call(entry.context, byteIndex) ||
|
|
1442
|
+
!Number.isInteger(byte) ||
|
|
1443
|
+
byte < 0 ||
|
|
1444
|
+
byte > 255) {
|
|
1445
|
+
throw new TypeError(`Lookup result[${index}] context must be bounded bytes`);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
totalContextBytes += entry.context.length;
|
|
1449
|
+
if (totalContextBytes > MAX_LOOKUP_TOTAL_CONTEXT_BYTES) {
|
|
1450
|
+
throw new RangeError('Lookup result contexts exceeded their total byte budget');
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
validateLookupOutput(value, label, expected, context) {
|
|
1456
|
+
this.assertStoredOutputRelations(value, label);
|
|
1457
|
+
if (expected !== undefined &&
|
|
1458
|
+
(value.txid.toLowerCase() !== expected.txid.toLowerCase() ||
|
|
1459
|
+
value.outputIndex !== expected.outputIndex)) {
|
|
1460
|
+
throw new TypeError(`${label} does not match the requested outpoint`);
|
|
1461
|
+
}
|
|
1462
|
+
if (value.beef === undefined)
|
|
1463
|
+
return;
|
|
1464
|
+
if (!Array.isArray(value.beef) ||
|
|
1465
|
+
value.beef.length === 0 ||
|
|
1466
|
+
value.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
|
|
1467
|
+
value.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
1468
|
+
throw new TypeError(`${label} has invalid or oversized transaction BEEF`);
|
|
1469
|
+
}
|
|
1470
|
+
const transaction = Transaction.fromBEEF(value.beef);
|
|
1471
|
+
if (transaction.id('hex').toLowerCase() !== value.txid.toLowerCase()) {
|
|
1472
|
+
throw new TypeError(`${label} BEEF does not match its transaction ID`);
|
|
1473
|
+
}
|
|
1474
|
+
if (value.outputIndex >= transaction.outputs.length) {
|
|
1475
|
+
throw new TypeError(`${label} index is outside its transaction`);
|
|
1476
|
+
}
|
|
1477
|
+
if (context !== undefined) {
|
|
1478
|
+
const key = this.toOutputCacheKey(value.txid, value.outputIndex);
|
|
1479
|
+
if (!context.budgetedOutputs.has(key)) {
|
|
1480
|
+
context.budgetedOutputs.add(key);
|
|
1481
|
+
context.totalBeefBytes += value.beef.length;
|
|
1482
|
+
if (context.totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
|
|
1483
|
+
throw new RangeError('Lookup transaction BEEF exceeded its total byte budget');
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
888
1487
|
}
|
|
889
1488
|
async preloadOutputsWithBEEF(outpoints, context) {
|
|
890
1489
|
if (outpoints.length === 0) {
|
|
@@ -893,6 +1492,8 @@ export class Engine {
|
|
|
893
1492
|
const deduped = [];
|
|
894
1493
|
const seen = new Set();
|
|
895
1494
|
for (const outpoint of outpoints) {
|
|
1495
|
+
assertHash(outpoint.txid, 'Lookup outpoint txid');
|
|
1496
|
+
assertOutputIndex(outpoint.outputIndex, 'Lookup outpoint output index');
|
|
896
1497
|
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
|
|
897
1498
|
if (seen.has(cacheKey)) {
|
|
898
1499
|
continue;
|
|
@@ -905,12 +1506,25 @@ export class Engine {
|
|
|
905
1506
|
if (deduped.length === 0) {
|
|
906
1507
|
return;
|
|
907
1508
|
}
|
|
1509
|
+
if (context.outputCache.size + deduped.length > MAX_LOOKUP_HYDRATION_NODES) {
|
|
1510
|
+
throw new RangeError('Lookup output traversal exceeded its work budget');
|
|
1511
|
+
}
|
|
908
1512
|
const findOutputsByOutpoints = this.storage.findOutputsByOutpoints;
|
|
909
1513
|
if (typeof findOutputsByOutpoints === 'function') {
|
|
910
1514
|
const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true);
|
|
1515
|
+
if (!Array.isArray(outputs) || outputs.length > MAX_LOOKUP_STORAGE_ROWS) {
|
|
1516
|
+
throw new TypeError('Storage returned invalid or oversized batched lookup outputs');
|
|
1517
|
+
}
|
|
1518
|
+
const requestedKeys = new Set(deduped.map(outpoint => this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)));
|
|
911
1519
|
const outputsByKey = new Map();
|
|
912
|
-
for (const output of outputs) {
|
|
913
|
-
|
|
1520
|
+
for (const [index, output] of outputs.entries()) {
|
|
1521
|
+
this.validateLookupOutput(output, `Batched lookup output[${index}]`);
|
|
1522
|
+
const key = this.toOutputCacheKey(output.txid, output.outputIndex);
|
|
1523
|
+
if (!requestedKeys.has(key)) {
|
|
1524
|
+
throw new TypeError('Storage returned an output that was not requested');
|
|
1525
|
+
}
|
|
1526
|
+
if (!outputsByKey.has(key))
|
|
1527
|
+
outputsByKey.set(key, output);
|
|
914
1528
|
}
|
|
915
1529
|
for (const outpoint of deduped) {
|
|
916
1530
|
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
|
|
@@ -931,43 +1545,80 @@ export class Engine {
|
|
|
931
1545
|
context.outputCache.set(cacheKey, cached);
|
|
932
1546
|
}
|
|
933
1547
|
const output = await cached;
|
|
1548
|
+
if (output !== null && output !== undefined) {
|
|
1549
|
+
this.validateLookupOutput(output, 'Lookup output', { txid, outputIndex }, context);
|
|
1550
|
+
}
|
|
934
1551
|
return output ?? null;
|
|
935
1552
|
}
|
|
936
|
-
async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context) {
|
|
937
|
-
if (output.beef === undefined) {
|
|
938
|
-
throw new Error('Output must have associated transaction BEEF!');
|
|
939
|
-
}
|
|
1553
|
+
async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context, ancestors = new Set()) {
|
|
940
1554
|
let shouldTraverseHistory;
|
|
941
1555
|
if (typeof historySelector === 'number') {
|
|
942
1556
|
shouldTraverseHistory = currentDepth <= historySelector;
|
|
943
1557
|
}
|
|
944
1558
|
else {
|
|
1559
|
+
if (currentDepth > MAX_LOOKUP_HISTORY_DEPTH) {
|
|
1560
|
+
throw new RangeError('Lookup history exceeded its maximum depth');
|
|
1561
|
+
}
|
|
1562
|
+
if (output.beef === undefined) {
|
|
1563
|
+
throw new Error('Output must have associated transaction BEEF!');
|
|
1564
|
+
}
|
|
945
1565
|
shouldTraverseHistory = await historySelector(output.beef, output.outputIndex, currentDepth);
|
|
1566
|
+
if (typeof shouldTraverseHistory !== 'boolean') {
|
|
1567
|
+
throw new TypeError('Lookup history selector must return a boolean');
|
|
1568
|
+
}
|
|
946
1569
|
}
|
|
947
1570
|
if (shouldTraverseHistory === false) {
|
|
948
1571
|
return undefined;
|
|
949
1572
|
}
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1573
|
+
if (currentDepth > MAX_LOOKUP_HISTORY_DEPTH) {
|
|
1574
|
+
throw new RangeError('Lookup history exceeded its maximum depth');
|
|
1575
|
+
}
|
|
1576
|
+
this.validateLookupOutput(output, 'Lookup history output', undefined, context);
|
|
1577
|
+
if (output.beef === undefined) {
|
|
1578
|
+
throw new Error('Output must have associated transaction BEEF!');
|
|
1579
|
+
}
|
|
1580
|
+
const outputKey = this.toOutputCacheKey(output.txid, output.outputIndex);
|
|
1581
|
+
if (ancestors.has(outputKey))
|
|
1582
|
+
throw new TypeError('Lookup history contains a cycle');
|
|
1583
|
+
context.hydratedNodes += 1;
|
|
1584
|
+
context.relationCount += output.outputsConsumed.length;
|
|
1585
|
+
if (context.hydratedNodes > MAX_LOOKUP_HYDRATION_NODES ||
|
|
1586
|
+
context.relationCount > MAX_OUTPUT_RELATIONS) {
|
|
1587
|
+
throw new RangeError('Lookup history traversal exceeded its work budget');
|
|
1588
|
+
}
|
|
958
1589
|
const tx = Transaction.fromBEEF(output.beef);
|
|
959
1590
|
const inputIndexBySource = new Map();
|
|
960
1591
|
tx.inputs.forEach((candidateInput, index) => {
|
|
961
1592
|
const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
|
|
962
1593
|
? candidateInput.sourceTXID
|
|
963
1594
|
: candidateInput.sourceTransaction?.id('hex');
|
|
964
|
-
if (sourceTXID === undefined)
|
|
1595
|
+
if (sourceTXID === undefined)
|
|
965
1596
|
return;
|
|
1597
|
+
const key = this.toOutputCacheKey(sourceTXID, candidateInput.sourceOutputIndex);
|
|
1598
|
+
if (inputIndexBySource.has(key)) {
|
|
1599
|
+
throw new TypeError('Lookup history transaction contains duplicate source outpoints');
|
|
966
1600
|
}
|
|
967
|
-
inputIndexBySource.set(
|
|
1601
|
+
inputIndexBySource.set(key, index);
|
|
968
1602
|
});
|
|
1603
|
+
for (const relation of output.outputsConsumed) {
|
|
1604
|
+
if (!inputIndexBySource.has(this.toOutputCacheKey(relation.txid, relation.outputIndex))) {
|
|
1605
|
+
throw new TypeError('Lookup history relation is not an input of its transaction');
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
await this.preloadOutputsWithBEEF(output.outputsConsumed, context);
|
|
1609
|
+
const nextAncestors = new Set(ancestors);
|
|
1610
|
+
nextAncestors.add(outputKey);
|
|
1611
|
+
const childNodes = [];
|
|
1612
|
+
for (const outputIdentifier of output.outputsConsumed) {
|
|
1613
|
+
const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context);
|
|
1614
|
+
if (childOutput === null)
|
|
1615
|
+
continue;
|
|
1616
|
+
const childNode = await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context, nextAncestors);
|
|
1617
|
+
if (childNode !== undefined)
|
|
1618
|
+
childNodes.push(childNode);
|
|
1619
|
+
}
|
|
969
1620
|
for (const child of childNodes) {
|
|
970
|
-
const inputIndex = inputIndexBySource.get(
|
|
1621
|
+
const inputIndex = inputIndexBySource.get(this.toOutputCacheKey(child.output.txid, child.output.outputIndex));
|
|
971
1622
|
if (inputIndex === -1 || inputIndex == null) {
|
|
972
1623
|
continue;
|
|
973
1624
|
}
|
|
@@ -999,6 +1650,54 @@ export class Engine {
|
|
|
999
1650
|
* @throws Will throw an error if there are issues during the advertisement synchronization process.
|
|
1000
1651
|
* @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
|
|
1001
1652
|
*/
|
|
1653
|
+
validateCurrentAdvertisements(value, protocol, advertiser) {
|
|
1654
|
+
if (!Array.isArray(value) || value.length > MAX_CURRENT_ADVERTISEMENTS) {
|
|
1655
|
+
throw new TypeError('Advertiser returned an invalid or oversized advertisement set');
|
|
1656
|
+
}
|
|
1657
|
+
let totalBeefBytes = 0;
|
|
1658
|
+
return value.map((candidate, index) => {
|
|
1659
|
+
if (typeof candidate !== 'object' || candidate === null) {
|
|
1660
|
+
throw new TypeError(`Advertisement[${index}] is invalid`);
|
|
1661
|
+
}
|
|
1662
|
+
const advertisement = candidate;
|
|
1663
|
+
if (advertisement.protocol !== protocol) {
|
|
1664
|
+
throw new TypeError(`Advertisement[${index}] has the wrong protocol`);
|
|
1665
|
+
}
|
|
1666
|
+
assertRegistryName(advertisement.topicOrService, `Advertisement[${index}] name`);
|
|
1667
|
+
if (typeof advertisement.identityKey !== 'string' ||
|
|
1668
|
+
!/^(?:02|03)[0-9a-fA-F]{64}$/.test(advertisement.identityKey) ||
|
|
1669
|
+
typeof advertisement.domain !== 'string' ||
|
|
1670
|
+
!this.isValidUrl(advertisement.domain) ||
|
|
1671
|
+
!Array.isArray(advertisement.beef) ||
|
|
1672
|
+
advertisement.beef.length === 0 ||
|
|
1673
|
+
advertisement.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
|
|
1674
|
+
advertisement.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
1675
|
+
throw new TypeError(`Advertisement[${index}] has invalid identity, domain, or BEEF`);
|
|
1676
|
+
}
|
|
1677
|
+
assertOutputIndex(advertisement.outputIndex, `Advertisement[${index}] output index`);
|
|
1678
|
+
totalBeefBytes += advertisement.beef.length;
|
|
1679
|
+
if (totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
|
|
1680
|
+
throw new RangeError('Advertisement BEEF exceeded its total byte budget');
|
|
1681
|
+
}
|
|
1682
|
+
const transaction = Transaction.fromBEEF(advertisement.beef);
|
|
1683
|
+
if (advertisement.outputIndex >= transaction.outputs.length) {
|
|
1684
|
+
throw new TypeError(`Advertisement[${index}] output index is outside its transaction`);
|
|
1685
|
+
}
|
|
1686
|
+
const parsed = advertiser.parseAdvertisement(transaction.outputs[advertisement.outputIndex].lockingScript);
|
|
1687
|
+
if (parsed.protocol !== advertisement.protocol ||
|
|
1688
|
+
parsed.identityKey.toLowerCase() !== advertisement.identityKey.toLowerCase() ||
|
|
1689
|
+
parsed.topicOrService !== advertisement.topicOrService ||
|
|
1690
|
+
normalizePeerEndpoint(parsed.domain) !== normalizePeerEndpoint(advertisement.domain)) {
|
|
1691
|
+
throw new TypeError(`Advertisement[${index}] metadata does not match its BEEF`);
|
|
1692
|
+
}
|
|
1693
|
+
return {
|
|
1694
|
+
...advertisement,
|
|
1695
|
+
identityKey: advertisement.identityKey.toLowerCase(),
|
|
1696
|
+
domain: normalizePeerEndpoint(advertisement.domain),
|
|
1697
|
+
beef: [...advertisement.beef]
|
|
1698
|
+
};
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1002
1701
|
async syncAdvertisements() {
|
|
1003
1702
|
if (this.advertiser === undefined ||
|
|
1004
1703
|
typeof this.hostingURL !== 'string' ||
|
|
@@ -1007,6 +1706,7 @@ export class Engine {
|
|
|
1007
1706
|
return;
|
|
1008
1707
|
}
|
|
1009
1708
|
const advertiser = this.advertiser;
|
|
1709
|
+
const normalizedHostingURL = normalizePeerEndpoint(this.hostingURL);
|
|
1010
1710
|
// Step 1: Retrieve Current Configuration
|
|
1011
1711
|
let configuredTopics = Object.keys(this.managers);
|
|
1012
1712
|
let configuredServices = Object.keys(this.lookupServices);
|
|
@@ -1016,13 +1716,13 @@ export class Engine {
|
|
|
1016
1716
|
configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap');
|
|
1017
1717
|
}
|
|
1018
1718
|
// Step 2: Fetch Existing Advertisements
|
|
1019
|
-
const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP');
|
|
1020
|
-
const currentSLAPAdvertisements = await advertiser.findAllAdvertisements('SLAP');
|
|
1719
|
+
const currentSHIPAdvertisements = this.validateCurrentAdvertisements(await advertiser.findAllAdvertisements('SHIP'), 'SHIP', advertiser);
|
|
1720
|
+
const currentSLAPAdvertisements = this.validateCurrentAdvertisements(await advertiser.findAllAdvertisements('SLAP'), 'SLAP', advertiser);
|
|
1021
1721
|
// Step 3: Compare and Determine Actions
|
|
1022
1722
|
const requiredSHIPAdvertisements = new Set(configuredTopics);
|
|
1023
1723
|
const requiredSLAPAdvertisements = new Set(configuredServices);
|
|
1024
|
-
const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain ===
|
|
1025
|
-
const slapsToCreate = Array.from(requiredSLAPAdvertisements).filter(topicOrService => !currentSLAPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain ===
|
|
1724
|
+
const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === normalizedHostingURL));
|
|
1725
|
+
const slapsToCreate = Array.from(requiredSLAPAdvertisements).filter(topicOrService => !currentSLAPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === normalizedHostingURL));
|
|
1026
1726
|
const shipsToRevoke = currentSHIPAdvertisements.filter(ad => !requiredSHIPAdvertisements.has(ad.topicOrService));
|
|
1027
1727
|
const slapsToRevoke = currentSLAPAdvertisements.filter(ad => !requiredSLAPAdvertisements.has(ad.topicOrService));
|
|
1028
1728
|
// Create needed SHIP/SLAP advertisements
|
|
@@ -1048,7 +1748,10 @@ export class Engine {
|
|
|
1048
1748
|
// Revoke all advertisements to revoke
|
|
1049
1749
|
try {
|
|
1050
1750
|
if (shipsToRevoke.length > 0 || slapsToRevoke.length > 0) {
|
|
1051
|
-
const taggedBEEF = await advertiser.revokeAdvertisements([
|
|
1751
|
+
const taggedBEEF = await advertiser.revokeAdvertisements([
|
|
1752
|
+
...shipsToRevoke,
|
|
1753
|
+
...slapsToRevoke
|
|
1754
|
+
]);
|
|
1052
1755
|
await this.submit(taggedBEEF);
|
|
1053
1756
|
}
|
|
1054
1757
|
}
|
|
@@ -1086,6 +1789,7 @@ export class Engine {
|
|
|
1086
1789
|
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
|
|
1087
1790
|
try {
|
|
1088
1791
|
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
|
|
1792
|
+
assertNonnegativeInteger(lastInteraction, 'Stored GASP last interaction');
|
|
1089
1793
|
const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
|
|
1090
1794
|
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
|
|
1091
1795
|
if (gasp.lastInteraction > lastInteraction) {
|
|
@@ -1119,84 +1823,241 @@ export class Engine {
|
|
|
1119
1823
|
});
|
|
1120
1824
|
const endpointSet = new Set();
|
|
1121
1825
|
if (lookupAnswer.type === 'output-list') {
|
|
1122
|
-
lookupAnswer.outputs
|
|
1826
|
+
for (const output of lookupAnswer.outputs) {
|
|
1123
1827
|
try {
|
|
1124
1828
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1125
|
-
|
|
1126
|
-
if (
|
|
1127
|
-
|
|
1829
|
+
assertOutputIndex(output.outputIndex, 'SHIP advertisement output index');
|
|
1830
|
+
if (output.txid !== undefined &&
|
|
1831
|
+
(typeof output.txid !== 'string' ||
|
|
1832
|
+
output.txid.toLowerCase() !== tx.id('hex').toLowerCase())) {
|
|
1833
|
+
throw new TypeError('SHIP advertisement transaction ID does not match its BEEF');
|
|
1834
|
+
}
|
|
1835
|
+
const selectedOutput = tx.outputs[output.outputIndex];
|
|
1836
|
+
if (selectedOutput == null || selectedOutput.satoshis !== 1) {
|
|
1837
|
+
throw new TypeError('SHIP advertisement must identify a one-satoshi output');
|
|
1838
|
+
}
|
|
1839
|
+
const advertisement = await decodeAndVerifyDiscoveryAdvertisement(selectedOutput.lockingScript, 'SHIP');
|
|
1840
|
+
if (advertisement.topicOrService !== topic) {
|
|
1841
|
+
throw new TypeError('SHIP advertisement does not match the requested topic');
|
|
1128
1842
|
}
|
|
1843
|
+
endpointSet.add(advertisement.domain);
|
|
1129
1844
|
}
|
|
1130
1845
|
catch (error) {
|
|
1131
1846
|
this.logger.error(advertisementErrorMessage, error);
|
|
1132
1847
|
}
|
|
1133
|
-
}
|
|
1848
|
+
}
|
|
1134
1849
|
}
|
|
1135
1850
|
syncEndpoints = Array.from(endpointSet);
|
|
1136
1851
|
}
|
|
1137
1852
|
if (!Array.isArray(syncEndpoints)) {
|
|
1138
1853
|
return [];
|
|
1139
1854
|
}
|
|
1140
|
-
|
|
1855
|
+
const normalizedHostingURL = typeof this.hostingURL === 'string'
|
|
1856
|
+
? (() => {
|
|
1857
|
+
try {
|
|
1858
|
+
return normalizePeerEndpoint(this.hostingURL);
|
|
1859
|
+
}
|
|
1860
|
+
catch {
|
|
1861
|
+
return this.hostingURL;
|
|
1862
|
+
}
|
|
1863
|
+
})()
|
|
1864
|
+
: undefined;
|
|
1865
|
+
const validated = new Set();
|
|
1866
|
+
for (const endpoint of syncEndpoints) {
|
|
1867
|
+
try {
|
|
1868
|
+
const normalized = normalizePeerEndpoint(endpoint);
|
|
1869
|
+
if (normalized !== normalizedHostingURL)
|
|
1870
|
+
validated.add(normalized);
|
|
1871
|
+
}
|
|
1872
|
+
catch (error) {
|
|
1873
|
+
this.logger.error(`[OVERLAY SYNC] Ignoring unsafe peer endpoint ${serializeLogValue(endpoint)}: ${serializeErrorForLog(error)}`);
|
|
1874
|
+
}
|
|
1875
|
+
if (validated.size >= MAX_SYNC_ENDPOINTS_PER_TOPIC)
|
|
1876
|
+
break;
|
|
1877
|
+
}
|
|
1878
|
+
return [...validated];
|
|
1879
|
+
}
|
|
1880
|
+
assertSupportedBASMTopic(topic) {
|
|
1881
|
+
assertTopic(topic, 'BASM topic');
|
|
1882
|
+
if (this.managers[topic] === undefined || this.managers[topic] === null) {
|
|
1883
|
+
throw new Error(`This server does not support this topic: ${topic}`);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
assertTopicAnchorTip(value, topic, label) {
|
|
1887
|
+
if (typeof value !== 'object' || value === null)
|
|
1888
|
+
throw new TypeError(`${label} is invalid`);
|
|
1889
|
+
const tip = value;
|
|
1890
|
+
assertTopic(tip.topic, `${label} topic`);
|
|
1891
|
+
if (tip.topic !== topic)
|
|
1892
|
+
throw new TypeError(`${label} does not match the requested topic`);
|
|
1893
|
+
if (!Number.isSafeInteger(tip.blockHeight) || tip.blockHeight < -1) {
|
|
1894
|
+
throw new TypeError(`${label} height is invalid`);
|
|
1895
|
+
}
|
|
1896
|
+
assertHash(tip.tac, `${label} TAC`);
|
|
1897
|
+
if (tip.blockHash !== undefined)
|
|
1898
|
+
assertHash(tip.blockHash, `${label} block hash`);
|
|
1899
|
+
if (tip.basmRoot !== undefined)
|
|
1900
|
+
assertHash(tip.basmRoot, `${label} BASM root`);
|
|
1901
|
+
if (tip.admittedCount !== undefined) {
|
|
1902
|
+
assertNonnegativeInteger(tip.admittedCount, `${label} admitted count`);
|
|
1903
|
+
if (tip.admittedCount > MAX_BASM_ADMITTED_PER_BLOCK) {
|
|
1904
|
+
throw new RangeError(`${label} admitted count is too large`);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
assertTopicBlockAnchor(value, topic, label, expectedHeight, expectedBlockHash) {
|
|
1909
|
+
if (typeof value !== 'object' || value === null)
|
|
1910
|
+
throw new TypeError(`${label} is invalid`);
|
|
1911
|
+
const anchor = value;
|
|
1912
|
+
assertTopic(anchor.topic, `${label} topic`);
|
|
1913
|
+
if (anchor.topic !== topic)
|
|
1914
|
+
throw new TypeError(`${label} does not match the requested topic`);
|
|
1915
|
+
assertNonnegativeInteger(anchor.blockHeight, `${label} height`);
|
|
1916
|
+
if (expectedHeight !== undefined && anchor.blockHeight !== expectedHeight) {
|
|
1917
|
+
throw new TypeError(`${label} does not match the requested height`);
|
|
1918
|
+
}
|
|
1919
|
+
assertHash(anchor.blockHash, `${label} block hash`);
|
|
1920
|
+
if (expectedBlockHash !== undefined &&
|
|
1921
|
+
anchor.blockHash.toLowerCase() !== expectedBlockHash.toLowerCase()) {
|
|
1922
|
+
throw new TypeError(`${label} does not match the requested block hash`);
|
|
1923
|
+
}
|
|
1924
|
+
assertHash(anchor.basmRoot, `${label} BASM root`);
|
|
1925
|
+
assertHash(anchor.tac, `${label} TAC`);
|
|
1926
|
+
assertNonnegativeInteger(anchor.admittedCount, `${label} admitted count`);
|
|
1927
|
+
if (anchor.admittedCount > MAX_BASM_ADMITTED_PER_BLOCK) {
|
|
1928
|
+
throw new RangeError(`${label} admitted count is too large`);
|
|
1929
|
+
}
|
|
1141
1930
|
}
|
|
1142
1931
|
async provideTopicAnchorTip(topic) {
|
|
1932
|
+
if (typeof this.storage.findTopicAnchorTip !== 'function') {
|
|
1933
|
+
throw Object.assign(new TypeError('Storage does not support BASM topic anchor tips'), {
|
|
1934
|
+
code: 'BASM_UNSUPPORTED'
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1143
1937
|
const tip = await this.storage.findTopicAnchorTip?.(topic);
|
|
1144
|
-
|
|
1938
|
+
if (tip !== undefined) {
|
|
1939
|
+
this.assertTopicAnchorTip(tip, topic, 'Stored BASM tip');
|
|
1940
|
+
return tip;
|
|
1941
|
+
}
|
|
1942
|
+
return {
|
|
1145
1943
|
topic,
|
|
1146
1944
|
blockHeight: -1,
|
|
1147
1945
|
tac: BASM_ZERO_HASH
|
|
1148
1946
|
};
|
|
1149
1947
|
}
|
|
1150
1948
|
async provideTopicAnchorRange(topic, fromHeight, toHeight) {
|
|
1949
|
+
this.assertSupportedBASMTopic(topic);
|
|
1151
1950
|
if (typeof this.storage.findTopicBlockAnchors !== 'function') {
|
|
1152
|
-
throw new TypeError('Storage does not support BASM topic anchor ranges')
|
|
1951
|
+
throw Object.assign(new TypeError('Storage does not support BASM topic anchor ranges'), {
|
|
1952
|
+
code: 'BASM_UNSUPPORTED'
|
|
1953
|
+
});
|
|
1153
1954
|
}
|
|
1154
|
-
if (!Number.
|
|
1955
|
+
if (!Number.isSafeInteger(fromHeight) ||
|
|
1956
|
+
!Number.isSafeInteger(toHeight) ||
|
|
1957
|
+
fromHeight < 0 ||
|
|
1958
|
+
toHeight < fromHeight) {
|
|
1155
1959
|
throw new Error('Invalid topic anchor range');
|
|
1156
1960
|
}
|
|
1157
1961
|
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
1158
1962
|
throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`);
|
|
1159
1963
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
}
|
|
1964
|
+
const anchors = await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight, toHeight - fromHeight + 2);
|
|
1965
|
+
if (!Array.isArray(anchors) || anchors.length > toHeight - fromHeight + 1) {
|
|
1966
|
+
throw new TypeError('Storage returned an invalid or oversized BASM anchor range');
|
|
1967
|
+
}
|
|
1968
|
+
let previousHeight = -1;
|
|
1969
|
+
for (const anchor of anchors) {
|
|
1970
|
+
this.assertTopicBlockAnchor(anchor, topic, 'Stored BASM anchor');
|
|
1971
|
+
if (anchor.blockHeight < fromHeight ||
|
|
1972
|
+
anchor.blockHeight > toHeight ||
|
|
1973
|
+
anchor.blockHeight <= previousHeight) {
|
|
1974
|
+
throw new TypeError('Storage returned an unbound or unordered BASM anchor range');
|
|
1975
|
+
}
|
|
1976
|
+
previousHeight = anchor.blockHeight;
|
|
1977
|
+
}
|
|
1978
|
+
return { topic, anchors };
|
|
1164
1979
|
}
|
|
1165
1980
|
async provideAdmittedList(topic, blockHeight, blockHash) {
|
|
1981
|
+
this.assertSupportedBASMTopic(topic);
|
|
1166
1982
|
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
|
|
1167
|
-
throw new TypeError('Storage does not support BASM admitted lists')
|
|
1983
|
+
throw Object.assign(new TypeError('Storage does not support BASM admitted lists'), {
|
|
1984
|
+
code: 'BASM_UNSUPPORTED'
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
assertNonnegativeInteger(blockHeight, 'BASM admitted-list height');
|
|
1988
|
+
if (blockHash !== undefined)
|
|
1989
|
+
assertHash(blockHash, 'BASM admitted-list block hash');
|
|
1990
|
+
const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash, MAX_BASM_ADMITTED_PER_BLOCK + 1);
|
|
1991
|
+
if (!Array.isArray(admitted) || admitted.length > MAX_BASM_ADMITTED_PER_BLOCK) {
|
|
1992
|
+
throw new RangeError('Storage returned an invalid or oversized BASM admitted list');
|
|
1993
|
+
}
|
|
1994
|
+
const seen = new Set();
|
|
1995
|
+
const seenBlockIndexes = new Set();
|
|
1996
|
+
for (const [index, item] of admitted.entries()) {
|
|
1997
|
+
if (typeof item !== 'object' || item === null)
|
|
1998
|
+
throw new TypeError(`Invalid BASM admission at index ${index}`);
|
|
1999
|
+
assertHash(item.txid, `BASM admission[${index}] txid`);
|
|
2000
|
+
assertNonnegativeInteger(item.blockIndex, `BASM admission[${index}] block index`);
|
|
2001
|
+
const canonical = item.txid.toLowerCase();
|
|
2002
|
+
if (seen.has(canonical))
|
|
2003
|
+
throw new TypeError('Storage returned duplicate BASM admissions');
|
|
2004
|
+
if (seenBlockIndexes.has(item.blockIndex)) {
|
|
2005
|
+
throw new TypeError('Storage returned duplicate BASM block indexes');
|
|
2006
|
+
}
|
|
2007
|
+
seen.add(canonical);
|
|
2008
|
+
seenBlockIndexes.add(item.blockIndex);
|
|
1168
2009
|
}
|
|
1169
2010
|
return {
|
|
1170
2011
|
topic,
|
|
1171
2012
|
blockHeight,
|
|
1172
2013
|
blockHash,
|
|
1173
|
-
admitted
|
|
2014
|
+
admitted
|
|
1174
2015
|
};
|
|
1175
2016
|
}
|
|
1176
2017
|
async provideCompoundMerklePath(topic, blockHeight, txids) {
|
|
2018
|
+
this.assertSupportedBASMTopic(topic);
|
|
1177
2019
|
if (typeof this.storage.findTransactionMerklePaths !== 'function') {
|
|
1178
|
-
throw new TypeError('Storage does not support direct Merkle path lookup')
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
throw new Error('At least one txid is required');
|
|
2020
|
+
throw Object.assign(new TypeError('Storage does not support direct Merkle path lookup'), {
|
|
2021
|
+
code: 'BASM_UNSUPPORTED'
|
|
2022
|
+
});
|
|
1182
2023
|
}
|
|
1183
|
-
|
|
2024
|
+
assertNonnegativeInteger(blockHeight, 'BASM compound-path height');
|
|
2025
|
+
assertTxidList(txids, 'BASM compound-path txids');
|
|
2026
|
+
const admitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, blockHeight, undefined, MAX_BASM_ADMITTED_PER_BLOCK + 1);
|
|
1184
2027
|
if (admitted !== undefined) {
|
|
1185
|
-
|
|
1186
|
-
|
|
2028
|
+
if (!Array.isArray(admitted) || admitted.length > MAX_BASM_ADMITTED_PER_BLOCK) {
|
|
2029
|
+
throw new TypeError('Storage returned an invalid or oversized BASM admitted list');
|
|
2030
|
+
}
|
|
2031
|
+
computeBasmRoot(admitted);
|
|
2032
|
+
const admittedSet = new Set(admitted.map(item => item.txid.toLowerCase()));
|
|
2033
|
+
const missingAdmissions = txids.filter(txid => !admittedSet.has(txid.toLowerCase()));
|
|
1187
2034
|
if (missingAdmissions.length > 0) {
|
|
1188
2035
|
throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`);
|
|
1189
2036
|
}
|
|
1190
2037
|
}
|
|
1191
2038
|
const proofs = await this.storage.findTransactionMerklePaths(txids);
|
|
1192
|
-
|
|
1193
|
-
|
|
2039
|
+
if (!Array.isArray(proofs) || proofs.length > txids.length) {
|
|
2040
|
+
throw new TypeError('Storage returned an invalid BASM proof set');
|
|
2041
|
+
}
|
|
2042
|
+
const requested = new Set(txids.map(txid => txid.toLowerCase()));
|
|
2043
|
+
const proofByTxid = new Map();
|
|
2044
|
+
for (const proof of proofs) {
|
|
2045
|
+
assertHash(proof.txid, 'BASM proof txid');
|
|
2046
|
+
const canonical = proof.txid.toLowerCase();
|
|
2047
|
+
if (!requested.has(canonical) ||
|
|
2048
|
+
proofByTxid.has(canonical) ||
|
|
2049
|
+
typeof proof.merklePath !== 'string') {
|
|
2050
|
+
throw new TypeError('Storage returned an unexpected or duplicate BASM proof');
|
|
2051
|
+
}
|
|
2052
|
+
proofByTxid.set(canonical, proof);
|
|
2053
|
+
}
|
|
2054
|
+
const missing = txids.filter(txid => !proofByTxid.has(txid.toLowerCase()));
|
|
1194
2055
|
if (missing.length > 0) {
|
|
1195
2056
|
throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`);
|
|
1196
2057
|
}
|
|
1197
2058
|
let compound;
|
|
1198
2059
|
for (const txid of txids) {
|
|
1199
|
-
const proof = proofByTxid.get(txid);
|
|
2060
|
+
const proof = proofByTxid.get(txid.toLowerCase());
|
|
1200
2061
|
if (proof === undefined)
|
|
1201
2062
|
continue;
|
|
1202
2063
|
const path = MerklePath.fromHex(proof.merklePath);
|
|
@@ -1220,15 +2081,43 @@ export class Engine {
|
|
|
1220
2081
|
merklePath: compound.toHex()
|
|
1221
2082
|
};
|
|
1222
2083
|
}
|
|
1223
|
-
async provideRawTransactions(txids) {
|
|
2084
|
+
async provideRawTransactions(txids, topic) {
|
|
1224
2085
|
if (typeof this.storage.findRawTransactions !== 'function') {
|
|
1225
|
-
throw new TypeError('Storage does not support raw transaction lookup')
|
|
2086
|
+
throw Object.assign(new TypeError('Storage does not support raw transaction lookup'), {
|
|
2087
|
+
code: 'BASM_UNSUPPORTED'
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
assertTxidList(txids, 'BASM raw-transaction txids');
|
|
2091
|
+
if (topic === undefined) {
|
|
2092
|
+
throw new TypeError('A BASM topic is required for raw transaction lookup');
|
|
2093
|
+
}
|
|
2094
|
+
this.assertSupportedBASMTopic(topic);
|
|
2095
|
+
const authorizedTxids = (await Promise.all(txids.map(async (txid) => {
|
|
2096
|
+
const authorized = await this.storage.doesAppliedTransactionExist({ txid, topic });
|
|
2097
|
+
if (typeof authorized !== 'boolean') {
|
|
2098
|
+
throw new TypeError('Storage returned an invalid BASM authorization verdict');
|
|
2099
|
+
}
|
|
2100
|
+
return { txid, authorized };
|
|
2101
|
+
})))
|
|
2102
|
+
.filter(entry => entry.authorized)
|
|
2103
|
+
.map(entry => entry.txid);
|
|
2104
|
+
const transactions = await this.storage.findRawTransactions(authorizedTxids);
|
|
2105
|
+
if (!Array.isArray(transactions) || transactions.length > authorizedTxids.length) {
|
|
2106
|
+
throw new TypeError('Storage returned an invalid BASM raw-transaction set');
|
|
2107
|
+
}
|
|
2108
|
+
const requested = new Set(authorizedTxids.map(txid => txid.toLowerCase()));
|
|
2109
|
+
const found = new Set();
|
|
2110
|
+
for (const transaction of transactions) {
|
|
2111
|
+
assertRawTransactionMatches(transaction.txid, transaction.rawTx);
|
|
2112
|
+
const canonical = transaction.txid.toLowerCase();
|
|
2113
|
+
if (!requested.has(canonical) || found.has(canonical)) {
|
|
2114
|
+
throw new TypeError('Storage returned an unexpected or duplicate BASM raw transaction');
|
|
2115
|
+
}
|
|
2116
|
+
found.add(canonical);
|
|
1226
2117
|
}
|
|
1227
|
-
const transactions = await this.storage.findRawTransactions(txids);
|
|
1228
|
-
const found = new Set(transactions.map(tx => tx.txid));
|
|
1229
2118
|
return {
|
|
1230
2119
|
transactions,
|
|
1231
|
-
missing: txids.filter(txid => !found.has(txid))
|
|
2120
|
+
missing: txids.filter(txid => !found.has(txid.toLowerCase()))
|
|
1232
2121
|
};
|
|
1233
2122
|
}
|
|
1234
2123
|
async startBASMSync() {
|
|
@@ -1254,40 +2143,122 @@ export class Engine {
|
|
|
1254
2143
|
fetchedTxCount: 0
|
|
1255
2144
|
};
|
|
1256
2145
|
try {
|
|
1257
|
-
const remote = new BASMRemote(endpoint, topic);
|
|
2146
|
+
const remote = new BASMRemote(endpoint, topic, this.basmFetchImpl);
|
|
1258
2147
|
const [localTip, remoteTip] = await Promise.all([
|
|
1259
2148
|
this.provideTopicAnchorTip(topic),
|
|
1260
2149
|
remote.requestTopicAnchorTip()
|
|
1261
2150
|
]);
|
|
1262
2151
|
report.localTip = localTip;
|
|
1263
2152
|
report.remoteTip = remoteTip;
|
|
2153
|
+
await this.requireMatchingRemoteBASMTip(remote, remoteTip);
|
|
1264
2154
|
if (localTip.blockHeight >= remoteTip.blockHeight) {
|
|
1265
|
-
|
|
1266
|
-
report.message = report.status === 'matched'
|
|
1267
|
-
? 'Topic anchor tips match'
|
|
1268
|
-
: 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation';
|
|
1269
|
-
return report;
|
|
2155
|
+
return await this.finishBASMWhenRemoteIsNotAhead(topic, localTip, remoteTip, report);
|
|
1270
2156
|
}
|
|
1271
|
-
|
|
1272
|
-
const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight);
|
|
1273
|
-
for (const remoteAnchor of range.anchors) {
|
|
1274
|
-
await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report);
|
|
1275
|
-
}
|
|
1276
|
-
const refreshedTip = await this.provideTopicAnchorTip(topic);
|
|
1277
|
-
report.localTip = refreshedTip;
|
|
1278
|
-
report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced';
|
|
1279
|
-
return report;
|
|
2157
|
+
return await this.advanceBASMWithRemoteAnchors(topic, remote, localTip, remoteTip, report);
|
|
1280
2158
|
}
|
|
1281
2159
|
catch (error) {
|
|
1282
|
-
report
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
2160
|
+
return this.markBASMPeerSyncError(report, topic, endpoint, error);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
async requireMatchingRemoteBASMTip(remote, remoteTip) {
|
|
2164
|
+
if (remoteTip.blockHeight < 0) {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
const tipRange = await remote.requestTopicAnchorRange(remoteTip.blockHeight, remoteTip.blockHeight);
|
|
2168
|
+
const remoteTipAnchor = tipRange.anchors[0];
|
|
2169
|
+
const tipAnchor = requireBASMDefined(remoteTipAnchor, remoteTipAnchor?.tac === remoteTip.tac, 'BASM tip does not match its anchor');
|
|
2170
|
+
for (const field of ['blockHash', 'basmRoot', 'admittedCount']) {
|
|
2171
|
+
requireBASM(remoteTip[field] === undefined || remoteTip[field] === tipAnchor[field], 'BASM tip metadata does not match its anchor');
|
|
2172
|
+
}
|
|
2173
|
+
await this.requireCanonicalBASMAnchor(tipAnchor);
|
|
2174
|
+
}
|
|
2175
|
+
async finishBASMWhenRemoteIsNotAhead(topic, localTip, remoteTip, report) {
|
|
2176
|
+
const tipsMatch = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight;
|
|
2177
|
+
if (localTip.blockHeight >= 0 && tipsMatch) {
|
|
2178
|
+
const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, localTip.blockHeight);
|
|
2179
|
+
await this.requireCanonicalBASMAnchor(requireBASMDefined(localAnchor, localAnchor?.tac === localTip.tac, 'Local BASM tip lacks its anchor'));
|
|
2180
|
+
}
|
|
2181
|
+
report.status = tipsMatch ? 'matched' : 'diverged';
|
|
2182
|
+
report.message = tipsMatch
|
|
2183
|
+
? 'Topic anchor tips match'
|
|
2184
|
+
: 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation';
|
|
2185
|
+
return report;
|
|
2186
|
+
}
|
|
2187
|
+
requireBASMRangePrefix(localTip, remoteTip, fromHeight, toHeight, anchors) {
|
|
2188
|
+
requireBASM(anchors.length > 0 && anchors.at(-1)?.blockHeight === toHeight, 'BASM range omits its requested target');
|
|
2189
|
+
requireBASM(localTip.blockHeight < 0 || anchors[0].blockHeight === fromHeight, 'BASM range omits its next height');
|
|
2190
|
+
let previousTac = localTip.tac;
|
|
2191
|
+
for (const anchor of anchors) {
|
|
2192
|
+
requireBASM(anchor.tac === computeTac(previousTac, anchor.blockHash, anchor.basmRoot), 'BASM range TAC is inconsistent with its prefix');
|
|
2193
|
+
previousTac = anchor.tac;
|
|
2194
|
+
}
|
|
2195
|
+
if (toHeight === remoteTip.blockHeight)
|
|
2196
|
+
requireBASM(previousTac === remoteTip.tac, 'BASM range differs from its tip');
|
|
2197
|
+
}
|
|
2198
|
+
async advanceBASMWithRemoteAnchors(topic, remote, localTip, remoteTip, report) {
|
|
2199
|
+
const fromHeight = localTip.blockHeight < 0
|
|
2200
|
+
? Math.max(remoteTip.blockHeight - DEFAULT_BASM_SYNC_PAGE_SIZE + 1, 0)
|
|
2201
|
+
: localTip.blockHeight + 1;
|
|
2202
|
+
const toHeight = Math.min(fromHeight + DEFAULT_BASM_SYNC_PAGE_SIZE - 1, remoteTip.blockHeight);
|
|
2203
|
+
const range = await remote.requestTopicAnchorRange(fromHeight, toHeight);
|
|
2204
|
+
this.requireBASMRangePrefix(localTip, remoteTip, fromHeight, toHeight, range.anchors);
|
|
2205
|
+
for (const remoteAnchor of range.anchors) {
|
|
2206
|
+
await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report);
|
|
2207
|
+
if (report.status === 'diverged')
|
|
2208
|
+
return report;
|
|
2209
|
+
}
|
|
2210
|
+
const finalRemoteTip = await remote.requestTopicAnchorTip();
|
|
2211
|
+
requireBASM(finalRemoteTip.blockHeight === remoteTip.blockHeight && finalRemoteTip.tac === remoteTip.tac, 'BASM peer history changed during reconciliation');
|
|
2212
|
+
const refreshedTip = await this.provideTopicAnchorTip(topic);
|
|
2213
|
+
report.localTip = refreshedTip;
|
|
2214
|
+
report.status =
|
|
2215
|
+
refreshedTip.blockHeight === remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac
|
|
2216
|
+
? 'matched'
|
|
2217
|
+
: 'advanced';
|
|
2218
|
+
return report;
|
|
2219
|
+
}
|
|
2220
|
+
markBASMPeerSyncError(report, topic, endpoint, error) {
|
|
2221
|
+
report.status = 'error';
|
|
2222
|
+
if (error instanceof Error && 'code' in error && typeof error.code === 'string')
|
|
2223
|
+
report.errorCode = error.code;
|
|
2224
|
+
report.message = error instanceof Error ? error.message : String(error);
|
|
2225
|
+
this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
|
|
2226
|
+
return report;
|
|
2227
|
+
}
|
|
2228
|
+
async requireCanonicalBASMAnchor(anchor, proofRoot) {
|
|
2229
|
+
if (this.chainTracker === 'scripts only' || this.topicAnchorHeaderResolver === undefined) {
|
|
2230
|
+
throw new Error('BASM reconciliation requires a ChainTracker and canonical header resolver');
|
|
2231
|
+
}
|
|
2232
|
+
const resolvedHeader = await this.topicAnchorHeaderResolver(anchor.blockHeight);
|
|
2233
|
+
const header = requireBASMDefined(resolvedHeader, resolvedHeader?.blockHeight === anchor.blockHeight, 'BASM canonical header is unavailable or has the wrong height');
|
|
2234
|
+
requireBASM(basmHash(header.blockHash.toLowerCase(), 'canonical block hash') === anchor.blockHash, 'BASM anchor block hash is not canonical');
|
|
2235
|
+
if (proofRoot !== undefined && header.merkleRoot !== undefined) {
|
|
2236
|
+
requireBASM(header.merkleRoot.toLowerCase() === proofRoot, 'BASM proof root differs from its canonical header');
|
|
2237
|
+
}
|
|
2238
|
+
if (header.blockTransactionCount !== undefined) {
|
|
2239
|
+
basmInteger(header.blockTransactionCount, 'canonical block transaction count', 1);
|
|
2240
|
+
requireBASM(anchor.admittedCount <= header.blockTransactionCount, 'BASM admitted count exceeds canonical block transaction count');
|
|
2241
|
+
}
|
|
2242
|
+
return header;
|
|
2243
|
+
}
|
|
2244
|
+
validateBASMProofPositions(path, admitted, count) {
|
|
2245
|
+
requireBASM(admitted.every(item => item.blockIndex < count), 'BASM admitted index exceeds canonical block transaction count');
|
|
2246
|
+
let width = count;
|
|
2247
|
+
for (let height = 0; height < path.path.length; height++) {
|
|
2248
|
+
requireBASM(height === 0 || width > 1, 'BASM proof exceeds canonical tree depth');
|
|
2249
|
+
for (const node of path.path[height]) {
|
|
2250
|
+
requireBASM(node.duplicate === true ? width % 2 === 1 && node.offset === width : node.offset < width, 'BASM proof node is outside canonical block positions');
|
|
2251
|
+
}
|
|
2252
|
+
width = Math.ceil(width / 2);
|
|
1286
2253
|
}
|
|
1287
2254
|
}
|
|
1288
2255
|
async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
|
|
2256
|
+
await this.requireCanonicalBASMAnchor(remoteAnchor);
|
|
1289
2257
|
report.checkedHeights.push(remoteAnchor.blockHeight);
|
|
1290
2258
|
const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash);
|
|
2259
|
+
if (localAnchor !== undefined) {
|
|
2260
|
+
this.assertTopicBlockAnchor(localAnchor, topic, 'Stored local BASM anchor', remoteAnchor.blockHeight, remoteAnchor.blockHash);
|
|
2261
|
+
}
|
|
1291
2262
|
if (localAnchor?.tac === remoteAnchor.tac) {
|
|
1292
2263
|
return;
|
|
1293
2264
|
}
|
|
@@ -1297,60 +2268,119 @@ export class Engine {
|
|
|
1297
2268
|
admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
|
|
1298
2269
|
throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
|
|
1299
2270
|
}
|
|
1300
|
-
const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? [];
|
|
2271
|
+
const localAdmitted = (await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash)) ?? [];
|
|
2272
|
+
// BRC-136: an empty topic at a height has k = 0 and R = 32 zero bytes, which
|
|
2273
|
+
// the root/count check above has already enforced. There is no transaction
|
|
2274
|
+
// to bind to a proof or to fetch, so the height is checked without a proof
|
|
2275
|
+
// round trip; it only diverges when this node admitted something there.
|
|
2276
|
+
if (admittedResponse.admitted.length === 0) {
|
|
2277
|
+
if (localAdmitted.length > 0)
|
|
2278
|
+
report.status = 'diverged';
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
1301
2281
|
const localTxids = new Set(localAdmitted.map(item => item.txid));
|
|
1302
2282
|
const missingTxids = admittedResponse.admitted
|
|
1303
2283
|
.map(item => item.txid)
|
|
1304
2284
|
.filter(txid => !localTxids.has(txid));
|
|
1305
2285
|
report.missingTxids.push(...missingTxids);
|
|
2286
|
+
// The BASM root only commits to txid order. Bind claimed original indices to
|
|
2287
|
+
// the compound path even when every remote txid is already local.
|
|
2288
|
+
const assurance = await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, admittedResponse.admitted, missingTxids);
|
|
2289
|
+
if (report.positionValidation !== 'encoded-offset-only')
|
|
2290
|
+
report.positionValidation = assurance;
|
|
1306
2291
|
if (missingTxids.length === 0) {
|
|
1307
2292
|
report.status = 'diverged';
|
|
1308
2293
|
return;
|
|
1309
2294
|
}
|
|
1310
|
-
await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids);
|
|
1311
2295
|
report.fetchedTxCount += missingTxids.length;
|
|
1312
2296
|
}
|
|
1313
|
-
async fetchBASMMissingTransactions(remote, topic, anchor, txids) {
|
|
2297
|
+
async fetchBASMMissingTransactions(remote, topic, anchor, admitted, txids) {
|
|
1314
2298
|
if (this.chainTracker === 'scripts only') {
|
|
1315
2299
|
throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs');
|
|
1316
2300
|
}
|
|
1317
|
-
|
|
2301
|
+
// Validate the whole claimed ordered subset, including entries already local:
|
|
2302
|
+
// the BASM root alone does not bind the peer's claimed original positions.
|
|
2303
|
+
const proofResponse = await remote.requestCompoundMerklePath(anchor.blockHeight, admitted.map(item => item.txid));
|
|
1318
2304
|
const compoundPath = MerklePath.fromHex(proofResponse.merklePath);
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
2305
|
+
requireBASM(compoundPath.blockHeight === anchor.blockHeight, 'BASM proof height does not match its anchor');
|
|
2306
|
+
requireBASM(compoundPath.toHex() === proofResponse.merklePath.toLowerCase(), 'BASM proof is not canonically encoded');
|
|
2307
|
+
const proofRoot = compoundPath.computeRoot();
|
|
2308
|
+
const proofHeader = await this.requireCanonicalBASMAnchor(anchor, proofRoot);
|
|
2309
|
+
if (proofHeader.blockTransactionCount !== undefined) {
|
|
2310
|
+
this.validateBASMProofPositions(compoundPath, admitted, proofHeader.blockTransactionCount);
|
|
2311
|
+
}
|
|
2312
|
+
for (const { txid, blockIndex } of admitted) {
|
|
2313
|
+
const leaf = compoundPath.path[0]?.find(item => item.hash === txid);
|
|
2314
|
+
requireBASM(leaf?.offset === blockIndex, 'BASM proof does not bind the admitted block index');
|
|
2315
|
+
requireBASM(compoundPath.path[0].length !== 1 || compoundPath.path.length !== 1 || blockIndex === 0, 'BASM singleton proof has a nonzero block index');
|
|
2316
|
+
requireBASM(compoundPath.computeRoot(txid) === proofRoot, 'BASM proof root does not match the admitted transaction');
|
|
2317
|
+
}
|
|
2318
|
+
// Inclusion is root/height, not coinbase maturity. MerklePath.verify also
|
|
2319
|
+
// applies the 100-block spendability rule at offset 0.
|
|
2320
|
+
const valid = await this.chainTracker.isValidRootForHeight(proofRoot, compoundPath.blockHeight);
|
|
2321
|
+
if (!valid) {
|
|
2322
|
+
throw new Error(`Peer supplied invalid compound Merkle path at height ${anchor.blockHeight}`);
|
|
2323
|
+
}
|
|
2324
|
+
if (txids.length === 0) {
|
|
2325
|
+
return proofHeader.blockTransactionCount === undefined
|
|
2326
|
+
? 'encoded-offset-only'
|
|
2327
|
+
: 'canonical-count';
|
|
1324
2328
|
}
|
|
1325
2329
|
const rawResponse = await remote.requestRawTransactions(txids);
|
|
1326
2330
|
if (rawResponse.missing.length > 0) {
|
|
1327
2331
|
throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`);
|
|
1328
2332
|
}
|
|
1329
|
-
|
|
2333
|
+
const transactions = rawResponse.transactions.map(record => {
|
|
1330
2334
|
const tx = Transaction.fromHex(record.rawTx);
|
|
1331
|
-
if (tx.id('hex') !== record.txid) {
|
|
2335
|
+
if (tx.id('hex') !== record.txid || tx.toHex() !== record.rawTx.toLowerCase()) {
|
|
1332
2336
|
throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`);
|
|
1333
2337
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
2338
|
+
tx.merklePath = compoundPath.extract([record.txid]);
|
|
2339
|
+
return tx;
|
|
2340
|
+
});
|
|
2341
|
+
const refreshedAnchor = (await remote.requestTopicAnchorRange(anchor.blockHeight, anchor.blockHeight)).anchors[0];
|
|
2342
|
+
requireBASM(refreshedAnchor?.blockHash === anchor.blockHash &&
|
|
2343
|
+
refreshedAnchor?.basmRoot === anchor.basmRoot &&
|
|
2344
|
+
refreshedAnchor?.admittedCount === anchor.admittedCount &&
|
|
2345
|
+
refreshedAnchor?.tac === anchor.tac, 'BASM peer anchor changed before admission');
|
|
2346
|
+
const commitHeader = await this.requireCanonicalBASMAnchor(anchor, proofRoot);
|
|
2347
|
+
requireBASM(commitHeader.blockTransactionCount === proofHeader.blockTransactionCount, 'BASM canonical block transaction count changed before admission');
|
|
2348
|
+
// Apply in the independently checked block order, regardless of raw response order.
|
|
2349
|
+
// Admit with 'historical-tx-no-spv': inclusion is already proven above by
|
|
2350
|
+
// isValidRootForHeight plus the canonical-header binding. Re-running
|
|
2351
|
+
// Transaction.verify would apply MerklePath.verify's coinbase 100-block
|
|
2352
|
+
// spendability rule and reject an admitted coinbase from a recent block.
|
|
2353
|
+
const transactionById = new Map(transactions.map(tx => [tx.id('hex'), tx]));
|
|
2354
|
+
for (const txid of txids) {
|
|
2355
|
+
const tx = transactionById.get(txid);
|
|
2356
|
+
requireBASM(tx !== undefined, 'BASM raw response omits a requested transaction');
|
|
2357
|
+
await this.submit({ beef: tx.toBEEF(), topics: [topic] }, undefined, 'historical-tx-no-spv');
|
|
1341
2358
|
}
|
|
2359
|
+
const finalHeader = await this.requireCanonicalBASMAnchor(anchor, proofRoot);
|
|
2360
|
+
requireBASM(finalHeader.blockTransactionCount === proofHeader.blockTransactionCount, 'BASM canonical block transaction count changed during admission');
|
|
2361
|
+
return proofHeader.blockTransactionCount === undefined
|
|
2362
|
+
? 'encoded-offset-only'
|
|
2363
|
+
: 'canonical-count';
|
|
1342
2364
|
}
|
|
1343
2365
|
async evictUnprovenTransactions(options = {}) {
|
|
1344
2366
|
if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
|
|
1345
2367
|
throw new TypeError('Storage does not support unproven transaction eviction');
|
|
1346
2368
|
}
|
|
2369
|
+
if (typeof this.storage.deleteAppliedTransaction !== 'function') {
|
|
2370
|
+
throw new TypeError('Storage does not support applied transaction eviction');
|
|
2371
|
+
}
|
|
1347
2372
|
if (this.chainTracker === 'scripts only') {
|
|
1348
2373
|
throw new Error('Unproven eviction requires a ChainTracker to determine block age');
|
|
1349
2374
|
}
|
|
2375
|
+
if (options.topic !== undefined)
|
|
2376
|
+
assertTopic(options.topic, 'Unproven eviction topic');
|
|
1350
2377
|
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
|
|
2378
|
+
assertUnprovenThreshold(thresholdBlocks);
|
|
1351
2379
|
const currentHeight = await this.chainTracker.currentHeight();
|
|
2380
|
+
assertNonnegativeInteger(currentHeight, 'Current chain height');
|
|
1352
2381
|
const cutoffHeight = currentHeight - thresholdBlocks;
|
|
1353
|
-
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
|
|
2382
|
+
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic, { maxCandidates: MAX_UNPROVEN_CANDIDATES, maxOutputs: MAX_EVICTION_OUTPUTS });
|
|
2383
|
+
this.validateUnprovenCandidates(candidates, cutoffHeight, options.topic);
|
|
1354
2384
|
let evictedOutputs = 0;
|
|
1355
2385
|
for (const candidate of candidates) {
|
|
1356
2386
|
for (const output of candidate.outputs) {
|
|
@@ -1365,7 +2395,7 @@ export class Engine {
|
|
|
1365
2395
|
await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
|
|
1366
2396
|
evictedOutputs++;
|
|
1367
2397
|
}
|
|
1368
|
-
await this.storage.deleteAppliedTransaction
|
|
2398
|
+
await this.storage.deleteAppliedTransaction(candidate.txid, candidate.topic);
|
|
1369
2399
|
}
|
|
1370
2400
|
return {
|
|
1371
2401
|
cutoffHeight,
|
|
@@ -1381,10 +2411,20 @@ export class Engine {
|
|
|
1381
2411
|
if (this.chainTracker === 'scripts only') {
|
|
1382
2412
|
throw new Error('Unproven proof refresh requires a ChainTracker to determine block age');
|
|
1383
2413
|
}
|
|
2414
|
+
if (typeof options !== 'object' ||
|
|
2415
|
+
options === null ||
|
|
2416
|
+
typeof options.proofProvider !== 'function') {
|
|
2417
|
+
throw new TypeError('Unproven proof refresh requires a proofProvider');
|
|
2418
|
+
}
|
|
2419
|
+
if (options.topic !== undefined)
|
|
2420
|
+
assertTopic(options.topic, 'Unproven refresh topic');
|
|
1384
2421
|
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
|
|
2422
|
+
assertUnprovenThreshold(thresholdBlocks);
|
|
1385
2423
|
const currentHeight = await this.chainTracker.currentHeight();
|
|
2424
|
+
assertNonnegativeInteger(currentHeight, 'Current chain height');
|
|
1386
2425
|
const cutoffHeight = currentHeight - thresholdBlocks;
|
|
1387
|
-
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
|
|
2426
|
+
const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic, { maxCandidates: MAX_UNPROVEN_CANDIDATES, maxOutputs: MAX_EVICTION_OUTPUTS });
|
|
2427
|
+
this.validateUnprovenCandidates(candidates, cutoffHeight, options.topic);
|
|
1388
2428
|
const txids = [...new Set(candidates.map(candidate => candidate.txid))];
|
|
1389
2429
|
let refreshedTransactions = 0;
|
|
1390
2430
|
let missingProofs = 0;
|
|
@@ -1404,7 +2444,7 @@ export class Engine {
|
|
|
1404
2444
|
failedProofs++;
|
|
1405
2445
|
failures.push({
|
|
1406
2446
|
txid,
|
|
1407
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2447
|
+
error: (error instanceof Error ? error.message : String(error)).slice(0, 1024)
|
|
1408
2448
|
});
|
|
1409
2449
|
}
|
|
1410
2450
|
}
|
|
@@ -1417,6 +2457,57 @@ export class Engine {
|
|
|
1417
2457
|
failures
|
|
1418
2458
|
};
|
|
1419
2459
|
}
|
|
2460
|
+
validateUnprovenCandidates(candidates, cutoffHeight, requestedTopic) {
|
|
2461
|
+
if (!Array.isArray(candidates) || candidates.length > MAX_UNPROVEN_CANDIDATES) {
|
|
2462
|
+
throw new TypeError('Storage returned an invalid or oversized unproven candidate set');
|
|
2463
|
+
}
|
|
2464
|
+
const seenCandidates = new Set();
|
|
2465
|
+
const seenOutputs = new Set();
|
|
2466
|
+
let outputCount = 0;
|
|
2467
|
+
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
2468
|
+
if (typeof candidate !== 'object' ||
|
|
2469
|
+
candidate === null ||
|
|
2470
|
+
!Array.isArray(candidate.outputs)) {
|
|
2471
|
+
throw new TypeError(`Storage returned an invalid unproven candidate at index ${candidateIndex}`);
|
|
2472
|
+
}
|
|
2473
|
+
assertHash(candidate.txid, `Unproven candidate[${candidateIndex}] txid`);
|
|
2474
|
+
assertTopic(candidate.topic, `Unproven candidate[${candidateIndex}] topic`);
|
|
2475
|
+
if (requestedTopic !== undefined && candidate.topic !== requestedTopic) {
|
|
2476
|
+
throw new TypeError('Storage returned an unproven candidate for a different topic');
|
|
2477
|
+
}
|
|
2478
|
+
if (candidate.firstSeenHeight === undefined) {
|
|
2479
|
+
throw new TypeError('Storage returned an unproven candidate without an age anchor');
|
|
2480
|
+
}
|
|
2481
|
+
assertNonnegativeInteger(candidate.firstSeenHeight, `Unproven candidate[${candidateIndex}] firstSeenHeight`);
|
|
2482
|
+
if (candidate.firstSeenHeight > cutoffHeight) {
|
|
2483
|
+
throw new TypeError('Storage returned an unproven candidate newer than the cutoff');
|
|
2484
|
+
}
|
|
2485
|
+
const candidateKey = `${candidate.txid.toLowerCase()}.${candidate.topic}`;
|
|
2486
|
+
if (seenCandidates.has(candidateKey)) {
|
|
2487
|
+
throw new TypeError('Storage returned duplicate unproven candidates');
|
|
2488
|
+
}
|
|
2489
|
+
seenCandidates.add(candidateKey);
|
|
2490
|
+
outputCount += candidate.outputs.length;
|
|
2491
|
+
if (outputCount > MAX_EVICTION_OUTPUTS) {
|
|
2492
|
+
throw new TypeError('Storage returned too many unproven outputs');
|
|
2493
|
+
}
|
|
2494
|
+
for (const [outputIndex, output] of candidate.outputs.entries()) {
|
|
2495
|
+
if (typeof output !== 'object' || output === null) {
|
|
2496
|
+
throw new TypeError(`Storage returned an invalid unproven output at ${candidateIndex}.${outputIndex}`);
|
|
2497
|
+
}
|
|
2498
|
+
assertHash(output.txid, `Unproven output[${candidateIndex}.${outputIndex}] txid`);
|
|
2499
|
+
assertOutputIndex(output.outputIndex, `Unproven output[${candidateIndex}.${outputIndex}] index`);
|
|
2500
|
+
if (output.txid.toLowerCase() !== candidate.txid.toLowerCase()) {
|
|
2501
|
+
throw new TypeError('Storage returned an unproven output for a different transaction');
|
|
2502
|
+
}
|
|
2503
|
+
const outputKey = `${candidateKey}.${output.outputIndex}`;
|
|
2504
|
+
if (seenOutputs.has(outputKey)) {
|
|
2505
|
+
throw new TypeError('Storage returned duplicate unproven outputs');
|
|
2506
|
+
}
|
|
2507
|
+
seenOutputs.add(outputKey);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
1420
2511
|
async maintainUnprovenTransactions(options) {
|
|
1421
2512
|
const refresh = await this.refreshUnprovenTransactionProofs(options);
|
|
1422
2513
|
const eviction = await this.evictUnprovenTransactions({
|
|
@@ -1429,7 +2520,34 @@ export class Engine {
|
|
|
1429
2520
|
if (typeof this.storage.deleteAppliedTransaction !== 'function') {
|
|
1430
2521
|
throw new TypeError('Storage does not support applied transaction eviction');
|
|
1431
2522
|
}
|
|
1432
|
-
|
|
2523
|
+
assertHash(txid, 'Evicted transaction txid');
|
|
2524
|
+
if (options.topic !== undefined)
|
|
2525
|
+
assertTopic(options.topic, 'Eviction topic');
|
|
2526
|
+
if (options.reason !== undefined &&
|
|
2527
|
+
(typeof options.reason !== 'string' ||
|
|
2528
|
+
new TextEncoder().encode(options.reason).byteLength > MAX_EVICTION_REASON_BYTES)) {
|
|
2529
|
+
throw new TypeError('Eviction reason must be a bounded string');
|
|
2530
|
+
}
|
|
2531
|
+
const outputs = await this.storage.findOutputsForTransaction(txid, false, MAX_EVICTION_OUTPUTS + 1);
|
|
2532
|
+
if (!Array.isArray(outputs) || outputs.length > MAX_EVICTION_OUTPUTS) {
|
|
2533
|
+
throw new TypeError('Storage returned an invalid or oversized eviction output set');
|
|
2534
|
+
}
|
|
2535
|
+
const seenOutputs = new Set();
|
|
2536
|
+
for (const [index, output] of outputs.entries()) {
|
|
2537
|
+
if (typeof output !== 'object' || output === null) {
|
|
2538
|
+
throw new TypeError(`Storage returned an invalid eviction output at index ${index}`);
|
|
2539
|
+
}
|
|
2540
|
+
assertHash(output.txid, `Eviction output[${index}] txid`);
|
|
2541
|
+
assertOutputIndex(output.outputIndex, `Eviction output[${index}] index`);
|
|
2542
|
+
assertTopic(output.topic, `Eviction output[${index}] topic`);
|
|
2543
|
+
if (output.txid.toLowerCase() !== txid.toLowerCase()) {
|
|
2544
|
+
throw new TypeError('Storage returned an eviction output for a different transaction');
|
|
2545
|
+
}
|
|
2546
|
+
const key = `${output.txid.toLowerCase()}.${output.outputIndex}.${output.topic}`;
|
|
2547
|
+
if (seenOutputs.has(key))
|
|
2548
|
+
throw new TypeError('Storage returned duplicate eviction outputs');
|
|
2549
|
+
seenOutputs.add(key);
|
|
2550
|
+
}
|
|
1433
2551
|
const filtered = options.topic === undefined
|
|
1434
2552
|
? outputs
|
|
1435
2553
|
: outputs.filter(output => output.topic === options.topic);
|
|
@@ -1469,13 +2587,42 @@ export class Engine {
|
|
|
1469
2587
|
* @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided min block height.
|
|
1470
2588
|
*/
|
|
1471
2589
|
async provideForeignSyncResponse(initialRequest, topic) {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2590
|
+
this.assertSupportedBASMTopic(topic);
|
|
2591
|
+
if (typeof initialRequest !== 'object' ||
|
|
2592
|
+
initialRequest === null ||
|
|
2593
|
+
initialRequest.version !== 1) {
|
|
2594
|
+
throw new TypeError('Unsupported or invalid GASP request version');
|
|
2595
|
+
}
|
|
2596
|
+
assertNonnegativeInteger(initialRequest.since, 'GASP request since');
|
|
2597
|
+
const limit = initialRequest.limit ?? 1000;
|
|
2598
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_GASP_PAGE_SIZE) {
|
|
2599
|
+
throw new TypeError(`GASP request limit must be between 1 and ${MAX_GASP_PAGE_SIZE}`);
|
|
2600
|
+
}
|
|
2601
|
+
const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, limit);
|
|
2602
|
+
if (!Array.isArray(outputs) || outputs.length > limit) {
|
|
2603
|
+
throw new TypeError('Storage returned an invalid or oversized GASP UTXO list');
|
|
2604
|
+
}
|
|
2605
|
+
const seen = new Set();
|
|
2606
|
+
const UTXOList = outputs.map((output, index) => {
|
|
2607
|
+
this.assertStoredOutputRelations(output, `GASP UTXO[${index}]`);
|
|
2608
|
+
if (output.topic !== topic)
|
|
2609
|
+
throw new TypeError('Storage returned a cross-topic GASP UTXO');
|
|
2610
|
+
assertNonnegativeInteger(output.score ?? 0, `GASP UTXO[${index}] score`);
|
|
2611
|
+
if ((output.score ?? 0) < initialRequest.since) {
|
|
2612
|
+
throw new TypeError('Storage returned a GASP UTXO below the requested score');
|
|
2613
|
+
}
|
|
2614
|
+
const outpoint = `${output.txid.toLowerCase()}.${output.outputIndex}`;
|
|
2615
|
+
if (seen.has(outpoint))
|
|
2616
|
+
throw new TypeError('Storage returned duplicate GASP UTXOs');
|
|
2617
|
+
seen.add(outpoint);
|
|
2618
|
+
return {
|
|
1475
2619
|
txid: output.txid,
|
|
1476
2620
|
outputIndex: output.outputIndex,
|
|
1477
2621
|
score: output.score ?? 0
|
|
1478
|
-
}
|
|
2622
|
+
};
|
|
2623
|
+
});
|
|
2624
|
+
return {
|
|
2625
|
+
UTXOList,
|
|
1479
2626
|
since: initialRequest.since
|
|
1480
2627
|
};
|
|
1481
2628
|
}
|
|
@@ -1488,51 +2635,91 @@ export class Engine {
|
|
|
1488
2635
|
* @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
|
|
1489
2636
|
* @throws An error if no output is found for the given transaction ID and output index.
|
|
1490
2637
|
*/
|
|
1491
|
-
async provideForeignGASPNode(graphID, txid, outputIndex) {
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
2638
|
+
async provideForeignGASPNode(graphID, txid, outputIndex, topic) {
|
|
2639
|
+
assertOutpoint(graphID, 'GASP graphID');
|
|
2640
|
+
assertHash(txid, 'GASP txid');
|
|
2641
|
+
assertOutputIndex(outputIndex, 'GASP output index');
|
|
2642
|
+
if (topic !== undefined)
|
|
2643
|
+
this.assertSupportedBASMTopic(topic);
|
|
2644
|
+
const searchedOutputs = new Set();
|
|
2645
|
+
let searchedTransactions = 0;
|
|
2646
|
+
let totalBeefBytes = 0;
|
|
2647
|
+
const hydrator = async (output, expected, depth, ancestors) => {
|
|
2648
|
+
if (output === null)
|
|
2649
|
+
return undefined;
|
|
2650
|
+
this.validateLookupOutput(output, 'Stored GASP output', expected);
|
|
2651
|
+
if (output.beef === undefined)
|
|
2652
|
+
throw new Error('Stored GASP output has no transaction BEEF');
|
|
2653
|
+
if (topic !== undefined && output.topic !== topic) {
|
|
2654
|
+
throw new Error('Requested GASP output is not admitted to this topic');
|
|
2655
|
+
}
|
|
2656
|
+
if (depth > 2048 || searchedOutputs.size >= 2048) {
|
|
2657
|
+
throw new Error('GASP ancestry search exceeded its resource limit');
|
|
2658
|
+
}
|
|
2659
|
+
const outputID = this.toOutputCacheKey(output.txid, output.outputIndex);
|
|
2660
|
+
if (ancestors.has(outputID))
|
|
2661
|
+
throw new Error('Cycle detected in stored GASP ancestry');
|
|
2662
|
+
if (searchedOutputs.has(outputID))
|
|
2663
|
+
return undefined;
|
|
2664
|
+
searchedOutputs.add(outputID);
|
|
2665
|
+
totalBeefBytes += output.beef.length;
|
|
2666
|
+
if (totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
|
|
2667
|
+
throw new Error('GASP transaction BEEF exceeded its resource limit');
|
|
1495
2668
|
}
|
|
1496
2669
|
const rootTx = Transaction.fromBEEF(output.beef);
|
|
1497
2670
|
let correctTx;
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
2671
|
+
const transactionStack = [rootTx];
|
|
2672
|
+
const visitedTransactions = new Set();
|
|
2673
|
+
while (transactionStack.length > 0) {
|
|
2674
|
+
const candidate = transactionStack.pop();
|
|
2675
|
+
const candidateTxid = candidate.id('hex');
|
|
2676
|
+
if (visitedTransactions.has(candidateTxid))
|
|
2677
|
+
continue;
|
|
2678
|
+
searchedTransactions += 1;
|
|
2679
|
+
if (searchedTransactions > 2048)
|
|
2680
|
+
throw new Error('GASP transaction ancestry exceeded its resource limit');
|
|
2681
|
+
visitedTransactions.add(candidateTxid);
|
|
2682
|
+
if (candidateTxid.toLowerCase() === txid.toLowerCase()) {
|
|
2683
|
+
correctTx = candidate;
|
|
2684
|
+
break;
|
|
1501
2685
|
}
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
if (input.sourceTransaction === undefined) {
|
|
1507
|
-
throw new Error('Incomplete SPV data!');
|
|
1508
|
-
}
|
|
1509
|
-
else {
|
|
1510
|
-
searchInput(input.sourceTransaction);
|
|
2686
|
+
for (const input of candidate.inputs) {
|
|
2687
|
+
if (input.sourceTransaction !== undefined) {
|
|
2688
|
+
if (transactionStack.length + searchedTransactions >= 2048) {
|
|
2689
|
+
throw new Error('GASP transaction ancestry exceeded its resource limit');
|
|
1511
2690
|
}
|
|
2691
|
+
transactionStack.push(input.sourceTransaction);
|
|
1512
2692
|
}
|
|
1513
2693
|
}
|
|
1514
|
-
}
|
|
1515
|
-
searchInput(rootTx);
|
|
2694
|
+
}
|
|
1516
2695
|
if (correctTx === undefined) {
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
catch (error) {
|
|
1526
|
-
// Best-effort: output may not be found or hydration fails for this candidate; try the next one
|
|
1527
|
-
this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`);
|
|
1528
|
-
continue;
|
|
2696
|
+
if (output.outputsConsumed.length > 2048) {
|
|
2697
|
+
throw new Error('Stored GASP ancestry fan-out exceeded its resource limit');
|
|
2698
|
+
}
|
|
2699
|
+
const sourceOutpoints = new Set();
|
|
2700
|
+
for (const input of rootTx.inputs) {
|
|
2701
|
+
const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
|
|
2702
|
+
if (sourceTXID !== undefined) {
|
|
2703
|
+
sourceOutpoints.add(this.toOutputCacheKey(sourceTXID, input.sourceOutputIndex));
|
|
1529
2704
|
}
|
|
1530
2705
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
2706
|
+
const nextAncestors = new Set(ancestors);
|
|
2707
|
+
nextAncestors.add(outputID);
|
|
2708
|
+
for (const currentOutput of output.outputsConsumed) {
|
|
2709
|
+
const relationKey = this.toOutputCacheKey(currentOutput.txid, currentOutput.outputIndex);
|
|
2710
|
+
if (!sourceOutpoints.has(relationKey)) {
|
|
2711
|
+
throw new TypeError('Stored GASP ancestry relation is not a transaction input');
|
|
2712
|
+
}
|
|
2713
|
+
const outputFound = await this.storage.findOutput(currentOutput.txid, currentOutput.outputIndex, topic, undefined, true);
|
|
2714
|
+
const foundNode = await hydrator(outputFound, currentOutput, depth + 1, nextAncestors);
|
|
2715
|
+
if (foundNode !== undefined)
|
|
2716
|
+
return foundNode;
|
|
1533
2717
|
}
|
|
1534
2718
|
}
|
|
1535
2719
|
else {
|
|
2720
|
+
if (outputIndex >= correctTx.outputs.length) {
|
|
2721
|
+
throw new Error('Requested GASP output index does not exist');
|
|
2722
|
+
}
|
|
1536
2723
|
const rawTx = correctTx.toHex();
|
|
1537
2724
|
const node = {
|
|
1538
2725
|
rawTx,
|
|
@@ -1544,11 +2731,16 @@ export class Engine {
|
|
|
1544
2731
|
}
|
|
1545
2732
|
return node;
|
|
1546
2733
|
}
|
|
1547
|
-
|
|
2734
|
+
return undefined;
|
|
1548
2735
|
};
|
|
1549
|
-
const
|
|
1550
|
-
const
|
|
1551
|
-
|
|
2736
|
+
const separator = graphID.lastIndexOf('.');
|
|
2737
|
+
const rootTxid = graphID.slice(0, separator);
|
|
2738
|
+
const rootOutputIndex = Number(graphID.slice(separator + 1));
|
|
2739
|
+
const output = await this.storage.findOutput(rootTxid, rootOutputIndex, topic, undefined, true);
|
|
2740
|
+
const node = await hydrator(output, { txid: rootTxid, outputIndex: rootOutputIndex }, 0, new Set());
|
|
2741
|
+
if (node === undefined)
|
|
2742
|
+
throw new Error('Unable to find output associated with your request!');
|
|
2743
|
+
return node;
|
|
1552
2744
|
}
|
|
1553
2745
|
/**
|
|
1554
2746
|
* Traverse and return the history of a UTXO.
|
|
@@ -1564,8 +2756,19 @@ export class Engine {
|
|
|
1564
2756
|
* @param {number} [currentDepth=0] - The current depth of the traversal relative to the top-level UTXO.
|
|
1565
2757
|
*
|
|
1566
2758
|
* @returns {Promise<Output | undefined>} - A promise that resolves to the output history if found, or undefined if not.
|
|
1567
|
-
|
|
2759
|
+
*/
|
|
1568
2760
|
async getUTXOHistory(output, historySelector, currentDepth = 0, context = this.createUTXOHistoryHydrationContext()) {
|
|
2761
|
+
assertNonnegativeInteger(currentDepth, 'Lookup history current depth');
|
|
2762
|
+
if (currentDepth > MAX_LOOKUP_HISTORY_DEPTH) {
|
|
2763
|
+
throw new RangeError('Lookup history current depth is too large');
|
|
2764
|
+
}
|
|
2765
|
+
if (historySelector !== undefined &&
|
|
2766
|
+
typeof historySelector !== 'function' &&
|
|
2767
|
+
(!Number.isSafeInteger(historySelector) ||
|
|
2768
|
+
historySelector < -1 ||
|
|
2769
|
+
historySelector > MAX_LOOKUP_HISTORY_DEPTH)) {
|
|
2770
|
+
throw new TypeError(`Lookup history must be -1 through ${MAX_LOOKUP_HISTORY_DEPTH}, or a function`);
|
|
2771
|
+
}
|
|
1569
2772
|
// If we have an output but no history selector, just return the output.
|
|
1570
2773
|
if (historySelector === undefined) {
|
|
1571
2774
|
return output;
|
|
@@ -1598,43 +2801,114 @@ export class Engine {
|
|
|
1598
2801
|
*/
|
|
1599
2802
|
async deleteUTXODeep(output) {
|
|
1600
2803
|
try {
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
2804
|
+
const queue = [output];
|
|
2805
|
+
const processed = new Set();
|
|
2806
|
+
let relationCount = 0;
|
|
2807
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
2808
|
+
if (queue.length > MAX_HISTORY_PRUNE_OUTPUTS) {
|
|
2809
|
+
throw new RangeError('Historical output pruning exceeded its work budget');
|
|
2810
|
+
}
|
|
2811
|
+
const current = queue[cursor];
|
|
2812
|
+
this.assertStoredOutputRelations(current, 'Historical output');
|
|
2813
|
+
const currentKey = `${current.txid.toLowerCase()}.${current.outputIndex}.${current.topic}`;
|
|
2814
|
+
if (processed.has(currentKey))
|
|
2815
|
+
continue;
|
|
2816
|
+
processed.add(currentKey);
|
|
2817
|
+
// A retained descendant still depends on this output and therefore on
|
|
2818
|
+
// its ancestors. Do not prune through a node that remains referenced.
|
|
2819
|
+
if (current.consumedBy.length !== 0)
|
|
2820
|
+
continue;
|
|
2821
|
+
await this.storage.deleteOutput(current.txid, current.outputIndex, current.topic);
|
|
2822
|
+
for (const lookupService of Object.values(this.lookupServices)) {
|
|
1606
2823
|
try {
|
|
1607
|
-
await
|
|
2824
|
+
await lookupService.outputNoLongerRetainedInHistory?.(current.txid, current.outputIndex, current.topic);
|
|
1608
2825
|
}
|
|
1609
|
-
catch (
|
|
1610
|
-
|
|
1611
|
-
this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`);
|
|
2826
|
+
catch (error) {
|
|
2827
|
+
this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${current.txid}.${current.outputIndex}: ${error}`);
|
|
1612
2828
|
}
|
|
1613
2829
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
2830
|
+
for (const outputIdentifier of current.outputsConsumed) {
|
|
2831
|
+
relationCount++;
|
|
2832
|
+
if (relationCount > MAX_OUTPUT_RELATIONS) {
|
|
2833
|
+
throw new RangeError('Historical output pruning exceeded its relation budget');
|
|
2834
|
+
}
|
|
2835
|
+
const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, current.topic);
|
|
2836
|
+
if (staleOutput === null || staleOutput === undefined)
|
|
2837
|
+
continue;
|
|
2838
|
+
this.assertStoredOutputRelations(staleOutput, 'Historical ancestor output');
|
|
2839
|
+
if (staleOutput.txid.toLowerCase() !== outputIdentifier.txid.toLowerCase() ||
|
|
2840
|
+
staleOutput.outputIndex !== outputIdentifier.outputIndex ||
|
|
2841
|
+
staleOutput.topic !== current.topic) {
|
|
2842
|
+
throw new TypeError('Storage returned an unbound historical ancestor output');
|
|
2843
|
+
}
|
|
2844
|
+
const remainingReferences = staleOutput.consumedBy.filter(reference => reference.txid.toLowerCase() !== current.txid.toLowerCase() ||
|
|
2845
|
+
reference.outputIndex !== current.outputIndex);
|
|
2846
|
+
// If the current output was not a recorded consumer, it has no
|
|
2847
|
+
// authority to prune this ancestor or any of its dependencies.
|
|
2848
|
+
if (remainingReferences.length === staleOutput.consumedBy.length)
|
|
2849
|
+
continue;
|
|
2850
|
+
await this.storage.updateConsumedBy(staleOutput.txid, staleOutput.outputIndex, staleOutput.topic, remainingReferences);
|
|
2851
|
+
staleOutput.consumedBy = remainingReferences;
|
|
2852
|
+
if (remainingReferences.length === 0)
|
|
2853
|
+
queue.push(staleOutput);
|
|
1631
2854
|
}
|
|
1632
|
-
|
|
1633
|
-
return await this.deleteUTXODeep(staleOutput);
|
|
1634
|
-
}));
|
|
2855
|
+
}
|
|
1635
2856
|
}
|
|
1636
2857
|
catch (error) {
|
|
1637
|
-
throw new Error(
|
|
2858
|
+
throw new Error('Failed to delete all stale outputs', { cause: error });
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
assertStoredOutputRelations(value, label) {
|
|
2862
|
+
if (typeof value !== 'object' || value === null)
|
|
2863
|
+
throw new TypeError(`${label} is invalid`);
|
|
2864
|
+
const candidate = value;
|
|
2865
|
+
assertHash(candidate.txid, `${label} txid`);
|
|
2866
|
+
assertOutputIndex(candidate.outputIndex, `${label} output index`);
|
|
2867
|
+
assertTopic(candidate.topic, `${label} topic`);
|
|
2868
|
+
if (!Array.isArray(candidate.outputScript) ||
|
|
2869
|
+
candidate.outputScript.length > MAX_STORED_OUTPUT_SCRIPT_BYTES ||
|
|
2870
|
+
candidate.outputScript.some((byte) => !Number.isInteger(byte) || byte < 0 || byte > 255) ||
|
|
2871
|
+
!Number.isSafeInteger(candidate.satoshis) ||
|
|
2872
|
+
candidate.satoshis < 0 ||
|
|
2873
|
+
typeof candidate.spent !== 'boolean') {
|
|
2874
|
+
throw new TypeError(`${label} has invalid output data`);
|
|
2875
|
+
}
|
|
2876
|
+
if (candidate.blockHeight !== undefined) {
|
|
2877
|
+
assertNonnegativeInteger(candidate.blockHeight, `${label} block height`);
|
|
2878
|
+
}
|
|
2879
|
+
if (candidate.score !== undefined) {
|
|
2880
|
+
assertNonnegativeInteger(candidate.score, `${label} score`);
|
|
2881
|
+
}
|
|
2882
|
+
if (!Array.isArray(candidate.consumedBy) ||
|
|
2883
|
+
candidate.consumedBy.length > MAX_OUTPUT_RELATIONS ||
|
|
2884
|
+
!Array.isArray(candidate.outputsConsumed) ||
|
|
2885
|
+
candidate.outputsConsumed.length > MAX_OUTPUT_RELATIONS) {
|
|
2886
|
+
throw new TypeError(`${label} has invalid or oversized relations`);
|
|
2887
|
+
}
|
|
2888
|
+
for (const [relationType, relations] of [
|
|
2889
|
+
['consumedBy', candidate.consumedBy],
|
|
2890
|
+
['outputsConsumed', candidate.outputsConsumed]
|
|
2891
|
+
]) {
|
|
2892
|
+
const seen = new Set();
|
|
2893
|
+
for (const [index, relation] of relations.entries()) {
|
|
2894
|
+
if (typeof relation !== 'object' || relation === null) {
|
|
2895
|
+
throw new TypeError(`${label} ${relationType}[${index}] is invalid`);
|
|
2896
|
+
}
|
|
2897
|
+
assertHash(relation.txid, `${label} ${relationType}[${index}] txid`);
|
|
2898
|
+
assertOutputIndex(relation.outputIndex, `${label} ${relationType}[${index}] output index`);
|
|
2899
|
+
const key = `${relation.txid.toLowerCase()}.${relation.outputIndex}`;
|
|
2900
|
+
if (seen.has(key))
|
|
2901
|
+
throw new TypeError(`${label} has duplicate ${relationType} relations`);
|
|
2902
|
+
seen.add(key);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
assertStoredOutputMatches(value, expected, label) {
|
|
2907
|
+
this.assertStoredOutputRelations(value, label);
|
|
2908
|
+
if (value.txid.toLowerCase() !== expected.txid.toLowerCase() ||
|
|
2909
|
+
value.outputIndex !== expected.outputIndex ||
|
|
2910
|
+
value.topic !== expected.topic) {
|
|
2911
|
+
throw new TypeError(`${label} does not match the requested outpoint and topic`);
|
|
1638
2912
|
}
|
|
1639
2913
|
}
|
|
1640
2914
|
/**
|
|
@@ -1647,47 +2921,104 @@ export class Engine {
|
|
|
1647
2921
|
* @param proof for txid
|
|
1648
2922
|
*/
|
|
1649
2923
|
updateInputProofs(tx, txid, proof) {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
2924
|
+
const pending = [tx];
|
|
2925
|
+
const visited = new WeakSet();
|
|
2926
|
+
let found = false;
|
|
2927
|
+
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
2928
|
+
if (pending.length > MAX_PROOF_UPDATE_OUTPUTS) {
|
|
2929
|
+
throw new RangeError('Merkle-proof source traversal exceeded its work budget');
|
|
2930
|
+
}
|
|
2931
|
+
const current = pending[cursor];
|
|
2932
|
+
if (visited.has(current))
|
|
2933
|
+
continue;
|
|
2934
|
+
visited.add(current);
|
|
2935
|
+
if (current.id('hex') === txid) {
|
|
2936
|
+
current.merklePath = proof;
|
|
2937
|
+
found = true;
|
|
1663
2938
|
continue;
|
|
1664
|
-
|
|
2939
|
+
}
|
|
2940
|
+
// A mined transaction's source graph is no longer part of its BEEF. Do
|
|
2941
|
+
// not replace an unrelated transaction's proof with an ancestor proof.
|
|
2942
|
+
if (current.merklePath !== undefined)
|
|
2943
|
+
continue;
|
|
2944
|
+
for (const input of current.inputs) {
|
|
2945
|
+
if (typeof input.sourceTransaction === 'object') {
|
|
2946
|
+
if (pending.length >= MAX_PROOF_UPDATE_OUTPUTS) {
|
|
2947
|
+
throw new RangeError('Merkle-proof source traversal exceeded its work budget');
|
|
2948
|
+
}
|
|
2949
|
+
pending.push(input.sourceTransaction);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
1665
2952
|
}
|
|
2953
|
+
return found;
|
|
1666
2954
|
}
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
async updateMerkleProof(output, txid, proof) {
|
|
1675
|
-
if (output.beef === undefined) {
|
|
1676
|
-
throw new Error('Output must have associated transaction BEEF!');
|
|
2955
|
+
prepareMerkleProofUpdate(output, txid, proof) {
|
|
2956
|
+
this.assertStoredOutputRelations(output, 'Merkle-proof output');
|
|
2957
|
+
if (!Array.isArray(output.beef) ||
|
|
2958
|
+
output.beef.length === 0 ||
|
|
2959
|
+
output.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
|
|
2960
|
+
output.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
|
|
2961
|
+
throw new TypeError('Merkle-proof output must have bounded transaction BEEF');
|
|
1677
2962
|
}
|
|
1678
2963
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2964
|
+
if (tx.id('hex').toLowerCase() !== output.txid.toLowerCase()) {
|
|
2965
|
+
throw new TypeError('Stored Merkle-proof output BEEF does not match its transaction ID');
|
|
2966
|
+
}
|
|
2967
|
+
if (output.outputIndex >= tx.outputs.length) {
|
|
2968
|
+
throw new TypeError('Stored Merkle-proof output index is outside its transaction');
|
|
2969
|
+
}
|
|
2970
|
+
if (!this.updateInputProofs(tx, txid, proof)) {
|
|
2971
|
+
throw new TypeError('Stored descendant BEEF does not contain the proven transaction');
|
|
2972
|
+
}
|
|
2973
|
+
return tx.toBEEF();
|
|
2974
|
+
}
|
|
2975
|
+
async collectMerkleProofOutputs(initialOutputs) {
|
|
2976
|
+
if (initialOutputs.length > MAX_PROOF_UPDATE_OUTPUTS) {
|
|
2977
|
+
throw new RangeError('Merkle-proof output set exceeded its work budget');
|
|
2978
|
+
}
|
|
2979
|
+
const pending = [...initialOutputs];
|
|
2980
|
+
const collected = [];
|
|
2981
|
+
const seenOutputs = new Set();
|
|
2982
|
+
const fetchedTransactions = new Set();
|
|
2983
|
+
let totalBeefBytes = 0;
|
|
2984
|
+
for (let cursor = 0; cursor < pending.length; cursor++) {
|
|
2985
|
+
if (pending.length > MAX_PROOF_UPDATE_OUTPUTS) {
|
|
2986
|
+
throw new RangeError('Merkle-proof descendant traversal exceeded its work budget');
|
|
2987
|
+
}
|
|
2988
|
+
const output = pending[cursor];
|
|
2989
|
+
this.assertStoredOutputRelations(output, 'Merkle-proof output');
|
|
2990
|
+
const key = `${output.txid.toLowerCase()}.${output.outputIndex}.${output.topic}`;
|
|
2991
|
+
if (seenOutputs.has(key))
|
|
2992
|
+
continue;
|
|
2993
|
+
seenOutputs.add(key);
|
|
2994
|
+
if (!Array.isArray(output.beef)) {
|
|
2995
|
+
throw new TypeError('Merkle-proof graph output is missing transaction BEEF');
|
|
2996
|
+
}
|
|
2997
|
+
totalBeefBytes += output.beef.length;
|
|
2998
|
+
if (totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
|
|
2999
|
+
throw new RangeError('Merkle-proof graph BEEF exceeded its total byte budget');
|
|
3000
|
+
}
|
|
3001
|
+
collected.push(output);
|
|
3002
|
+
for (const relation of output.consumedBy) {
|
|
3003
|
+
const transactionKey = relation.txid.toLowerCase();
|
|
3004
|
+
if (fetchedTransactions.has(transactionKey))
|
|
3005
|
+
continue;
|
|
3006
|
+
fetchedTransactions.add(transactionKey);
|
|
3007
|
+
const remaining = MAX_PROOF_UPDATE_OUTPUTS - pending.length;
|
|
3008
|
+
const descendants = await this.storage.findOutputsForTransaction(relation.txid, true, remaining + 1);
|
|
3009
|
+
if (!Array.isArray(descendants) || descendants.length > remaining) {
|
|
3010
|
+
throw new TypeError('Storage returned an invalid or oversized proof descendant set');
|
|
3011
|
+
}
|
|
3012
|
+
for (const descendant of descendants) {
|
|
3013
|
+
this.assertStoredOutputRelations(descendant, 'Merkle-proof descendant output');
|
|
3014
|
+
if (descendant.txid.toLowerCase() !== transactionKey) {
|
|
3015
|
+
throw new TypeError('Storage returned an unbound Merkle-proof descendant output');
|
|
3016
|
+
}
|
|
3017
|
+
pending.push(descendant);
|
|
3018
|
+
}
|
|
1689
3019
|
}
|
|
1690
3020
|
}
|
|
3021
|
+
return collected;
|
|
1691
3022
|
}
|
|
1692
3023
|
/**
|
|
1693
3024
|
* Recursively prune UTXOs when an incoming Merkle Proof is received.
|
|
@@ -1697,17 +3028,49 @@ export class Engine {
|
|
|
1697
3028
|
* @param blockHeight - The block height associated with the incoming merkle proof.
|
|
1698
3029
|
*/
|
|
1699
3030
|
async handleNewMerkleProof(txid, proof, blockHeight) {
|
|
1700
|
-
|
|
1701
|
-
if (
|
|
1702
|
-
throw new Error('
|
|
3031
|
+
assertHash(txid, 'Merkle proof transaction ID');
|
|
3032
|
+
if (this.chainTracker === 'scripts only') {
|
|
3033
|
+
throw new Error('Merkle proof ingestion requires a ChainTracker');
|
|
3034
|
+
}
|
|
3035
|
+
if (blockHeight !== undefined) {
|
|
3036
|
+
assertNonnegativeInteger(blockHeight, 'Merkle proof block height');
|
|
3037
|
+
if (blockHeight !== proof.blockHeight) {
|
|
3038
|
+
throw new Error('Merkle proof block height does not match the supplied proof');
|
|
3039
|
+
}
|
|
1703
3040
|
}
|
|
1704
3041
|
const proofMetadata = extractMerkleProofMetadata(txid, proof);
|
|
3042
|
+
if (proofMetadata === undefined) {
|
|
3043
|
+
throw new Error('Merkle proof does not contain the claimed transaction');
|
|
3044
|
+
}
|
|
3045
|
+
if (!(await proof.verify(txid, this.chainTracker))) {
|
|
3046
|
+
throw new Error('Merkle proof is not valid for the claimed block height');
|
|
3047
|
+
}
|
|
3048
|
+
const outputs = await this.storage.findOutputsForTransaction(txid, true, MAX_PROOF_UPDATE_OUTPUTS + 1);
|
|
3049
|
+
if (!Array.isArray(outputs) ||
|
|
3050
|
+
outputs.length === 0 ||
|
|
3051
|
+
outputs.length > MAX_PROOF_UPDATE_OUTPUTS) {
|
|
3052
|
+
throw new Error('Could not find matching transaction outputs for proof ingest!');
|
|
3053
|
+
}
|
|
3054
|
+
for (const output of outputs) {
|
|
3055
|
+
this.assertStoredOutputRelations(output, 'Merkle-proof root output');
|
|
3056
|
+
if (output.txid.toLowerCase() !== txid.toLowerCase()) {
|
|
3057
|
+
throw new TypeError('Storage returned an unbound Merkle-proof root output');
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
const graphOutputs = await this.collectMerkleProofOutputs(outputs);
|
|
3061
|
+
const preparedUpdates = graphOutputs.map(output => ({
|
|
3062
|
+
output,
|
|
3063
|
+
beef: this.prepareMerkleProofUpdate(output, txid, proof)
|
|
3064
|
+
}));
|
|
3065
|
+
for (const update of preparedUpdates) {
|
|
3066
|
+
await this.storage.updateTransactionBEEF(update.output.txid, update.beef);
|
|
3067
|
+
update.output.beef = update.beef;
|
|
3068
|
+
}
|
|
1705
3069
|
const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight;
|
|
1706
3070
|
const resolvedBlockHash = resolvedBlockHeight === undefined
|
|
1707
3071
|
? undefined
|
|
1708
3072
|
: await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot);
|
|
1709
3073
|
for (const output of outputs) {
|
|
1710
|
-
await this.updateMerkleProof(output, txid, proof);
|
|
1711
3074
|
// Add the associated blockHeight
|
|
1712
3075
|
if (resolvedBlockHeight !== undefined) {
|
|
1713
3076
|
output.blockHeight = resolvedBlockHeight;
|
|
@@ -1715,7 +3078,6 @@ export class Engine {
|
|
|
1715
3078
|
}
|
|
1716
3079
|
if (output.beef !== undefined) {
|
|
1717
3080
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1718
|
-
this.updateInputProofs(tx, txid, proof);
|
|
1719
3081
|
await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash);
|
|
1720
3082
|
}
|
|
1721
3083
|
if (resolvedBlockHeight !== undefined) {
|
|
@@ -1739,10 +3101,10 @@ export class Engine {
|
|
|
1739
3101
|
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported topic managers and their metadata
|
|
1740
3102
|
*/
|
|
1741
3103
|
async listTopicManagers() {
|
|
1742
|
-
const result =
|
|
1743
|
-
for (const t
|
|
3104
|
+
const result = Object.create(null);
|
|
3105
|
+
for (const t of Object.keys(this.managers)) {
|
|
1744
3106
|
try {
|
|
1745
|
-
result[t] = await this.managers[t].getMetaData();
|
|
3107
|
+
result[t] = validateComponentMetadata(await this.managers[t].getMetaData());
|
|
1746
3108
|
}
|
|
1747
3109
|
catch (e) {
|
|
1748
3110
|
this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`);
|
|
@@ -1760,10 +3122,10 @@ export class Engine {
|
|
|
1760
3122
|
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported lookup services and their metadata
|
|
1761
3123
|
*/
|
|
1762
3124
|
async listLookupServiceProviders() {
|
|
1763
|
-
const result =
|
|
1764
|
-
for (const ls
|
|
3125
|
+
const result = Object.create(null);
|
|
3126
|
+
for (const ls of Object.keys(this.lookupServices)) {
|
|
1765
3127
|
try {
|
|
1766
|
-
result[ls] = await this.lookupServices[ls].getMetaData();
|
|
3128
|
+
result[ls] = validateComponentMetadata(await this.lookupServices[ls].getMetaData());
|
|
1767
3129
|
}
|
|
1768
3130
|
catch (e) {
|
|
1769
3131
|
this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`);
|
|
@@ -1781,8 +3143,13 @@ export class Engine {
|
|
|
1781
3143
|
* @returns {Promise<string>} - the documentation for the topic manager
|
|
1782
3144
|
*/
|
|
1783
3145
|
async getDocumentationForTopicManager(manager) {
|
|
1784
|
-
const
|
|
1785
|
-
|
|
3146
|
+
const service = typeof manager === 'string' && Object.prototype.hasOwnProperty.call(this.managers, manager)
|
|
3147
|
+
? this.managers[manager]
|
|
3148
|
+
: undefined;
|
|
3149
|
+
const documentation = await service?.getDocumentation?.();
|
|
3150
|
+
return documentation === undefined
|
|
3151
|
+
? 'No documentation found!'
|
|
3152
|
+
: validateComponentDocumentation(documentation);
|
|
1786
3153
|
}
|
|
1787
3154
|
/**
|
|
1788
3155
|
* Run a query to get the documentation for a particular lookup service
|
|
@@ -1790,8 +3157,14 @@ export class Engine {
|
|
|
1790
3157
|
* @returns {Promise<string>} - the documentation for the lookup service
|
|
1791
3158
|
*/
|
|
1792
3159
|
async getDocumentationForLookupServiceProvider(provider) {
|
|
1793
|
-
const
|
|
1794
|
-
|
|
3160
|
+
const service = typeof provider === 'string' &&
|
|
3161
|
+
Object.prototype.hasOwnProperty.call(this.lookupServices, provider)
|
|
3162
|
+
? this.lookupServices[provider]
|
|
3163
|
+
: undefined;
|
|
3164
|
+
const documentation = await service?.getDocumentation?.();
|
|
3165
|
+
return documentation === undefined
|
|
3166
|
+
? 'No documentation found!'
|
|
3167
|
+
: validateComponentDocumentation(documentation);
|
|
1795
3168
|
}
|
|
1796
3169
|
/**
|
|
1797
3170
|
* Validates a URL to ensure it does not match disallowed patterns:
|
|
@@ -1805,17 +3178,16 @@ export class Engine {
|
|
|
1805
3178
|
*/
|
|
1806
3179
|
isValidUrl(url) {
|
|
1807
3180
|
try {
|
|
1808
|
-
const parsedUrl = new URL(url);
|
|
1809
|
-
// Disallow http:
|
|
1810
|
-
if (parsedUrl.protocol === 'http:') {
|
|
1811
|
-
return false;
|
|
1812
|
-
}
|
|
3181
|
+
const parsedUrl = new URL(normalizePeerEndpoint(url));
|
|
1813
3182
|
// Disallow localhost with or without a port
|
|
1814
3183
|
if (/^localhost(:\d+)?$/i.test(parsedUrl.hostname)) {
|
|
1815
3184
|
return false;
|
|
1816
3185
|
}
|
|
1817
3186
|
// Disallow internal and non-routable IP addresses
|
|
1818
3187
|
const ipAddress = parsedUrl.hostname;
|
|
3188
|
+
const literal = /^[\d.]+$/.test(ipAddress) || ipAddress.includes(':');
|
|
3189
|
+
if (literal && !isPublicNetworkAddress(ipAddress))
|
|
3190
|
+
return false;
|
|
1819
3191
|
// Regex for non-routable IPv4 IPs
|
|
1820
3192
|
const nonRoutableIpv4Patterns = [
|
|
1821
3193
|
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
|
|
@@ -1825,7 +3197,7 @@ export class Engine {
|
|
|
1825
3197
|
/^0\.0\.0\.0$/ // Non-routable address
|
|
1826
3198
|
];
|
|
1827
3199
|
// Check for IPv4 matches
|
|
1828
|
-
if (nonRoutableIpv4Patterns.some(
|
|
3200
|
+
if (nonRoutableIpv4Patterns.some(pattern => pattern.test(ipAddress))) {
|
|
1829
3201
|
return false;
|
|
1830
3202
|
}
|
|
1831
3203
|
// Check for non-routable IPv6 addresses explicitly
|