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