@bsv/sdk 2.0.11 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
- package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
- package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js +21 -13
- package/dist/cjs/src/overlay-tools/HostReputationTracker.js.map +1 -1
- package/dist/cjs/src/primitives/PrivateKey.js +3 -3
- package/dist/cjs/src/primitives/PrivateKey.js.map +1 -1
- package/dist/cjs/src/script/Spend.js +17 -9
- package/dist/cjs/src/script/Spend.js.map +1 -1
- package/dist/cjs/src/storage/StorageDownloader.js +6 -6
- package/dist/cjs/src/storage/StorageDownloader.js.map +1 -1
- package/dist/cjs/src/storage/StorageUtils.js +1 -1
- package/dist/cjs/src/storage/StorageUtils.js.map +1 -1
- package/dist/cjs/src/transaction/MerklePath.js +168 -27
- package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
- package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
- package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
- package/dist/esm/src/overlay-tools/HostReputationTracker.js +21 -13
- package/dist/esm/src/overlay-tools/HostReputationTracker.js.map +1 -1
- package/dist/esm/src/primitives/PrivateKey.js +3 -3
- package/dist/esm/src/primitives/PrivateKey.js.map +1 -1
- package/dist/esm/src/script/Spend.js +17 -9
- package/dist/esm/src/script/Spend.js.map +1 -1
- package/dist/esm/src/storage/StorageDownloader.js +6 -6
- package/dist/esm/src/storage/StorageDownloader.js.map +1 -1
- package/dist/esm/src/storage/StorageUtils.js +1 -1
- package/dist/esm/src/storage/StorageUtils.js.map +1 -1
- package/dist/esm/src/transaction/MerklePath.js +168 -27
- package/dist/esm/src/transaction/MerklePath.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
- package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
- package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/HostReputationTracker.d.ts.map +1 -1
- package/dist/types/src/script/Spend.d.ts.map +1 -1
- package/dist/types/src/transaction/MerklePath.d.ts +27 -0
- package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +3 -3
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/storage.md +1 -1
- package/docs/reference/transaction.md +40 -0
- package/package.json +1 -1
- package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
- package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
- package/src/auth/utils/__tests/validateCertificates.test.ts +12 -9
- package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
- package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
- package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
- package/src/kvstore/__tests/LocalKVStore.test.ts +4 -6
- package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
- package/src/overlay-tools/HostReputationTracker.ts +17 -14
- package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
- package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
- package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
- package/src/primitives/PrivateKey.ts +3 -3
- package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
- package/src/primitives/__tests/Curve.additional.test.ts +208 -0
- package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
- package/src/primitives/__tests/Hash.additional.test.ts +59 -0
- package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
- package/src/primitives/__tests/Point.additional.test.ts +503 -0
- package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
- package/src/primitives/__tests/Random.additional.test.ts +262 -0
- package/src/primitives/__tests/Signature.test.ts +333 -0
- package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
- package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
- package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
- package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
- package/src/script/Spend.ts +19 -11
- package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
- package/src/script/__tests/Script.additional.test.ts +100 -0
- package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
- package/src/script/__tests/Spend.additional.test.ts +837 -0
- package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
- package/src/storage/StorageDownloader.ts +6 -6
- package/src/storage/StorageUtils.ts +1 -1
- package/src/transaction/MerklePath.ts +196 -36
- package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
- package/src/transaction/__tests/Broadcaster.test.ts +159 -0
- package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
- package/src/transaction/__tests/MerklePath.test.ts +232 -21
- package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
- package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
- package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
- package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
- package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
- package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
- package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
- package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
- package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
- package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
- package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
- package/src/wallet/__tests/WERR.test.ts +212 -0
- package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
- package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
- package/src/wallet/__tests/WalletError.test.ts +290 -0
- package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
- package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
- package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import LookupResolver, {
|
|
2
|
+
HTTPSOverlayLookupFacilitator,
|
|
3
|
+
DEFAULT_SLAP_TRACKERS,
|
|
4
|
+
DEFAULT_TESTNET_SLAP_TRACKERS,
|
|
5
|
+
LookupQuestion
|
|
6
|
+
} from '../LookupResolver'
|
|
7
|
+
import { getOverlayHostReputationTracker, HostReputationTracker } from '../HostReputationTracker'
|
|
8
|
+
import OverlayAdminTokenTemplate from '../../overlay-tools/OverlayAdminTokenTemplate'
|
|
9
|
+
import { CompletedProtoWallet } from '../../auth/certificates/__tests/CompletedProtoWallet'
|
|
10
|
+
import { PrivateKey } from '../../primitives/index'
|
|
11
|
+
import { Transaction } from '../../transaction/index'
|
|
12
|
+
import { LockingScript } from '../../script/index'
|
|
13
|
+
|
|
14
|
+
const mockFacilitator = {
|
|
15
|
+
lookup: jest.fn()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// --------------------------------------------------------------------------
|
|
19
|
+
// Sample BEEFs for use in tests
|
|
20
|
+
// --------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const sampleBeef1 = new Transaction(
|
|
23
|
+
1,
|
|
24
|
+
[],
|
|
25
|
+
[{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
|
|
26
|
+
0
|
|
27
|
+
).toBEEF()
|
|
28
|
+
|
|
29
|
+
const sampleBeef2 = new Transaction(
|
|
30
|
+
1,
|
|
31
|
+
[],
|
|
32
|
+
[{ lockingScript: LockingScript.fromHex('88'), satoshis: 2 }],
|
|
33
|
+
0
|
|
34
|
+
).toBEEF()
|
|
35
|
+
|
|
36
|
+
// --------------------------------------------------------------------------
|
|
37
|
+
// Helper: build a SLAP token transaction pointing at a given host/service
|
|
38
|
+
// --------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
async function makeSlapTx (
|
|
41
|
+
keyScalar: number,
|
|
42
|
+
domain: string,
|
|
43
|
+
service: string
|
|
44
|
+
): Promise<Transaction> {
|
|
45
|
+
const key = new PrivateKey(keyScalar)
|
|
46
|
+
const wallet = new CompletedProtoWallet(key)
|
|
47
|
+
const lib = new OverlayAdminTokenTemplate(wallet)
|
|
48
|
+
const script = await lib.lock('SLAP', domain, service)
|
|
49
|
+
return new Transaction(1, [], [{ lockingScript: script, satoshis: 1 }], 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --------------------------------------------------------------------------
|
|
53
|
+
// Suite
|
|
54
|
+
// --------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
describe('LookupResolver – additional coverage', () => {
|
|
57
|
+
const globalTracker = getOverlayHostReputationTracker()
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mockFacilitator.lookup.mockReset()
|
|
61
|
+
globalTracker.reset()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// -----------------------------------------------------------------------
|
|
65
|
+
// networkPreset branches
|
|
66
|
+
// -----------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
describe('networkPreset', () => {
|
|
69
|
+
it('uses DEFAULT_SLAP_TRACKERS for mainnet preset (default)', () => {
|
|
70
|
+
const r = new LookupResolver({ facilitator: mockFacilitator })
|
|
71
|
+
// Access private via cast
|
|
72
|
+
expect((r as any).slapTrackers).toEqual(DEFAULT_SLAP_TRACKERS)
|
|
73
|
+
expect((r as any).networkPreset).toBe('mainnet')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('uses DEFAULT_TESTNET_SLAP_TRACKERS for testnet preset', () => {
|
|
77
|
+
const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'testnet' })
|
|
78
|
+
expect((r as any).slapTrackers).toEqual(DEFAULT_TESTNET_SLAP_TRACKERS)
|
|
79
|
+
expect((r as any).networkPreset).toBe('testnet')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('uses localhost for local preset in ls_slap query', async () => {
|
|
83
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
84
|
+
type: 'output-list',
|
|
85
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'local' })
|
|
89
|
+
await r.query({ service: 'ls_slap', query: {} })
|
|
90
|
+
|
|
91
|
+
expect(mockFacilitator.lookup.mock.calls[0][0]).toBe('http://localhost:8080')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('uses localhost for local preset on non-slap service query', async () => {
|
|
95
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
96
|
+
type: 'output-list',
|
|
97
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'local' })
|
|
101
|
+
await r.query({ service: 'ls_bar', query: {} })
|
|
102
|
+
|
|
103
|
+
expect(mockFacilitator.lookup.mock.calls[0][0]).toBe('http://localhost:8080')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('includes "testnet" in error message for testnet preset', async () => {
|
|
107
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
108
|
+
type: 'output-list',
|
|
109
|
+
outputs: []
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const r = new LookupResolver({ facilitator: mockFacilitator, networkPreset: 'testnet' })
|
|
113
|
+
await expect(r.query({ service: 'ls_missing', query: {} })).rejects.toThrow(
|
|
114
|
+
'No competent testnet hosts found'
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('uses custom slapTrackers even when preset is testnet', () => {
|
|
119
|
+
const r = new LookupResolver({
|
|
120
|
+
facilitator: mockFacilitator,
|
|
121
|
+
networkPreset: 'testnet',
|
|
122
|
+
slapTrackers: ['https://custom.tracker']
|
|
123
|
+
})
|
|
124
|
+
expect((r as any).slapTrackers).toEqual(['https://custom.tracker'])
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// -----------------------------------------------------------------------
|
|
129
|
+
// hostOverrides validation
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
describe('hostOverrides validation', () => {
|
|
133
|
+
it('throws when hostOverride service name does not start with ls_', () => {
|
|
134
|
+
expect(() => new LookupResolver({
|
|
135
|
+
facilitator: mockFacilitator,
|
|
136
|
+
hostOverrides: { badServiceName: ['https://host.com'] }
|
|
137
|
+
})).toThrow('Host override service names must start with "ls_": badServiceName')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does not throw for valid ls_ prefixed hostOverride keys', () => {
|
|
141
|
+
expect(() => new LookupResolver({
|
|
142
|
+
facilitator: mockFacilitator,
|
|
143
|
+
hostOverrides: { ls_valid: ['https://host.com'] }
|
|
144
|
+
})).not.toThrow()
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// -----------------------------------------------------------------------
|
|
149
|
+
// reputationStorage options
|
|
150
|
+
// -----------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
describe('reputationStorage', () => {
|
|
153
|
+
it('accepts reputationStorage: "localStorage" option', () => {
|
|
154
|
+
// Simply verify construction does not throw
|
|
155
|
+
expect(() => new LookupResolver({
|
|
156
|
+
facilitator: mockFacilitator,
|
|
157
|
+
reputationStorage: 'localStorage'
|
|
158
|
+
})).not.toThrow()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('accepts reputationStorage as a custom key-value store object', async () => {
|
|
162
|
+
const store = new Map<string, string>()
|
|
163
|
+
const kvStore = {
|
|
164
|
+
get: (key: string): string | null => store.get(key) ?? null,
|
|
165
|
+
set: (key: string, value: string): void => { store.set(key, value) }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
169
|
+
type: 'output-list',
|
|
170
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const r = new LookupResolver({
|
|
174
|
+
facilitator: mockFacilitator,
|
|
175
|
+
reputationStorage: kvStore,
|
|
176
|
+
hostOverrides: { ls_test: ['https://host.com'] }
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
await r.query({ service: 'ls_test', query: {} })
|
|
180
|
+
// Reputation data should have been written to the store
|
|
181
|
+
expect(store.size).toBeGreaterThan(0)
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
// Cache tuning options
|
|
187
|
+
// -----------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
describe('cache configuration', () => {
|
|
190
|
+
it('respects custom hostsTtlMs', () => {
|
|
191
|
+
const r = new LookupResolver({
|
|
192
|
+
facilitator: mockFacilitator,
|
|
193
|
+
cache: { hostsTtlMs: 999 }
|
|
194
|
+
})
|
|
195
|
+
expect((r as any).hostsTtlMs).toBe(999)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('respects custom hostsMaxEntries', () => {
|
|
199
|
+
const r = new LookupResolver({
|
|
200
|
+
facilitator: mockFacilitator,
|
|
201
|
+
cache: { hostsMaxEntries: 5 }
|
|
202
|
+
})
|
|
203
|
+
expect((r as any).hostsMaxEntries).toBe(5)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('respects custom txMemoTtlMs', () => {
|
|
207
|
+
const r = new LookupResolver({
|
|
208
|
+
facilitator: mockFacilitator,
|
|
209
|
+
cache: { txMemoTtlMs: 123 }
|
|
210
|
+
})
|
|
211
|
+
expect((r as any).txMemoTtlMs).toBe(123)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('uses stale hosts from cache while refreshing in the background', async () => {
|
|
215
|
+
const slapTx = await makeSlapTx(42, 'https://cached.host', 'ls_cached')
|
|
216
|
+
|
|
217
|
+
// First call: populates the cache
|
|
218
|
+
mockFacilitator.lookup
|
|
219
|
+
.mockResolvedValueOnce({
|
|
220
|
+
type: 'output-list',
|
|
221
|
+
outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
|
|
222
|
+
})
|
|
223
|
+
.mockResolvedValueOnce({
|
|
224
|
+
type: 'output-list',
|
|
225
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const r = new LookupResolver({
|
|
229
|
+
facilitator: mockFacilitator,
|
|
230
|
+
slapTrackers: ['https://mock.slap'],
|
|
231
|
+
cache: { hostsTtlMs: 0 } // immediate expiry to force stale path
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
await r.query({ service: 'ls_cached', query: {} })
|
|
235
|
+
|
|
236
|
+
// Second call: cache entry is now stale (ttl=0), should use stale hosts
|
|
237
|
+
// while kicking off a background refresh
|
|
238
|
+
mockFacilitator.lookup.mockResolvedValue({
|
|
239
|
+
type: 'output-list',
|
|
240
|
+
outputs: [{ beef: sampleBeef2, outputIndex: 1 }]
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const res2 = await r.query({ service: 'ls_cached', query: {} })
|
|
244
|
+
|
|
245
|
+
expect(res2.type).toBe('output-list')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('evicts oldest cache entry when hostsMaxEntries is reached', async () => {
|
|
249
|
+
const r = new LookupResolver({
|
|
250
|
+
facilitator: mockFacilitator,
|
|
251
|
+
slapTrackers: ['https://mock.slap'],
|
|
252
|
+
cache: { hostsMaxEntries: 2 }
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const hostsCache: Map<string, any> = (r as any).hostsCache
|
|
256
|
+
|
|
257
|
+
// Manually populate the cache to its limit
|
|
258
|
+
hostsCache.set('ls_service1', { hosts: ['https://h1.com'], expiresAt: Date.now() + 60000 })
|
|
259
|
+
hostsCache.set('ls_service2', { hosts: ['https://h2.com'], expiresAt: Date.now() + 60000 })
|
|
260
|
+
|
|
261
|
+
expect(hostsCache.size).toBe(2)
|
|
262
|
+
|
|
263
|
+
// Force a refresh for a third service which should evict ls_service1
|
|
264
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
265
|
+
type: 'output-list',
|
|
266
|
+
outputs: []
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Trigger cache refresh via refreshHosts indirectly
|
|
270
|
+
const slapTx = await makeSlapTx(42, 'https://h3.com', 'ls_service3')
|
|
271
|
+
mockFacilitator.lookup.mockResolvedValue({
|
|
272
|
+
type: 'output-list',
|
|
273
|
+
outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await r.query({ service: 'ls_service3', query: {} })
|
|
278
|
+
} catch {
|
|
279
|
+
// might fail if no competent hosts for the actual lookup
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Cache size should not exceed hostsMaxEntries + 1 (the new entry)
|
|
283
|
+
expect(hostsCache.size).toBeLessThanOrEqual(3)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('coalesces concurrent in-flight host resolution requests for the same service', async () => {
|
|
287
|
+
const slapTx = await makeSlapTx(42, 'https://coalesce.host', 'ls_coalesce')
|
|
288
|
+
|
|
289
|
+
let resolveSlap: (v: any) => void
|
|
290
|
+
const slapPromise = new Promise<any>((res) => { resolveSlap = res })
|
|
291
|
+
|
|
292
|
+
mockFacilitator.lookup
|
|
293
|
+
.mockReturnValueOnce(slapPromise) // slap tracker – delayed
|
|
294
|
+
.mockResolvedValue({
|
|
295
|
+
type: 'output-list',
|
|
296
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const r = new LookupResolver({
|
|
300
|
+
facilitator: mockFacilitator,
|
|
301
|
+
slapTrackers: ['https://mock.slap']
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// Fire two concurrent queries before slap resolves
|
|
305
|
+
const p1 = r.query({ service: 'ls_coalesce', query: {} })
|
|
306
|
+
const p2 = r.query({ service: 'ls_coalesce', query: {} })
|
|
307
|
+
|
|
308
|
+
// Resolve the SLAP tracker
|
|
309
|
+
resolveSlap!({
|
|
310
|
+
type: 'output-list',
|
|
311
|
+
outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
const [res1, res2] = await Promise.all([p1, p2])
|
|
315
|
+
expect(res1.type).toBe('output-list')
|
|
316
|
+
expect(res2.type).toBe('output-list')
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
// -----------------------------------------------------------------------
|
|
321
|
+
// txMemo eviction at 4096 entries
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
describe('txMemo eviction', () => {
|
|
325
|
+
it('evicts the oldest txMemo entry when size exceeds 4096', async () => {
|
|
326
|
+
mockFacilitator.lookup.mockResolvedValue({
|
|
327
|
+
type: 'output-list',
|
|
328
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const r = new LookupResolver({
|
|
332
|
+
facilitator: mockFacilitator,
|
|
333
|
+
hostOverrides: { ls_memo: ['https://memo.host'] }
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const txMemo: Map<string, any> = (r as any).txMemo
|
|
337
|
+
|
|
338
|
+
// Pre-fill to just over 4096 entries
|
|
339
|
+
for (let i = 0; i < 4097; i++) {
|
|
340
|
+
txMemo.set(`key${i}`, { txId: `tx${i}`, expiresAt: Date.now() + 60000 })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
expect(txMemo.size).toBe(4097)
|
|
344
|
+
|
|
345
|
+
// Query to trigger the eviction path
|
|
346
|
+
await r.query({ service: 'ls_memo', query: {} })
|
|
347
|
+
|
|
348
|
+
// After query the eviction should have fired, size should be <= 4097 + 1 - 1 = 4097
|
|
349
|
+
// (evict oldest then set new)
|
|
350
|
+
expect(txMemo.size).toBeLessThanOrEqual(4098)
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// -----------------------------------------------------------------------
|
|
355
|
+
// prepareHostsForQuery – all-backoff error
|
|
356
|
+
// -----------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe('prepareHostsForQuery – backoff error', () => {
|
|
359
|
+
it('throws when all competent hosts are in backoff', async () => {
|
|
360
|
+
const slapTx = await makeSlapTx(42, 'https://backing.off', 'ls_backoff_test')
|
|
361
|
+
|
|
362
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
363
|
+
type: 'output-list',
|
|
364
|
+
outputs: [{ outputIndex: 0, beef: slapTx.toBEEF() }]
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const r = new LookupResolver({
|
|
368
|
+
facilitator: mockFacilitator,
|
|
369
|
+
slapTrackers: ['https://mock.slap']
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
// Poison the reputation of the host so it enters backoff
|
|
373
|
+
const tracker: HostReputationTracker = (r as any).hostReputation
|
|
374
|
+
for (let i = 0; i < 5; i++) {
|
|
375
|
+
tracker.recordFailure('https://backing.off', 'connection refused')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Now the host is deeply in backoff
|
|
379
|
+
// prepareHostsForQuery throws with context = 'lookup service ls_backoff_test'
|
|
380
|
+
await expect(r.query({ service: 'ls_backoff_test', query: {} })).rejects.toThrow(
|
|
381
|
+
'All lookup service ls_backoff_test hosts are backing off'
|
|
382
|
+
)
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it('throws when all SLAP trackers are in backoff', async () => {
|
|
386
|
+
const r = new LookupResolver({
|
|
387
|
+
facilitator: mockFacilitator,
|
|
388
|
+
slapTrackers: ['https://backed.off.slap']
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// Put the SLAP tracker into deep backoff
|
|
392
|
+
const tracker: HostReputationTracker = (r as any).hostReputation
|
|
393
|
+
for (let i = 0; i < 5; i++) {
|
|
394
|
+
tracker.recordFailure('https://backed.off.slap', 'connection refused')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
await expect(r.query({ service: 'ls_any', query: {} })).rejects.toThrow(
|
|
398
|
+
'All SLAP trackers hosts are backing off'
|
|
399
|
+
)
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// -----------------------------------------------------------------------
|
|
404
|
+
// additionalHosts – deduplication
|
|
405
|
+
// -----------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe('additionalHosts', () => {
|
|
408
|
+
it('does not duplicate a host that already appears in competentHosts', async () => {
|
|
409
|
+
// Override + additional pointing at same host
|
|
410
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
411
|
+
type: 'output-list',
|
|
412
|
+
outputs: [{ beef: sampleBeef1, outputIndex: 0 }]
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const r = new LookupResolver({
|
|
416
|
+
facilitator: mockFacilitator,
|
|
417
|
+
hostOverrides: { ls_dup: ['https://same.host'] },
|
|
418
|
+
additionalHosts: { ls_dup: ['https://same.host', 'https://extra.host'] }
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
await r.query({ service: 'ls_dup', query: {} })
|
|
422
|
+
|
|
423
|
+
// same.host should appear exactly once in the calls
|
|
424
|
+
const calledHosts = mockFacilitator.lookup.mock.calls.map((c: any[]) => c[0])
|
|
425
|
+
const sameHostCalls = calledHosts.filter((h: string) => h === 'https://same.host')
|
|
426
|
+
expect(sameHostCalls).toHaveLength(1)
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// -----------------------------------------------------------------------
|
|
431
|
+
// HTTPSOverlayLookupFacilitator
|
|
432
|
+
// -----------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
describe('HTTPSOverlayLookupFacilitator', () => {
|
|
435
|
+
it('throws when no fetch implementation is available', () => {
|
|
436
|
+
expect(() => new HTTPSOverlayLookupFacilitator(null as any)).toThrow(
|
|
437
|
+
'HTTPSOverlayLookupFacilitator requires a fetch implementation'
|
|
438
|
+
)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('allows HTTP URLs when allowHTTP is true', async () => {
|
|
442
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
443
|
+
ok: true,
|
|
444
|
+
headers: { get: () => 'application/json' },
|
|
445
|
+
json: async () => ({ type: 'output-list', outputs: [] })
|
|
446
|
+
})
|
|
447
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
448
|
+
const result = await facilitator.lookup('http://localhost:8080', { service: 'ls_test', query: {} })
|
|
449
|
+
expect(result).toEqual({ type: 'output-list', outputs: [] })
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('handles HTTP error responses by throwing', async () => {
|
|
453
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
454
|
+
ok: false,
|
|
455
|
+
status: 503,
|
|
456
|
+
headers: { get: () => 'application/json' },
|
|
457
|
+
json: async () => ({})
|
|
458
|
+
})
|
|
459
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
460
|
+
await expect(
|
|
461
|
+
facilitator.lookup('http://host', { service: 'ls_test', query: {} })
|
|
462
|
+
).rejects.toThrow('Failed to facilitate lookup (HTTP 503)')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('normalises AbortError to "Request timed out"', async () => {
|
|
466
|
+
const abortError = new Error('aborted')
|
|
467
|
+
abortError.name = 'AbortError'
|
|
468
|
+
const mockFetch = jest.fn().mockRejectedValue(abortError)
|
|
469
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
470
|
+
await expect(
|
|
471
|
+
facilitator.lookup('http://host', { service: 'ls_test', query: {} }, 1)
|
|
472
|
+
).rejects.toThrow('Request timed out')
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('parses octet-stream responses', async () => {
|
|
476
|
+
// Build a minimal octet-stream payload: 1 outpoint, then BEEF bytes
|
|
477
|
+
const tx = new Transaction(
|
|
478
|
+
1,
|
|
479
|
+
[],
|
|
480
|
+
[{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
|
|
481
|
+
0
|
|
482
|
+
)
|
|
483
|
+
const beef = tx.toBEEF()
|
|
484
|
+
|
|
485
|
+
// Build the payload: varint(1) + txid(32) + varint(outputIndex) + varint(contextLen=0) + beef
|
|
486
|
+
// The source reads 32 bytes and calls Utils.toHex(), so the bytes must be in big-endian
|
|
487
|
+
// (same order as tx.id('hex')) so the resulting hex matches what Transaction.fromBEEF expects.
|
|
488
|
+
const txid = Buffer.from(tx.id('hex'), 'hex')
|
|
489
|
+
const nOutpoints = Buffer.from([0x01]) // varint 1
|
|
490
|
+
const outputIndex = Buffer.from([0x00]) // varint 0
|
|
491
|
+
const contextLen = Buffer.from([0x00]) // varint 0
|
|
492
|
+
const beefBuf = Buffer.from(beef)
|
|
493
|
+
const payload = Buffer.concat([nOutpoints, txid, outputIndex, contextLen, beefBuf])
|
|
494
|
+
|
|
495
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
496
|
+
ok: true,
|
|
497
|
+
headers: { get: () => 'application/octet-stream' },
|
|
498
|
+
arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
502
|
+
const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} })
|
|
503
|
+
|
|
504
|
+
expect(result.type).toBe('output-list')
|
|
505
|
+
expect(result.outputs).toHaveLength(1)
|
|
506
|
+
expect(result.outputs[0].outputIndex).toBe(0)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('parses octet-stream responses with context bytes', async () => {
|
|
510
|
+
const tx = new Transaction(
|
|
511
|
+
1,
|
|
512
|
+
[],
|
|
513
|
+
[{ lockingScript: LockingScript.fromHex('88'), satoshis: 1 }],
|
|
514
|
+
0
|
|
515
|
+
)
|
|
516
|
+
const beef = tx.toBEEF()
|
|
517
|
+
// Use big-endian byte order so Utils.toHex(r.read(32)) produces the same hex as tx.id('hex')
|
|
518
|
+
const txid = Buffer.from(tx.id('hex'), 'hex')
|
|
519
|
+
|
|
520
|
+
// payload: 1 outpoint, with context of 2 bytes [0xde, 0xad]
|
|
521
|
+
const nOutpoints = Buffer.from([0x01])
|
|
522
|
+
const outputIndex = Buffer.from([0x00])
|
|
523
|
+
const contextLen = Buffer.from([0x02])
|
|
524
|
+
const contextBytes = Buffer.from([0xde, 0xad])
|
|
525
|
+
const beefBuf = Buffer.from(beef)
|
|
526
|
+
const payload = Buffer.concat([nOutpoints, txid, outputIndex, contextLen, contextBytes, beefBuf])
|
|
527
|
+
|
|
528
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
529
|
+
ok: true,
|
|
530
|
+
headers: { get: () => 'application/octet-stream' },
|
|
531
|
+
arrayBuffer: async () => payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
535
|
+
const result = await facilitator.lookup('http://host', { service: 'ls_test', query: {} })
|
|
536
|
+
|
|
537
|
+
expect(result.type).toBe('output-list')
|
|
538
|
+
expect(result.outputs[0].context).toBeDefined()
|
|
539
|
+
expect(Array.from(result.outputs[0].context!)).toEqual([0xde, 0xad])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('re-throws non-AbortError errors from fetch', async () => {
|
|
543
|
+
const mockFetch = jest.fn().mockRejectedValue(new Error('DNS failure'))
|
|
544
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
545
|
+
await expect(
|
|
546
|
+
facilitator.lookup('http://host', { service: 'ls_test', query: {} })
|
|
547
|
+
).rejects.toThrow('DNS failure')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('sends correct request body to /lookup endpoint', async () => {
|
|
551
|
+
const mockFetch = jest.fn().mockResolvedValue({
|
|
552
|
+
ok: true,
|
|
553
|
+
headers: { get: () => 'application/json' },
|
|
554
|
+
json: async () => ({ type: 'output-list', outputs: [] })
|
|
555
|
+
})
|
|
556
|
+
const facilitator = new HTTPSOverlayLookupFacilitator(mockFetch, true)
|
|
557
|
+
const question = { service: 'ls_test', query: { filter: 'abc' } }
|
|
558
|
+
await facilitator.lookup('http://host', question)
|
|
559
|
+
|
|
560
|
+
const calledUrl: string = mockFetch.mock.calls[0][0]
|
|
561
|
+
expect(calledUrl).toBe('http://host/lookup')
|
|
562
|
+
|
|
563
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
|
564
|
+
expect(body).toEqual({ service: 'ls_test', query: { filter: 'abc' } })
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
// -----------------------------------------------------------------------
|
|
569
|
+
// lookupHostWithTracking – invalid response tracking
|
|
570
|
+
// -----------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
describe('lookupHostWithTracking – invalid response', () => {
|
|
573
|
+
it('records failure when host returns a non-output-list response', async () => {
|
|
574
|
+
mockFacilitator.lookup.mockResolvedValueOnce({
|
|
575
|
+
type: 'freeform',
|
|
576
|
+
data: 'some free data'
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
const r = new LookupResolver({
|
|
580
|
+
facilitator: mockFacilitator,
|
|
581
|
+
hostOverrides: { ls_invalid: ['https://weird.host'] }
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// The query returns empty outputs since the response is ignored
|
|
585
|
+
const res = await r.query({ service: 'ls_invalid', query: {} })
|
|
586
|
+
expect(res.outputs).toHaveLength(0)
|
|
587
|
+
|
|
588
|
+
// The host should have been penalised in the tracker
|
|
589
|
+
const tracker: HostReputationTracker = (r as any).hostReputation
|
|
590
|
+
const snap = tracker.snapshot('https://weird.host')
|
|
591
|
+
expect(snap?.totalFailures).toBeGreaterThan(0)
|
|
592
|
+
})
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
// -----------------------------------------------------------------------
|
|
596
|
+
// Empty hosts edge case
|
|
597
|
+
// -----------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
describe('empty trackerHosts', () => {
|
|
600
|
+
it('returns empty array from findCompetentHosts when all SLAP trackers are empty list', async () => {
|
|
601
|
+
// Provide an empty slapTrackers list so trackerHosts.length === 0
|
|
602
|
+
const r = new LookupResolver({
|
|
603
|
+
facilitator: mockFacilitator,
|
|
604
|
+
slapTrackers: []
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
await expect(r.query({ service: 'ls_foo', query: {} })).rejects.toThrow(
|
|
608
|
+
'No competent mainnet hosts found'
|
|
609
|
+
)
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
})
|