@bsv/sdk 1.9.1 → 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.
Files changed (44) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/kvstore/GlobalKVStore.js +116 -98
  3. package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
  4. package/dist/cjs/src/kvstore/types.js.map +1 -1
  5. package/dist/cjs/src/overlay-tools/index.js +1 -0
  6. package/dist/cjs/src/overlay-tools/index.js.map +1 -1
  7. package/dist/cjs/src/overlay-tools/withDoubleSpendRetry.js +55 -0
  8. package/dist/cjs/src/overlay-tools/withDoubleSpendRetry.js.map +1 -0
  9. package/dist/cjs/src/registry/RegistryClient.js +144 -25
  10. package/dist/cjs/src/registry/RegistryClient.js.map +1 -1
  11. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  12. package/dist/esm/src/kvstore/GlobalKVStore.js +117 -99
  13. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
  14. package/dist/esm/src/kvstore/types.js.map +1 -1
  15. package/dist/esm/src/overlay-tools/index.js +1 -0
  16. package/dist/esm/src/overlay-tools/index.js.map +1 -1
  17. package/dist/esm/src/overlay-tools/withDoubleSpendRetry.js +48 -0
  18. package/dist/esm/src/overlay-tools/withDoubleSpendRetry.js.map +1 -0
  19. package/dist/esm/src/registry/RegistryClient.js +148 -26
  20. package/dist/esm/src/registry/RegistryClient.js.map +1 -1
  21. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  22. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
  23. package/dist/types/src/kvstore/types.d.ts +2 -0
  24. package/dist/types/src/kvstore/types.d.ts.map +1 -1
  25. package/dist/types/src/overlay-tools/index.d.ts +1 -0
  26. package/dist/types/src/overlay-tools/index.d.ts.map +1 -1
  27. package/dist/types/src/overlay-tools/withDoubleSpendRetry.d.ts +14 -0
  28. package/dist/types/src/overlay-tools/withDoubleSpendRetry.d.ts.map +1 -0
  29. package/dist/types/src/registry/RegistryClient.d.ts +31 -5
  30. package/dist/types/src/registry/RegistryClient.d.ts.map +1 -1
  31. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  32. package/dist/umd/bundle.js +3 -3
  33. package/dist/umd/bundle.js.map +1 -1
  34. package/docs/reference/kvstore.md +9 -0
  35. package/docs/reference/overlay-tools.md +32 -0
  36. package/docs/reference/registry.md +34 -9
  37. package/package.json +1 -1
  38. package/src/kvstore/GlobalKVStore.ts +134 -114
  39. package/src/kvstore/__tests/GlobalKVStore.test.ts +11 -1
  40. package/src/kvstore/types.ts +2 -0
  41. package/src/overlay-tools/index.ts +1 -0
  42. package/src/overlay-tools/withDoubleSpendRetry.ts +71 -0
  43. package/src/registry/RegistryClient.ts +180 -36
  44. package/src/registry/__tests/RegistryClient.test.ts +211 -54
@@ -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
  | |
@@ -205,22 +205,27 @@ It provides methods to:
205
205
  - Register new definitions using pushdrop-based UTXOs.
206
206
  - Resolve existing definitions using a lookup service.
207
207
  - List registry entries associated with the operator's wallet.
208
- - Revoke an existing registry entry by spending its UTXO.
208
+ - Remove existing registry entries by spending their UTXOs.
209
+ - Update existing registry entries.
209
210
 
210
211
  Registry operators use this client to establish and manage
211
212
  canonical references for baskets, protocols, and certificate types.
212
213
 
