Skip to content

Commit 279aabc

Browse files
Initial implementation
1 parent 7326e19 commit 279aabc

File tree

5 files changed

+219
-115
lines changed

5 files changed

+219
-115
lines changed

src/kvstore/GlobalKVStore.ts

Lines changed: 134 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Transaction from '../transaction/Transaction.js'
22
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'
44
import { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js'
55
import { WalletInterface, WalletProtocol, CreateActionInput, OutpointString, PubKeyHex, CreateActionOutput, HexString } from '../wallet/Wallet.interfaces.js'
66
import { 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)

src/kvstore/__tests/GlobalKVStore.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ jest.mock('../../overlay-tools/Historian.js')
1818
jest.mock('../kvStoreInterpreter.js')
1919
jest.mock('../../script/index.js')
2020
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+
})
2232
jest.mock('../../wallet/ProtoWallet.js')
2333
jest.mock('../../wallet/WalletClient.js')
2434

src/kvstore/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface KVStoreConfig {
2525
networkPreset?: 'mainnet' | 'testnet' | 'local'
2626
/** Whether to accept delayed broadcast */
2727
acceptDelayedBroadcast?: boolean
28+
/** Whether to let overlay handle broadcasting (prevents UTXO spending on rejection) */
29+
overlayBroadcast?: boolean
2830
/** Description for token set */
2931
tokenSetDescription?: string
3032
/** Description for token update */

src/overlay-tools/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './LookupResolver.js'
22
export * from './SHIPBroadcaster.js'
3+
export * from './withDoubleSpendRetry.js'
34
export { default as OverlayAdminTokenTemplate } from './OverlayAdminTokenTemplate.js'
45
export { default as LookupResolver } from './LookupResolver.js'
56

0 commit comments

Comments
 (0)