@bsv/sdk
Version:
BSV Blockchain Software Development Kit
416 lines • 18.8 kB
JavaScript
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