UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

333 lines 14.9 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 (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __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 }); const PushDrop_js_1 = __importDefault(require("../script/templates/PushDrop.js")); const Utils = __importStar(require("../primitives/utils.js")); const WalletClient_js_1 = __importDefault(require("../wallet/WalletClient.js")); const Transaction_js_1 = __importDefault(require("../transaction/Transaction.js")); const Beef_js_1 = require("../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. */ class LocalKVStore { /** * 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_js_1.default(), context = 'kvstore default', encrypt = true, originator, acceptDelayedBroadcast = false) { this.acceptDelayedBroadcast = false; /** * A map to store locks for each key to ensure atomic updates. * @private */ this.keyLocks = new Map(); 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_js_1.Beef.fromBinary(lor.BEEF)); const decoded = PushDrop_js_1.default.decode(lockingScript); if (decoded.fields.length < 1 || decoded.fields.length > 2) { throw new Error('Invalid token.'); } field = decoded.fields[0]; } catch (_) { 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.`); } 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_js_1.default.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_js_1.default(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 (_) { throw new Error(`There are ${outputs.length} outputs with tag ${key} that cannot be unlocked.`); } 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_js_1.default(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 (_) { throw new Error(`There are ${totalOutputs} outputs with tag ${key} that cannot be unlocked.`); } } if (outputs.length === totalOutputs) { break; } } return txids; } finally { this.finishOperationOnKey(key, lockQueue); } } } exports.default = LocalKVStore; //# sourceMappingURL=LocalKVStore.js.map