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