UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

416 lines 18.8 kB
import Transaction from '../transaction/Transaction.js'; import * as Utils from '../primitives/utils.js'; import { TopicBroadcaster, LookupResolver } from '../overlay-tools/index.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 { kvStoreInterpreter } from './kvStoreInterpreter.js'; import { ProtoWallet } from '../wallet/ProtoWallet.js'; import { kvProtocol } from './types.js'; /** * Default configuration values for GlobalKVStore operations. * Provides sensible defaults for overlay connection and protocol settings. */ const DEFAULT_CONFIG = { protocolID: [1, 'kvstore'], serviceName: 'ls_kvstore', tokenAmount: 1, topics: ['tm_kvstore'], networkPreset: 'mainnet', acceptDelayedBroadcast: false, tokenSetDescription: '', tokenUpdateDescription: '', 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 */ wallet; /** * Configuration for the KVStore instance containing all runtime options. * @private * @readonly */ config; /** * Historian instance used to extract history from transaction outputs. * @private */ historian; /** * Lookup resolver used to query the overlay for transaction outputs. * @private */ lookupResolver; /** * Topic broadcaster used to broadcast transactions to the overlay. * @private */ topicBroadcaster; /** * A map to store locks for each key to ensure atomic updates. * @private */ keyLocks = new Map(); /** * Cached user identity key * @private */ cachedIdentityKey = 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 = {}) { // Merge with defaults to create a fully resolved config this.config = { ...DEFAULT_CONFIG, ...config }; this.wallet = config.wallet ?? new WalletClient(); this.historian = new Historian(kvStoreInterpreter); this.lookupResolver = new LookupResolver({ networkPreset: this.config.networkPreset }); this.topicBroadcaster = new TopicBroadcaster(this.config.topics, { 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, options = {}) { 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, value, options = {}) { 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; try { // Check for existing token to spend const existingEntries = await this.queryOverlay({ key, controller }, { includeToken: true }); const existingToken = existingEntries.length > 0 ? existingEntries[0].token : undefined; // Create PushDrop locking script const pushdrop = new PushDrop(this.wallet, this.config.originator); const lockingScript = await pushdrop.lock([ Utils.toArray(JSON.stringify(protocolID), 'utf8'), Utils.toArray(key, 'utf8'), Utils.toArray(value, 'utf8'), Utils.toArray(controller, 'hex') ], protocolID ?? this.config.protocolID, Utils.toUTF8(Utils.toArray(key, 'utf8')), 'anyone', true); let inputs = []; let inputBEEF; if (existingToken != null) { inputs = [{ outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, unlockingScriptLength: 74, inputDescription: 'Previous KVStore token' }]; inputBEEF = existingToken.beef; } if (inputs.length > 0) { // Update existing token const { signableTransaction } = await this.wallet.createAction({ description: tokenUpdateDescription, inputBEEF: inputBEEF?.toBinary(), inputs, outputs: [{ satoshis: tokenAmount ?? this.config.tokenAmount, lockingScript: lockingScript.toHex(), outputDescription: 'KVStore token' }], options: { acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, 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, key, 'anyone'); const unlockingScript = await unlocker.sign(tx, 0); const { tx: finalTx } = await this.wallet.signAction({ reference: signableTransaction.reference, spends: { 0: { unlockingScript: unlockingScript.toHex() } } }, 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, lockingScript: lockingScript.toHex(), outputDescription: 'KVStore token' }], options: { acceptDelayedBroadcast: this.config.acceptDelayedBroadcast, 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`; } } 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, outputs, options = {}) { 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 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 = [{ outpoint: `${existingToken.txid}.${existingToken.outputIndex}`, unlockingScriptLength: 74, inputDescription: 'KVStore token to remove' }]; const pushdrop = new PushDrop(this.wallet, this.config.originator); const { signableTransaction } = await this.wallet.createAction({ description: tokenRemovalDescription, inputBEEF: existingToken.beef.toBinary(), inputs, outputs, options: { acceptDelayedBroadcast: this.config.acceptDelayedBroadcast } }, 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, key, 'anyone'); const unlockingScript = await unlocker.sign(tx, 0); const { tx: finalTx } = await this.wallet.signAction({ reference: signableTransaction.reference, spends: { 0: { unlockingScript: unlockingScript.toHex() } } }, 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'); } 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 */ async queueOperationOnKey(key) { // 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 = () => { }; const newLock = new Promise((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 */ finishOperationOnKey(key, lockQueue) { 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 */ async getIdentityKey() { 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 */ async queryOverlay(query, options = {}) { const answer = await this.lookupResolver.query({ service: options.serviceName ?? this.config.serviceName, query }); if (answer.type !== 'output-list' || answer.outputs.length === 0) { return []; } const entries = []; 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); if (decoded.fields.length !== 5) { continue; } // Verify signature const anyoneWallet = new ProtoWallet('anyone'); const signature = decoded.fields.pop(); 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; } const entry = { 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])) }; 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 */ async submitToOverlay(transaction) { return await this.topicBroadcaster.broadcast(transaction); } } export default GlobalKVStore; //# sourceMappingURL=GlobalKVStore.js.map