213
214
  ```ts
214
215
  export class RegistryClient {
215
- constructor(private readonly wallet: WalletInterface = new WalletClient())
216
+ constructor(private readonly wallet: WalletInterface = new WalletClient(), options: {
217
+ acceptDelayedBroadcast?: boolean;
218
+ resolver?: LookupResolver;
219
+ } = {})
216
220
  async registerDefinition(data: DefinitionData): Promise<BroadcastResponse | BroadcastFailure>
217
221
  async resolve<T extends DefinitionType>(definitionType: T, query: RegistryQueryMapping[T]): Promise<DefinitionData[]>
218
222
  async listOwnRegistryEntries(definitionType: DefinitionType): Promise<RegistryRecord[]>
219
- async revokeOwnRegistryEntry(registryRecord: RegistryRecord): Promise<BroadcastResponse | BroadcastFailure>
223
+ async removeDefinition(registryRecord: RegistryRecord): Promise<BroadcastResponse | BroadcastFailure>
224
+ async updateDefinition(registryRecord: RegistryRecord, updatedData: DefinitionData): Promise<BroadcastResponse | BroadcastFailure>
220
225
  }
221
226
  ```
222
227
 
223
- See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [DefinitionData](./registry.md#type-definitiondata), [DefinitionType](./registry.md#type-definitiontype), [RegistryQueryMapping](./registry.md#interface-registryquerymapping), [RegistryRecord](./registry.md#type-registryrecord), [WalletClient](./wallet.md#class-walletclient), [WalletInterface](./wallet.md#interface-walletinterface)
228
+ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [DefinitionData](./registry.md#type-definitiondata), [DefinitionType](./registry.md#type-definitiontype), [LookupResolver](./overlay-tools.md#class-lookupresolver), [RegistryQueryMapping](./registry.md#interface-registryquerymapping), [RegistryRecord](./registry.md#type-registryrecord), [WalletClient](./wallet.md#class-walletclient), [WalletInterface](./wallet.md#interface-walletinterface)
224
229
 
225
230
  #### Method listOwnRegistryEntries
226
231
 
@@ -264,6 +269,24 @@ Argument Details
264
269
  + **data**
265
270
  + Structured information about a 'basket', 'protocol', or 'certificate'.
266
271
 
272
+ #### Method removeDefinition
273
+
274
+ Removes a registry definition by spending its associated UTXO.
275
+
276
+ ```ts
277
+ async removeDefinition(registryRecord: RegistryRecord): Promise<BroadcastResponse | BroadcastFailure>
278
+ ```
279
+ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [RegistryRecord](./registry.md#type-registryrecord)
280
+
281
+ Returns
282
+
283
+ Broadcast success/failure.
284
+
285
+ Argument Details
286
+
287
+ + **registryRecord**
288
+ + The registry record to remove (must have valid txid, outputIndex, and lockingScript).
289
+
267
290
  #### Method resolve
268
291
 
269
292
  Resolves registrant tokens of a particular type using a lookup service.
@@ -292,14 +315,14 @@ Argument Details
292
315
  + **query**
293
316
  + The query object used to filter registry records, whose shape is determined by the registry type.
294
317
 
295
- #### Method revokeOwnRegistryEntry
318
+ #### Method updateDefinition
296
319
 
297
- Revokes a registry record by spending its associated UTXO.
320
+ Updates an existing registry record by spending its UTXO and creating a new one with updated data.
298
321
 
299
322
  ```ts
300
- async revokeOwnRegistryEntry(registryRecord: RegistryRecord): Promise<BroadcastResponse | BroadcastFailure>
323
+ async updateDefinition(registryRecord: RegistryRecord, updatedData: DefinitionData): Promise<BroadcastResponse | BroadcastFailure>
301
324
  ```
302
- See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [RegistryRecord](./registry.md#type-registryrecord)
325
+ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [DefinitionData](./registry.md#type-definitiondata), [RegistryRecord](./registry.md#type-registryrecord)
303
326
 
304
327
  Returns
305
328
 
@@ -308,7 +331,9 @@ Broadcast success/failure.
308
331
  Argument Details
309
332
 
310
333
  + **registryRecord**
311
- + Must have valid txid, outputIndex, and lockingScript.
334
+ + The existing registry record to update (must have valid txid, outputIndex, and lockingScript).
335
+ + **updatedData**
336
+ + The new definition data to replace the old record.
312
337
 
313
338
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
314
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -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
- // Check for existing token to spend
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
- let inputs: CreateActionInput[] = []
171
- let inputBEEF: Beef | undefined
172
-
173
- if (existingToken != null) {
174
- inputs = [{
175
- outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
176
- unlockingScriptLength: 74,
177
- inputDescription: 'Previous KVStore token'
178
- }]
179
- inputBEEF = existingToken.beef
180
- }
181
-
182
- if (inputs.length > 0) {
183
- // Update existing token
184
- const { signableTransaction } = await this.wallet.createAction({
185
- description: tokenUpdateDescription,
186
- inputBEEF: inputBEEF?.toBinary(),
187
- inputs,
188
- outputs: [{
189
- satoshis: tokenAmount ?? this.config.tokenAmount as number,
190
- lockingScript: lockingScript.toHex(),
191
- outputDescription: 'KVStore token'
192
- }],
193
- options: {
194
- acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
195
- randomizeOutputs: false
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
- const { tx: finalTx } = await this.wallet.signAction({
212
- reference: signableTransaction.reference,
213
- spends: { 0: { unlockingScript: unlockingScript.toHex() } }
214
- }, this.config.originator)
215
-
216
- if (finalTx == null) {
217
- throw new Error('Unable to finalize update transaction')
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
- const transaction = Transaction.fromAtomicBEEF(finalTx)
221
- await this.submitToOverlay(transaction)
222
- return `${transaction.id('hex')}.0`
223
- } else {
224
- // Create new token
225
- const { tx } = await this.wallet.createAction({
226
- description: tokenSetDescription,
227
- outputs: [{
228
- satoshis: tokenAmount ?? this.config.tokenAmount as number,
229
- lockingScript: lockingScript.toHex(),
230
- outputDescription: 'KVStore token'
231
- }],
232
- options: {
233
- acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
234
- randomizeOutputs: false
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
- if (tx == null) {
239
- throw new Error('Failed to create transaction')
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
- const transaction = Transaction.fromAtomicBEEF(tx)
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 existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
284
+ const pushdrop = new PushDrop(this.wallet, this.config.originator)
278
285
 
279
- if (existingEntries.length === 0 || existingEntries[0].token == null) {
280
- throw new Error('The item did not exist, no item was deleted.')
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
- const existingToken = existingEntries[0].token
284
- const inputs: CreateActionInput[] = [{
285
- outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
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
- const pushdrop = new PushDrop(this.wallet, this.config.originator)
291
- const { signableTransaction } = await this.wallet.createAction({
292
- description: tokenRemovalDescription,
293
- inputBEEF: existingToken.beef.toBinary(),
294
- inputs,
295
- outputs,
296
- options: {
297
- acceptDelayedBroadcast: this.config.acceptDelayedBroadcast
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
- if (signableTransaction == null) {
302
- throw new Error('Unable to create removal transaction')
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
- const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
306
- const unlocker = pushdrop.unlock(
307
- protocolID ?? this.config.protocolID as WalletProtocol,
308
- key,
309
- 'anyone'
310
- )
311
- const unlockingScript = await unlocker.sign(tx, 0)
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
- const { tx: finalTx } = await this.wallet.signAction({
314
- reference: signableTransaction.reference,
315
- spends: { 0: { unlockingScript: unlockingScript.toHex() } }
316
- }, this.config.originator)
335
+ if (finalTx == null) {
336
+ throw new Error('Unable to finalize removal transaction')
337
+ }
317
338
 
318
- if (finalTx == null) {
319
- throw new Error('Unable to finalize removal transaction')
320
- }
339
+ const transaction = Transaction.fromAtomicBEEF(finalTx)
340
+ await this.submitToOverlay(transaction)
341
+ return transaction.id('hex')
342
+ }, this.topicBroadcaster)
321
343
 
322
- const transaction = Transaction.fromAtomicBEEF(finalTx)
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
 
@@ -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
+ }