@bsv/sdk 1.9.2 → 1.9.3
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/kvstore/GlobalKVStore.js +116 -98
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/types.js.map +1 -1
- package/dist/cjs/src/overlay-tools/index.js +1 -0
- package/dist/cjs/src/overlay-tools/index.js.map +1 -1
- package/dist/cjs/src/overlay-tools/withDoubleSpendRetry.js +55 -0
- package/dist/cjs/src/overlay-tools/withDoubleSpendRetry.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/kvstore/GlobalKVStore.js +117 -99
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/types.js.map +1 -1
- package/dist/esm/src/overlay-tools/index.js +1 -0
- package/dist/esm/src/overlay-tools/index.js.map +1 -1
- package/dist/esm/src/overlay-tools/withDoubleSpendRetry.js +48 -0
- package/dist/esm/src/overlay-tools/withDoubleSpendRetry.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
- package/dist/types/src/kvstore/types.d.ts +2 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/index.d.ts +1 -0
- package/dist/types/src/overlay-tools/index.d.ts.map +1 -1
- package/dist/types/src/overlay-tools/withDoubleSpendRetry.d.ts +14 -0
- package/dist/types/src/overlay-tools/withDoubleSpendRetry.d.ts.map +1 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +2 -2
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/kvstore.md +9 -0
- package/docs/reference/overlay-tools.md +32 -0
- package/package.json +1 -1
- package/src/kvstore/GlobalKVStore.ts +134 -114
- package/src/kvstore/__tests/GlobalKVStore.test.ts +11 -1
- package/src/kvstore/types.ts +2 -0
- package/src/overlay-tools/index.ts +1 -0
- package/src/overlay-tools/withDoubleSpendRetry.ts +71 -0
|
@@ -50,6 +50,7 @@ export interface KVStoreConfig {
|
|
|
50
50
|
wallet?: WalletInterface;
|
|
51
51
|
networkPreset?: "mainnet" | "testnet" | "local";
|
|
52
52
|
acceptDelayedBroadcast?: boolean;
|
|
53
|
+
overlayBroadcast?: boolean;
|
|
53
54
|
tokenSetDescription?: string;
|
|
54
55
|
tokenUpdateDescription?: string;
|
|
55
56
|
tokenRemovalDescription?: string;
|
|
@@ -82,6 +83,14 @@ Originator
|
|
|
82
83
|
originator?: string
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
#### Property overlayBroadcast
|
|
87
|
+
|
|
88
|
+
Whether to let overlay handle broadcasting (prevents UTXO spending on rejection)
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
overlayBroadcast?: boolean
|
|
92
|
+
```
|
|
93
|
+
|
|
85
94
|
#### Property overlayHost
|
|
86
95
|
|
|
87
96
|
The overlay service host URL
|
|
@@ -564,6 +564,38 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
|
|
|
564
564
|
---
|
|
565
565
|
## Functions
|
|
566
566
|
|
|
567
|
+
### Function: withDoubleSpendRetry
|
|
568
|
+
|
|
569
|
+
Executes an operation with automatic retry logic for double-spend errors.
|
|
570
|
+
When a double-spend is detected, broadcasts the competing transaction to
|
|
571
|
+
update the overlay with missing state, then retries the operation.
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
export async function withDoubleSpendRetry<T>(operation: () => Promise<T>, broadcaster: TopicBroadcaster, maxRetries: number = MAX_DOUBLE_SPEND_RETRIES): Promise<T>
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
See also: [TopicBroadcaster](./overlay-tools.md#class-topicbroadcaster)
|
|
578
|
+
|
|
579
|
+
Returns
|
|
580
|
+
|
|
581
|
+
The result of the successful operation
|
|
582
|
+
|
|
583
|
+
Argument Details
|
|
584
|
+
|
|
585
|
+
+ **operation**
|
|
586
|
+
+ The async operation to execute (e.g., createAction + signAction)
|
|
587
|
+
+ **broadcaster**
|
|
588
|
+
+ The TopicBroadcaster to use for syncing missing state
|
|
589
|
+
+ **maxRetries**
|
|
590
|
+
+ Maximum number of retry attempts (default: MAX_DOUBLE_SPEND_RETRIES)
|
|
591
|
+
|
|
592
|
+
Throws
|
|
593
|
+
|
|
594
|
+
If max retries exceeded or non-double-spend error occurs
|
|
595
|
+
|
|
596
|
+
Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
|
|
597
|
+
|
|
598
|
+
---
|
|
567
599
|
## Types
|
|
568
600
|
|
|
569
601
|
| |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Transaction from '../transaction/Transaction.js'
|
|
2
2
|
import * as Utils from '../primitives/utils.js'
|
|
3
|
-
import { TopicBroadcaster, LookupResolver } from '../overlay-tools/index.js'
|
|
3
|
+
import { TopicBroadcaster, LookupResolver, withDoubleSpendRetry } from '../overlay-tools/index.js'
|
|
4
4
|
import { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js'
|
|
5
5
|
import { WalletInterface, WalletProtocol, CreateActionInput, OutpointString, PubKeyHex, CreateActionOutput, HexString } from '../wallet/Wallet.interfaces.js'
|
|
6
6
|
import { PushDrop } from '../script/index.js'
|
|
@@ -22,6 +22,7 @@ const DEFAULT_CONFIG: KVStoreConfig = {
|
|
|
22
22
|
topics: ['tm_kvstore'],
|
|
23
23
|
networkPreset: 'mainnet',
|
|
24
24
|
acceptDelayedBroadcast: false,
|
|
25
|
+
overlayBroadcast: false, // Use overlay broadcasting to prevent UTXO spending on broadcast rejection.
|
|
25
26
|
tokenSetDescription: '', // Will be set dynamically
|
|
26
27
|
tokenUpdateDescription: '', // Will be set dynamically
|
|
27
28
|
tokenRemovalDescription: '' // Will be set dynamically
|
|
@@ -141,11 +142,7 @@ export class GlobalKVStore {
|
|
|
141
142
|
const tags = options.tags ?? []
|
|
142
143
|
|
|
143
144
|
try {
|
|
144
|
-
//
|
|
145
|
-
const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
|
|
146
|
-
const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined
|
|
147
|
-
|
|
148
|
-
// Create PushDrop locking script
|
|
145
|
+
// Create PushDrop locking script (reusable across retries)
|
|
149
146
|
const pushdrop = new PushDrop(this.wallet, this.config.originator)
|
|
150
147
|
const lockingScriptFields = [
|
|
151
148
|
Utils.toArray(JSON.stringify(protocolID), 'utf8'),
|
|
@@ -167,82 +164,92 @@ export class GlobalKVStore {
|
|
|
167
164
|
true
|
|
168
165
|
)
|
|
169
166
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
167
|
+
// Wrap entire operation in double-spend retry, including overlay query
|
|
168
|
+
const outpoint = await withDoubleSpendRetry(async () => {
|
|
169
|
+
// Re-query overlay on each attempt to get fresh token state
|
|
170
|
+
const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
|
|
171
|
+
const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined
|
|
172
|
+
|
|
173
|
+
if (existingToken != null) {
|
|
174
|
+
// Update existing token
|
|
175
|
+
const inputs: CreateActionInput[] = [{
|
|
176
|
+
outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
|
|
177
|
+
unlockingScriptLength: 74,
|
|
178
|
+
inputDescription: 'Previous KVStore token'
|
|
179
|
+
}]
|
|
180
|
+
const inputBEEF = existingToken.beef
|
|
181
|
+
|
|
182
|
+
const { signableTransaction } = await this.wallet.createAction({
|
|
183
|
+
description: tokenUpdateDescription,
|
|
184
|
+
inputBEEF: inputBEEF.toBinary(),
|
|
185
|
+
inputs,
|
|
186
|
+
outputs: [{
|
|
187
|
+
satoshis: tokenAmount ?? this.config.tokenAmount as number,
|
|
188
|
+
lockingScript: lockingScript.toHex(),
|
|
189
|
+
outputDescription: 'KVStore token'
|
|
190
|
+
}],
|
|
191
|
+
options: {
|
|
192
|
+
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
|
|
193
|
+
noSend: this.config.overlayBroadcast,
|
|
194
|
+
randomizeOutputs: false
|
|
195
|
+
}
|
|
196
|
+
}, this.config.originator)
|
|
197
|
+
|
|
198
|
+
if (signableTransaction == null) {
|
|
199
|
+
throw new Error('Unable to create update transaction')
|
|
196
200
|
}
|
|
197
|
-
}, this.config.originator)
|
|
198
|
-
|
|
199
|
-
if (signableTransaction == null) {
|
|
200
|
-
throw new Error('Unable to create update transaction')
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
|
|
204
|
-
const unlocker = pushdrop.unlock(
|
|
205
|
-
this.config.protocolID as WalletProtocol,
|
|
206
|
-
key,
|
|
207
|
-
'anyone'
|
|
208
|
-
)
|
|
209
|
-
const unlockingScript = await unlocker.sign(tx, 0)
|
|
210
201
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
202
|
+
const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
|
|
203
|
+
const unlocker = pushdrop.unlock(
|
|
204
|
+
this.config.protocolID as WalletProtocol,
|
|
205
|
+
key,
|
|
206
|
+
'anyone'
|
|
207
|
+
)
|
|
208
|
+
const unlockingScript = await unlocker.sign(tx, 0)
|
|
209
|
+
|
|
210
|
+
const { tx: finalTx } = await this.wallet.signAction({
|
|
211
|
+
reference: signableTransaction.reference,
|
|
212
|
+
spends: { 0: { unlockingScript: unlockingScript.toHex() } },
|
|
213
|
+
options: {
|
|
214
|
+
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
|
|
215
|
+
noSend: this.config.overlayBroadcast
|
|
216
|
+
}
|
|
217
|
+
}, this.config.originator)
|
|
218
|
+
|
|
219
|
+
if (finalTx == null) {
|
|
220
|
+
throw new Error('Unable to finalize update transaction')
|
|
221
|
+
}
|
|
219
222
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
223
|
+
const transaction = Transaction.fromAtomicBEEF(finalTx)
|
|
224
|
+
await this.submitToOverlay(transaction)
|
|
225
|
+
return `${transaction.id('hex')}.0`
|
|
226
|
+
} else {
|
|
227
|
+
// Create new token
|
|
228
|
+
const { tx } = await this.wallet.createAction({
|
|
229
|
+
description: tokenSetDescription,
|
|
230
|
+
outputs: [{
|
|
231
|
+
satoshis: tokenAmount ?? this.config.tokenAmount as number,
|
|
232
|
+
lockingScript: lockingScript.toHex(),
|
|
233
|
+
outputDescription: 'KVStore token'
|
|
234
|
+
}],
|
|
235
|
+
options: {
|
|
236
|
+
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
|
|
237
|
+
noSend: this.config.overlayBroadcast,
|
|
238
|
+
randomizeOutputs: false
|
|
239
|
+
}
|
|
240
|
+
}, this.config.originator)
|
|
241
|
+
|
|
242
|
+
if (tx == null) {
|
|
243
|
+
throw new Error('Failed to create transaction')
|
|
235
244
|
}
|
|
236
|
-
}, this.config.originator)
|
|
237
245
|
|
|
238
|
-
|
|
239
|
-
|
|
246
|
+
const transaction = Transaction.fromAtomicBEEF(tx)
|
|
247
|
+
await this.submitToOverlay(transaction)
|
|
248
|
+
return `${transaction.id('hex')}.0`
|
|
240
249
|
}
|
|
250
|
+
}, this.topicBroadcaster)
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
await this.submitToOverlay(transaction)
|
|
244
|
-
return `${transaction.id('hex')}.0`
|
|
245
|
-
}
|
|
252
|
+
return outpoint
|
|
246
253
|
} finally {
|
|
247
254
|
if (lockQueue.length > 0) {
|
|
248
255
|
this.finishOperationOnKey(key, lockQueue)
|
|
@@ -274,54 +281,67 @@ export class GlobalKVStore {
|
|
|
274
281
|
const tokenRemovalDescription = (options.tokenRemovalDescription != null && options.tokenRemovalDescription !== '') ? options.tokenRemovalDescription : `Remove KVStore value for ${key}`
|
|
275
282
|
|
|
276
283
|
try {
|
|
277
|
-
const
|
|
284
|
+
const pushdrop = new PushDrop(this.wallet, this.config.originator)
|
|
278
285
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
286
|
+
// Remove token with double-spend retry
|
|
287
|
+
const txid = await withDoubleSpendRetry(async () => {
|
|
288
|
+
// Re-query overlay on each attempt to get fresh token state
|
|
289
|
+
const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
|
|
282
290
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
unlockingScriptLength: 74,
|
|
287
|
-
inputDescription: 'KVStore token to remove'
|
|
288
|
-
}]
|
|
291
|
+
if (existingEntries.length === 0 || existingEntries[0].token == null) {
|
|
292
|
+
throw new Error('The item did not exist, no item was deleted.')
|
|
293
|
+
}
|
|
289
294
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
const existingToken = existingEntries[0].token
|
|
296
|
+
const inputs: CreateActionInput[] = [{
|
|
297
|
+
outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
|
|
298
|
+
unlockingScriptLength: 74,
|
|
299
|
+
inputDescription: 'KVStore token to remove'
|
|
300
|
+
}]
|
|
301
|
+
|
|
302
|
+
const { signableTransaction } = await this.wallet.createAction({
|
|
303
|
+
description: tokenRemovalDescription,
|
|
304
|
+
inputBEEF: existingToken.beef.toBinary(),
|
|
305
|
+
inputs,
|
|
306
|
+
outputs,
|
|
307
|
+
options: {
|
|
308
|
+
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
|
|
309
|
+
randomizeOutputs: false,
|
|
310
|
+
noSend: this.config.overlayBroadcast
|
|
311
|
+
}
|
|
312
|
+
}, this.config.originator)
|
|
313
|
+
|
|
314
|
+
if (signableTransaction == null) {
|
|
315
|
+
throw new Error('Unable to create removal transaction')
|
|
298
316
|
}
|
|
299
|
-
}, this.config.originator)
|
|
300
317
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
318
|
+
const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
|
|
319
|
+
const unlocker = pushdrop.unlock(
|
|
320
|
+
protocolID ?? this.config.protocolID as WalletProtocol,
|
|
321
|
+
key,
|
|
322
|
+
'anyone'
|
|
323
|
+
)
|
|
324
|
+
const unlockingScript = await unlocker.sign(tx, 0)
|
|
304
325
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
const { tx: finalTx } = await this.wallet.signAction({
|
|
327
|
+
reference: signableTransaction.reference,
|
|
328
|
+
spends: { 0: { unlockingScript: unlockingScript.toHex() } },
|
|
329
|
+
options: {
|
|
330
|
+
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
|
|
331
|
+
noSend: this.config.overlayBroadcast
|
|
332
|
+
}
|
|
333
|
+
}, this.config.originator)
|
|
312
334
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}, this.config.originator)
|
|
335
|
+
if (finalTx == null) {
|
|
336
|
+
throw new Error('Unable to finalize removal transaction')
|
|
337
|
+
}
|
|
317
338
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
339
|
+
const transaction = Transaction.fromAtomicBEEF(finalTx)
|
|
340
|
+
await this.submitToOverlay(transaction)
|
|
341
|
+
return transaction.id('hex')
|
|
342
|
+
}, this.topicBroadcaster)
|
|
321
343
|
|
|
322
|
-
|
|
323
|
-
await this.submitToOverlay(transaction)
|
|
324
|
-
return transaction.id('hex')
|
|
344
|
+
return txid
|
|
325
345
|
} finally {
|
|
326
346
|
if (lockQueue.length > 0) {
|
|
327
347
|
this.finishOperationOnKey(key, lockQueue)
|
|
@@ -18,7 +18,17 @@ jest.mock('../../overlay-tools/Historian.js')
|
|
|
18
18
|
jest.mock('../kvStoreInterpreter.js')
|
|
19
19
|
jest.mock('../../script/index.js')
|
|
20
20
|
jest.mock('../../primitives/utils.js')
|
|
21
|
-
jest.mock('../../overlay-tools/index.js')
|
|
21
|
+
jest.mock('../../overlay-tools/index.js', () => {
|
|
22
|
+
const actual = jest.requireActual('../../overlay-tools/index.js')
|
|
23
|
+
return {
|
|
24
|
+
...actual,
|
|
25
|
+
// Keep withDoubleSpendRetry as the real implementation
|
|
26
|
+
withDoubleSpendRetry: actual.withDoubleSpendRetry,
|
|
27
|
+
// Mock the classes
|
|
28
|
+
TopicBroadcaster: jest.fn(),
|
|
29
|
+
LookupResolver: jest.fn()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
22
32
|
jest.mock('../../wallet/ProtoWallet.js')
|
|
23
33
|
jest.mock('../../wallet/WalletClient.js')
|
|
24
34
|
|
package/src/kvstore/types.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface KVStoreConfig {
|
|
|
25
25
|
networkPreset?: 'mainnet' | 'testnet' | 'local'
|
|
26
26
|
/** Whether to accept delayed broadcast */
|
|
27
27
|
acceptDelayedBroadcast?: boolean
|
|
28
|
+
/** Whether to let overlay handle broadcasting (prevents UTXO spending on rejection) */
|
|
29
|
+
overlayBroadcast?: boolean
|
|
28
30
|
/** Description for token set */
|
|
29
31
|
tokenSetDescription?: string
|
|
30
32
|
/** Description for token update */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './LookupResolver.js'
|
|
2
2
|
export * from './SHIPBroadcaster.js'
|
|
3
|
+
export * from './withDoubleSpendRetry.js'
|
|
3
4
|
export { default as OverlayAdminTokenTemplate } from './OverlayAdminTokenTemplate.js'
|
|
4
5
|
export { default as LookupResolver } from './LookupResolver.js'
|
|
5
6
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import Transaction from '../transaction/Transaction.js'
|
|
2
|
+
import { WERR_REVIEW_ACTIONS } from '../wallet/WERR_REVIEW_ACTIONS.js'
|
|
3
|
+
import { ReviewActionResult } from '../wallet/Wallet.interfaces.js'
|
|
4
|
+
import TopicBroadcaster from './SHIPBroadcaster.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maximum number of retry attempts for double-spend resolution
|
|
8
|
+
*/
|
|
9
|
+
const MAX_DOUBLE_SPEND_RETRIES = 5
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Executes an operation with automatic retry logic for double-spend errors.
|
|
13
|
+
* When a double-spend is detected, broadcasts the competing transaction to
|
|
14
|
+
* update the overlay with missing state, then retries the operation.
|
|
15
|
+
*
|
|
16
|
+
* @param operation - The async operation to execute (e.g., createAction + signAction)
|
|
17
|
+
* @param broadcaster - The TopicBroadcaster to use for syncing missing state
|
|
18
|
+
* @param maxRetries - Maximum number of retry attempts (default: MAX_DOUBLE_SPEND_RETRIES)
|
|
19
|
+
* @returns The result of the successful operation
|
|
20
|
+
* @throws If max retries exceeded or non-double-spend error occurs
|
|
21
|
+
*/
|
|
22
|
+
export async function withDoubleSpendRetry<T> (
|
|
23
|
+
operation: () => Promise<T>,
|
|
24
|
+
broadcaster: TopicBroadcaster,
|
|
25
|
+
maxRetries: number = MAX_DOUBLE_SPEND_RETRIES
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
let attempts = 0
|
|
28
|
+
|
|
29
|
+
while (attempts < maxRetries) {
|
|
30
|
+
attempts++
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await operation()
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Only handle double-spend errors on non-final attempts
|
|
36
|
+
if (attempts < maxRetries && error.name === 'WERR_REVIEW_ACTIONS') {
|
|
37
|
+
const reviewError = error as WERR_REVIEW_ACTIONS
|
|
38
|
+
|
|
39
|
+
// Check if any action in the batch has a double-spend
|
|
40
|
+
const doubleSpendResult = reviewError.reviewActionResults.find(
|
|
41
|
+
(result: ReviewActionResult) => result.status === 'doubleSpend'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (doubleSpendResult?.competingBeef != null &&
|
|
45
|
+
doubleSpendResult?.competingTxs != null &&
|
|
46
|
+
doubleSpendResult?.competingTxs.length > 0) {
|
|
47
|
+
const competingTx = Transaction.fromBEEF(
|
|
48
|
+
doubleSpendResult.competingBeef,
|
|
49
|
+
doubleSpendResult.competingTxs[0]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Recursively handle double-spend errors during broadcast
|
|
53
|
+
// The broadcast itself might fail if the competing tx depends on another missing tx
|
|
54
|
+
await withDoubleSpendRetry(
|
|
55
|
+
async () => await broadcaster.broadcast(competingTx),
|
|
56
|
+
broadcaster,
|
|
57
|
+
maxRetries - attempts // Reduce max retries based on attempts already used
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Non-double-spend error or max retries exceeded - rethrow
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// This should never be reached, but TypeScript requires it
|
|
70
|
+
throw new Error('Unexpected end of retry loop')
|
|
71
|
+
}
|