UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

470 lines 23.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.GlobalKVStore = void 0; const Transaction_js_1 = __importDefault(require("../transaction/Transaction.js")); const Utils = __importStar(require("../primitives/utils.js")); const index_js_1 = require("../overlay-tools/index.js"); const index_js_2 = require("../script/index.js"); const WalletClient_js_1 = __importDefault(require("../wallet/WalletClient.js")); const Beef_js_1 = require("../transaction/Beef.js"); const Historian_js_1 = require("../overlay-tools/Historian.js"); const kvStoreInterpreter_js_1 = require("./kvStoreInterpreter.js"); const ProtoWallet_js_1 = require("../wallet/ProtoWallet.js"); const types_js_1 = require("./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, 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. */ class GlobalKVStore { /** * 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 = {}) { /** * A map to store locks for each key to ensure atomic updates. * @private */ this.keyLocks = new Map(); /** * Cached user identity key * @private */ this.cachedIdentityKey = null; // Merge with defaults to create a fully resolved config this.config = { ...DEFAULT_CONFIG, ...config }; this.wallet = config.wallet ?? new WalletClient_js_1.default(); this.historian = new Historian_js_1.Historian(kvStoreInterpreter_js_1.kvStoreInterpreter); this.lookupResolver = new index_js_1.LookupResolver({ networkPreset: this.config.networkPreset }); this.topicBroadcaster = new index_js_1.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; const tags = options.tags ?? []; try { // Create PushDrop locking script (reusable across retries) const pushdrop = new index_js_2.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, Utils.toUTF8(Utils.toArray(key, 'utf8')), 'anyone', true); // Wrap entire operation in double-spend retry, including overlay query const outpoint = await (0, index_js_1.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 = [{ 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, 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_js_1.default.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() } }, 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_js_1.default.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, noSend: this.config.overlayBroadcast, randomizeOutputs: false } }, this.config.originator); if (tx == null) { throw new Error('Failed to create transaction'); } const transaction = Transaction_js_1.default.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, 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 pushdrop = new index_js_2.PushDrop(this.wallet, this.config.originator); // Remove token with double-spend retry const txid = await (0, index_js_1.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 = [{ 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_js_1.default.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() } }, 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_js_1.default.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 */ 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_js_1.default.fromBEEF(result.beef); const output = tx.outputs[result.outputIndex]; const decoded = index_js_2.PushDrop.decode(output.lockingScript); // Support backwards compatibility: old format without tags, new format with tags const expectedFieldCount = Object.keys(types_js_1.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_js_1.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[types_js_1.kvProtocol.controller]), protocolID: JSON.parse(Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.protocolID])), keyID: Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.key]) }); } catch (error) { // Skip all outputs that fail signature verification continue; } // Extract tags if present (backwards compatible) let tags; if (hasTagsField && decoded.fields[types_js_1.kvProtocol.tags] != null) { try { tags = JSON.parse(Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.tags])); } catch (e) { // If tags parsing fails, continue without tags tags = undefined; } } const entry = { key: Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.key]), value: Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.value]), controller: Utils.toHex(decoded.fields[types_js_1.kvProtocol.controller]), protocolID: JSON.parse(Utils.toUTF8(decoded.fields[types_js_1.kvProtocol.protocolID])), tags }; if (options.includeToken === true) { entry.token = { txid: tx.id('hex'), outputIndex: result.outputIndex, beef: Beef_js_1.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); } } exports.GlobalKVStore = GlobalKVStore; exports.default = GlobalKVStore; //# sourceMappingURL=GlobalKVStore.js.map