@bsv/sdk
Version:
BSV Blockchain Software Development Kit
524 lines (462 loc) • 20.4 kB
text/typescript
import Transaction from '../transaction/Transaction.js'
import * as Utils from '../primitives/utils.js'
import { TopicBroadcaster, LookupResolver, withDoubleSpendRetry } from '../overlay-tools/index.js'
import { BroadcastResponse, BroadcastFailure } from '../transaction/Broadcaster.js'
import { WalletInterface, WalletProtocol, CreateActionInput, OutpointString, PubKeyHex, CreateActionOutput, HexString } from '../wallet/Wallet.interfaces.js'
import { PushDrop } from '../script/index.js'
import WalletClient from '../wallet/WalletClient.js'
import { Beef } from '../transaction/Beef.js'
import { Historian } from '../overlay-tools/Historian.js'
import { KVContext, kvStoreInterpreter } from './kvStoreInterpreter.js'
import { ProtoWallet } from '../wallet/ProtoWallet.js'
import { kvProtocol, KVStoreConfig, KVStoreQuery, KVStoreEntry, KVStoreGetOptions, KVStoreSetOptions, KVStoreRemoveOptions } from './types.js'
/**
* Default configuration values for GlobalKVStore operations.
* Provides sensible defaults for overlay connection and protocol settings.
*/
const DEFAULT_CONFIG: KVStoreConfig = {
protocolID: [1, 'kvstore'],
serviceName: 'ls_kvstore',
tokenAmount: 1,
topics: ['tm_kvstore'],
networkPreset: 'mainnet',
acceptDelayedBroadcast: false,
overlayBroadcast: false, // Use overlay broadcasting to prevent UTXO spending on broadcast rejection.
tokenSetDescription: '', // Will be set dynamically
tokenUpdateDescription: '', // Will be set dynamically
tokenRemovalDescription: '' // Will be set dynamically
}
/**
* Implements a global key-value storage system which uses an overlay service to track key-value pairs.
* Each key-value pair is represented by a PushDrop token output.
* Allows getting, setting, and removing key-value pairs with optional fetching by protocolID and history tracking.
*/
export class GlobalKVStore {
/**
* The wallet interface used to create transactions and perform cryptographic operations.
* @readonly
*/
private readonly wallet: WalletInterface
/**
* Configuration for the KVStore instance containing all runtime options.
* @private
* @readonly
*/
private readonly config: KVStoreConfig
/**
* Historian instance used to extract history from transaction outputs.
* @private
*/
private readonly historian: Historian<string, KVContext>
/**
* Lookup resolver used to query the overlay for transaction outputs.
* @private
*/
private readonly lookupResolver: LookupResolver
/**
* Topic broadcaster used to broadcast transactions to the overlay.
* @private
*/
private readonly topicBroadcaster: TopicBroadcaster
/**
* A map to store locks for each key to ensure atomic updates.
* @private
*/
private readonly keyLocks: Map<string, Array<(value: void | PromiseLike<void>) => void>> = new Map()
/**
* Cached user identity key
* @private
*/
private cachedIdentityKey: PubKeyHex | null = null
/**
* Creates an instance of the GlobalKVStore.
*
* @param {KVStoreConfig} [config={}] - Configuration options for the KVStore. Defaults to empty object.
* @param {WalletInterface} [config.wallet] - Wallet to use for operations. Defaults to WalletClient.
* @throws {Error} If the configuration contains invalid parameters.
*/
constructor (config: KVStoreConfig = {}) {
// Merge with defaults to create a fully resolved config
this.config = { ...DEFAULT_CONFIG, ...config }
this.wallet = config.wallet ?? new WalletClient()
this.historian = new Historian<string, KVContext>(kvStoreInterpreter)
this.lookupResolver = new LookupResolver({
networkPreset: this.config.networkPreset
})
this.topicBroadcaster = new TopicBroadcaster(this.config.topics as string[], {
networkPreset: this.config.networkPreset
})
}
/**
* Retrieves data from the KVStore.
* Can query by key+controller (single result), protocolID, controller, or key (multiple results).
*
* @param {KVStoreQuery} query - Query parameters sent to overlay
* @param {KVStoreGetOptions} [options={}] - Configuration options for the get operation
* @returns {Promise<KVStoreEntry | KVStoreEntry[] | undefined>} Single entry for key+controller queries, array for all other queries
*/
async get (query: KVStoreQuery, options: KVStoreGetOptions = {}): Promise<KVStoreEntry | KVStoreEntry[] | undefined> {
if (Object.keys(query).length === 0) {
throw new Error('Must specify either key, controller, or protocolID')
}
if (query.key != null && query.controller != null) {
// Specific key+controller query - return single entry
const entries = await this.queryOverlay(query, options)
return entries.length > 0 ? entries[0] : undefined
}
return await this.queryOverlay(query, options)
}
/**
* Sets a key-value pair. The current user (wallet identity) becomes the controller.
*
* @param {string} key - The key to set (user computes this however they want)
* @param {string} value - The value to store
* @param {KVStoreSetOptions} [options={}] - Configuration options for the set operation
* @returns {Promise<OutpointString>} The outpoint of the created token
*/
async set (key: string, value: string, options: KVStoreSetOptions = {}): Promise<OutpointString> {
if (typeof key !== 'string' || key.length === 0) {
throw new Error('Key must be a non-empty string.')
}
if (typeof value !== 'string') {
throw new Error('Value must be a string.')
}
const controller = await this.getIdentityKey()
const lockQueue = await this.queueOperationOnKey(key)
const protocolID = options.protocolID ?? this.config.protocolID
const tokenSetDescription = (options.tokenSetDescription != null && options.tokenSetDescription !== '') ? options.tokenSetDescription : `Create KVStore value for ${key}`
const tokenUpdateDescription = (options.tokenUpdateDescription != null && options.tokenUpdateDescription !== '') ? options.tokenUpdateDescription : `Update KVStore value for ${key}`
const tokenAmount = options.tokenAmount ?? this.config.tokenAmount
const tags = options.tags ?? []
try {
// Create PushDrop locking script (reusable across retries)
const pushdrop = new PushDrop(this.wallet, this.config.originator)
const lockingScriptFields = [
Utils.toArray(JSON.stringify(protocolID), 'utf8'),
Utils.toArray(key, 'utf8'),
Utils.toArray(value, 'utf8'),
Utils.toArray(controller, 'hex')
]
// Add tags as optional 5th field for backwards compatibility
if (tags.length > 0) {
lockingScriptFields.push(Utils.toArray(JSON.stringify(tags), 'utf8'))
}
const lockingScript = await pushdrop.lock(
lockingScriptFields,
protocolID ?? this.config.protocolID as WalletProtocol,
Utils.toUTF8(Utils.toArray(key, 'utf8')),
'anyone',
true
)
// Wrap entire operation in double-spend retry, including overlay query
const outpoint = await withDoubleSpendRetry(async () => {
// Re-query overlay on each attempt to get fresh token state
const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined
if (existingToken != null) {
// Update existing token
const inputs: CreateActionInput[] = [{
outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
unlockingScriptLength: 74,
inputDescription: 'Previous KVStore token'
}]
const inputBEEF = existingToken.beef
const { signableTransaction } = await this.wallet.createAction({
description: tokenUpdateDescription,
inputBEEF: inputBEEF.toBinary(),
inputs,
outputs: [{
satoshis: tokenAmount ?? this.config.tokenAmount as number,
lockingScript: lockingScript.toHex(),
outputDescription: 'KVStore token'
}],
options: {
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
noSend: this.config.overlayBroadcast,
randomizeOutputs: false
}
}, this.config.originator)
if (signableTransaction == null) {
throw new Error('Unable to create update transaction')
}
const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
const unlocker = pushdrop.unlock(
this.config.protocolID as WalletProtocol,
key,
'anyone'
)
const unlockingScript = await unlocker.sign(tx, 0)
const { tx: finalTx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: { 0: { unlockingScript: unlockingScript.toHex() } },
options: {
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
noSend: this.config.overlayBroadcast
}
}, this.config.originator)
if (finalTx == null) {
throw new Error('Unable to finalize update transaction')
}
const transaction = Transaction.fromAtomicBEEF(finalTx)
await this.submitToOverlay(transaction)
return `${transaction.id('hex')}.0`
} else {
// Create new token
const { tx } = await this.wallet.createAction({
description: tokenSetDescription,
outputs: [{
satoshis: tokenAmount ?? this.config.tokenAmount as number,
lockingScript: lockingScript.toHex(),
outputDescription: 'KVStore token'
}],
options: {
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
noSend: this.config.overlayBroadcast,
randomizeOutputs: false
}
}, this.config.originator)
if (tx == null) {
throw new Error('Failed to create transaction')
}
const transaction = Transaction.fromAtomicBEEF(tx)
await this.submitToOverlay(transaction)
return `${transaction.id('hex')}.0`
}
}, this.topicBroadcaster)
return outpoint
} finally {
if (lockQueue.length > 0) {
this.finishOperationOnKey(key, lockQueue)
}
}
}
/**
* Removes the key-value pair associated with the given key from the overlay service.
*
* @param {string} key - The key to remove.
* @param {CreateActionOutput[] | undefined} [outputs=undefined] - Additional outputs to include in the removal transaction.
* @param {KVStoreRemoveOptions} [options=undefined] - Optional parameters for the removal operation.
* @returns {Promise<HexString>} A promise that resolves to the txid of the removal transaction if successful.
* @throws {Error} If the key is invalid.
* @throws {Error} If the key does not exist in the store.
* @throws {Error} If the overlay service is unreachable or the transaction fails.
* @throws {Error} If there are existing tokens that cannot be unlocked.
*/
async remove (key: string, outputs?: CreateActionOutput[], options: KVStoreRemoveOptions = {}): Promise<HexString> {
if (typeof key !== 'string' || key.length === 0) {
throw new Error('Key must be a non-empty string.')
}
const controller = await this.getIdentityKey()
const lockQueue = await this.queueOperationOnKey(key)
const protocolID = options.protocolID ?? this.config.protocolID
const tokenRemovalDescription = (options.tokenRemovalDescription != null && options.tokenRemovalDescription !== '') ? options.tokenRemovalDescription : `Remove KVStore value for ${key}`
try {
const pushdrop = new PushDrop(this.wallet, this.config.originator)
// Remove token with double-spend retry
const txid = await withDoubleSpendRetry(async () => {
// Re-query overlay on each attempt to get fresh token state
const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true })
if (existingEntries.length === 0 || existingEntries[0].token == null) {
throw new Error('The item did not exist, no item was deleted.')
}
const existingToken = existingEntries[0].token
const inputs: CreateActionInput[] = [{
outpoint: `${existingToken.txid}.${existingToken.outputIndex}`,
unlockingScriptLength: 74,
inputDescription: 'KVStore token to remove'
}]
const { signableTransaction } = await this.wallet.createAction({
description: tokenRemovalDescription,
inputBEEF: existingToken.beef.toBinary(),
inputs,
outputs,
options: {
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
randomizeOutputs: false,
noSend: this.config.overlayBroadcast
}
}, this.config.originator)
if (signableTransaction == null) {
throw new Error('Unable to create removal transaction')
}
const tx = Transaction.fromAtomicBEEF(signableTransaction.tx)
const unlocker = pushdrop.unlock(
protocolID ?? this.config.protocolID as WalletProtocol,
key,
'anyone'
)
const unlockingScript = await unlocker.sign(tx, 0)
const { tx: finalTx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: { 0: { unlockingScript: unlockingScript.toHex() } },
options: {
acceptDelayedBroadcast: this.config.acceptDelayedBroadcast,
noSend: this.config.overlayBroadcast
}
}, this.config.originator)
if (finalTx == null) {
throw new Error('Unable to finalize removal transaction')
}
const transaction = Transaction.fromAtomicBEEF(finalTx)
await this.submitToOverlay(transaction)
return transaction.id('hex')
}, this.topicBroadcaster)
return txid
} finally {
if (lockQueue.length > 0) {
this.finishOperationOnKey(key, lockQueue)
}
}
}
/**
* Queues an operation on a specific key to ensure atomic updates.
* Prevents concurrent operations on the same key from interfering with each other.
*
* @param {string} key - The key to queue an operation for.
* @returns {Promise<Array<(value: void | PromiseLike<void>) => void>>} The lock queue for cleanup.
* @private
*/
private async queueOperationOnKey (key: string): Promise<Array<(value: void | PromiseLike<void>) => void>> {
// Check if a lock exists for this key and wait for it to resolve
let lockQueue = this.keyLocks.get(key)
if (lockQueue == null) {
lockQueue = []
this.keyLocks.set(key, lockQueue)
}
let resolveNewLock: () => void = () => { }
const newLock = new Promise<void>((resolve) => {
resolveNewLock = resolve
if (lockQueue != null) { lockQueue.push(resolve) }
})
// If we are the only request, resolve the lock immediately, queue remains at 1 item until request ends.
if (lockQueue.length === 1) {
resolveNewLock()
}
await newLock
return lockQueue
}
/**
* Finishes an operation on a key and resolves the next waiting operation.
*
* @param {string} key - The key to finish the operation for.
* @param {Array<(value: void | PromiseLike<void>) => void>} lockQueue - The lock queue from queueOperationOnKey.
* @private
*/
private finishOperationOnKey (key: string, lockQueue: Array<(value: void | PromiseLike<void>) => void>): void {
lockQueue.shift() // Remove the current lock from the queue
if (lockQueue.length > 0) {
// If there are more locks waiting, resolve the next one
lockQueue[0]()
} else {
// Clean up empty queue to prevent memory leak
this.keyLocks.delete(key)
}
}
/**
* Helper function to fetch and cache user identity key
*
* @returns {Promise<PubKeyHex>} The identity key of the current user
* @private
*/
private async getIdentityKey (): Promise<PubKeyHex> {
if (this.cachedIdentityKey == null) {
this.cachedIdentityKey = (await this.wallet.getPublicKey({ identityKey: true }, this.config.originator)).publicKey
}
return this.cachedIdentityKey
}
/**
* Queries the overlay service for KV entries.
*
* @param {KVStoreQuery} query - Query parameters sent to overlay
* @param {KVStoreGetOptions} options - Configuration options for the query
* @returns {Promise<KVStoreEntry[]>} Array of matching KV entries
* @private
*/
private async queryOverlay (query: KVStoreQuery, options: KVStoreGetOptions = {}): Promise<KVStoreEntry[]> {
const answer = await this.lookupResolver.query({
service: options.serviceName ?? this.config.serviceName as string,
query
})
if (answer.type !== 'output-list' || answer.outputs.length === 0) {
return []
}
const entries: KVStoreEntry[] = []
for (const result of answer.outputs) {
try {
const tx = Transaction.fromBEEF(result.beef)
const output = tx.outputs[result.outputIndex]
const decoded = PushDrop.decode(output.lockingScript)
// Support backwards compatibility: old format without tags, new format with tags
const expectedFieldCount = Object.keys(kvProtocol).length
const hasTagsField = decoded.fields.length === expectedFieldCount
const isOldFormat = decoded.fields.length === expectedFieldCount - 1
if (!isOldFormat && !hasTagsField) {
continue
}
// Verify signature
const anyoneWallet = new ProtoWallet('anyone')
const signature = decoded.fields.pop() as number[]
try {
await anyoneWallet.verifySignature({
data: decoded.fields.reduce((a, e) => [...a, ...e], []),
signature,
counterparty: Utils.toHex(decoded.fields[kvProtocol.controller]),
protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
keyID: Utils.toUTF8(decoded.fields[kvProtocol.key])
})
} catch (error) {
// Skip all outputs that fail signature verification
continue
}
// Extract tags if present (backwards compatible)
let tags: string[] | undefined
if (hasTagsField && decoded.fields[kvProtocol.tags] != null) {
try {
tags = JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.tags]))
} catch (e) {
// If tags parsing fails, continue without tags
tags = undefined
}
}
const entry: KVStoreEntry = {
key: Utils.toUTF8(decoded.fields[kvProtocol.key]),
value: Utils.toUTF8(decoded.fields[kvProtocol.value]),
controller: Utils.toHex(decoded.fields[kvProtocol.controller]),
protocolID: JSON.parse(Utils.toUTF8(decoded.fields[kvProtocol.protocolID])),
tags
}
if (options.includeToken === true) {
entry.token = {
txid: tx.id('hex'),
outputIndex: result.outputIndex,
beef: Beef.fromBinary(result.beef),
satoshis: output.satoshis ?? 1
}
}
if (options.history === true) {
entry.history = await this.historian.buildHistory(tx, {
key: entry.key,
protocolID: entry.protocolID
})
}
entries.push(entry)
} catch (error) {
continue
}
}
return entries
}
/**
* Submits a transaction to an overlay service using TopicBroadcaster.
* Broadcasts the transaction to the configured topics for network propagation.
*
* @param {Transaction} transaction - The transaction to broadcast.
* @returns {Promise<BroadcastResponse | BroadcastFailure>} The broadcast result.
* @throws {Error} If the broadcast fails or the network is unreachable.
* @private
*/
private async submitToOverlay (transaction: Transaction): Promise<BroadcastResponse | BroadcastFailure> {
return await this.topicBroadcaster.broadcast(transaction)
}
}
export default GlobalKVStore