@bsv/overlay 2.3.1 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +166 -0
  2. package/dist/cjs/mod.d.ts +3 -0
  3. package/dist/cjs/mod.d.ts.map +1 -1
  4. package/dist/cjs/mod.js +8 -1
  5. package/dist/cjs/mod.js.map +1 -1
  6. package/dist/cjs/package.json +10 -4
  7. package/dist/cjs/src/BASM.d.ts +6 -0
  8. package/dist/cjs/src/BASM.d.ts.map +1 -1
  9. package/dist/cjs/src/BASM.js +21 -8
  10. package/dist/cjs/src/BASM.js.map +1 -1
  11. package/dist/cjs/src/BASMRemote.d.ts +8 -1
  12. package/dist/cjs/src/BASMRemote.d.ts.map +1 -1
  13. package/dist/cjs/src/BASMRemote.js +179 -13
  14. package/dist/cjs/src/BASMRemote.js.map +1 -1
  15. package/dist/cjs/src/BASMValidation.d.ts +27 -0
  16. package/dist/cjs/src/BASMValidation.d.ts.map +1 -0
  17. package/dist/cjs/src/BASMValidation.js +108 -0
  18. package/dist/cjs/src/BASMValidation.js.map +1 -0
  19. package/dist/cjs/src/DiscoveryAdvertisementValidation.d.ts +11 -0
  20. package/dist/cjs/src/DiscoveryAdvertisementValidation.d.ts.map +1 -0
  21. package/dist/cjs/src/DiscoveryAdvertisementValidation.js +60 -0
  22. package/dist/cjs/src/DiscoveryAdvertisementValidation.js.map +1 -0
  23. package/dist/cjs/src/Engine.d.ts +37 -13
  24. package/dist/cjs/src/Engine.d.ts.map +1 -1
  25. package/dist/cjs/src/Engine.js +1713 -336
  26. package/dist/cjs/src/Engine.js.map +1 -1
  27. package/dist/cjs/src/EngineAdmission.d.ts +70 -0
  28. package/dist/cjs/src/EngineAdmission.d.ts.map +1 -0
  29. package/dist/cjs/src/EngineAdmission.js +297 -0
  30. package/dist/cjs/src/EngineAdmission.js.map +1 -0
  31. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts +4 -3
  32. package/dist/cjs/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  33. package/dist/cjs/src/GASP/OverlayGASPRemote.js +27 -43
  34. package/dist/cjs/src/GASP/OverlayGASPRemote.js.map +1 -1
  35. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts +22 -16
  36. package/dist/cjs/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  37. package/dist/cjs/src/GASP/OverlayGASPStorage.js +206 -57
  38. package/dist/cjs/src/GASP/OverlayGASPStorage.js.map +1 -1
  39. package/dist/cjs/src/RemoteSecurity.d.ts +50 -0
  40. package/dist/cjs/src/RemoteSecurity.d.ts.map +1 -0
  41. package/dist/cjs/src/RemoteSecurity.js +301 -0
  42. package/dist/cjs/src/RemoteSecurity.js.map +1 -0
  43. package/dist/cjs/src/storage/AdmissionStorage.d.ts +185 -0
  44. package/dist/cjs/src/storage/AdmissionStorage.d.ts.map +1 -0
  45. package/dist/cjs/src/storage/AdmissionStorage.js +93 -0
  46. package/dist/cjs/src/storage/AdmissionStorage.js.map +1 -0
  47. package/dist/cjs/src/storage/RecoveryContract.d.ts +50 -0
  48. package/dist/cjs/src/storage/RecoveryContract.d.ts.map +1 -0
  49. package/dist/cjs/src/storage/RecoveryContract.js +35 -0
  50. package/dist/cjs/src/storage/RecoveryContract.js.map +1 -0
  51. package/dist/cjs/src/storage/Storage.d.ts +32 -19
  52. package/dist/cjs/src/storage/Storage.d.ts.map +1 -1
  53. package/dist/cjs/src/storage/Storage.js +5 -0
  54. package/dist/cjs/src/storage/Storage.js.map +1 -1
  55. package/dist/cjs/src/storage/knex/KnexStorage.d.ts +8 -8
  56. package/dist/cjs/src/storage/knex/KnexStorage.d.ts.map +1 -1
  57. package/dist/cjs/src/storage/knex/KnexStorage.js +98 -61
  58. package/dist/cjs/src/storage/knex/KnexStorage.js.map +1 -1
  59. package/dist/cjs/src/storage/knex/all-migrations.d.ts.map +1 -1
  60. package/dist/cjs/src/storage/knex/all-migrations.js +5 -1
  61. package/dist/cjs/src/storage/knex/all-migrations.js.map +1 -1
  62. package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts +4 -0
  63. package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts.map +1 -0
  64. package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js +36 -0
  65. package/dist/cjs/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js.map +1 -0
  66. package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts +5 -0
  67. package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts.map +1 -0
  68. package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.js +16 -0
  69. package/dist/cjs/src/storage/knex/migrations/2026-09-20-001-spent-by.js.map +1 -0
  70. package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.d.ts +18 -0
  71. package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.d.ts.map +1 -0
  72. package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.js +229 -0
  73. package/dist/cjs/src/storage/mongo/MongoAdmissionPlan.js.map +1 -0
  74. package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.d.ts +112 -0
  75. package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.d.ts.map +1 -0
  76. package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.js +675 -0
  77. package/dist/cjs/src/storage/mongo/MongoAdmissionStorage.js.map +1 -0
  78. package/dist/cjs/src/storage/mongo/MongoOverlayStorage.d.ts +71 -0
  79. package/dist/cjs/src/storage/mongo/MongoOverlayStorage.d.ts.map +1 -0
  80. package/dist/cjs/src/storage/mongo/MongoOverlayStorage.js +453 -0
  81. package/dist/cjs/src/storage/mongo/MongoOverlayStorage.js.map +1 -0
  82. package/dist/cjs/src/storage/mongo/MongoPayloadStore.d.ts +134 -0
  83. package/dist/cjs/src/storage/mongo/MongoPayloadStore.d.ts.map +1 -0
  84. package/dist/cjs/src/storage/mongo/MongoPayloadStore.js +756 -0
  85. package/dist/cjs/src/storage/mongo/MongoPayloadStore.js.map +1 -0
  86. package/dist/cjs/src/storage/mongo/MongoReadGuards.d.ts +44 -0
  87. package/dist/cjs/src/storage/mongo/MongoReadGuards.d.ts.map +1 -0
  88. package/dist/cjs/src/storage/mongo/MongoReadGuards.js +135 -0
  89. package/dist/cjs/src/storage/mongo/MongoReadGuards.js.map +1 -0
  90. package/dist/cjs/src/storage/mongo/MongoSchema.d.ts +67 -0
  91. package/dist/cjs/src/storage/mongo/MongoSchema.d.ts.map +1 -0
  92. package/dist/cjs/src/storage/mongo/MongoSchema.js +743 -0
  93. package/dist/cjs/src/storage/mongo/MongoSchema.js.map +1 -0
  94. package/dist/cjs/src/storage/mongo/MongoTransactionRunner.d.ts +69 -0
  95. package/dist/cjs/src/storage/mongo/MongoTransactionRunner.d.ts.map +1 -0
  96. package/dist/cjs/src/storage/mongo/MongoTransactionRunner.js +368 -0
  97. package/dist/cjs/src/storage/mongo/MongoTransactionRunner.js.map +1 -0
  98. package/dist/cjs/src/storage/mongo.d.ts +9 -0
  99. package/dist/cjs/src/storage/mongo.d.ts.map +1 -0
  100. package/dist/cjs/src/storage/mongo.js +25 -0
  101. package/dist/cjs/src/storage/mongo.js.map +1 -0
  102. package/dist/esm/mod.js +3 -0
  103. package/dist/esm/mod.js.map +1 -1
  104. package/dist/esm/src/BASM.js +21 -8
  105. package/dist/esm/src/BASM.js.map +1 -1
  106. package/dist/esm/src/BASMRemote.js +176 -12
  107. package/dist/esm/src/BASMRemote.js.map +1 -1
  108. package/dist/esm/src/BASMValidation.js +95 -0
  109. package/dist/esm/src/BASMValidation.js.map +1 -0
  110. package/dist/esm/src/DiscoveryAdvertisementValidation.js +57 -0
  111. package/dist/esm/src/DiscoveryAdvertisementValidation.js.map +1 -0
  112. package/dist/esm/src/Engine.js +1698 -326
  113. package/dist/esm/src/Engine.js.map +1 -1
  114. package/dist/esm/src/EngineAdmission.js +280 -0
  115. package/dist/esm/src/EngineAdmission.js.map +1 -0
  116. package/dist/esm/src/GASP/OverlayGASPRemote.js +27 -43
  117. package/dist/esm/src/GASP/OverlayGASPRemote.js.map +1 -1
  118. package/dist/esm/src/GASP/OverlayGASPStorage.js +200 -56
  119. package/dist/esm/src/GASP/OverlayGASPStorage.js.map +1 -1
  120. package/dist/esm/src/RemoteSecurity.js +279 -0
  121. package/dist/esm/src/RemoteSecurity.js.map +1 -0
  122. package/dist/esm/src/storage/AdmissionStorage.js +85 -0
  123. package/dist/esm/src/storage/AdmissionStorage.js.map +1 -0
  124. package/dist/esm/src/storage/RecoveryContract.js +31 -0
  125. package/dist/esm/src/storage/RecoveryContract.js.map +1 -0
  126. package/dist/esm/src/storage/Storage.js +4 -1
  127. package/dist/esm/src/storage/Storage.js.map +1 -1
  128. package/dist/esm/src/storage/knex/KnexStorage.js +98 -61
  129. package/dist/esm/src/storage/knex/KnexStorage.js.map +1 -1
  130. package/dist/esm/src/storage/knex/all-migrations.js +5 -1
  131. package/dist/esm/src/storage/knex/all-migrations.js.map +1 -1
  132. package/dist/esm/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js +32 -0
  133. package/dist/esm/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.js.map +1 -0
  134. package/dist/esm/src/storage/knex/migrations/2026-09-20-001-spent-by.js +12 -0
  135. package/dist/esm/src/storage/knex/migrations/2026-09-20-001-spent-by.js.map +1 -0
  136. package/dist/esm/src/storage/mongo/MongoAdmissionPlan.js +217 -0
  137. package/dist/esm/src/storage/mongo/MongoAdmissionPlan.js.map +1 -0
  138. package/dist/esm/src/storage/mongo/MongoAdmissionStorage.js +673 -0
  139. package/dist/esm/src/storage/mongo/MongoAdmissionStorage.js.map +1 -0
  140. package/dist/esm/src/storage/mongo/MongoOverlayStorage.js +451 -0
  141. package/dist/esm/src/storage/mongo/MongoOverlayStorage.js.map +1 -0
  142. package/dist/esm/src/storage/mongo/MongoPayloadStore.js +747 -0
  143. package/dist/esm/src/storage/mongo/MongoPayloadStore.js.map +1 -0
  144. package/dist/esm/src/storage/mongo/MongoReadGuards.js +129 -0
  145. package/dist/esm/src/storage/mongo/MongoReadGuards.js.map +1 -0
  146. package/dist/esm/src/storage/mongo/MongoSchema.js +730 -0
  147. package/dist/esm/src/storage/mongo/MongoSchema.js.map +1 -0
  148. package/dist/esm/src/storage/mongo/MongoTransactionRunner.js +371 -0
  149. package/dist/esm/src/storage/mongo/MongoTransactionRunner.js.map +1 -0
  150. package/dist/esm/src/storage/mongo.js +9 -0
  151. package/dist/esm/src/storage/mongo.js.map +1 -0
  152. package/dist/types/mod.d.ts +3 -0
  153. package/dist/types/mod.d.ts.map +1 -1
  154. package/dist/types/src/BASM.d.ts +6 -0
  155. package/dist/types/src/BASM.d.ts.map +1 -1
  156. package/dist/types/src/BASMRemote.d.ts +8 -1
  157. package/dist/types/src/BASMRemote.d.ts.map +1 -1
  158. package/dist/types/src/BASMValidation.d.ts +27 -0
  159. package/dist/types/src/BASMValidation.d.ts.map +1 -0
  160. package/dist/types/src/DiscoveryAdvertisementValidation.d.ts +11 -0
  161. package/dist/types/src/DiscoveryAdvertisementValidation.d.ts.map +1 -0
  162. package/dist/types/src/Engine.d.ts +37 -13
  163. package/dist/types/src/Engine.d.ts.map +1 -1
  164. package/dist/types/src/EngineAdmission.d.ts +70 -0
  165. package/dist/types/src/EngineAdmission.d.ts.map +1 -0
  166. package/dist/types/src/GASP/OverlayGASPRemote.d.ts +4 -3
  167. package/dist/types/src/GASP/OverlayGASPRemote.d.ts.map +1 -1
  168. package/dist/types/src/GASP/OverlayGASPStorage.d.ts +22 -16
  169. package/dist/types/src/GASP/OverlayGASPStorage.d.ts.map +1 -1
  170. package/dist/types/src/RemoteSecurity.d.ts +50 -0
  171. package/dist/types/src/RemoteSecurity.d.ts.map +1 -0
  172. package/dist/types/src/storage/AdmissionStorage.d.ts +185 -0
  173. package/dist/types/src/storage/AdmissionStorage.d.ts.map +1 -0
  174. package/dist/types/src/storage/RecoveryContract.d.ts +50 -0
  175. package/dist/types/src/storage/RecoveryContract.d.ts.map +1 -0
  176. package/dist/types/src/storage/Storage.d.ts +32 -19
  177. package/dist/types/src/storage/Storage.d.ts.map +1 -1
  178. package/dist/types/src/storage/knex/KnexStorage.d.ts +8 -8
  179. package/dist/types/src/storage/knex/KnexStorage.d.ts.map +1 -1
  180. package/dist/types/src/storage/knex/all-migrations.d.ts.map +1 -1
  181. package/dist/types/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts +4 -0
  182. package/dist/types/src/storage/knex/migrations/2026-09-17-001-topical-uniqueness.d.ts.map +1 -0
  183. package/dist/types/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts +5 -0
  184. package/dist/types/src/storage/knex/migrations/2026-09-20-001-spent-by.d.ts.map +1 -0
  185. package/dist/types/src/storage/mongo/MongoAdmissionPlan.d.ts +18 -0
  186. package/dist/types/src/storage/mongo/MongoAdmissionPlan.d.ts.map +1 -0
  187. package/dist/types/src/storage/mongo/MongoAdmissionStorage.d.ts +112 -0
  188. package/dist/types/src/storage/mongo/MongoAdmissionStorage.d.ts.map +1 -0
  189. package/dist/types/src/storage/mongo/MongoOverlayStorage.d.ts +71 -0
  190. package/dist/types/src/storage/mongo/MongoOverlayStorage.d.ts.map +1 -0
  191. package/dist/types/src/storage/mongo/MongoPayloadStore.d.ts +134 -0
  192. package/dist/types/src/storage/mongo/MongoPayloadStore.d.ts.map +1 -0
  193. package/dist/types/src/storage/mongo/MongoReadGuards.d.ts +44 -0
  194. package/dist/types/src/storage/mongo/MongoReadGuards.d.ts.map +1 -0
  195. package/dist/types/src/storage/mongo/MongoSchema.d.ts +67 -0
  196. package/dist/types/src/storage/mongo/MongoSchema.d.ts.map +1 -0
  197. package/dist/types/src/storage/mongo/MongoTransactionRunner.d.ts +69 -0
  198. package/dist/types/src/storage/mongo/MongoTransactionRunner.d.ts.map +1 -0
  199. package/dist/types/src/storage/mongo.d.ts +9 -0
  200. package/dist/types/src/storage/mongo.d.ts.map +1 -0
  201. package/package.json +11 -5
