@bsv/overlay 2.3.0 → 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/LICENSES/Open-BSV-License-4-standard-pre-2026.txt +28 -0
- package/README.md +171 -1
- package/THIRD_PARTY_NOTICES.md +49 -0
- package/dist/cjs/mod.d.ts +4 -1
- 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 +13 -5
- 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 +38 -13
- package/dist/cjs/src/Engine.d.ts.map +1 -1
- package/dist/cjs/src/Engine.js +1735 -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/TopicManager.d.ts +13 -3
- package/dist/cjs/src/TopicManager.d.ts.map +1 -1
- 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 +1720 -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 +4 -1
- 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 +38 -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/TopicManager.d.ts +13 -3
- package/dist/types/src/TopicManager.d.ts.map +1 -1
- 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 +14 -6
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)}`);
|
|
@@ -483,6 +867,22 @@ export class Engine {
|
|
|
483
867
|
}
|
|
484
868
|
this.endTime(`broadcast_${txid.substring(0, 10)}`);
|
|
485
869
|
}
|
|
870
|
+
async abortProvisionalAdmissions(validations, failedTopics, beef) {
|
|
871
|
+
await Promise.all(validations.map(async (validation) => {
|
|
872
|
+
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
873
|
+
return;
|
|
874
|
+
const outputs = validation.admissibleOutputs.outputsToAdmit;
|
|
875
|
+
const abort = this.managers[validation.topic].abortAdmissibleOutputs;
|
|
876
|
+
if (outputs.length === 0 || abort === undefined)
|
|
877
|
+
return;
|
|
878
|
+
try {
|
|
879
|
+
await abort.call(this.managers[validation.topic], beef, outputs);
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
this.logger.error(`Error aborting provisional topic admission: topic=${serializeLogValue(validation.topic)} error=${serializeErrorForLog(error)}`);
|
|
883
|
+
}
|
|
884
|
+
}));
|
|
885
|
+
}
|
|
486
886
|
async notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues) {
|
|
487
887
|
if (typeof lookupService.outputSpent !== 'function')
|
|
488
888
|
return;
|
|
@@ -534,29 +934,24 @@ export class Engine {
|
|
|
534
934
|
async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
|
|
535
935
|
if (output === null)
|
|
536
936
|
return;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}));
|
|
547
|
-
}
|
|
548
|
-
catch (error) {
|
|
549
|
-
this.logger.error('Error marking UTXO as spent:', error);
|
|
550
|
-
}
|
|
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
|
+
}));
|
|
551
946
|
}
|
|
552
947
|
async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
|
|
553
|
-
|
|
948
|
+
for (const validation of validations) {
|
|
554
949
|
if (validation.isDupe || failedTopics.has(validation.topic))
|
|
555
|
-
|
|
556
|
-
|
|
950
|
+
continue;
|
|
951
|
+
for (const output of validation.previousOutputs) {
|
|
557
952
|
await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
560
955
|
}
|
|
561
956
|
classifyPreviousCoins(tx, validation) {
|
|
562
957
|
const outputsConsumed = [];
|
|
@@ -586,8 +981,10 @@ export class Engine {
|
|
|
586
981
|
this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
587
982
|
await Promise.all(outputs.map(async (coin) => {
|
|
588
983
|
const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
|
|
589
|
-
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');
|
|
590
986
|
await this.deleteUTXODeep(output);
|
|
987
|
+
}
|
|
591
988
|
}));
|
|
592
989
|
this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
|
|
593
990
|
}
|
|
@@ -650,7 +1047,17 @@ export class Engine {
|
|
|
650
1047
|
const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
|
|
651
1048
|
if (storedOutput === undefined || storedOutput === null)
|
|
652
1049
|
return;
|
|
653
|
-
|
|
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());
|
|
654
1061
|
await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
|
|
655
1062
|
}
|
|
656
1063
|
async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
|
|
@@ -659,7 +1066,7 @@ export class Engine {
|
|
|
659
1066
|
await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
|
|
660
1067
|
steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
|
|
661
1068
|
const newUTXOs = [];
|
|
662
|
-
|
|
1069
|
+
for (const outputIndex of validation.admissibleOutputs.outputsToAdmit) {
|
|
663
1070
|
await this.admitOutput(outputIndex, {
|
|
664
1071
|
tx,
|
|
665
1072
|
txid,
|
|
@@ -669,7 +1076,7 @@ export class Engine {
|
|
|
669
1076
|
newUTXOs,
|
|
670
1077
|
offChainValues
|
|
671
1078
|
});
|
|
672
|
-
}
|
|
1079
|
+
}
|
|
673
1080
|
this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
|
|
674
1081
|
const appliedRecord = await this.buildAppliedTransactionRecord(tx);
|
|
675
1082
|
await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
|
|
@@ -690,20 +1097,14 @@ export class Engine {
|
|
|
690
1097
|
const topic = validation.topic;
|
|
691
1098
|
if (dupeTopics.has(topic) || failedTopics.has(topic))
|
|
692
1099
|
continue;
|
|
693
|
-
|
|
694
|
-
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
695
|
-
}
|
|
696
|
-
catch (error) {
|
|
697
|
-
this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
|
|
698
|
-
}
|
|
1100
|
+
await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
|
|
699
1101
|
}
|
|
700
1102
|
}
|
|
701
1103
|
async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
|
|
702
1104
|
this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
703
1105
|
const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
|
|
704
1106
|
!dupeTopics.has(topic) &&
|
|
705
|
-
(steak[topic].outputsToAdmit.length !== 0 ||
|
|
706
|
-
steak[topic].coinsRemoved?.length !== 0));
|
|
1107
|
+
(steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0));
|
|
707
1108
|
if (relevantTopics.length === 0) {
|
|
708
1109
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
709
1110
|
return;
|
|
@@ -717,8 +1118,8 @@ export class Engine {
|
|
|
717
1118
|
resolver: new LookupResolver(resolverConfig)
|
|
718
1119
|
};
|
|
719
1120
|
}
|
|
720
|
-
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
|
|
721
1121
|
try {
|
|
1122
|
+
const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
|
|
722
1123
|
await shipBroadcaster.broadcast(tx);
|
|
723
1124
|
}
|
|
724
1125
|
catch (error) {
|
|
@@ -726,6 +1127,68 @@ export class Engine {
|
|
|
726
1127
|
}
|
|
727
1128
|
this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
|
|
728
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
|
+
}
|
|
729
1192
|
/**
|
|
730
1193
|
* Submits a transaction for processing by Overlay Services.
|
|
731
1194
|
* @param {TaggedBEEF} taggedBEEF - The transaction to process
|
|
@@ -733,20 +1196,68 @@ export class Engine {
|
|
|
733
1196
|
* @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
|
|
734
1197
|
* @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
|
|
735
1198
|
*
|
|
736
|
-
* 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.
|
|
737
1201
|
*
|
|
738
1202
|
* @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
|
|
739
1203
|
*/
|
|
740
1204
|
async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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`);
|
|
745
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`);
|
|
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);
|
|
746
1253
|
// Validate the transaction SPV information
|
|
747
1254
|
const tx = Transaction.fromBEEF(taggedBEEF.beef);
|
|
748
1255
|
const txid = tx.id('hex');
|
|
749
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.
|
|
750
1261
|
if (mode !== 'historical-tx-no-spv') {
|
|
751
1262
|
this.startTime(`chainTracker_${txid.substring(0, 10)}`);
|
|
752
1263
|
const txValid = await tx.verify(this.chainTracker);
|
|
@@ -788,31 +1299,56 @@ export class Engine {
|
|
|
788
1299
|
// abort/release their held inputs, so broadcasting it anyway would
|
|
789
1300
|
// desync their wallets from the chain.
|
|
790
1301
|
const anyTopicAccepted = validations.some(validation => this.isTopicSubmissionAccepted(validation, failedTopics));
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (onSteakReady !== undefined) {
|
|
794
|
-
onSteakReady(steak);
|
|
1302
|
+
try {
|
|
1303
|
+
await this.broadcastAcceptedSubmission(tx, txid, mode, anyTopicAccepted);
|
|
795
1304
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
await this.
|
|
801
|
-
|
|
802
|
-
dupeTopics,
|
|
803
|
-
failedTopics,
|
|
1305
|
+
catch (error) {
|
|
1306
|
+
await this.abortProvisionalAdmissions(validations, failedTopics, taggedBEEF.beef);
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
const admissionSteak = await this.acknowledgeOverlayAdmission({
|
|
1310
|
+
taggedBEEF,
|
|
804
1311
|
steak,
|
|
805
1312
|
tx,
|
|
806
1313
|
txid,
|
|
807
|
-
|
|
808
|
-
offChainValues
|
|
1314
|
+
mode,
|
|
1315
|
+
offChainValues,
|
|
1316
|
+
validations,
|
|
1317
|
+
failedTopics,
|
|
1318
|
+
anyTopicAccepted,
|
|
1319
|
+
onSteakReady
|
|
809
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);
|
|
810
1347
|
// If we don't have an advertiser or we are dealing with historical transactions, just return the steak
|
|
811
|
-
if (this.
|
|
1348
|
+
if (this.shouldSkipPropagation(mode)) {
|
|
812
1349
|
return steak;
|
|
813
1350
|
}
|
|
814
1351
|
await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
|
|
815
|
-
// Immediately return from the function without waiting for the promises to resolve.
|
|
816
1352
|
return steak;
|
|
817
1353
|
}
|
|
818
1354
|
/**
|
|
@@ -821,13 +1357,23 @@ export class Engine {
|
|
|
821
1357
|
* @returns The answer to the question
|
|
822
1358
|
*/
|
|
823
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');
|
|
824
1364
|
// Validate a lookup service for the provider is found
|
|
825
|
-
const lookupService = this.lookupServices
|
|
1365
|
+
const lookupService = Object.prototype.hasOwnProperty.call(this.lookupServices, lookupQuestion.service)
|
|
1366
|
+
? this.lookupServices[lookupQuestion.service]
|
|
1367
|
+
: undefined;
|
|
826
1368
|
if (lookupService === undefined || lookupService === null)
|
|
827
1369
|
throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
|
|
828
1370
|
const lookupResult = await lookupService.lookup(lookupQuestion);
|
|
829
|
-
|
|
830
|
-
|
|
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}`);
|
|
831
1377
|
}
|
|
832
1378
|
const hydrationContext = this.createUTXOHistoryHydrationContext();
|
|
833
1379
|
await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
|
|
@@ -848,9 +1394,7 @@ export class Engine {
|
|
|
848
1394
|
};
|
|
849
1395
|
})))
|
|
850
1396
|
.filter((output) => output !== null)
|
|
851
|
-
.map(({ beef, outputIndex, context }) =>
|
|
852
|
-
? { beef, outputIndex }
|
|
853
|
-
: { beef, outputIndex, context }));
|
|
1397
|
+
.map(({ beef, outputIndex, context }) => context === undefined ? { beef, outputIndex } : { beef, outputIndex, context });
|
|
854
1398
|
return {
|
|
855
1399
|
type: 'output-list',
|
|
856
1400
|
outputs: hydratedOutputs
|
|
@@ -858,11 +1402,88 @@ export class Engine {
|
|
|
858
1402
|
}
|
|
859
1403
|
createUTXOHistoryHydrationContext() {
|
|
860
1404
|
return {
|
|
861
|
-
outputCache: new Map()
|
|
1405
|
+
outputCache: new Map(),
|
|
1406
|
+
budgetedOutputs: new Set(),
|
|
1407
|
+
hydratedNodes: 0,
|
|
1408
|
+
relationCount: 0,
|
|
1409
|
+
totalBeefBytes: 0
|
|
862
1410
|
};
|
|
863
1411
|
}
|
|
864
1412
|
toOutputCacheKey(txid, outputIndex) {
|
|
865
|
-
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
|
+
}
|
|
866
1487
|
}
|
|
867
1488
|
async preloadOutputsWithBEEF(outpoints, context) {
|
|
868
1489
|
if (outpoints.length === 0) {
|
|
@@ -871,6 +1492,8 @@ export class Engine {
|
|
|
871
1492
|
const deduped = [];
|
|
872
1493
|
const seen = new Set();
|
|
873
1494
|
for (const outpoint of outpoints) {
|
|
1495
|
+
assertHash(outpoint.txid, 'Lookup outpoint txid');
|
|
1496
|
+
assertOutputIndex(outpoint.outputIndex, 'Lookup outpoint output index');
|
|
874
1497
|
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
|
|
875
1498
|
if (seen.has(cacheKey)) {
|
|
876
1499
|
continue;
|
|
@@ -883,12 +1506,25 @@ export class Engine {
|
|
|
883
1506
|
if (deduped.length === 0) {
|
|
884
1507
|
return;
|
|
885
1508
|
}
|
|
1509
|
+
if (context.outputCache.size + deduped.length > MAX_LOOKUP_HYDRATION_NODES) {
|
|
1510
|
+
throw new RangeError('Lookup output traversal exceeded its work budget');
|
|
1511
|
+
}
|
|
886
1512
|
const findOutputsByOutpoints = this.storage.findOutputsByOutpoints;
|
|
887
1513
|
if (typeof findOutputsByOutpoints === 'function') {
|
|
888
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)));
|
|
889
1519
|
const outputsByKey = new Map();
|
|
890
|
-
for (const output of outputs) {
|
|
891
|
-
|
|
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);
|
|
892
1528
|
}
|
|
893
1529
|
for (const outpoint of deduped) {
|
|
894
1530
|
const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
|
|
@@ -909,43 +1545,80 @@ export class Engine {
|
|
|
909
1545
|
context.outputCache.set(cacheKey, cached);
|
|
910
1546
|
}
|
|
911
1547
|
const output = await cached;
|
|
1548
|
+
if (output !== null && output !== undefined) {
|
|
1549
|
+
this.validateLookupOutput(output, 'Lookup output', { txid, outputIndex }, context);
|
|
1550
|
+
}
|
|
912
1551
|
return output ?? null;
|
|
913
1552
|
}
|
|
914
|
-
async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context) {
|
|
915
|
-
if (output.beef === undefined) {
|
|
916
|
-
throw new Error('Output must have associated transaction BEEF!');
|
|
917
|
-
}
|
|
1553
|
+
async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context, ancestors = new Set()) {
|
|
918
1554
|
let shouldTraverseHistory;
|
|
919
1555
|
if (typeof historySelector === 'number') {
|
|
920
1556
|
shouldTraverseHistory = currentDepth <= historySelector;
|
|
921
1557
|
}
|
|
922
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
|
+
}
|
|
923
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
|
+
}
|
|
924
1569
|
}
|
|
925
1570
|
if (shouldTraverseHistory === false) {
|
|
926
1571
|
return undefined;
|
|
927
1572
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
+
}
|
|
936
1589
|
const tx = Transaction.fromBEEF(output.beef);
|
|
937
1590
|
const inputIndexBySource = new Map();
|
|
938
1591
|
tx.inputs.forEach((candidateInput, index) => {
|
|
939
1592
|
const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
|
|
940
1593
|
? candidateInput.sourceTXID
|
|
941
1594
|
: candidateInput.sourceTransaction?.id('hex');
|
|
942
|
-
if (sourceTXID === undefined)
|
|
1595
|
+
if (sourceTXID === undefined)
|
|
943
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');
|
|
944
1600
|
}
|
|
945
|
-
inputIndexBySource.set(
|
|
1601
|
+
inputIndexBySource.set(key, index);
|
|
946
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
|
+
}
|
|
947
1620
|
for (const child of childNodes) {
|
|
948
|
-
const inputIndex = inputIndexBySource.get(
|
|
1621
|
+
const inputIndex = inputIndexBySource.get(this.toOutputCacheKey(child.output.txid, child.output.outputIndex));
|
|
949
1622
|
if (inputIndex === -1 || inputIndex == null) {
|
|
950
1623
|
continue;
|
|
951
1624
|
}
|
|
@@ -977,6 +1650,54 @@ export class Engine {
|
|
|
977
1650
|
* @throws Will throw an error if there are issues during the advertisement synchronization process.
|
|
978
1651
|
* @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
|
|
979
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
|
+
}
|
|
980
1701
|
async syncAdvertisements() {
|
|
981
1702
|
if (this.advertiser === undefined ||
|
|
982
1703
|
typeof this.hostingURL !== 'string' ||
|
|
@@ -985,6 +1706,7 @@ export class Engine {
|
|
|
985
1706
|
return;
|
|
986
1707
|
}
|
|
987
1708
|
const advertiser = this.advertiser;
|
|
1709
|
+
const normalizedHostingURL = normalizePeerEndpoint(this.hostingURL);
|
|
988
1710
|
// Step 1: Retrieve Current Configuration
|
|
989
1711
|
let configuredTopics = Object.keys(this.managers);
|
|
990
1712
|
let configuredServices = Object.keys(this.lookupServices);
|
|
@@ -994,13 +1716,13 @@ export class Engine {
|
|
|
994
1716
|
configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap');
|
|
995
1717
|
}
|
|
996
1718
|
// Step 2: Fetch Existing Advertisements
|
|
997
|
-
const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP');
|
|
998
|
-
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);
|
|
999
1721
|
// Step 3: Compare and Determine Actions
|
|
1000
1722
|
const requiredSHIPAdvertisements = new Set(configuredTopics);
|
|
1001
1723
|
const requiredSLAPAdvertisements = new Set(configuredServices);
|
|
1002
|
-
const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain ===
|
|
1003
|
-
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));
|
|
1004
1726
|
const shipsToRevoke = currentSHIPAdvertisements.filter(ad => !requiredSHIPAdvertisements.has(ad.topicOrService));
|
|
1005
1727
|
const slapsToRevoke = currentSLAPAdvertisements.filter(ad => !requiredSLAPAdvertisements.has(ad.topicOrService));
|
|
1006
1728
|
// Create needed SHIP/SLAP advertisements
|
|
@@ -1026,7 +1748,10 @@ export class Engine {
|
|
|
1026
1748
|
// Revoke all advertisements to revoke
|
|
1027
1749
|
try {
|
|
1028
1750
|
if (shipsToRevoke.length > 0 || slapsToRevoke.length > 0) {
|
|
1029
|
-
const taggedBEEF = await advertiser.revokeAdvertisements([
|
|
1751
|
+
const taggedBEEF = await advertiser.revokeAdvertisements([
|
|
1752
|
+
...shipsToRevoke,
|
|
1753
|
+
...slapsToRevoke
|
|
1754
|
+
]);
|
|
1030
1755
|
await this.submit(taggedBEEF);
|
|
1031
1756
|
}
|
|
1032
1757
|
}
|
|
@@ -1064,6 +1789,7 @@ export class Engine {
|
|
|
1064
1789
|
this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
|
|
1065
1790
|
try {
|
|
1066
1791
|
const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
|
|
1792
|
+
assertNonnegativeInteger(lastInteraction, 'Stored GASP last interaction');
|
|
1067
1793
|
const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
|
|
1068
1794
|
await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
|
|
1069
1795
|
if (gasp.lastInteraction > lastInteraction) {
|
|
@@ -1097,84 +1823,241 @@ export class Engine {
|
|
|
1097
1823
|
});
|
|
1098
1824
|
const endpointSet = new Set();
|
|
1099
1825
|
if (lookupAnswer.type === 'output-list') {
|
|
1100
|
-
lookupAnswer.outputs
|
|
1826
|
+
for (const output of lookupAnswer.outputs) {
|
|
1101
1827
|
try {
|
|
1102
1828
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1103
|
-
|
|
1104
|
-
if (
|
|
1105
|
-
|
|
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');
|
|
1106
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');
|
|
1842
|
+
}
|
|
1843
|
+
endpointSet.add(advertisement.domain);
|
|
1107
1844
|
}
|
|
1108
1845
|
catch (error) {
|
|
1109
1846
|
this.logger.error(advertisementErrorMessage, error);
|
|
1110
1847
|
}
|
|
1111
|
-
}
|
|
1848
|
+
}
|
|
1112
1849
|
}
|
|
1113
1850
|
syncEndpoints = Array.from(endpointSet);
|
|
1114
1851
|
}
|
|
1115
1852
|
if (!Array.isArray(syncEndpoints)) {
|
|
1116
1853
|
return [];
|
|
1117
1854
|
}
|
|
1118
|
-
|
|
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
|
+
}
|
|
1119
1930
|
}
|
|
1120
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
|
+
}
|
|
1121
1937
|
const tip = await this.storage.findTopicAnchorTip?.(topic);
|
|
1122
|
-
|
|
1938
|
+
if (tip !== undefined) {
|
|
1939
|
+
this.assertTopicAnchorTip(tip, topic, 'Stored BASM tip');
|
|
1940
|
+
return tip;
|
|
1941
|
+
}
|
|
1942
|
+
return {
|
|
1123
1943
|
topic,
|
|
1124
1944
|
blockHeight: -1,
|
|
1125
1945
|
tac: BASM_ZERO_HASH
|
|
1126
1946
|
};
|
|
1127
1947
|
}
|
|
1128
1948
|
async provideTopicAnchorRange(topic, fromHeight, toHeight) {
|
|
1949
|
+
this.assertSupportedBASMTopic(topic);
|
|
1129
1950
|
if (typeof this.storage.findTopicBlockAnchors !== 'function') {
|
|
1130
|
-
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
|
+
});
|
|
1131
1954
|
}
|
|
1132
|
-
if (!Number.
|
|
1955
|
+
if (!Number.isSafeInteger(fromHeight) ||
|
|
1956
|
+
!Number.isSafeInteger(toHeight) ||
|
|
1957
|
+
fromHeight < 0 ||
|
|
1958
|
+
toHeight < fromHeight) {
|
|
1133
1959
|
throw new Error('Invalid topic anchor range');
|
|
1134
1960
|
}
|
|
1135
1961
|
if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
|
|
1136
1962
|
throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`);
|
|
1137
1963
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
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 };
|
|
1142
1979
|
}
|
|
1143
1980
|
async provideAdmittedList(topic, blockHeight, blockHash) {
|
|
1981
|
+
this.assertSupportedBASMTopic(topic);
|
|
1144
1982
|
if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
|
|
1145
|
-
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);
|
|
1146
2009
|
}
|
|
1147
2010
|
return {
|
|
1148
2011
|
topic,
|
|
1149
2012
|
blockHeight,
|
|
1150
2013
|
blockHash,
|
|
1151
|
-
admitted
|
|
2014
|
+
admitted
|
|
1152
2015
|
};
|
|
1153
2016
|
}
|
|
1154
2017
|
async provideCompoundMerklePath(topic, blockHeight, txids) {
|
|
2018
|
+
this.assertSupportedBASMTopic(topic);
|
|
1155
2019
|
if (typeof this.storage.findTransactionMerklePaths !== 'function') {
|
|
1156
|
-
throw new TypeError('Storage does not support direct Merkle path lookup')
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
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
|
+
});
|
|
1160
2023
|
}
|
|
1161
|
-
|
|
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);
|
|
1162
2027
|
if (admitted !== undefined) {
|
|
1163
|
-
|
|
1164
|
-
|
|
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()));
|
|
1165
2034
|
if (missingAdmissions.length > 0) {
|
|
1166
2035
|
throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`);
|
|
1167
2036
|
}
|
|
1168
2037
|
}
|
|
1169
2038
|
const proofs = await this.storage.findTransactionMerklePaths(txids);
|
|
1170
|
-
|
|
1171
|
-
|
|
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()));
|
|
1172
2055
|
if (missing.length > 0) {
|
|
1173
2056
|
throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`);
|
|
1174
2057
|
}
|
|
1175
2058
|
let compound;
|
|
1176
2059
|
for (const txid of txids) {
|
|
1177
|
-
const proof = proofByTxid.get(txid);
|
|
2060
|
+
const proof = proofByTxid.get(txid.toLowerCase());
|
|
1178
2061
|
if (proof === undefined)
|
|
1179
2062
|
continue;
|
|
1180
2063
|
const path = MerklePath.fromHex(proof.merklePath);
|
|
@@ -1198,15 +2081,43 @@ export class Engine {
|
|
|
1198
2081
|
merklePath: compound.toHex()
|
|
1199
2082
|
};
|
|
1200
2083
|
}
|
|
1201
|
-
async provideRawTransactions(txids) {
|
|
2084
|
+
async provideRawTransactions(txids, topic) {
|
|
1202
2085
|
if (typeof this.storage.findRawTransactions !== 'function') {
|
|
1203
|
-
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);
|
|
1204
2117
|
}
|
|
1205
|
-
const transactions = await this.storage.findRawTransactions(txids);
|
|
1206
|
-
const found = new Set(transactions.map(tx => tx.txid));
|
|
1207
2118
|
return {
|
|
1208
2119
|
transactions,
|
|
1209
|
-
missing: txids.filter(txid => !found.has(txid))
|
|
2120
|
+
missing: txids.filter(txid => !found.has(txid.toLowerCase()))
|
|
1210
2121
|
};
|
|
1211
2122
|
}
|
|
1212
2123
|
async startBASMSync() {
|
|
@@ -1232,40 +2143,122 @@ export class Engine {
|
|
|
1232
2143
|
fetchedTxCount: 0
|
|
1233
2144
|
};
|
|
1234
2145
|
try {
|
|
1235
|
-
const remote = new BASMRemote(endpoint, topic);
|
|
2146
|
+
const remote = new BASMRemote(endpoint, topic, this.basmFetchImpl);
|
|
1236
2147
|
const [localTip, remoteTip] = await Promise.all([
|
|
1237
2148
|
this.provideTopicAnchorTip(topic),
|
|
1238
2149
|
remote.requestTopicAnchorTip()
|
|
1239
2150
|
]);
|
|
1240
2151
|
report.localTip = localTip;
|
|
1241
2152
|
report.remoteTip = remoteTip;
|
|
2153
|
+
await this.requireMatchingRemoteBASMTip(remote, remoteTip);
|
|
1242
2154
|
if (localTip.blockHeight >= remoteTip.blockHeight) {
|
|
1243
|
-
|
|
1244
|
-
report.message = report.status === 'matched'
|
|
1245
|
-
? 'Topic anchor tips match'
|
|
1246
|
-
: 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation';
|
|
1247
|
-
return report;
|
|
2155
|
+
return await this.finishBASMWhenRemoteIsNotAhead(topic, localTip, remoteTip, report);
|
|
1248
2156
|
}
|
|
1249
|
-
|
|
1250
|
-
const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight);
|
|
1251
|
-
for (const remoteAnchor of range.anchors) {
|
|
1252
|
-
await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report);
|
|
1253
|
-
}
|
|
1254
|
-
const refreshedTip = await this.provideTopicAnchorTip(topic);
|
|
1255
|
-
report.localTip = refreshedTip;
|
|
1256
|
-
report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced';
|
|
1257
|
-
return report;
|
|
2157
|
+
return await this.advanceBASMWithRemoteAnchors(topic, remote, localTip, remoteTip, report);
|
|
1258
2158
|
}
|
|
1259
2159
|
catch (error) {
|
|
1260
|
-
report
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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);
|
|
1264
2253
|
}
|
|
1265
2254
|
}
|
|
1266
2255
|
async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
|
|
2256
|
+
await this.requireCanonicalBASMAnchor(remoteAnchor);
|
|
1267
2257
|
report.checkedHeights.push(remoteAnchor.blockHeight);
|
|
1268
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
|
+
}
|
|
1269
2262
|
if (localAnchor?.tac === remoteAnchor.tac) {
|
|
1270
2263
|
return;
|
|
1271
2264
|
}
|
|
@@ -1275,60 +2268,119 @@ export class Engine {
|
|
|
1275
2268
|
admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
|
|
1276
2269
|
throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
|
|
1277
2270
|
}
|
|
1278
|
-
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
|
+
}
|
|
1279
2281
|
const localTxids = new Set(localAdmitted.map(item => item.txid));
|
|
1280
2282
|
const missingTxids = admittedResponse.admitted
|
|
1281
2283
|
.map(item => item.txid)
|
|
1282
2284
|
.filter(txid => !localTxids.has(txid));
|
|
1283
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;
|
|
1284
2291
|
if (missingTxids.length === 0) {
|
|
1285
2292
|
report.status = 'diverged';
|
|
1286
2293
|
return;
|
|
1287
2294
|
}
|
|
1288
|
-
await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids);
|
|
1289
2295
|
report.fetchedTxCount += missingTxids.length;
|
|
1290
2296
|
}
|
|
1291
|
-
async fetchBASMMissingTransactions(remote, topic, anchor, txids) {
|
|
2297
|
+
async fetchBASMMissingTransactions(remote, topic, anchor, admitted, txids) {
|
|
1292
2298
|
if (this.chainTracker === 'scripts only') {
|
|
1293
2299
|
throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs');
|
|
1294
2300
|
}
|
|
1295
|
-
|
|
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));
|
|
1296
2304
|
const compoundPath = MerklePath.fromHex(proofResponse.merklePath);
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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';
|
|
1302
2328
|
}
|
|
1303
2329
|
const rawResponse = await remote.requestRawTransactions(txids);
|
|
1304
2330
|
if (rawResponse.missing.length > 0) {
|
|
1305
2331
|
throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`);
|
|
1306
2332
|
}
|
|
1307
|
-
|
|
2333
|
+
const transactions = rawResponse.transactions.map(record => {
|
|
1308
2334
|
const tx = Transaction.fromHex(record.rawTx);
|
|
1309
|
-
if (tx.id('hex') !== record.txid) {
|
|
2335
|
+
if (tx.id('hex') !== record.txid || tx.toHex() !== record.rawTx.toLowerCase()) {
|
|
1310
2336
|
throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`);
|
|
1311
2337
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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');
|
|
1319
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';
|
|
1320
2364
|
}
|
|
1321
2365
|
async evictUnprovenTransactions(options = {}) {
|
|
1322
2366
|
if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
|
|
1323
2367
|
throw new TypeError('Storage does not support unproven transaction eviction');
|
|
1324
2368
|
}
|
|
2369
|
+
if (typeof this.storage.deleteAppliedTransaction !== 'function') {
|
|
2370
|
+
throw new TypeError('Storage does not support applied transaction eviction');
|
|
2371
|
+
}
|
|
1325
2372
|
if (this.chainTracker === 'scripts only') {
|
|
1326
2373
|
throw new Error('Unproven eviction requires a ChainTracker to determine block age');
|
|
1327
2374
|
}
|
|
2375
|
+
if (options.topic !== undefined)
|
|
2376
|
+
assertTopic(options.topic, 'Unproven eviction topic');
|
|
1328
2377
|
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
|
|
2378
|
+
assertUnprovenThreshold(thresholdBlocks);
|
|
1329
2379
|
const currentHeight = await this.chainTracker.currentHeight();
|
|
2380
|
+
assertNonnegativeInteger(currentHeight, 'Current chain height');
|
|
1330
2381
|
const cutoffHeight = currentHeight - thresholdBlocks;
|
|
1331
|
-
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);
|
|
1332
2384
|
let evictedOutputs = 0;
|
|
1333
2385
|
for (const candidate of candidates) {
|
|
1334
2386
|
for (const output of candidate.outputs) {
|
|
@@ -1343,7 +2395,7 @@ export class Engine {
|
|
|
1343
2395
|
await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
|
|
1344
2396
|
evictedOutputs++;
|
|
1345
2397
|
}
|
|
1346
|
-
await this.storage.deleteAppliedTransaction
|
|
2398
|
+
await this.storage.deleteAppliedTransaction(candidate.txid, candidate.topic);
|
|
1347
2399
|
}
|
|
1348
2400
|
return {
|
|
1349
2401
|
cutoffHeight,
|
|
@@ -1359,10 +2411,20 @@ export class Engine {
|
|
|
1359
2411
|
if (this.chainTracker === 'scripts only') {
|
|
1360
2412
|
throw new Error('Unproven proof refresh requires a ChainTracker to determine block age');
|
|
1361
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');
|
|
1362
2421
|
const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
|
|
2422
|
+
assertUnprovenThreshold(thresholdBlocks);
|
|
1363
2423
|
const currentHeight = await this.chainTracker.currentHeight();
|
|
2424
|
+
assertNonnegativeInteger(currentHeight, 'Current chain height');
|
|
1364
2425
|
const cutoffHeight = currentHeight - thresholdBlocks;
|
|
1365
|
-
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);
|
|
1366
2428
|
const txids = [...new Set(candidates.map(candidate => candidate.txid))];
|
|
1367
2429
|
let refreshedTransactions = 0;
|
|
1368
2430
|
let missingProofs = 0;
|
|
@@ -1382,7 +2444,7 @@ export class Engine {
|
|
|
1382
2444
|
failedProofs++;
|
|
1383
2445
|
failures.push({
|
|
1384
2446
|
txid,
|
|
1385
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2447
|
+
error: (error instanceof Error ? error.message : String(error)).slice(0, 1024)
|
|
1386
2448
|
});
|
|
1387
2449
|
}
|
|
1388
2450
|
}
|
|
@@ -1395,6 +2457,57 @@ export class Engine {
|
|
|
1395
2457
|
failures
|
|
1396
2458
|
};
|
|
1397
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
|
+
}
|
|
1398
2511
|
async maintainUnprovenTransactions(options) {
|
|
1399
2512
|
const refresh = await this.refreshUnprovenTransactionProofs(options);
|
|
1400
2513
|
const eviction = await this.evictUnprovenTransactions({
|
|
@@ -1407,7 +2520,34 @@ export class Engine {
|
|
|
1407
2520
|
if (typeof this.storage.deleteAppliedTransaction !== 'function') {
|
|
1408
2521
|
throw new TypeError('Storage does not support applied transaction eviction');
|
|
1409
2522
|
}
|
|
1410
|
-
|
|
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
|
+
}
|
|
1411
2551
|
const filtered = options.topic === undefined
|
|
1412
2552
|
? outputs
|
|
1413
2553
|
: outputs.filter(output => output.topic === options.topic);
|
|
@@ -1447,13 +2587,42 @@ export class Engine {
|
|
|
1447
2587
|
* @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided min block height.
|
|
1448
2588
|
*/
|
|
1449
2589
|
async provideForeignSyncResponse(initialRequest, topic) {
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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 {
|
|
1453
2619
|
txid: output.txid,
|
|
1454
2620
|
outputIndex: output.outputIndex,
|
|
1455
2621
|
score: output.score ?? 0
|
|
1456
|
-
}
|
|
2622
|
+
};
|
|
2623
|
+
});
|
|
2624
|
+
return {
|
|
2625
|
+
UTXOList,
|
|
1457
2626
|
since: initialRequest.since
|
|
1458
2627
|
};
|
|
1459
2628
|
}
|
|
@@ -1466,51 +2635,91 @@ export class Engine {
|
|
|
1466
2635
|
* @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
|
|
1467
2636
|
* @throws An error if no output is found for the given transaction ID and output index.
|
|
1468
2637
|
*/
|
|
1469
|
-
async provideForeignGASPNode(graphID, txid, outputIndex) {
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
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');
|
|
1473
2668
|
}
|
|
1474
2669
|
const rootTx = Transaction.fromBEEF(output.beef);
|
|
1475
2670
|
let correctTx;
|
|
1476
|
-
const
|
|
1477
|
-
|
|
1478
|
-
|
|
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;
|
|
1479
2685
|
}
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
if (input.sourceTransaction === undefined) {
|
|
1485
|
-
throw new Error('Incomplete SPV data!');
|
|
1486
|
-
}
|
|
1487
|
-
else {
|
|
1488
|
-
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');
|
|
1489
2690
|
}
|
|
2691
|
+
transactionStack.push(input.sourceTransaction);
|
|
1490
2692
|
}
|
|
1491
2693
|
}
|
|
1492
|
-
}
|
|
1493
|
-
searchInput(rootTx);
|
|
2694
|
+
}
|
|
1494
2695
|
if (correctTx === undefined) {
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
catch (error) {
|
|
1504
|
-
// Best-effort: output may not be found or hydration fails for this candidate; try the next one
|
|
1505
|
-
this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`);
|
|
1506
|
-
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));
|
|
1507
2704
|
}
|
|
1508
2705
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
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;
|
|
1511
2717
|
}
|
|
1512
2718
|
}
|
|
1513
2719
|
else {
|
|
2720
|
+
if (outputIndex >= correctTx.outputs.length) {
|
|
2721
|
+
throw new Error('Requested GASP output index does not exist');
|
|
2722
|
+
}
|
|
1514
2723
|
const rawTx = correctTx.toHex();
|
|
1515
2724
|
const node = {
|
|
1516
2725
|
rawTx,
|
|
@@ -1522,11 +2731,16 @@ export class Engine {
|
|
|
1522
2731
|
}
|
|
1523
2732
|
return node;
|
|
1524
2733
|
}
|
|
1525
|
-
|
|
2734
|
+
return undefined;
|
|
1526
2735
|
};
|
|
1527
|
-
const
|
|
1528
|
-
const
|
|
1529
|
-
|
|
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;
|
|
1530
2744
|
}
|
|
1531
2745
|
/**
|
|
1532
2746
|
* Traverse and return the history of a UTXO.
|
|
@@ -1542,8 +2756,19 @@ export class Engine {
|
|
|
1542
2756
|
* @param {number} [currentDepth=0] - The current depth of the traversal relative to the top-level UTXO.
|
|
1543
2757
|
*
|
|
1544
2758
|
* @returns {Promise<Output | undefined>} - A promise that resolves to the output history if found, or undefined if not.
|
|
1545
|
-
|
|
2759
|
+
*/
|
|
1546
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
|
+
}
|
|
1547
2772
|
// If we have an output but no history selector, just return the output.
|
|
1548
2773
|
if (historySelector === undefined) {
|
|
1549
2774
|
return output;
|
|
@@ -1576,43 +2801,114 @@ export class Engine {
|
|
|
1576
2801
|
*/
|
|
1577
2802
|
async deleteUTXODeep(output) {
|
|
1578
2803
|
try {
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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)) {
|
|
1584
2823
|
try {
|
|
1585
|
-
await
|
|
2824
|
+
await lookupService.outputNoLongerRetainedInHistory?.(current.txid, current.outputIndex, current.topic);
|
|
1586
2825
|
}
|
|
1587
|
-
catch (
|
|
1588
|
-
|
|
1589
|
-
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}`);
|
|
1590
2828
|
}
|
|
1591
2829
|
}
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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);
|
|
1609
2854
|
}
|
|
1610
|
-
|
|
1611
|
-
return await this.deleteUTXODeep(staleOutput);
|
|
1612
|
-
}));
|
|
2855
|
+
}
|
|
1613
2856
|
}
|
|
1614
2857
|
catch (error) {
|
|
1615
|
-
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`);
|
|
1616
2912
|
}
|
|
1617
2913
|
}
|
|
1618
2914
|
/**
|
|
@@ -1625,47 +2921,104 @@ export class Engine {
|
|
|
1625
2921
|
* @param proof for txid
|
|
1626
2922
|
*/
|
|
1627
2923
|
updateInputProofs(tx, txid, proof) {
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
for (const input of tx.inputs) {
|
|
1638
|
-
// All inputs must have sourceTransactions
|
|
1639
|
-
const stx = input.sourceTransaction;
|
|
1640
|
-
if (typeof stx !== 'object')
|
|
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))
|
|
1641
2933
|
continue;
|
|
1642
|
-
|
|
2934
|
+
visited.add(current);
|
|
2935
|
+
if (current.id('hex') === txid) {
|
|
2936
|
+
current.merklePath = proof;
|
|
2937
|
+
found = true;
|
|
2938
|
+
continue;
|
|
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
|
+
}
|
|
1643
2952
|
}
|
|
2953
|
+
return found;
|
|
1644
2954
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
async updateMerkleProof(output, txid, proof) {
|
|
1653
|
-
if (output.beef === undefined) {
|
|
1654
|
-
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');
|
|
1655
2962
|
}
|
|
1656
2963
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
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
|
+
}
|
|
1667
3019
|
}
|
|
1668
3020
|
}
|
|
3021
|
+
return collected;
|
|
1669
3022
|
}
|
|
1670
3023
|
/**
|
|
1671
3024
|
* Recursively prune UTXOs when an incoming Merkle Proof is received.
|
|
@@ -1675,17 +3028,49 @@ export class Engine {
|
|
|
1675
3028
|
* @param blockHeight - The block height associated with the incoming merkle proof.
|
|
1676
3029
|
*/
|
|
1677
3030
|
async handleNewMerkleProof(txid, proof, blockHeight) {
|
|
1678
|
-
|
|
1679
|
-
if (
|
|
1680
|
-
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
|
+
}
|
|
1681
3040
|
}
|
|
1682
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
|
+
}
|
|
1683
3069
|
const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight;
|
|
1684
3070
|
const resolvedBlockHash = resolvedBlockHeight === undefined
|
|
1685
3071
|
? undefined
|
|
1686
3072
|
: await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot);
|
|
1687
3073
|
for (const output of outputs) {
|
|
1688
|
-
await this.updateMerkleProof(output, txid, proof);
|
|
1689
3074
|
// Add the associated blockHeight
|
|
1690
3075
|
if (resolvedBlockHeight !== undefined) {
|
|
1691
3076
|
output.blockHeight = resolvedBlockHeight;
|
|
@@ -1693,7 +3078,6 @@ export class Engine {
|
|
|
1693
3078
|
}
|
|
1694
3079
|
if (output.beef !== undefined) {
|
|
1695
3080
|
const tx = Transaction.fromBEEF(output.beef);
|
|
1696
|
-
this.updateInputProofs(tx, txid, proof);
|
|
1697
3081
|
await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash);
|
|
1698
3082
|
}
|
|
1699
3083
|
if (resolvedBlockHeight !== undefined) {
|
|
@@ -1717,10 +3101,10 @@ export class Engine {
|
|
|
1717
3101
|
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported topic managers and their metadata
|
|
1718
3102
|
*/
|
|
1719
3103
|
async listTopicManagers() {
|
|
1720
|
-
const result =
|
|
1721
|
-
for (const t
|
|
3104
|
+
const result = Object.create(null);
|
|
3105
|
+
for (const t of Object.keys(this.managers)) {
|
|
1722
3106
|
try {
|
|
1723
|
-
result[t] = await this.managers[t].getMetaData();
|
|
3107
|
+
result[t] = validateComponentMetadata(await this.managers[t].getMetaData());
|
|
1724
3108
|
}
|
|
1725
3109
|
catch (e) {
|
|
1726
3110
|
this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`);
|
|
@@ -1738,10 +3122,10 @@ export class Engine {
|
|
|
1738
3122
|
* @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported lookup services and their metadata
|
|
1739
3123
|
*/
|
|
1740
3124
|
async listLookupServiceProviders() {
|
|
1741
|
-
const result =
|
|
1742
|
-
for (const ls
|
|
3125
|
+
const result = Object.create(null);
|
|
3126
|
+
for (const ls of Object.keys(this.lookupServices)) {
|
|
1743
3127
|
try {
|
|
1744
|
-
result[ls] = await this.lookupServices[ls].getMetaData();
|
|
3128
|
+
result[ls] = validateComponentMetadata(await this.lookupServices[ls].getMetaData());
|
|
1745
3129
|
}
|
|
1746
3130
|
catch (e) {
|
|
1747
3131
|
this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`);
|
|
@@ -1759,8 +3143,13 @@ export class Engine {
|
|
|
1759
3143
|
* @returns {Promise<string>} - the documentation for the topic manager
|
|
1760
3144
|
*/
|
|
1761
3145
|
async getDocumentationForTopicManager(manager) {
|
|
1762
|
-
const
|
|
1763
|
-
|
|
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);
|
|
1764
3153
|
}
|
|
1765
3154
|
/**
|
|
1766
3155
|
* Run a query to get the documentation for a particular lookup service
|
|
@@ -1768,8 +3157,14 @@ export class Engine {
|
|
|
1768
3157
|
* @returns {Promise<string>} - the documentation for the lookup service
|
|
1769
3158
|
*/
|
|
1770
3159
|
async getDocumentationForLookupServiceProvider(provider) {
|
|
1771
|
-
const
|
|
1772
|
-
|
|
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);
|
|
1773
3168
|
}
|
|
1774
3169
|
/**
|
|
1775
3170
|
* Validates a URL to ensure it does not match disallowed patterns:
|
|
@@ -1783,17 +3178,16 @@ export class Engine {
|
|
|
1783
3178
|
*/
|
|
1784
3179
|
isValidUrl(url) {
|
|
1785
3180
|
try {
|
|
1786
|
-
const parsedUrl = new URL(url);
|
|
1787
|
-
// Disallow http:
|
|
1788
|
-
if (parsedUrl.protocol === 'http:') {
|
|
1789
|
-
return false;
|
|
1790
|
-
}
|
|
3181
|
+
const parsedUrl = new URL(normalizePeerEndpoint(url));
|
|
1791
3182
|
// Disallow localhost with or without a port
|
|
1792
3183
|
if (/^localhost(:\d+)?$/i.test(parsedUrl.hostname)) {
|
|
1793
3184
|
return false;
|
|
1794
3185
|
}
|
|
1795
3186
|
// Disallow internal and non-routable IP addresses
|
|
1796
3187
|
const ipAddress = parsedUrl.hostname;
|
|
3188
|
+
const literal = /^[\d.]+$/.test(ipAddress) || ipAddress.includes(':');
|
|
3189
|
+
if (literal && !isPublicNetworkAddress(ipAddress))
|
|
3190
|
+
return false;
|
|
1797
3191
|
// Regex for non-routable IPv4 IPs
|
|
1798
3192
|
const nonRoutableIpv4Patterns = [
|
|
1799
3193
|
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
|
|
@@ -1803,7 +3197,7 @@ export class Engine {
|
|
|
1803
3197
|
/^0\.0\.0\.0$/ // Non-routable address
|
|
1804
3198
|
];
|
|
1805
3199
|
// Check for IPv4 matches
|
|
1806
|
-
if (nonRoutableIpv4Patterns.some(
|
|
3200
|
+
if (nonRoutableIpv4Patterns.some(pattern => pattern.test(ipAddress))) {
|
|
1807
3201
|
return false;
|
|
1808
3202
|
}
|
|
1809
3203
|
// Check for non-routable IPv6 addresses explicitly
|