@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.
- 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/src/registry/RegistryClient.js +144 -25
- package/dist/cjs/src/registry/RegistryClient.js.map +1 -1
- 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/src/registry/RegistryClient.js +148 -26
- package/dist/esm/src/registry/RegistryClient.js.map +1 -1
- 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/src/registry/RegistryClient.d.ts +31 -5
- package/dist/types/src/registry/RegistryClient.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/kvstore.md +9 -0
- package/docs/reference/overlay-tools.md +32 -0
- package/docs/reference/registry.md +34 -9
- 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
- package/src/registry/RegistryClient.ts +180 -36
- 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
|
-
-
|
|
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
|
|
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
|
|
318
|
+
#### Method updateDefinition
|
|
296
319
|
|
|
297
|
-
|
|
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
|
|
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
|
-
+
|
|
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
|
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
|
+
}
|