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