@@ -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)}`);
@@ -550,29 +934,24 @@ export class Engine {
550
934
  async markPreviousOutputSpent(output, topic, tx, txid, offChainValues) {
551
935
  if (output === null)
552
936
  return;
553
- try {
554
- await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic);
555
- await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
556
- try {
557
- await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
558
- }
559
- catch (error) {
560
- this.logger.error('Error in lookup service for outputSpent:', error);
561
- }
562
- }));
563
- }
564
- catch (error) {
565
- this.logger.error('Error marking UTXO as spent:', error);
566
- }
937
+ await this.storage.markUTXOAsSpent(output.txid, output.outputIndex, topic, txid);
938
+ await Promise.all(Object.values(this.lookupServices).map(async (lookupService) => {
939
+ try {
940
+ await this.notifyOutputSpent(lookupService, tx, txid, output, topic, offChainValues);
941
+ }
942
+ catch (error) {
943
+ this.logger.error('Error in lookup service for outputSpent:', error);
944
+ }
945
+ }));
567
946
  }
568
947
  async markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues) {
569
- await Promise.all(validations.map(async (validation) => {
948
+ for (const validation of validations) {
570
949
  if (validation.isDupe || failedTopics.has(validation.topic))
571
- return;
572
- await Promise.all(validation.previousOutputs.map(async (output) => {
950
+ continue;
951
+ for (const output of validation.previousOutputs) {
573
952
  await this.markPreviousOutputSpent(output, validation.topic, tx, txid, offChainValues);
574
- }));
575
- }));
953
+ }
954
+ }
576
955
  }
577
956
  classifyPreviousCoins(tx, validation) {
578
957
  const outputsConsumed = [];
@@ -602,8 +981,10 @@ export class Engine {
602
981
  this.startTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
603
982
  await Promise.all(outputs.map(async (coin) => {
604
983
  const output = await this.storage.findOutput(coin.txid, coin.previousOutputIndex, topic);
605
- if (output !== undefined && output !== null)
984
+ if (output !== undefined && output !== null) {
985
+ this.assertStoredOutputMatches(output, { txid: coin.txid, outputIndex: coin.previousOutputIndex, topic }, 'Stale topical output');
606
986
  await this.deleteUTXODeep(output);
987
+ }
607
988
  }));
608
989
  this.endTime(`lookForStaleOutputs_${txid.substring(0, 10)}`);
609
990
  }
@@ -666,7 +1047,17 @@ export class Engine {
666
1047
  const storedOutput = await this.storage.findOutput(output.txid, output.outputIndex, topic);
667
1048
  if (storedOutput === undefined || storedOutput === null)
668
1049
  return;
669
- 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());
670
1061
  await this.storage.updateConsumedBy(output.txid, output.outputIndex, topic, consumedBy);
671
1062
  }
672
1063
  async applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues) {
@@ -675,7 +1066,7 @@ export class Engine {
675
1066
  await this.removeStaleOutputs(outputsToMarkStale, topic, txid);
676
1067
  steak[topic].coinsRemoved = outputsToMarkStale.map(output => output.inputIndex);
677
1068
  const newUTXOs = [];
678
- await Promise.all(validation.admissibleOutputs.outputsToAdmit.map(async (outputIndex) => {
1069
+ for (const outputIndex of validation.admissibleOutputs.outputsToAdmit) {
679
1070
  await this.admitOutput(outputIndex, {
680
1071
  tx,
681
1072
  txid,
@@ -685,7 +1076,7 @@ export class Engine {
685
1076
  newUTXOs,
686
1077
  offChainValues
687
1078
  });
688
- }));
1079
+ }
689
1080
  this.startTime(`outputConsumed_${txid.substring(0, 10)}`);
690
1081
  const appliedRecord = await this.buildAppliedTransactionRecord(tx);
691
1082
  await this.recordTransactionData(tx, beef, appliedRecord.blockHash);
@@ -706,20 +1097,14 @@ export class Engine {
706
1097
  const topic = validation.topic;
707
1098
  if (dupeTopics.has(topic) || failedTopics.has(topic))
708
1099
  continue;
709
- try {
710
- await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
711
- }
712
- catch (error) {
713
- this.logger.error('Error updating storage and notifying lookup services for topic', topic, error);
714
- }
1100
+ await this.applyTopicStorageMutation(validation, steak, tx, txid, beef, offChainValues);
715
1101
  }
716
1102
  }
717
1103
  async propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid) {
718
1104
  this.startTime(`transactionPropagation_${txid.substring(0, 10)}`);
719
1105
  const relevantTopics = taggedBEEF.topics.filter(topic => steak[topic] !== undefined &&
720
1106
  !dupeTopics.has(topic) &&
721
- (steak[topic].outputsToAdmit.length !== 0 ||
722
- steak[topic].coinsRemoved?.length !== 0));
1107
+ (steak[topic].outputsToAdmit.length !== 0 || steak[topic].coinsRemoved?.length !== 0));
723
1108
  if (relevantTopics.length === 0) {
724
1109
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
725
1110
  return;
@@ -733,8 +1118,8 @@ export class Engine {
733
1118
  resolver: new LookupResolver(resolverConfig)
734
1119
  };
735
1120
  }
736
- const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
737
1121
  try {
1122
+ const shipBroadcaster = new SHIPBroadcaster(relevantTopics, customBroadcasterConfig);
738
1123
  await shipBroadcaster.broadcast(tx);
739
1124
  }
740
1125
  catch (error) {
@@ -742,6 +1127,68 @@ export class Engine {
742
1127
  }
743
1128
  this.endTime(`transactionPropagation_${txid.substring(0, 10)}`);
744
1129
  }
1130
+ assertSupportedTopics(topics) {
1131
+ for (const topic of topics) {
1132
+ if (this.managers[topic] === undefined || this.managers[topic] === null) {
1133
+ throw new Error(`This server does not support this topic: ${topic}`);
1134
+ }
1135
+ }
1136
+ }
1137
+ shouldSkipPropagation(mode) {
1138
+ return (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv');
1139
+ }
1140
+ notifySteakReady(callback, steak) {
1141
+ if (callback === undefined)
1142
+ return;
1143
+ try {
1144
+ callback(steak);
1145
+ }
1146
+ catch (error) {
1147
+ this.logger.error('Error in onSteakReady callback:', error);
1148
+ }
1149
+ }
1150
+ async acknowledgeOverlayAdmission(context) {
1151
+ const admissionHost = getOverlayAdmissionHost(this.storage);
1152
+ if (admissionHost === undefined)
1153
+ return undefined;
1154
+ const { taggedBEEF, steak, tx, txid, mode, offChainValues, validations, failedTopics, anyTopicAccepted, onSteakReady } = context;
1155
+ if (!anyTopicAccepted) {
1156
+ this.notifySteakReady(onSteakReady, steak);
1157
+ return steak;
1158
+ }
1159
+ // A topic counts toward `anyTopicAccepted` when it is a dupe (so the
1160
+ // caller still gets broadcast/propagation for a retried submission), but
1161
+ // a dupe must never be resubmitted for admission: it was already
1162
+ // committed under a (possibly different) operation, and re-including it
1163
+ // here would make commitAdmission reject the whole plan. When nothing
1164
+ // left over is a genuinely new admission, skip the plan/commit entirely
1165
+ // and hand back the STEAK already computed from validation — it reports
1166
+ // each dupe topic as accepted-with-nothing-new, same as the classic
1167
+ // (non-admission) storage path does.
1168
+ if (selectNewAdmissionTopics(validations, failedTopics).length === 0) {
1169
+ this.notifySteakReady(onSteakReady, steak);
1170
+ return steak;
1171
+ }
1172
+ const applied = await this.buildAppliedTransactionRecord(tx);
1173
+ const buildPlan = async () => await buildOverlayAdmissionPlan({
1174
+ host: admissionHost,
1175
+ tx,
1176
+ txid,
1177
+ beef: taggedBEEF.beef,
1178
+ topics: taggedBEEF.topics,
1179
+ mode: overlayAdmissionMode(mode),
1180
+ offChainValues,
1181
+ validations,
1182
+ failedTopics,
1183
+ lookupServices: this.lookupServices,
1184
+ includePropagation: !this.shouldSkipPropagation(mode),
1185
+ applied
1186
+ });
1187
+ const committed = await waitForAdmissionReceipt(admissionHost.admission, await buildPlan(), buildPlan);
1188
+ const acknowledged = JSON.parse(committed.receipt.steak);
1189
+ this.notifySteakReady(onSteakReady, acknowledged);
1190
+ return acknowledged;
1191
+ }
745
1192
  /**
746
1193
  * Submits a transaction for processing by Overlay Services.
747
1194
  * @param {TaggedBEEF} taggedBEEF - The transaction to process
@@ -749,20 +1196,68 @@ export class Engine {
749
1196
  * @param {string} mode — Indicates the submission behavior, whether historical or current. Historical transactions are not broadcast or propagated.
750
1197
  * @param {number[]} offChainValues — Values necessary to evaluate topical admittance that are not stored on-chain.
751
1198
  *
752
- * The optional callback 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.
753
1201
  *
754
1202
  * @returns {Promise<STEAK>} The submitted transaction execution acknowledgement
755
1203
  */
756
1204
  async submit(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
757
- for (const t of taggedBEEF.topics) {
758
- if (this.managers[t] === undefined || this.managers[t] === null) {
759
- throw new Error(`This server does not support this topic: ${t}`);
760
- }
1205
+ const previous = this.submissionTail;
1206
+ let release;
1207
+ this.submissionTail = new Promise(resolve => {
1208
+ release = resolve;
1209
+ });
1210
+ await previous;
1211
+ try {
1212
+ return await this.submitUnlocked(taggedBEEF, onSteakReady, mode, offChainValues);
1213
+ }
1214
+ finally {
1215
+ release();
1216
+ }
1217
+ }
1218
+ async submitUnlocked(taggedBEEF, onSteakReady, mode = 'current-tx', offChainValues) {
1219
+ if (typeof taggedBEEF !== 'object' ||
1220
+ taggedBEEF === null ||
1221
+ !Array.isArray(taggedBEEF.beef) ||
1222
+ !Array.isArray(taggedBEEF.topics)) {
1223
+ throw new TypeError('Tagged BEEF must contain byte and topic arrays');
1224
+ }
1225
+ if (taggedBEEF.beef.length === 0 ||
1226
+ taggedBEEF.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
1227
+ taggedBEEF.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
1228
+ throw new TypeError(`Tagged BEEF must contain between 1 and ${MAX_SUBMISSION_BEEF_BYTES} bytes`);
1229
+ }
1230
+ if (taggedBEEF.topics.length === 0 || taggedBEEF.topics.length > MAX_SUBMISSION_TOPICS) {
1231
+ throw new TypeError(`Tagged BEEF must contain between 1 and ${MAX_SUBMISSION_TOPICS} topics`);
761
1232
  }
1233
+ if (!['historical-tx', 'current-tx', 'historical-tx-no-spv'].includes(mode)) {
1234
+ throw new TypeError('Invalid overlay submission mode');
1235
+ }
1236
+ if (onSteakReady !== undefined && typeof onSteakReady !== 'function') {
1237
+ throw new TypeError('onSteakReady must be a function');
1238
+ }
1239
+ if (offChainValues !== undefined &&
1240
+ (!Array.isArray(offChainValues) ||
1241
+ offChainValues.length > MAX_OFF_CHAIN_VALUES ||
1242
+ offChainValues.some(value => !Number.isSafeInteger(value)))) {
1243
+ throw new TypeError('offChainValues must be a bounded array of safe integers');
1244
+ }
1245
+ const uniqueTopics = new Set();
1246
+ for (const topic of taggedBEEF.topics) {
1247
+ assertTopic(topic, 'Tagged BEEF topic');
1248
+ if (uniqueTopics.has(topic))
1249
+ throw new TypeError('Tagged BEEF contains duplicate topics');
1250
+ uniqueTopics.add(topic);
1251
+ }
1252
+ this.assertSupportedTopics(taggedBEEF.topics);
762
1253
  // Validate the transaction SPV information
763
1254
  const tx = Transaction.fromBEEF(taggedBEEF.beef);
764
1255
  const txid = tx.id('hex');
765
1256
  this.startTime(`submit_${txid}`);
1257
+ // Every submission is SPV-verified except 'historical-tx-no-spv', which is
1258
+ // reserved for callers that have already proven inclusion independently
1259
+ // (GASP graph finalization and BASM reconciliation). 'historical-tx' is a
1260
+ // public submission mode, so it keeps the full SPV check.
766
1261
  if (mode !== 'historical-tx-no-spv') {
767
1262
  this.startTime(`chainTracker_${txid.substring(0, 10)}`);
768
1263
  const txValid = await tx.verify(this.chainTracker);
@@ -811,30 +1306,49 @@ export class Engine {
811
1306
  await this.abortProvisionalAdmissions(validations, failedTopics, taggedBEEF.beef);
812
1307
  throw error;
813
1308
  }
814
- // Call the callback function with STEAK if it is provided (before storage mutations)
815
- if (onSteakReady !== undefined) {
816
- onSteakReady(steak);
817
- }
818
- // ===================================================================
819
- // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
820
- // ===================================================================
821
- // Mark previous outputs as spent and notify lookup services
822
- await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
823
- await this.applyStorageMutations(validations, {
824
- dupeTopics,
825
- failedTopics,
1309
+ const admissionSteak = await this.acknowledgeOverlayAdmission({
1310
+ taggedBEEF,
826
1311
  steak,
827
1312
  tx,
828
1313
  txid,
829
- beef: taggedBEEF.beef,
830
- offChainValues
1314
+ mode,
1315
+ offChainValues,
1316
+ validations,
1317
+ failedTopics,
1318
+ anyTopicAccepted,
1319
+ onSteakReady
831
1320
  });
1321
+ if (admissionSteak !== undefined)
1322
+ return admissionSteak;
1323
+ // ===================================================================
1324
+ // PHASE 3: MUTATE STORAGE (only after broadcast succeeded)
1325
+ // ===================================================================
1326
+ // Mark previous outputs as spent and notify lookup services
1327
+ try {
1328
+ await this.markPreviousOutputsSpent(validations, failedTopics, tx, txid, offChainValues);
1329
+ await this.applyStorageMutations(validations, {
1330
+ dupeTopics,
1331
+ failedTopics,
1332
+ steak,
1333
+ tx,
1334
+ txid,
1335
+ beef: taggedBEEF.beef,
1336
+ offChainValues
1337
+ });
1338
+ }
1339
+ catch (error) {
1340
+ await this.abortProvisionalAdmissions(validations, failedTopics, taggedBEEF.beef);
1341
+ throw error;
1342
+ }
1343
+ // A STEAK is only ready after the corresponding local state is durable.
1344
+ // This prevents a callback from reporting success for a storage mutation
1345
+ // that later fails.
1346
+ this.notifySteakReady(onSteakReady, steak);
832
1347
  // If we don't have an advertiser or we are dealing with historical transactions, just return the steak
833
- if (this.advertiser === undefined || mode === 'historical-tx' || mode === 'historical-tx-no-spv') {
1348
+ if (this.shouldSkipPropagation(mode)) {
834
1349
  return steak;
835
1350
  }
836
1351
  await this.propagateSubmission(taggedBEEF, steak, dupeTopics, tx, txid);
837
- // Immediately return from the function without waiting for the promises to resolve.
838
1352
  return steak;
839
1353
  }
840
1354
  /**
@@ -843,13 +1357,23 @@ export class Engine {
843
1357
  * @returns The answer to the question
844
1358
  */
845
1359
  async lookup(lookupQuestion) {
1360
+ if (typeof lookupQuestion !== 'object' || lookupQuestion === null) {
1361
+ throw new TypeError('Lookup question must be an object');
1362
+ }
1363
+ assertRegistryName(lookupQuestion.service, 'Lookup service name');
846
1364
  // Validate a lookup service for the provider is found
847
- const lookupService = this.lookupServices[lookupQuestion.service];
1365
+ const lookupService = Object.prototype.hasOwnProperty.call(this.lookupServices, lookupQuestion.service)
1366
+ ? this.lookupServices[lookupQuestion.service]
1367
+ : undefined;
848
1368
  if (lookupService === undefined || lookupService === null)
849
1369
  throw new Error(`Lookup service not found for provider: ${lookupQuestion.service}`);
850
1370
  const lookupResult = await lookupService.lookup(lookupQuestion);
851
- if (this.maxLookupResults !== -1 && lookupResult.length > this.maxLookupResults) {
852
- 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}`);
853
1377
  }
