11import Transaction from '../transaction/Transaction.js'
22import * 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'
44import { BroadcastResponse , BroadcastFailure } from '../transaction/Broadcaster.js'
55import { WalletInterface , WalletProtocol , CreateActionInput , OutpointString , PubKeyHex , CreateActionOutput , HexString } from '../wallet/Wallet.interfaces.js'
66import { PushDrop } from '../script/index.js'
@@ -22,6 +22,7 @@ const DEFAULT_CONFIG: KVStoreConfig = {
2222 topics : [ 'tm_kvstore' ] ,
2323 networkPreset : 'mainnet' ,
2424 acceptDelayedBroadcast : false ,
25+ overlayBroadcast : true , // Let overlay handle broadcasting to prevent UTXO spending on rejection
2526 tokenSetDescription : '' , // Will be set dynamically
2627 tokenUpdateDescription : '' , // Will be set dynamically
2728 tokenRemovalDescription : '' // Will be set dynamically
@@ -141,11 +142,7 @@ export class GlobalKVStore {
141142 const tags = options . tags ?? [ ]
142143
143144 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)
149146 const pushdrop = new PushDrop ( this . wallet , this . config . originator )
150147 const lockingScriptFields = [
151148 Utils . toArray ( JSON . stringify ( protocolID ) , 'utf8' ) ,
@@ -167,82 +164,92 @@ export class GlobalKVStore {
167164 true
168165 )
169166
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' )
196200 }
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 )
210201
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+ }
219222
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' )
235244 }
236- } , this . config . originator )
237245
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`
240249 }
250+ } , this . topicBroadcaster )
241251
242- const transaction = Transaction . fromAtomicBEEF ( tx )
243- await this . submitToOverlay ( transaction )
244- return `${ transaction . id ( 'hex' ) } .0`
245- }
252+ return outpoint
246253 } finally {
247254 if ( lockQueue . length > 0 ) {
248255 this . finishOperationOnKey ( key , lockQueue )
@@ -274,54 +281,67 @@ export class GlobalKVStore {
274281 const tokenRemovalDescription = ( options . tokenRemovalDescription != null && options . tokenRemovalDescription !== '' ) ? options . tokenRemovalDescription : `Remove KVStore value for ${ key } `
275282
276283 try {
277- const existingEntries = await this . queryOverlay ( { key , controller } , { includeToken : true } )
284+ const pushdrop = new PushDrop ( this . wallet , this . config . originator )
278285
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 } )
282290
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+ }
289294
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' )
298316 }
299- } , this . config . originator )
300317
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 )
304325
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 )
312334
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+ }
317338
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 )
321343
322- const transaction = Transaction . fromAtomicBEEF ( finalTx )
323- await this . submitToOverlay ( transaction )
324- return transaction . id ( 'hex' )
344+ return txid
325345 } finally {
326346 if ( lockQueue . length > 0 ) {
327347 this . finishOperationOnKey ( key , lockQueue )
0 commit comments