UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

328 lines 14.3 kB
import PushDrop from '../script/templates/PushDrop.js'; import * as Utils from '../primitives/utils.js'; import WalletClient from '../wallet/WalletClient.js'; import Transaction from '../transaction/Transaction.js'; import { Beef } from '../transaction/Beef.js'; /** * Implements a key-value storage system backed by transaction outputs managed by a wallet. * Each key-value pair is represented by a PushDrop token output in a specific context (basket). * Allows setting, getting, and removing key-value pairs, with optional encryption. */ export default class LocalKVStore { /** * The wallet interface used to manage outputs and perform cryptographic operations. * @private * @readonly */ wallet; /** * The context (basket name) used to namespace the key-value pairs within the wallet. * @private * @readonly */ context; /** * Flag indicating whether values should be encrypted before storing. * @private * @readonly */ encrypt; /** * An originator to use with PushDrop and the wallet. * @private * @readonly */ originator; acceptDelayedBroadcast = false; /** * A map to store locks for each key to ensure atomic updates. * @private */ keyLocks = new Map(); /** * Creates an instance of the localKVStore. * * @param {WalletInterface} [wallet=new WalletClient()] - The wallet interface to use. Defaults to a new WalletClient instance. * @param {string} [context='kvstoredefault'] - The context (basket) for namespacing keys. Defaults to 'kvstore default'. * @param {boolean} [encrypt=true] - Whether to encrypt values. Defaults to true. * @param {string} [originator] — An originator to use with PushDrop and the wallet, if provided. * @throws {Error} If the context is missing or empty. */ constructor(wallet = new WalletClient(), context = 'kvstore default', encrypt = true, originator, acceptDelayedBroadcast = false) { if (typeof context !== 'string' || context.length < 1) { throw new Error('A context in which to operate is required.'); } this.wallet = wallet; this.context = context; this.encrypt = encrypt; this.originator = originator; this.acceptDelayedBroadcast = acceptDelayedBroadcast; } 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; } 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](); } } getProtocol(key) { return { protocolID: [2, this.context], keyID: key }; } async getOutputs(key, limit) { const results = await this.wallet.listOutputs({ basket: this.context, tags: [key], tagQueryMode: 'all', include: 'entire transactions', limit }); return results; } /** * Retrieves the value associated with a given key. * * @param {string} key - The key to retrieve the value for. * @param {string | undefined} [defaultValue=undefined] - The value to return if the key is not found. * @returns {Promise<string | undefined>} A promise that resolves to the value as a string, * the defaultValue if the key is not found, or undefined if no defaultValue is provided. * @throws {Error} If too many outputs are found for the key (ambiguous state). * @throws {Error} If the found output's locking script cannot be decoded or represents an invalid token format. */ async get(key, defaultValue = undefined) { const lockQueue = await this.queueOperationOnKey(key); try { const r = await this.lookupValue(key, defaultValue, 5); return r.value; } finally { this.finishOperationOnKey(key, lockQueue); } } getLockingScript(output, beef) { const [txid, vout] = output.outpoint.split('.'); const tx = beef.findTxid(txid)?.tx; if (tx == null) { throw new Error(`beef must contain txid ${txid}`); } const lockingScript = tx.outputs[Number(vout)].lockingScript; return lockingScript; } async lookupValue(key, defaultValue, limit) { const lor = await this.getOutputs(key, limit); const r = { value: defaultValue, outpoint: undefined, lor }; const { outputs } = lor; if (outputs.length === 0) { return r; } const output = outputs.slice(-1)[0]; r.outpoint = output.outpoint; let field; try { if (lor.BEEF === undefined) { throw new Error('entire transactions listOutputs option must return valid BEEF'); } const lockingScript = this.getLockingScript(output, Beef.fromBinary(lor.BEEF)); const decoded = PushDrop.decode(lockingScript); if (decoded.fields.length < 1 || decoded.fields.length > 2) { throw new Error('Invalid token.'); } field = decoded.fields[0]; } catch (error) { throw new Error(`Invalid value found. You need to call set to collapse the corrupted state (or relinquish the corrupted ${outputs[0].outpoint} output from the ${this.context} basket) before you can get this value again. Original error: ${error instanceof Error ? error.message : String(error)}`); } if (!this.encrypt) { r.value = Utils.toUTF8(field); } else { const { plaintext } = await this.wallet.decrypt({ ...this.getProtocol(key), ciphertext: field }); r.value = Utils.toUTF8(plaintext); } return r; } getInputs(outputs) { const inputs = []; for (let i = 0; i < outputs.length; i++) { inputs.push({ outpoint: outputs[i].outpoint, unlockingScriptLength: 74, inputDescription: 'Previous key-value token' }); } return inputs; } async getSpends(key, outputs, pushdrop, atomicBEEF) { const p = this.getProtocol(key); const tx = Transaction.fromAtomicBEEF(atomicBEEF); const spends = {}; for (let i = 0; i < outputs.length; i++) { const unlocker = pushdrop.unlock(p.protocolID, p.keyID, 'self'); const unlockingScript = await unlocker.sign(tx, i); spends[i] = { unlockingScript: unlockingScript.toHex() }; } return spends; } /** * Sets or updates the value associated with a given key atomically. * If the key already exists (one or more outputs found), it spends the existing output(s) * and creates a new one with the updated value. If multiple outputs exist for the key, * they are collapsed into a single new output. * If the key does not exist, it creates a new output. * Handles encryption if enabled. * If signing the update/collapse transaction fails, it relinquishes the original outputs and starts over with a new chain. * Ensures atomicity by locking the key during the operation, preventing concurrent updates * to the same key from missing earlier changes. * * @param {string} key - The key to set or update. * @param {string} value - The value to associate with the key. * @returns {Promise<OutpointString>} A promise that resolves to the outpoint string (txid.vout) of the new or updated token output. */ async set(key, value) { const lockQueue = await this.queueOperationOnKey(key); try { const current = await this.lookupValue(key, undefined, 10); if (current.value === value) { if (current.outpoint === undefined) { throw new Error('outpoint must be valid when value is valid and unchanged'); } // Don't create a new transaction if the value doesn't need to change return current.outpoint; } const protocol = this.getProtocol(key); let valueAsArray = Utils.toArray(value, 'utf8'); if (this.encrypt) { const { ciphertext } = await this.wallet.encrypt({ ...protocol, plaintext: valueAsArray }); valueAsArray = ciphertext; } const pushdrop = new PushDrop(this.wallet, this.originator); const lockingScript = await pushdrop.lock([valueAsArray], protocol.protocolID, protocol.keyID, 'self'); const { outputs, BEEF: inputBEEF } = current.lor; let outpoint; try { const inputs = this.getInputs(outputs); const { txid, signableTransaction } = await this.wallet.createAction({ description: `Update ${key} in ${this.context}`, inputBEEF, inputs, outputs: [{ basket: this.context, tags: [key], lockingScript: lockingScript.toHex(), satoshis: 1, outputDescription: 'Key-value token' }], options: { acceptDelayedBroadcast: this.acceptDelayedBroadcast, randomizeOutputs: false } }); if (outputs.length > 0 && typeof signableTransaction !== 'object') { throw new Error('Wallet did not return a signable transaction when expected.'); } if (signableTransaction == null) { outpoint = `${txid}.0`; } else { const spends = await this.getSpends(key, outputs, pushdrop, signableTransaction.tx); const { txid } = await this.wallet.signAction({ reference: signableTransaction.reference, spends }); outpoint = `${txid}.0`; } } catch (error) { throw new Error(`There are ${outputs.length} outputs with tag ${key} that cannot be unlocked. Original error: ${error instanceof Error ? error.message : String(error)}`); } return outpoint; } finally { this.finishOperationOnKey(key, lockQueue); } } /** * Removes the key-value pair associated with the given key. * It finds the existing output(s) for the key and spends them without creating a new output. * If multiple outputs exist, they are all spent in the same transaction. * If the key does not exist, it does nothing. * If signing the removal transaction fails, it relinquishes the original outputs instead of spending. * * @param {string} key - The key to remove. * @returns {Promise<string[]>} A promise that resolves to the txids of the removal transactions if successful. */ async remove(key) { const lockQueue = await this.queueOperationOnKey(key); try { const txids = []; for (;;) { const { outputs, BEEF: inputBEEF, totalOutputs } = await this.getOutputs(key); if (outputs.length > 0) { const pushdrop = new PushDrop(this.wallet, this.originator); try { const inputs = this.getInputs(outputs); const { signableTransaction } = await this.wallet.createAction({ description: `Remove ${key} in ${this.context}`, inputBEEF, inputs, options: { acceptDelayedBroadcast: this.acceptDelayedBroadcast } }); if (typeof signableTransaction !== 'object') { throw new Error('Wallet did not return a signable transaction when expected.'); } const spends = await this.getSpends(key, outputs, pushdrop, signableTransaction.tx); const { txid } = await this.wallet.signAction({ reference: signableTransaction.reference, spends }); if (txid === undefined) { throw new Error('signAction must return a valid txid'); } txids.push(txid); } catch (error) { throw new Error(`There are ${totalOutputs} outputs with tag ${key} that cannot be unlocked. Original error: ${error instanceof Error ? error.message : String(error)}`); } } if (outputs.length === totalOutputs) { break; } } return txids; } finally { this.finishOperationOnKey(key, lockQueue); } } } //# sourceMappingURL=LocalKVStore.js.map