854
1378
  const hydrationContext = this.createUTXOHistoryHydrationContext();
855
1379
  await this.preloadOutputsWithBEEF(lookupResult.map(({ txid, outputIndex }) => ({ txid, outputIndex })), hydrationContext);
@@ -870,9 +1394,7 @@ export class Engine {
870
1394
  };
871
1395
  })))
872
1396
  .filter((output) => output !== null)
873
- .map(({ beef, outputIndex, context }) => (context === undefined
874
- ? { beef, outputIndex }
875
- : { beef, outputIndex, context }));
1397
+ .map(({ beef, outputIndex, context }) => context === undefined ? { beef, outputIndex } : { beef, outputIndex, context });
876
1398
  return {
877
1399
  type: 'output-list',
878
1400
  outputs: hydratedOutputs
@@ -880,11 +1402,88 @@ export class Engine {
880
1402
  }
881
1403
  createUTXOHistoryHydrationContext() {
882
1404
  return {
883
- outputCache: new Map()
1405
+ outputCache: new Map(),
1406
+ budgetedOutputs: new Set(),
1407
+ hydratedNodes: 0,
1408
+ relationCount: 0,
1409
+ totalBeefBytes: 0
884
1410
  };
885
1411
  }
886
1412
  toOutputCacheKey(txid, outputIndex) {
887
- return `${txid}:${outputIndex}`;
1413
+ return `${txid.toLowerCase()}:${outputIndex}`;
1414
+ }
1415
+ assertLookupFormula(value) {
1416
+ if (!Array.isArray(value))
1417
+ throw new TypeError('Lookup service must return an array');
1418
+ if (value.length > MAX_LOOKUP_FORMULAS) {
1419
+ throw new RangeError(`Lookup returned more than ${MAX_LOOKUP_FORMULAS} results`);
1420
+ }
1421
+ let totalContextBytes = 0;
1422
+ for (const [index, entry] of value.entries()) {
1423
+ if (typeof entry !== 'object' || entry === null) {
1424
+ throw new TypeError(`Lookup result[${index}] is invalid`);
1425
+ }
1426
+ assertHash(entry.txid, `Lookup result[${index}] txid`);
1427
+ assertOutputIndex(entry.outputIndex, `Lookup result[${index}] output index`);
1428
+ if (entry.history !== undefined &&
1429
+ typeof entry.history !== 'function' &&
1430
+ (!Number.isSafeInteger(entry.history) ||
1431
+ entry.history < -1 ||
1432
+ entry.history > MAX_LOOKUP_HISTORY_DEPTH)) {
1433
+ throw new TypeError(`Lookup result[${index}] history must be -1 through ${MAX_LOOKUP_HISTORY_DEPTH}, or a function`);
1434
+ }
1435
+ if (entry.context !== undefined) {
1436
+ if (!Array.isArray(entry.context) || entry.context.length > MAX_LOOKUP_CONTEXT_BYTES) {
1437
+ throw new TypeError(`Lookup result[${index}] context must be bounded bytes`);
1438
+ }
1439
+ for (let byteIndex = 0; byteIndex < entry.context.length; byteIndex++) {
1440
+ const byte = entry.context[byteIndex];
1441
+ if (!Object.prototype.hasOwnProperty.call(entry.context, byteIndex) ||
1442
+ !Number.isInteger(byte) ||
1443
+ byte < 0 ||
1444
+ byte > 255) {
1445
+ throw new TypeError(`Lookup result[${index}] context must be bounded bytes`);
1446
+ }
1447
+ }
1448
+ totalContextBytes += entry.context.length;
1449
+ if (totalContextBytes > MAX_LOOKUP_TOTAL_CONTEXT_BYTES) {
1450
+ throw new RangeError('Lookup result contexts exceeded their total byte budget');
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ validateLookupOutput(value, label, expected, context) {
1456
+ this.assertStoredOutputRelations(value, label);
1457
+ if (expected !== undefined &&
1458
+ (value.txid.toLowerCase() !== expected.txid.toLowerCase() ||
1459
+ value.outputIndex !== expected.outputIndex)) {
1460
+ throw new TypeError(`${label} does not match the requested outpoint`);
1461
+ }
1462
+ if (value.beef === undefined)
1463
+ return;
1464
+ if (!Array.isArray(value.beef) ||
1465
+ value.beef.length === 0 ||
1466
+ value.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
1467
+ value.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
1468
+ throw new TypeError(`${label} has invalid or oversized transaction BEEF`);
1469
+ }
1470
+ const transaction = Transaction.fromBEEF(value.beef);
1471
+ if (transaction.id('hex').toLowerCase() !== value.txid.toLowerCase()) {
1472
+ throw new TypeError(`${label} BEEF does not match its transaction ID`);
1473
+ }
1474
+ if (value.outputIndex >= transaction.outputs.length) {
1475
+ throw new TypeError(`${label} index is outside its transaction`);
1476
+ }
1477
+ if (context !== undefined) {
1478
+ const key = this.toOutputCacheKey(value.txid, value.outputIndex);
1479
+ if (!context.budgetedOutputs.has(key)) {
1480
+ context.budgetedOutputs.add(key);
1481
+ context.totalBeefBytes += value.beef.length;
1482
+ if (context.totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
1483
+ throw new RangeError('Lookup transaction BEEF exceeded its total byte budget');
1484
+ }
1485
+ }
1486
+ }
888
1487
  }
889
1488
  async preloadOutputsWithBEEF(outpoints, context) {
890
1489
  if (outpoints.length === 0) {
@@ -893,6 +1492,8 @@ export class Engine {
893
1492
  const deduped = [];
894
1493
  const seen = new Set();
895
1494
  for (const outpoint of outpoints) {
1495
+ assertHash(outpoint.txid, 'Lookup outpoint txid');
1496
+ assertOutputIndex(outpoint.outputIndex, 'Lookup outpoint output index');
896
1497
  const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
897
1498
  if (seen.has(cacheKey)) {
898
1499
  continue;
@@ -905,12 +1506,25 @@ export class Engine {
905
1506
  if (deduped.length === 0) {
906
1507
  return;
907
1508
  }
1509
+ if (context.outputCache.size + deduped.length > MAX_LOOKUP_HYDRATION_NODES) {
1510
+ throw new RangeError('Lookup output traversal exceeded its work budget');
1511
+ }
908
1512
  const findOutputsByOutpoints = this.storage.findOutputsByOutpoints;
909
1513
  if (typeof findOutputsByOutpoints === 'function') {
910
1514
  const outputs = await findOutputsByOutpoints.call(this.storage, deduped, true);
1515
+ if (!Array.isArray(outputs) || outputs.length > MAX_LOOKUP_STORAGE_ROWS) {
1516
+ throw new TypeError('Storage returned invalid or oversized batched lookup outputs');
1517
+ }
1518
+ const requestedKeys = new Set(deduped.map(outpoint => this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex)));
911
1519
  const outputsByKey = new Map();
912
- for (const output of outputs) {
913
- 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);
914
1528
  }
915
1529
  for (const outpoint of deduped) {
916
1530
  const cacheKey = this.toOutputCacheKey(outpoint.txid, outpoint.outputIndex);
@@ -931,43 +1545,80 @@ export class Engine {
931
1545
  context.outputCache.set(cacheKey, cached);
932
1546
  }
933
1547
  const output = await cached;
1548
+ if (output !== null && output !== undefined) {
1549
+ this.validateLookupOutput(output, 'Lookup output', { txid, outputIndex }, context);
1550
+ }
934
1551
  return output ?? null;
935
1552
  }
936
- async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context) {
937
- if (output.beef === undefined) {
938
- throw new Error('Output must have associated transaction BEEF!');
939
- }
1553
+ async hydrateUTXOHistoryNode(output, historySelector, currentDepth, context, ancestors = new Set()) {
940
1554
  let shouldTraverseHistory;
941
1555
  if (typeof historySelector === 'number') {
942
1556
  shouldTraverseHistory = currentDepth <= historySelector;
943
1557
  }
944
1558
  else {
1559
+ if (currentDepth > MAX_LOOKUP_HISTORY_DEPTH) {
1560
+ throw new RangeError('Lookup history exceeded its maximum depth');
1561
+ }
1562
+ if (output.beef === undefined) {
1563
+ throw new Error('Output must have associated transaction BEEF!');
1564
+ }
945
1565
  shouldTraverseHistory = await historySelector(output.beef, output.outputIndex, currentDepth);
1566
+ if (typeof shouldTraverseHistory !== 'boolean') {
1567
+ throw new TypeError('Lookup history selector must return a boolean');
1568
+ }
946
1569
  }
947
1570
  if (shouldTraverseHistory === false) {
948
1571
  return undefined;
949
1572
  }
950
- await this.preloadOutputsWithBEEF(output.outputsConsumed, context);
951
- const childNodes = (await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
952
- const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context);
953
- if (childOutput === null) {
954
- return undefined;
955
- }
956
- return await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context);
957
- }))).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
+ }
958
1589
  const tx = Transaction.fromBEEF(output.beef);
959
1590
  const inputIndexBySource = new Map();
960
1591
  tx.inputs.forEach((candidateInput, index) => {
961
1592
  const sourceTXID = candidateInput.sourceTXID !== undefined && candidateInput.sourceTXID !== ''
962
1593
  ? candidateInput.sourceTXID
963
1594
  : candidateInput.sourceTransaction?.id('hex');
964
- if (sourceTXID === undefined) {
1595
+ if (sourceTXID === undefined)
965
1596
  return;
1597
+ const key = this.toOutputCacheKey(sourceTXID, candidateInput.sourceOutputIndex);
1598
+ if (inputIndexBySource.has(key)) {
1599
+ throw new TypeError('Lookup history transaction contains duplicate source outpoints');
966
1600
  }
967
- inputIndexBySource.set(`${sourceTXID}:${candidateInput.sourceOutputIndex}`, index);
1601
+ inputIndexBySource.set(key, index);
968
1602
  });
1603
+ for (const relation of output.outputsConsumed) {
1604
+ if (!inputIndexBySource.has(this.toOutputCacheKey(relation.txid, relation.outputIndex))) {
1605
+ throw new TypeError('Lookup history relation is not an input of its transaction');
1606
+ }
1607
+ }
1608
+ await this.preloadOutputsWithBEEF(output.outputsConsumed, context);
1609
+ const nextAncestors = new Set(ancestors);
1610
+ nextAncestors.add(outputKey);
1611
+ const childNodes = [];
1612
+ for (const outputIdentifier of output.outputsConsumed) {
1613
+ const childOutput = await this.loadOutputWithBEEF(outputIdentifier.txid, outputIdentifier.outputIndex, context);
1614
+ if (childOutput === null)
1615
+ continue;
1616
+ const childNode = await this.hydrateUTXOHistoryNode(childOutput, historySelector, currentDepth + 1, context, nextAncestors);
1617
+ if (childNode !== undefined)
1618
+ childNodes.push(childNode);
1619
+ }
969
1620
  for (const child of childNodes) {
970
- const inputIndex = inputIndexBySource.get(`${child.output.txid}:${child.output.outputIndex}`);
1621
+ const inputIndex = inputIndexBySource.get(this.toOutputCacheKey(child.output.txid, child.output.outputIndex));
971
1622
  if (inputIndex === -1 || inputIndex == null) {
972
1623
  continue;
973
1624
  }
@@ -999,6 +1650,54 @@ export class Engine {
999
1650
  * @throws Will throw an error if there are issues during the advertisement synchronization process.
1000
1651
  * @returns {Promise<void>} A promise that resolves when the synchronization process is complete.
1001
1652
  */
1653
+ validateCurrentAdvertisements(value, protocol, advertiser) {
1654
+ if (!Array.isArray(value) || value.length > MAX_CURRENT_ADVERTISEMENTS) {
1655
+ throw new TypeError('Advertiser returned an invalid or oversized advertisement set');
1656
+ }
1657
+ let totalBeefBytes = 0;
1658
+ return value.map((candidate, index) => {
1659
+ if (typeof candidate !== 'object' || candidate === null) {
1660
+ throw new TypeError(`Advertisement[${index}] is invalid`);
1661
+ }
1662
+ const advertisement = candidate;
1663
+ if (advertisement.protocol !== protocol) {
1664
+ throw new TypeError(`Advertisement[${index}] has the wrong protocol`);
1665
+ }
1666
+ assertRegistryName(advertisement.topicOrService, `Advertisement[${index}] name`);
1667
+ if (typeof advertisement.identityKey !== 'string' ||
1668
+ !/^(?:02|03)[0-9a-fA-F]{64}$/.test(advertisement.identityKey) ||
1669
+ typeof advertisement.domain !== 'string' ||
1670
+ !this.isValidUrl(advertisement.domain) ||
1671
+ !Array.isArray(advertisement.beef) ||
1672
+ advertisement.beef.length === 0 ||
1673
+ advertisement.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
1674
+ advertisement.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
1675
+ throw new TypeError(`Advertisement[${index}] has invalid identity, domain, or BEEF`);
1676
+ }
1677
+ assertOutputIndex(advertisement.outputIndex, `Advertisement[${index}] output index`);
1678
+ totalBeefBytes += advertisement.beef.length;
1679
+ if (totalBeefBytes > MAX_LOOKUP_TOTAL_BEEF_BYTES) {
1680
+ throw new RangeError('Advertisement BEEF exceeded its total byte budget');
1681
+ }
1682
+ const transaction = Transaction.fromBEEF(advertisement.beef);
1683
+ if (advertisement.outputIndex >= transaction.outputs.length) {
1684
+ throw new TypeError(`Advertisement[${index}] output index is outside its transaction`);
1685
+ }
1686
+ const parsed = advertiser.parseAdvertisement(transaction.outputs[advertisement.outputIndex].lockingScript);
1687
+ if (parsed.protocol !== advertisement.protocol ||
1688
+ parsed.identityKey.toLowerCase() !== advertisement.identityKey.toLowerCase() ||
1689
+ parsed.topicOrService !== advertisement.topicOrService ||
1690
+ normalizePeerEndpoint(parsed.domain) !== normalizePeerEndpoint(advertisement.domain)) {
1691
+ throw new TypeError(`Advertisement[${index}] metadata does not match its BEEF`);
1692
+ }
1693
+ return {
1694
+ ...advertisement,
1695
+ identityKey: advertisement.identityKey.toLowerCase(),
1696
+ domain: normalizePeerEndpoint(advertisement.domain),
1697
+ beef: [...advertisement.beef]
1698
+ };
1699
+ });
1700
+ }
1002
1701
  async syncAdvertisements() {
1003
1702
  if (this.advertiser === undefined ||
1004
1703
  typeof this.hostingURL !== 'string' ||
@@ -1007,6 +1706,7 @@ export class Engine {
1007
1706
  return;
1008
1707
  }
1009
1708
  const advertiser = this.advertiser;
1709
+ const normalizedHostingURL = normalizePeerEndpoint(this.hostingURL);
1010
1710
  // Step 1: Retrieve Current Configuration
1011
1711
  let configuredTopics = Object.keys(this.managers);
1012
1712
  let configuredServices = Object.keys(this.lookupServices);
@@ -1016,13 +1716,13 @@ export class Engine {
1016
1716
  configuredServices = configuredServices.filter(service => service !== 'ls_ship' && service !== 'ls_slap');
1017
1717
  }
1018
1718
  // Step 2: Fetch Existing Advertisements
1019
- const currentSHIPAdvertisements = await advertiser.findAllAdvertisements('SHIP');
1020
- const currentSLAPAdvertisements = await advertiser.findAllAdvertisements('SLAP');
1719
+ const currentSHIPAdvertisements = this.validateCurrentAdvertisements(await advertiser.findAllAdvertisements('SHIP'), 'SHIP', advertiser);
1720
+ const currentSLAPAdvertisements = this.validateCurrentAdvertisements(await advertiser.findAllAdvertisements('SLAP'), 'SLAP', advertiser);
1021
1721
  // Step 3: Compare and Determine Actions
1022
1722
  const requiredSHIPAdvertisements = new Set(configuredTopics);
1023
1723
  const requiredSLAPAdvertisements = new Set(configuredServices);
1024
- const shipsToCreate = Array.from(requiredSHIPAdvertisements).filter(topicOrService => !currentSHIPAdvertisements.some(x => x.topicOrService === topicOrService && x.domain === this.hostingURL));
1025
- 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));
1026
1726
  const shipsToRevoke = currentSHIPAdvertisements.filter(ad => !requiredSHIPAdvertisements.has(ad.topicOrService));
1027
1727
  const slapsToRevoke = currentSLAPAdvertisements.filter(ad => !requiredSLAPAdvertisements.has(ad.topicOrService));
1028
1728
  // Create needed SHIP/SLAP advertisements
@@ -1048,7 +1748,10 @@ export class Engine {
1048
1748
  // Revoke all advertisements to revoke
1049
1749
  try {
1050
1750
  if (shipsToRevoke.length > 0 || slapsToRevoke.length > 0) {
1051
- const taggedBEEF = await advertiser.revokeAdvertisements([...shipsToRevoke, ...slapsToRevoke]);
1751
+ const taggedBEEF = await advertiser.revokeAdvertisements([
1752
+ ...shipsToRevoke,
1753
+ ...slapsToRevoke
1754
+ ]);
1052
1755
  await this.submit(taggedBEEF);
1053
1756
  }
1054
1757
  }
@@ -1086,6 +1789,7 @@ export class Engine {
1086
1789
  this.logger.info(`[GASP SYNC] Starting sync for topic "${topic}" with peer "${endpoint}"`);
1087
1790
  try {
1088
1791
  const lastInteraction = await this.storage.getLastInteraction(endpoint, topic);
1792
+ assertNonnegativeInteger(lastInteraction, 'Stored GASP last interaction');
1089
1793
  const gasp = new GASP(new OverlayGASPStorage(topic, this), new OverlayGASPRemote(endpoint, topic), lastInteraction, `[GASP Sync of ${topic} with ${endpoint}]`, true, true);
1090
1794
  await gasp.sync(endpoint, DEFAULT_GASP_SYNC_LIMIT);
1091
1795
  if (gasp.lastInteraction > lastInteraction) {
@@ -1119,84 +1823,241 @@ export class Engine {
1119
1823
  });
1120
1824
  const endpointSet = new Set();
1121
1825
  if (lookupAnswer.type === 'output-list') {
1122
- lookupAnswer.outputs.forEach(output => {
1826
+ for (const output of lookupAnswer.outputs) {
1123
1827
  try {
1124
1828
  const tx = Transaction.fromBEEF(output.beef);
1125
- const advertisement = this.advertiser?.parseAdvertisement(tx.outputs[output.outputIndex].lockingScript);
1126
- if (advertisement?.protocol === 'SHIP') {
1127
- 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');
1834
+ }
1835
+ const selectedOutput = tx.outputs[output.outputIndex];
1836
+ if (selectedOutput == null || selectedOutput.satoshis !== 1) {
1837
+ throw new TypeError('SHIP advertisement must identify a one-satoshi output');
1838
+ }
1839
+ const advertisement = await decodeAndVerifyDiscoveryAdvertisement(selectedOutput.lockingScript, 'SHIP');
1840
+ if (advertisement.topicOrService !== topic) {
1841
+ throw new TypeError('SHIP advertisement does not match the requested topic');
1128
1842
  }
1843
+ endpointSet.add(advertisement.domain);
1129
1844
  }
1130
1845
  catch (error) {
1131
1846
  this.logger.error(advertisementErrorMessage, error);
1132
1847
  }
1133
- });
1848
+ }
1134
1849
  }
1135
1850
  syncEndpoints = Array.from(endpointSet);
1136
1851
  }
1137
1852
  if (!Array.isArray(syncEndpoints)) {
1138
1853
  return [];
1139
1854
  }
1140
- 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
+ }
1141
1930
  }
1142
1931
  async provideTopicAnchorTip(topic) {
1932
+ if (typeof this.storage.findTopicAnchorTip !== 'function') {
1933
+ throw Object.assign(new TypeError('Storage does not support BASM topic anchor tips'), {
1934
+ code: 'BASM_UNSUPPORTED'
1935
+ });
1936
+ }
1143
1937
  const tip = await this.storage.findTopicAnchorTip?.(topic);
1144
- return tip ?? {
1938
+ if (tip !== undefined) {
1939
+ this.assertTopicAnchorTip(tip, topic, 'Stored BASM tip');
1940
+ return tip;
1941
+ }
1942
+ return {
1145
1943
  topic,
1146
1944
  blockHeight: -1,
1147
1945
  tac: BASM_ZERO_HASH
1148
1946
  };
1149
1947
  }
1150
1948
  async provideTopicAnchorRange(topic, fromHeight, toHeight) {
1949
+ this.assertSupportedBASMTopic(topic);
1151
1950
  if (typeof this.storage.findTopicBlockAnchors !== 'function') {
1152
- throw new TypeError('Storage does not support BASM topic anchor ranges');
1951
+ throw Object.assign(new TypeError('Storage does not support BASM topic anchor ranges'), {
1952
+ code: 'BASM_UNSUPPORTED'
1953
+ });
1153
1954
  }
1154
- if (!Number.isInteger(fromHeight) || !Number.isInteger(toHeight) || fromHeight < 0 || toHeight < fromHeight) {
1955
+ if (!Number.isSafeInteger(fromHeight) ||
1956
+ !Number.isSafeInteger(toHeight) ||
1957
+ fromHeight < 0 ||
1958
+ toHeight < fromHeight) {
1155
1959
  throw new Error('Invalid topic anchor range');
1156
1960
  }
1157
1961
  if (toHeight - fromHeight + 1 > DEFAULT_BASM_RANGE_LIMIT) {
1158
1962
  throw new Error(`Topic anchor range is capped at ${DEFAULT_BASM_RANGE_LIMIT} heights`);
1159
1963
  }
1160
- return {
1161
- topic,
1162
- anchors: await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight)
1163
- };
1964
+ const anchors = await this.storage.findTopicBlockAnchors(topic, fromHeight, toHeight, toHeight - fromHeight + 2);
1965
+ if (!Array.isArray(anchors) || anchors.length > toHeight - fromHeight + 1) {
1966
+ throw new TypeError('Storage returned an invalid or oversized BASM anchor range');
1967
+ }
1968
+ let previousHeight = -1;
1969
+ for (const anchor of anchors) {
1970
+ this.assertTopicBlockAnchor(anchor, topic, 'Stored BASM anchor');
1971
+ if (anchor.blockHeight < fromHeight ||
1972
+ anchor.blockHeight > toHeight ||
1973
+ anchor.blockHeight <= previousHeight) {
1974
+ throw new TypeError('Storage returned an unbound or unordered BASM anchor range');
1975
+ }
1976
+ previousHeight = anchor.blockHeight;
1977
+ }
1978
+ return { topic, anchors };
1164
1979
  }
1165
1980
  async provideAdmittedList(topic, blockHeight, blockHash) {
1981
+ this.assertSupportedBASMTopic(topic);
1166
1982
  if (typeof this.storage.findAdmittedTransactionsForBlock !== 'function') {
1167
- throw new TypeError('Storage does not support BASM admitted lists');
1983
+ throw Object.assign(new TypeError('Storage does not support BASM admitted lists'), {
1984
+ code: 'BASM_UNSUPPORTED'
1985
+ });
1986
+ }
1987
+ assertNonnegativeInteger(blockHeight, 'BASM admitted-list height');
1988
+ if (blockHash !== undefined)
1989
+ assertHash(blockHash, 'BASM admitted-list block hash');
1990
+ const admitted = await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash, MAX_BASM_ADMITTED_PER_BLOCK + 1);
1991
+ if (!Array.isArray(admitted) || admitted.length > MAX_BASM_ADMITTED_PER_BLOCK) {
1992
+ throw new RangeError('Storage returned an invalid or oversized BASM admitted list');
1993
+ }
1994
+ const seen = new Set();
1995
+ const seenBlockIndexes = new Set();
1996
+ for (const [index, item] of admitted.entries()) {
1997
+ if (typeof item !== 'object' || item === null)
1998
+ throw new TypeError(`Invalid BASM admission at index ${index}`);
1999
+ assertHash(item.txid, `BASM admission[${index}] txid`);
2000
+ assertNonnegativeInteger(item.blockIndex, `BASM admission[${index}] block index`);
2001
+ const canonical = item.txid.toLowerCase();
2002
+ if (seen.has(canonical))
2003
+ throw new TypeError('Storage returned duplicate BASM admissions');
2004
+ if (seenBlockIndexes.has(item.blockIndex)) {
2005
+ throw new TypeError('Storage returned duplicate BASM block indexes');
2006
+ }
2007
+ seen.add(canonical);
2008
+ seenBlockIndexes.add(item.blockIndex);
1168
2009
  }
1169
2010
  return {
1170
2011
  topic,
1171
2012
  blockHeight,
1172
2013
  blockHash,
1173
- admitted: await this.storage.findAdmittedTransactionsForBlock(topic, blockHeight, blockHash)
2014
+ admitted
1174
2015
  };
1175
2016
  }
1176
2017
  async provideCompoundMerklePath(topic, blockHeight, txids) {
2018
+ this.assertSupportedBASMTopic(topic);
1177
2019
  if (typeof this.storage.findTransactionMerklePaths !== 'function') {
1178
- throw new TypeError('Storage does not support direct Merkle path lookup');
1179
- }
1180
- if (txids.length === 0) {
1181
- throw new Error('At least one txid is required');
2020
+ throw Object.assign(new TypeError('Storage does not support direct Merkle path lookup'), {
2021
+ code: 'BASM_UNSUPPORTED'
2022
+ });
1182
2023
  }
1183
- 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);
1184
2027
  if (admitted !== undefined) {
1185
- const admittedSet = new Set(admitted.map(item => item.txid));
1186
- 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()));
1187
2034
  if (missingAdmissions.length > 0) {
1188
2035
  throw new Error(`Requested txids are not admitted to topic ${topic} at height ${blockHeight}: ${missingAdmissions.join(',')}`);
1189
2036
  }
1190
2037
  }
1191
2038
  const proofs = await this.storage.findTransactionMerklePaths(txids);
1192
- const proofByTxid = new Map(proofs.map(proof => [proof.txid, proof]));
1193
- 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()));
1194
2055
  if (missing.length > 0) {
1195
2056
  throw new Error(`No direct Merkle path found for txids: ${missing.join(',')}`);
1196
2057
  }
1197
2058
  let compound;
1198
2059
  for (const txid of txids) {
1199
- const proof = proofByTxid.get(txid);
2060
+ const proof = proofByTxid.get(txid.toLowerCase());
1200
2061
  if (proof === undefined)
1201
2062
  continue;
1202
2063
  const path = MerklePath.fromHex(proof.merklePath);
@@ -1220,15 +2081,43 @@ export class Engine {
1220
2081
  merklePath: compound.toHex()
1221
2082
  };
1222
2083
  }
1223
- async provideRawTransactions(txids) {
2084
+ async provideRawTransactions(txids, topic) {
1224
2085
  if (typeof this.storage.findRawTransactions !== 'function') {
1225
- throw new TypeError('Storage does not support raw transaction lookup');
2086
+ throw Object.assign(new TypeError('Storage does not support raw transaction lookup'), {
2087
+ code: 'BASM_UNSUPPORTED'
2088
+ });
2089
+ }
2090
+ assertTxidList(txids, 'BASM raw-transaction txids');
2091
+ if (topic === undefined) {
2092
+ throw new TypeError('A BASM topic is required for raw transaction lookup');
2093
+ }
2094
+ this.assertSupportedBASMTopic(topic);
2095
+ const authorizedTxids = (await Promise.all(txids.map(async (txid) => {
2096
+ const authorized = await this.storage.doesAppliedTransactionExist({ txid, topic });
2097
+ if (typeof authorized !== 'boolean') {
2098
+ throw new TypeError('Storage returned an invalid BASM authorization verdict');
2099
+ }
2100
+ return { txid, authorized };
2101
+ })))
2102
+ .filter(entry => entry.authorized)
2103
+ .map(entry => entry.txid);
2104
+ const transactions = await this.storage.findRawTransactions(authorizedTxids);
2105
+ if (!Array.isArray(transactions) || transactions.length > authorizedTxids.length) {
2106
+ throw new TypeError('Storage returned an invalid BASM raw-transaction set');
2107
+ }
2108
+ const requested = new Set(authorizedTxids.map(txid => txid.toLowerCase()));
2109
+ const found = new Set();
2110
+ for (const transaction of transactions) {
2111
+ assertRawTransactionMatches(transaction.txid, transaction.rawTx);
2112
+ const canonical = transaction.txid.toLowerCase();
2113
+ if (!requested.has(canonical) || found.has(canonical)) {
2114
+ throw new TypeError('Storage returned an unexpected or duplicate BASM raw transaction');
2115
+ }
2116
+ found.add(canonical);
1226
2117
  }
1227
- const transactions = await this.storage.findRawTransactions(txids);
1228
- const found = new Set(transactions.map(tx => tx.txid));
1229
2118
  return {
1230
2119
  transactions,
1231
- missing: txids.filter(txid => !found.has(txid))
2120
+ missing: txids.filter(txid => !found.has(txid.toLowerCase()))
1232
2121
  };
1233
2122
  }
1234
2123
  async startBASMSync() {
@@ -1254,40 +2143,122 @@ export class Engine {
1254
2143
  fetchedTxCount: 0
1255
2144
  };
1256
2145
  try {
1257
- const remote = new BASMRemote(endpoint, topic);
2146
+ const remote = new BASMRemote(endpoint, topic, this.basmFetchImpl);
1258
2147
  const [localTip, remoteTip] = await Promise.all([
1259
2148
  this.provideTopicAnchorTip(topic),
1260
2149
  remote.requestTopicAnchorTip()
1261
2150
  ]);
1262
2151
  report.localTip = localTip;
1263
2152
  report.remoteTip = remoteTip;
2153
+ await this.requireMatchingRemoteBASMTip(remote, remoteTip);
1264
2154
  if (localTip.blockHeight >= remoteTip.blockHeight) {
1265
- report.status = localTip.tac === remoteTip.tac && localTip.blockHeight === remoteTip.blockHeight ? 'matched' : 'diverged';
1266
- report.message = report.status === 'matched'
1267
- ? 'Topic anchor tips match'
1268
- : 'Remote tip is not ahead; historical divergence needs manual or binary-search reconciliation';
1269
- return report;
2155
+ return await this.finishBASMWhenRemoteIsNotAhead(topic, localTip, remoteTip, report);
1270
2156
  }
1271
- const fromHeight = Math.max(localTip.blockHeight + 1, remoteTip.blockHeight - DEFAULT_BASM_RANGE_LIMIT + 1, 0);
1272
- const range = await remote.requestTopicAnchorRange(fromHeight, remoteTip.blockHeight);
1273
- for (const remoteAnchor of range.anchors) {
1274
- await this.reconcileRemoteAnchor(topic, remote, remoteAnchor, report);
1275
- }
1276
- const refreshedTip = await this.provideTopicAnchorTip(topic);
1277
- report.localTip = refreshedTip;
1278
- report.status = refreshedTip.blockHeight >= remoteTip.blockHeight && refreshedTip.tac === remoteTip.tac ? 'matched' : 'advanced';
1279
- return report;
2157
+ return await this.advanceBASMWithRemoteAnchors(topic, remote, localTip, remoteTip, report);
1280
2158
  }
1281
2159
  catch (error) {
1282
- report.status = 'error';
1283
- report.message = error instanceof Error ? error.message : String(error);
1284
- this.logger.error(`[BASM SYNC] Sync failed for topic "${topic}" with peer "${endpoint}"`, error);
1285
- 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);
1286
2253
  }
1287
2254
  }
1288
2255
  async reconcileRemoteAnchor(topic, remote, remoteAnchor, report) {
2256
+ await this.requireCanonicalBASMAnchor(remoteAnchor);
1289
2257
  report.checkedHeights.push(remoteAnchor.blockHeight);
1290
2258
  const localAnchor = await this.storage.findTopicBlockAnchor?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash);
2259
+ if (localAnchor !== undefined) {
2260
+ this.assertTopicBlockAnchor(localAnchor, topic, 'Stored local BASM anchor', remoteAnchor.blockHeight, remoteAnchor.blockHash);
2261
+ }
1291
2262
  if (localAnchor?.tac === remoteAnchor.tac) {
1292
2263
  return;
1293
2264
  }
@@ -1297,60 +2268,119 @@ export class Engine {
1297
2268
  admittedResponse.admitted.length !== remoteAnchor.admittedCount) {
1298
2269
  throw new Error(`Peer ${report.endpoint} supplied an admitted list inconsistent with its anchor at height ${remoteAnchor.blockHeight}`);
1299
2270
  }
1300
- const localAdmitted = await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash) ?? [];
2271
+ const localAdmitted = (await this.storage.findAdmittedTransactionsForBlock?.(topic, remoteAnchor.blockHeight, remoteAnchor.blockHash)) ?? [];
2272
+ // BRC-136: an empty topic at a height has k = 0 and R = 32 zero bytes, which
2273
+ // the root/count check above has already enforced. There is no transaction
2274
+ // to bind to a proof or to fetch, so the height is checked without a proof
2275
+ // round trip; it only diverges when this node admitted something there.
2276
+ if (admittedResponse.admitted.length === 0) {
2277
+ if (localAdmitted.length > 0)
2278
+ report.status = 'diverged';
2279
+ return;
2280
+ }
1301
2281
  const localTxids = new Set(localAdmitted.map(item => item.txid));
1302
2282
  const missingTxids = admittedResponse.admitted
1303
2283
  .map(item => item.txid)
1304
2284
  .filter(txid => !localTxids.has(txid));
1305
2285
  report.missingTxids.push(...missingTxids);
2286
+ // The BASM root only commits to txid order. Bind claimed original indices to
2287
+ // the compound path even when every remote txid is already local.
2288
+ const assurance = await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, admittedResponse.admitted, missingTxids);
2289
+ if (report.positionValidation !== 'encoded-offset-only')
2290
+ report.positionValidation = assurance;
1306
2291
  if (missingTxids.length === 0) {
1307
2292
  report.status = 'diverged';
1308
2293
  return;
1309
2294
  }
1310
- await this.fetchBASMMissingTransactions(remote, topic, remoteAnchor, missingTxids);
1311
2295
  report.fetchedTxCount += missingTxids.length;
1312
2296
  }
1313
- async fetchBASMMissingTransactions(remote, topic, anchor, txids) {
2297
+ async fetchBASMMissingTransactions(remote, topic, anchor, admitted, txids) {
1314
2298
  if (this.chainTracker === 'scripts only') {
1315
2299
  throw new Error('BASM reconciliation requires a ChainTracker capable of validating BUMP proofs');
1316
2300
  }
1317
- 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));
1318
2304
  const compoundPath = MerklePath.fromHex(proofResponse.merklePath);
1319
- for (const txid of txids) {
1320
- const valid = await compoundPath.verify(txid, this.chainTracker);
1321
- if (!valid) {
1322
- throw new Error(`Peer supplied invalid compound Merkle path for ${txid} at height ${anchor.blockHeight}`);
1323
- }
2305
+ requireBASM(compoundPath.blockHeight === anchor.blockHeight, 'BASM proof height does not match its anchor');
2306
+ requireBASM(compoundPath.toHex() === proofResponse.merklePath.toLowerCase(), 'BASM proof is not canonically encoded');
2307
+ const proofRoot = compoundPath.computeRoot();
2308
+ const proofHeader = await this.requireCanonicalBASMAnchor(anchor, proofRoot);
2309
+ if (proofHeader.blockTransactionCount !== undefined) {
2310
+ this.validateBASMProofPositions(compoundPath, admitted, proofHeader.blockTransactionCount);
2311
+ }
2312
+ for (const { txid, blockIndex } of admitted) {
2313
+ const leaf = compoundPath.path[0]?.find(item => item.hash === txid);
2314
+ requireBASM(leaf?.offset === blockIndex, 'BASM proof does not bind the admitted block index');
2315
+ requireBASM(compoundPath.path[0].length !== 1 || compoundPath.path.length !== 1 || blockIndex === 0, 'BASM singleton proof has a nonzero block index');
2316
+ requireBASM(compoundPath.computeRoot(txid) === proofRoot, 'BASM proof root does not match the admitted transaction');
2317
+ }
2318
+ // Inclusion is root/height, not coinbase maturity. MerklePath.verify also
2319
+ // applies the 100-block spendability rule at offset 0.
2320
+ const valid = await this.chainTracker.isValidRootForHeight(proofRoot, compoundPath.blockHeight);
2321
+ if (!valid) {
2322
+ throw new Error(`Peer supplied invalid compound Merkle path at height ${anchor.blockHeight}`);
2323
+ }
2324
+ if (txids.length === 0) {
2325
+ return proofHeader.blockTransactionCount === undefined
2326
+ ? 'encoded-offset-only'
2327
+ : 'canonical-count';
1324
2328
  }
1325
2329
  const rawResponse = await remote.requestRawTransactions(txids);
1326
2330
  if (rawResponse.missing.length > 0) {
1327
2331
  throw new Error(`Peer did not return raw transactions for txids: ${rawResponse.missing.join(',')}`);
1328
2332
  }
1329
- for (const record of rawResponse.transactions) {
2333
+ const transactions = rawResponse.transactions.map(record => {
1330
2334
  const tx = Transaction.fromHex(record.rawTx);
1331
- if (tx.id('hex') !== record.txid) {
2335
+ if (tx.id('hex') !== record.txid || tx.toHex() !== record.rawTx.toLowerCase()) {
1332
2336
  throw new Error(`Raw transaction txid mismatch: expected ${record.txid}, got ${tx.id('hex')}`);
1333
2337
  }
1334
- try {
1335
- tx.merklePath = compoundPath.extract([record.txid]);
1336
- }
1337
- catch {
1338
- tx.merklePath = compoundPath;
1339
- }
1340
- 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');
1341
2358
  }
2359
+ const finalHeader = await this.requireCanonicalBASMAnchor(anchor, proofRoot);
2360
+ requireBASM(finalHeader.blockTransactionCount === proofHeader.blockTransactionCount, 'BASM canonical block transaction count changed during admission');
2361
+ return proofHeader.blockTransactionCount === undefined
2362
+ ? 'encoded-offset-only'
2363
+ : 'canonical-count';
1342
2364
  }
1343
2365
  async evictUnprovenTransactions(options = {}) {
1344
2366
  if (typeof this.storage.findUnprovenAppliedTransactions !== 'function') {
1345
2367
  throw new TypeError('Storage does not support unproven transaction eviction');
1346
2368
  }
2369
+ if (typeof this.storage.deleteAppliedTransaction !== 'function') {
2370
+ throw new TypeError('Storage does not support applied transaction eviction');
2371
+ }
1347
2372
  if (this.chainTracker === 'scripts only') {
1348
2373
  throw new Error('Unproven eviction requires a ChainTracker to determine block age');
1349
2374
  }
2375
+ if (options.topic !== undefined)
2376
+ assertTopic(options.topic, 'Unproven eviction topic');
1350
2377
  const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
2378
+ assertUnprovenThreshold(thresholdBlocks);
1351
2379
  const currentHeight = await this.chainTracker.currentHeight();
2380
+ assertNonnegativeInteger(currentHeight, 'Current chain height');
1352
2381
  const cutoffHeight = currentHeight - thresholdBlocks;
1353
- const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
2382
+ const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic, { maxCandidates: MAX_UNPROVEN_CANDIDATES, maxOutputs: MAX_EVICTION_OUTPUTS });
2383
+ this.validateUnprovenCandidates(candidates, cutoffHeight, options.topic);
1354
2384
  let evictedOutputs = 0;
1355
2385
  for (const candidate of candidates) {
1356
2386
  for (const output of candidate.outputs) {
@@ -1365,7 +2395,7 @@ export class Engine {
1365
2395
  await this.storage.deleteOutput(output.txid, output.outputIndex, candidate.topic);
1366
2396
  evictedOutputs++;
1367
2397
  }
1368
- await this.storage.deleteAppliedTransaction?.(candidate.txid, candidate.topic);
2398
+ await this.storage.deleteAppliedTransaction(candidate.txid, candidate.topic);
1369
2399
  }
1370
2400
  return {
1371
2401
  cutoffHeight,
@@ -1381,10 +2411,20 @@ export class Engine {
1381
2411
  if (this.chainTracker === 'scripts only') {
1382
2412
  throw new Error('Unproven proof refresh requires a ChainTracker to determine block age');
1383
2413
  }
2414
+ if (typeof options !== 'object' ||
2415
+ options === null ||
2416
+ typeof options.proofProvider !== 'function') {
2417
+ throw new TypeError('Unproven proof refresh requires a proofProvider');
2418
+ }
2419
+ if (options.topic !== undefined)
2420
+ assertTopic(options.topic, 'Unproven refresh topic');
1384
2421
  const thresholdBlocks = options.thresholdBlocks ?? this.unprovenEvictionBlocks;
2422
+ assertUnprovenThreshold(thresholdBlocks);
1385
2423
  const currentHeight = await this.chainTracker.currentHeight();
2424
+ assertNonnegativeInteger(currentHeight, 'Current chain height');
1386
2425
  const cutoffHeight = currentHeight - thresholdBlocks;
1387
- const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic);
2426
+ const candidates = await this.storage.findUnprovenAppliedTransactions(cutoffHeight, options.topic, { maxCandidates: MAX_UNPROVEN_CANDIDATES, maxOutputs: MAX_EVICTION_OUTPUTS });
2427
+ this.validateUnprovenCandidates(candidates, cutoffHeight, options.topic);
1388
2428
  const txids = [...new Set(candidates.map(candidate => candidate.txid))];
1389
2429
  let refreshedTransactions = 0;
1390
2430
  let missingProofs = 0;
@@ -1404,7 +2444,7 @@ export class Engine {
1404
2444
  failedProofs++;
1405
2445
  failures.push({
1406
2446
  txid,
1407
- error: error instanceof Error ? error.message : String(error)
2447
+ error: (error instanceof Error ? error.message : String(error)).slice(0, 1024)
1408
2448
  });
1409
2449
  }
1410
2450
  }
@@ -1417,6 +2457,57 @@ export class Engine {
1417
2457
  failures
1418
2458
  };
1419
2459
  }
2460
+ validateUnprovenCandidates(candidates, cutoffHeight, requestedTopic) {
2461
+ if (!Array.isArray(candidates) || candidates.length > MAX_UNPROVEN_CANDIDATES) {
2462
+ throw new TypeError('Storage returned an invalid or oversized unproven candidate set');
2463
+ }
2464
+ const seenCandidates = new Set();
2465
+ const seenOutputs = new Set();
2466
+ let outputCount = 0;
2467
+ for (const [candidateIndex, candidate] of candidates.entries()) {
2468
+ if (typeof candidate !== 'object' ||
2469
+ candidate === null ||
2470
+ !Array.isArray(candidate.outputs)) {
2471
+ throw new TypeError(`Storage returned an invalid unproven candidate at index ${candidateIndex}`);
2472
+ }
2473
+ assertHash(candidate.txid, `Unproven candidate[${candidateIndex}] txid`);
2474
+ assertTopic(candidate.topic, `Unproven candidate[${candidateIndex}] topic`);
2475
+ if (requestedTopic !== undefined && candidate.topic !== requestedTopic) {
2476
+ throw new TypeError('Storage returned an unproven candidate for a different topic');
2477
+ }
2478
+ if (candidate.firstSeenHeight === undefined) {
2479
+ throw new TypeError('Storage returned an unproven candidate without an age anchor');
2480
+ }
2481
+ assertNonnegativeInteger(candidate.firstSeenHeight, `Unproven candidate[${candidateIndex}] firstSeenHeight`);
2482
+ if (candidate.firstSeenHeight > cutoffHeight) {
2483
+ throw new TypeError('Storage returned an unproven candidate newer than the cutoff');
2484
+ }
2485
+ const candidateKey = `${candidate.txid.toLowerCase()}.${candidate.topic}`;
2486
+ if (seenCandidates.has(candidateKey)) {
2487
+ throw new TypeError('Storage returned duplicate unproven candidates');
2488
+ }
2489
+ seenCandidates.add(candidateKey);
2490
+ outputCount += candidate.outputs.length;
2491
+ if (outputCount > MAX_EVICTION_OUTPUTS) {
2492
+ throw new TypeError('Storage returned too many unproven outputs');
2493
+ }
2494
+ for (const [outputIndex, output] of candidate.outputs.entries()) {
2495
+ if (typeof output !== 'object' || output === null) {
2496
+ throw new TypeError(`Storage returned an invalid unproven output at ${candidateIndex}.${outputIndex}`);
2497
+ }
2498
+ assertHash(output.txid, `Unproven output[${candidateIndex}.${outputIndex}] txid`);
2499
+ assertOutputIndex(output.outputIndex, `Unproven output[${candidateIndex}.${outputIndex}] index`);
2500
+ if (output.txid.toLowerCase() !== candidate.txid.toLowerCase()) {
2501
+ throw new TypeError('Storage returned an unproven output for a different transaction');
2502
+ }
2503
+ const outputKey = `${candidateKey}.${output.outputIndex}`;
2504
+ if (seenOutputs.has(outputKey)) {
2505
+ throw new TypeError('Storage returned duplicate unproven outputs');
2506
+ }
2507
+ seenOutputs.add(outputKey);
2508
+ }
2509
+ }
2510
+ }
1420
2511
  async maintainUnprovenTransactions(options) {
1421
2512
  const refresh = await this.refreshUnprovenTransactionProofs(options);
1422
2513
  const eviction = await this.evictUnprovenTransactions({
@@ -1429,7 +2520,34 @@ export class Engine {
1429
2520
  if (typeof this.storage.deleteAppliedTransaction !== 'function') {
1430
2521
  throw new TypeError('Storage does not support applied transaction eviction');
1431
2522
  }
1432
- 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
+ }
1433
2551
  const filtered = options.topic === undefined
1434
2552
  ? outputs
1435
2553
  : outputs.filter(output => output.topic === options.topic);
@@ -1469,13 +2587,42 @@ export class Engine {
1469
2587
  * @returns A promise that resolves to a GASPInitialResponse containing the list of UTXOs and the provided min block height.
1470
2588
  */
1471
2589
  async provideForeignSyncResponse(initialRequest, topic) {
1472
- const outputs = await this.storage.findUTXOsForTopic(topic, initialRequest.since, initialRequest.limit);
1473
- return {
1474
- 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 {
1475
2619
  txid: output.txid,
1476
2620
  outputIndex: output.outputIndex,
1477
2621
  score: output.score ?? 0
1478
- })),
2622
+ };
2623
+ });
2624
+ return {
2625
+ UTXOList,
1479
2626
  since: initialRequest.since
1480
2627
  };
1481
2628
  }
@@ -1488,51 +2635,91 @@ export class Engine {
1488
2635
  * @returns A promise that resolves to a GASPNode containing the raw transaction and other optional data.
1489
2636
  * @throws An error if no output is found for the given transaction ID and output index.
1490
2637
  */
1491
- async provideForeignGASPNode(graphID, txid, outputIndex) {
1492
- const hydrator = async (output) => {
1493
- if (output?.beef === undefined) {
1494
- 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');
1495
2668
  }
1496
2669
  const rootTx = Transaction.fromBEEF(output.beef);
1497
2670
  let correctTx;
1498
- const searchInput = (tx) => {
1499
- if (tx.id('hex') === txid) {
1500
- 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;
1501
2685
  }
1502
- else {
1503
- // For each input, look it up and recurse.
1504
- for (const input of tx.inputs) {
1505
- // We should always have a source transaction
1506
- if (input.sourceTransaction === undefined) {
1507
- throw new Error('Incomplete SPV data!');
1508
- }
1509
- else {
1510
- searchInput(input.sourceTransaction);
2686
+ for (const input of candidate.inputs) {
2687
+ if (input.sourceTransaction !== undefined) {
2688
+ if (transactionStack.length + searchedTransactions >= 2048) {
2689
+ throw new Error('GASP transaction ancestry exceeded its resource limit');
1511
2690
  }
2691
+ transactionStack.push(input.sourceTransaction);
1512
2692
  }
1513
2693
  }
1514
- };
1515
- searchInput(rootTx);
2694
+ }
1516
2695
  if (correctTx === undefined) {
1517
- // Recursively try to find a matching output
1518
- let foundNode;
1519
- for (const currentOutput of output.outputsConsumed) {
1520
- try {
1521
- const outputFound = await this.storage.findOutput(currentOutput.txid, currentOutput.outputIndex, undefined, undefined, true);
1522
- foundNode = await hydrator(outputFound);
1523
- break;
1524
- }
1525
- catch (error) {
1526
- // Best-effort: output may not be found or hydration fails for this candidate; try the next one
1527
- this.logger.debug(`Unable to hydrate output ${currentOutput.txid}.${currentOutput.outputIndex}: ${error}`);
1528
- continue;
2696
+ if (output.outputsConsumed.length > 2048) {
2697
+ throw new Error('Stored GASP ancestry fan-out exceeded its resource limit');
2698
+ }
2699
+ const sourceOutpoints = new Set();
2700
+ for (const input of rootTx.inputs) {
2701
+ const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex');
2702
+ if (sourceTXID !== undefined) {
2703
+ sourceOutpoints.add(this.toOutputCacheKey(sourceTXID, input.sourceOutputIndex));
1529
2704
  }
1530
2705
  }
1531
- if (foundNode !== undefined) {
1532
- 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;
1533
2717
  }
1534
2718
  }
1535
2719
  else {
2720
+ if (outputIndex >= correctTx.outputs.length) {
2721
+ throw new Error('Requested GASP output index does not exist');
2722
+ }
1536
2723
  const rawTx = correctTx.toHex();
1537
2724
  const node = {
1538
2725
  rawTx,
@@ -1544,11 +2731,16 @@ export class Engine {
1544
2731
  }
1545
2732
  return node;
1546
2733
  }
1547
- throw new Error('Unable to find output associated with your request!');
2734
+ return undefined;
1548
2735
  };
1549
- const [rootTxid, rootOutputIndex] = graphID.split('.');
1550
- const output = await this.storage.findOutput(rootTxid, Number(rootOutputIndex), undefined, undefined, true);
1551
- 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;
1552
2744
  }
1553
2745
  /**
1554
2746
  * Traverse and return the history of a UTXO.
@@ -1564,8 +2756,19 @@ export class Engine {
1564
2756
  * @param {number} [currentDepth=0] - The current depth of the traversal relative to the top-level UTXO.
1565
2757
  *
1566
2758
  * @returns {Promise<Output | undefined>} - A promise that resolves to the output history if found, or undefined if not.
1567
- */
2759
+ */
1568
2760
  async getUTXOHistory(output, historySelector, currentDepth = 0, context = this.createUTXOHistoryHydrationContext()) {
2761
+ assertNonnegativeInteger(currentDepth, 'Lookup history current depth');
2762
+ if (currentDepth > MAX_LOOKUP_HISTORY_DEPTH) {
2763
+ throw new RangeError('Lookup history current depth is too large');
2764
+ }
2765
+ if (historySelector !== undefined &&
2766
+ typeof historySelector !== 'function' &&
2767
+ (!Number.isSafeInteger(historySelector) ||
2768
+ historySelector < -1 ||
2769
+ historySelector > MAX_LOOKUP_HISTORY_DEPTH)) {
2770
+ throw new TypeError(`Lookup history must be -1 through ${MAX_LOOKUP_HISTORY_DEPTH}, or a function`);
2771
+ }
1569
2772
  // If we have an output but no history selector, just return the output.
1570
2773
  if (historySelector === undefined) {
1571
2774
  return output;
@@ -1598,43 +2801,114 @@ export class Engine {
1598
2801
  */
1599
2802
  async deleteUTXODeep(output) {
1600
2803
  try {
1601
- // Delete the current output IFF there are no references to it
1602
- if (output.consumedBy.length === 0) {
1603
- await this.storage.deleteOutput(output.txid, output.outputIndex, output.topic);
1604
- // Notify the lookup services of the UTXO being deleted
1605
- 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)) {
1606
2823
  try {
1607
- await l.outputNoLongerRetainedInHistory?.(output.txid, output.outputIndex, output.topic);
2824
+ await lookupService.outputNoLongerRetainedInHistory?.(current.txid, current.outputIndex, current.topic);
1608
2825
  }
1609
- catch (e) {
1610
- // Best-effort notification; lookup service failure must not abort UTXO deletion
1611
- this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${output.txid}.${output.outputIndex}: ${e}`);
2826
+ catch (error) {
2827
+ this.logger.debug(`outputNoLongerRetainedInHistory notification failed for ${current.txid}.${current.outputIndex}: ${error}`);
1612
2828
  }
1613
2829
  }
1614
- }
1615
- // If there are no more consumed utxos, return
1616
- if (output.outputsConsumed.length === 0) {
1617
- return;
1618
- }
1619
- // Delete any stale outputs that were consumed as inputs
1620
- await Promise.all(output.outputsConsumed.map(async (outputIdentifier) => {
1621
- const staleOutput = await this.storage.findOutput(outputIdentifier.txid, outputIdentifier.outputIndex, output.topic);
1622
- // Make sure an output was found
1623
- if (staleOutput === null || staleOutput === undefined) {
1624
- return undefined;
1625
- }
1626
- // Parse out the existing data, then concat the new outputs with no duplicates
1627
- if (staleOutput.consumedBy.length !== 0) {
1628
- staleOutput.consumedBy = staleOutput.consumedBy.filter(x => x.txid !== output.txid && x.outputIndex !== output.outputIndex);
1629
- // Update with the new consumedBy data
1630
- 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);
1631
2854
  }
1632
- // Find previousUTXO history
1633
- return await this.deleteUTXODeep(staleOutput);
1634
- }));
2855
+ }
1635
2856
  }
1636
2857
  catch (error) {
1637
- 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`);
1638
2912
  }
1639
2913
  }
1640
2914
  /**
@@ -1647,47 +2921,104 @@ export class Engine {
1647
2921
  * @param proof for txid
1648
2922
  */
1649
2923
  updateInputProofs(tx, txid, proof) {
1650
- if (tx.id('hex') === txid) {
1651
- // Update the merkle path to handle potential reorgs
1652
- tx.merklePath = proof;
1653
- return;
1654
- }
1655
- // A mined transaction's source graph is no longer part of its BEEF. Do not
1656
- // replace an unrelated transaction's proof with the proof for an ancestor.
1657
- if (tx.merklePath !== undefined)
1658
- return;
1659
- for (const input of tx.inputs) {
1660
- // All inputs must have sourceTransactions
1661
- const stx = input.sourceTransaction;
1662
- 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))
2933
+ continue;
2934
+ visited.add(current);
2935
+ if (current.id('hex') === txid) {
2936
+ current.merklePath = proof;
2937
+ found = true;
1663
2938
  continue;
1664
- this.updateInputProofs(stx, txid, proof);
2939
+ }
2940
+ // A mined transaction's source graph is no longer part of its BEEF. Do
2941
+ // not replace an unrelated transaction's proof with an ancestor proof.
2942
+ if (current.merklePath !== undefined)
2943
+ continue;
2944
+ for (const input of current.inputs) {
2945
+ if (typeof input.sourceTransaction === 'object') {
2946
+ if (pending.length >= MAX_PROOF_UPDATE_OUTPUTS) {
2947
+ throw new RangeError('Merkle-proof source traversal exceeded its work budget');
2948
+ }
2949
+ pending.push(input.sourceTransaction);
2950
+ }
2951
+ }
1665
2952
  }
2953
+ return found;
1666
2954
  }
1667
- /**
1668
- * Recursively updates beefs (merkle proofs) of this output and its consumedBy lineage.
1669
- *
1670
- * @param output - An output derived from txid which may benefit from new proof.
1671
- * @param txid - The txid for which proof is a valid merkle path.
1672
- * @param proof - The merklePath proving txid is a mined transaction hash
1673
- */
1674
- async updateMerkleProof(output, txid, proof) {
1675
- if (output.beef === undefined) {
1676
- throw new Error('Output must have associated transaction BEEF!');
2955
+ prepareMerkleProofUpdate(output, txid, proof) {
2956
+ this.assertStoredOutputRelations(output, 'Merkle-proof output');
2957
+ if (!Array.isArray(output.beef) ||
2958
+ output.beef.length === 0 ||
2959
+ output.beef.length > MAX_SUBMISSION_BEEF_BYTES ||
2960
+ output.beef.some(byte => !Number.isInteger(byte) || byte < 0 || byte > 255)) {
2961
+ throw new TypeError('Merkle-proof output must have bounded transaction BEEF');
1677
2962
  }
1678
2963
  const tx = Transaction.fromBEEF(output.beef);
1679
- // Update this transaction, or recursively update the matching source
1680
- // transaction. This also persists replacement proofs after a reorg.
1681
- this.updateInputProofs(tx, txid, proof);
1682
- // Update the output's BEEF in the storage DB
1683
- await this.storage.updateTransactionBEEF(output.txid, tx.toBEEF());
1684
- // Recursively update the consumedBy outputs
1685
- for (const consumingOutput of output.consumedBy) {
1686
- const consumedOutputs = await this.storage.findOutputsForTransaction(consumingOutput.txid, true);
1687
- for (const consumedOutput of consumedOutputs) {
1688
- 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
+ }
1689
3019
  }
1690
3020
  }
3021
+ return collected;
1691
3022
  }
1692
3023
  /**
1693
3024
  * Recursively prune UTXOs when an incoming Merkle Proof is received.
@@ -1697,17 +3028,49 @@ export class Engine {
1697
3028
  * @param blockHeight - The block height associated with the incoming merkle proof.
1698
3029
  */
1699
3030
  async handleNewMerkleProof(txid, proof, blockHeight) {
1700
- const outputs = await this.storage.findOutputsForTransaction(txid, true);
1701
- if (outputs === undefined || outputs.length === 0) {
1702
- 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
+ }
1703
3040
  }
1704
3041
  const proofMetadata = extractMerkleProofMetadata(txid, proof);
3042
+ if (proofMetadata === undefined) {
3043
+ throw new Error('Merkle proof does not contain the claimed transaction');
3044
+ }
3045
+ if (!(await proof.verify(txid, this.chainTracker))) {
3046
+ throw new Error('Merkle proof is not valid for the claimed block height');
3047
+ }
3048
+ const outputs = await this.storage.findOutputsForTransaction(txid, true, MAX_PROOF_UPDATE_OUTPUTS + 1);
3049
+ if (!Array.isArray(outputs) ||
3050
+ outputs.length === 0 ||
3051
+ outputs.length > MAX_PROOF_UPDATE_OUTPUTS) {
3052
+ throw new Error('Could not find matching transaction outputs for proof ingest!');
3053
+ }
3054
+ for (const output of outputs) {
3055
+ this.assertStoredOutputRelations(output, 'Merkle-proof root output');
3056
+ if (output.txid.toLowerCase() !== txid.toLowerCase()) {
3057
+ throw new TypeError('Storage returned an unbound Merkle-proof root output');
3058
+ }
3059
+ }
3060
+ const graphOutputs = await this.collectMerkleProofOutputs(outputs);
3061
+ const preparedUpdates = graphOutputs.map(output => ({
3062
+ output,
3063
+ beef: this.prepareMerkleProofUpdate(output, txid, proof)
3064
+ }));
3065
+ for (const update of preparedUpdates) {
3066
+ await this.storage.updateTransactionBEEF(update.output.txid, update.beef);
3067
+ update.output.beef = update.beef;
3068
+ }
1705
3069
  const resolvedBlockHeight = blockHeight ?? proofMetadata?.blockHeight;
1706
3070
  const resolvedBlockHash = resolvedBlockHeight === undefined
1707
3071
  ? undefined
1708
3072
  : await this.resolveBlockHash(resolvedBlockHeight, proofMetadata?.merkleRoot);
1709
3073
  for (const output of outputs) {
1710
- await this.updateMerkleProof(output, txid, proof);
1711
3074
  // Add the associated blockHeight
1712
3075
  if (resolvedBlockHeight !== undefined) {
1713
3076
  output.blockHeight = resolvedBlockHeight;
@@ -1715,7 +3078,6 @@ export class Engine {
1715
3078
  }
1716
3079
  if (output.beef !== undefined) {
1717
3080
  const tx = Transaction.fromBEEF(output.beef);
1718
- this.updateInputProofs(tx, txid, proof);
1719
3081
  await this.recordTransactionData(tx, tx.toBEEF(), resolvedBlockHash);
1720
3082
  }
1721
3083
  if (resolvedBlockHeight !== undefined) {
@@ -1739,10 +3101,10 @@ export class Engine {
1739
3101
  * @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported topic managers and their metadata
1740
3102
  */
1741
3103
  async listTopicManagers() {
1742
- const result = {};
1743
- for (const t in this.managers) {
3104
+ const result = Object.create(null);
3105
+ for (const t of Object.keys(this.managers)) {
1744
3106
  try {
1745
- result[t] = await this.managers[t].getMetaData();
3107
+ result[t] = validateComponentMetadata(await this.managers[t].getMetaData());
1746
3108
  }
1747
3109
  catch (e) {
1748
3110
  this.logger.warn(`Unable to get metadata for topic manager: ${t}: ${e}`);
@@ -1760,10 +3122,10 @@ export class Engine {
1760
3122
  * @returns {Promise<Record<string, { name: string; shortDescription: string; iconURL?: string; version?: string; informationURL?: string; }>>} - Supported lookup services and their metadata
1761
3123
  */
1762
3124
  async listLookupServiceProviders() {
1763
- const result = {};
1764
- for (const ls in this.lookupServices) {
3125
+ const result = Object.create(null);
3126
+ for (const ls of Object.keys(this.lookupServices)) {
1765
3127
  try {
1766
- result[ls] = await this.lookupServices[ls].getMetaData();
3128
+ result[ls] = validateComponentMetadata(await this.lookupServices[ls].getMetaData());
1767
3129
  }
1768
3130
  catch (e) {
1769
3131
  this.logger.warn(`Unable to get metadata for lookup service: ${ls}: ${e}`);
@@ -1781,8 +3143,13 @@ export class Engine {
1781
3143
  * @returns {Promise<string>} - the documentation for the topic manager
1782
3144
  */
1783
3145
  async getDocumentationForTopicManager(manager) {
1784
- const documentation = await this.managers[manager]?.getDocumentation?.();
1785
- 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);
1786
3153
  }
1787
3154
  /**
1788
3155
  * Run a query to get the documentation for a particular lookup service
@@ -1790,8 +3157,14 @@ export class Engine {
1790
3157
  * @returns {Promise<string>} - the documentation for the lookup service
1791
3158
  */
1792
3159
  async getDocumentationForLookupServiceProvider(provider) {
1793
- const documentation = await this.lookupServices[provider]?.getDocumentation?.();
1794
- 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);
1795
3168
  }
1796
3169
  /**
1797
3170
  * Validates a URL to ensure it does not match disallowed patterns:
@@ -1805,17 +3178,16 @@ export class Engine {
1805
3178
  */
1806
3179
  isValidUrl(url) {
1807
3180
  try {
1808
- const parsedUrl = new URL(url);
1809
- // Disallow http:
1810
- if (parsedUrl.protocol === 'http:') {
1811
- return false;
1812
- }
3181
+ const parsedUrl = new URL(normalizePeerEndpoint(url));
1813
3182
  // Disallow localhost with or without a port
1814
3183
  if (/^localhost(:\d+)?$/i.test(parsedUrl.hostname)) {
1815
3184
  return false;
1816
3185
  }
1817
3186
  // Disallow internal and non-routable IP addresses
1818
3187
  const ipAddress = parsedUrl.hostname;
3188
+ const literal = /^[\d.]+$/.test(ipAddress) || ipAddress.includes(':');
3189
+ if (literal && !isPublicNetworkAddress(ipAddress))
3190
+ return false;
1819
3191
  // Regex for non-routable IPv4 IPs
1820
3192
  const nonRoutableIpv4Patterns = [
1821
3193
  /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, // Loopback IPs
@@ -1825,7 +3197,7 @@ export class Engine {
1825
3197
  /^0\.0\.0\.0$/ // Non-routable address
1826
3198
  ];
1827
3199
  // Check for IPv4 matches
1828
- if (nonRoutableIpv4Patterns.some((pattern) => pattern.test(ipAddress))) {
3200
+ if (nonRoutableIpv4Patterns.some(pattern => pattern.test(ipAddress))) {
1829
3201
  return false;
1830
3202
  }
1831
3203
  // Check for non-routable IPv6 addresses explicitly