@bsv/overlay 2.3.0 → 2.6.1

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