@bsv/sdk
Version:
BSV Blockchain Software Development Kit
413 lines • 18.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.deserializeWalletProtocol = exports.RegistryClient = void 0;
const index_js_1 = require("../wallet/index.js");
const index_js_2 = require("../primitives/index.js");
const index_js_3 = require("../transaction/index.js");
const index_js_4 = require("../overlay-tools/index.js");
const index_js_5 = require("../script/index.js");
const REGISTRANT_TOKEN_AMOUNT = 1;
/**
* RegistryClient manages on-chain registry definitions for three types:
* - basket (basket-based items)
* - protocol (protocol-based items)
* - certificate (certificate-based items)
*
* It provides methods to:
* - Register new definitions using pushdrop-based UTXOs.
* - Resolve existing definitions using a lookup service.
* - List registry entries associated with the operator's wallet.
* - Revoke an existing registry entry by spending its UTXO.
*
* Registry operators use this client to establish and manage
* canonical references for baskets, protocols, and certificate types.
*/
class RegistryClient {
constructor(wallet = new index_js_1.WalletClient()) {
this.wallet = wallet;
}
/**
* Publishes a new on-chain definition for baskets, protocols, or certificates.
* The definition data is encoded in a pushdrop-based UTXO.
*
* Registry operators (i.e., identity key owners) can create these definitions
* to establish canonical references for basket IDs, protocol specs, or certificate schemas.
*
* @param data - Structured information about a 'basket', 'protocol', or 'certificate'.
* @returns A promise with the broadcast result or failure.
*/
async registerDefinition(data) {
const registryOperator = (await this.wallet.getPublicKey({ identityKey: true })).publicKey;
const pushdrop = new index_js_5.PushDrop(this.wallet);
// Convert definition data into PushDrop fields
const fields = this.buildPushDropFields(data, registryOperator);
// Convert the user-friendly definitionType to the actual wallet protocol
const protocol = this.mapDefinitionTypeToWalletProtocol(data.definitionType);
// Lock the fields into a pushdrop-based UTXO
const lockingScript = await pushdrop.lock(fields, protocol, '1', 'anyone', true);
// Create a transaction
const { tx } = await this.wallet.createAction({
description: `Register a new ${data.definitionType} item`,
outputs: [
{
satoshis: REGISTRANT_TOKEN_AMOUNT,
lockingScript: lockingScript.toHex(),
outputDescription: `New ${data.definitionType} registration token`,
basket: this.mapDefinitionTypeToBasketName(data.definitionType)
}
],
options: {
randomizeOutputs: false
}
});
if (tx === undefined) {
throw new Error(`Failed to create ${data.definitionType} registration transaction!`);
}
// Broadcast to the relevant topic
const broadcaster = new index_js_4.TopicBroadcaster([this.mapDefinitionTypeToTopic(data.definitionType)], {
networkPreset: this.network ?? (this.network = (await this.wallet.getNetwork({})).network)
});
return await broadcaster.broadcast(index_js_3.Transaction.fromAtomicBEEF(tx));
}
/**
* Resolves registrant tokens of a particular type using a lookup service.
*
* The query object shape depends on the registry type:
* - For "basket", the query is of type BasketMapQuery:
* { basketID?: string; name?: string; registryOperators?: string[]; }
* - For "protocol", the query is of type ProtoMapQuery:
* { name?: string; registryOperators?: string[]; protocolID?: WalletProtocol; }
* - For "certificate", the query is of type CertMapQuery:
* { type?: string; name?: string; registryOperators?: string[]; }
*
* @param definitionType - The registry type, which can be 'basket', 'protocol', or 'certificate'.
* @param query - The query object used to filter registry records, whose shape is determined by the registry type.
* @returns A promise that resolves to an array of matching registry records.
*/
async resolve(definitionType, query) {
const resolver = new index_js_4.LookupResolver();
const serviceName = this.mapDefinitionTypeToServiceName(definitionType);
// Make the lookup query
const result = await resolver.query({ service: serviceName, query });
if (result.type !== 'output-list') {
return [];
}
const parsedRegistryRecords = [];
for (const output of result.outputs) {
try {
const parsedTx = index_js_3.Transaction.fromBEEF(output.beef);
const lockingScript = parsedTx.outputs[output.outputIndex].lockingScript;
const record = await this.parseLockingScript(definitionType, lockingScript);
parsedRegistryRecords.push(record);
}
catch {
// Skip invalid or non-pushdrop outputs
}
}
return parsedRegistryRecords;
}
/**
* Lists the registry operator's published definitions for the given type.
*
* Returns parsed registry records including transaction details such as txid, outputIndex, satoshis, and the locking script.
*
* @param definitionType - The type of registry definition to list ('basket', 'protocol', or 'certificate').
* @returns A promise that resolves to an array of RegistryRecord objects.
*/
async listOwnRegistryEntries(definitionType) {
const relevantBasketName = this.mapDefinitionTypeToBasketName(definitionType);
const { outputs, BEEF } = await this.wallet.listOutputs({
basket: relevantBasketName,
include: 'entire transactions'
});
const results = [];
for (const output of outputs) {
if (!output.spendable) {
continue;
}
try {
const [txid, outputIndex] = output.outpoint.split('.');
const tx = index_js_3.Transaction.fromBEEF(BEEF);
const lockingScript = tx.outputs[outputIndex].lockingScript;
const record = await this.parseLockingScript(definitionType, lockingScript);
results.push({
...record,
txid,
outputIndex: Number(outputIndex),
satoshis: output.satoshis,
lockingScript: lockingScript.toHex(),
beef: BEEF
});
}
catch {
// Ignore parse errors
}
}
return results;
}
/**
* Revokes a registry record by spending its associated UTXO.
*
* @param registryRecord - Must have valid txid, outputIndex, and lockingScript.
* @returns Broadcast success/failure.
*/
async revokeOwnRegistryEntry(registryRecord) {
if (registryRecord.txid === undefined || typeof registryRecord.outputIndex === 'undefined' || registryRecord.lockingScript === undefined) {
throw new Error('Invalid registry record. Missing txid, outputIndex, or lockingScript.');
}
// Check if the registry record belongs to the current user
const currentIdentityKey = (await this.wallet.getPublicKey({ identityKey: true })).publicKey;
if (registryRecord.registryOperator !== currentIdentityKey) {
throw new Error('This registry token does not belong to the current wallet.');
}
// Create a descriptive label for the item we’re revoking
const itemIdentifier = registryRecord.definitionType === 'basket'
? registryRecord.basketID
: registryRecord.definitionType === 'protocol'
? registryRecord.name
: registryRecord.definitionType === 'certificate'
? (registryRecord.name !== undefined ? registryRecord.name : registryRecord.type)
: 'unknown';
const outpoint = `${registryRecord.txid}.${registryRecord.outputIndex}`;
const { signableTransaction } = await this.wallet.createAction({
description: `Revoke ${registryRecord.definitionType} item: ${itemIdentifier}`,
inputBEEF: registryRecord.beef,
inputs: [
{
outpoint,
unlockingScriptLength: 73,
inputDescription: `Revoking ${registryRecord.definitionType} token`
}
]
});
if (signableTransaction === undefined) {
throw new Error('Failed to create signable transaction.');
}
const partialTx = index_js_3.Transaction.fromBEEF(signableTransaction.tx);
// Prepare the unlocker
const pushdrop = new index_js_5.PushDrop(this.wallet);
const unlocker = await pushdrop.unlock(this.mapDefinitionTypeToWalletProtocol(registryRecord.definitionType), '1', 'anyone', 'all', false, registryRecord.satoshis, index_js_5.LockingScript.fromHex(registryRecord.lockingScript));
// Convert to Transaction, apply signature
const finalUnlockScript = await unlocker.sign(partialTx, registryRecord.outputIndex);
// Complete signing with the final unlock script
const { tx: signedTx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: {
[registryRecord.outputIndex]: {
unlockingScript: finalUnlockScript.toHex()
}
},
options: {
acceptDelayedBroadcast: false
}
});
if (signedTx === undefined) {
throw new Error('Failed to finalize the transaction signature.');
}
// Broadcast
const broadcaster = new index_js_4.TopicBroadcaster([this.mapDefinitionTypeToTopic(registryRecord.definitionType)], {
networkPreset: this.network ?? (this.network = (await this.wallet.getNetwork({})).network)
});
return await broadcaster.broadcast(index_js_3.Transaction.fromAtomicBEEF(signedTx));
}
// --------------------------------------------------------------------------
// INTERNAL UTILITY METHODS
// --------------------------------------------------------------------------
/**
* Convert definition data into an array of pushdrop fields (strings).
* Each definition type has a slightly different shape.
*/
buildPushDropFields(data, registryOperator) {
let fields;
switch (data.definitionType) {
case 'basket':
fields = [
data.basketID,
data.name,
data.iconURL,
data.description,
data.documentationURL
];
break;
case 'protocol':
fields = [
JSON.stringify(data.protocolID),
data.name,
data.iconURL,
data.description,
data.documentationURL
];
break;
case 'certificate':
fields = [
data.type,
data.name,
data.iconURL,
data.description,
data.documentationURL,
JSON.stringify(data.fields)
];
break;
default:
throw new Error('Unsupported definition type');
}
// Append the operator's public identity key last
fields.push(registryOperator);
return fields.map(field => index_js_2.Utils.toArray(field));
}
/**
* Decodes a pushdrop locking script for a given definition type,
* returning a typed record with the appropriate fields.
*/
async parseLockingScript(definitionType, lockingScript) {
const decoded = index_js_5.PushDrop.decode(lockingScript);
if (decoded.fields.length === 0) {
throw new Error('Not a valid registry pushdrop script.');
}
let registryOperator;
let parsedData;
switch (definitionType) {
case 'basket': {
if (decoded.fields.length !== 7) {
throw new Error('Unexpected field count for basket type.');
}
const [basketID, name, iconURL, description, docURL, operator] = decoded.fields;
registryOperator = index_js_2.Utils.toUTF8(operator);
parsedData = {
definitionType: 'basket',
basketID: index_js_2.Utils.toUTF8(basketID),
name: index_js_2.Utils.toUTF8(name),
iconURL: index_js_2.Utils.toUTF8(iconURL),
description: index_js_2.Utils.toUTF8(description),
documentationURL: index_js_2.Utils.toUTF8(docURL)
};
break;
}
case 'protocol': {
if (decoded.fields.length !== 7) {
throw new Error('Unexpected field count for protocol type.');
}
const [protocolID, name, iconURL, description, docURL, operator] = decoded.fields;
registryOperator = index_js_2.Utils.toUTF8(operator);
parsedData = {
definitionType: 'protocol',
protocolID: deserializeWalletProtocol(index_js_2.Utils.toUTF8(protocolID)),
name: index_js_2.Utils.toUTF8(name),
iconURL: index_js_2.Utils.toUTF8(iconURL),
description: index_js_2.Utils.toUTF8(description),
documentationURL: index_js_2.Utils.toUTF8(docURL)
};
break;
}
case 'certificate': {
if (decoded.fields.length !== 8) {
throw new Error('Unexpected field count for certificate type.');
}
const [certType, name, iconURL, description, docURL, fieldsJSON, operator] = decoded.fields;
registryOperator = index_js_2.Utils.toUTF8(operator);
let parsedFields = {};
try {
parsedFields = JSON.parse(index_js_2.Utils.toUTF8(fieldsJSON));
}
catch {
// If there's a JSON parse error, assume empty
}
parsedData = {
definitionType: 'certificate',
type: index_js_2.Utils.toUTF8(certType),
name: index_js_2.Utils.toUTF8(name),
iconURL: index_js_2.Utils.toUTF8(iconURL),
description: index_js_2.Utils.toUTF8(description),
documentationURL: index_js_2.Utils.toUTF8(docURL),
fields: parsedFields
};
break;
}
default:
throw new Error(`Unsupported definition type: ${definitionType}`);
}
// Return the typed data plus the operator key
return { ...parsedData, registryOperator };
}
/**
* Convert our definitionType to the wallet protocol format ([protocolID, keyID]).
*/
mapDefinitionTypeToWalletProtocol(definitionType) {
switch (definitionType) {
case 'basket':
return [1, 'basketmap'];
case 'protocol':
return [1, 'protomap'];
case 'certificate':
return [1, 'certmap'];
default:
throw new Error(`Unknown definition type: ${definitionType}`);
}
}
/**
* Convert 'basket'|'protocol'|'certificate' to the basket name used by the wallet.
*/
mapDefinitionTypeToBasketName(definitionType) {
switch (definitionType) {
case 'basket':
return 'basketmap';
case 'protocol':
return 'protomap';
case 'certificate':
return 'certmap';
default:
throw new Error(`Unknown definition type: ${definitionType}`);
}
}
/**
* Convert 'basket'|'protocol'|'certificate' to the broadcast topic name.
*/
mapDefinitionTypeToTopic(definitionType) {
switch (definitionType) {
case 'basket':
return 'tm_basketmap';
case 'protocol':
return 'tm_protomap';
case 'certificate':
return 'tm_certmap';
default:
throw new Error(`Unknown definition type: ${definitionType}`);
}
}
/**
* Convert 'basket'|'protocol'|'certificate' to the lookup service name.
*/
mapDefinitionTypeToServiceName(definitionType) {
switch (definitionType) {
case 'basket':
return 'ls_basketmap';
case 'protocol':
return 'ls_protomap';
case 'certificate':
return 'ls_certmap';
default:
throw new Error(`Unknown definition type: ${definitionType}`);
}
}
}
exports.RegistryClient = RegistryClient;
function deserializeWalletProtocol(str) {
// Parse the JSON string back into a JavaScript value.
const parsed = JSON.parse(str);
// Validate that the parsed value is an array with exactly two elements.
if (!Array.isArray(parsed) || parsed.length !== 2) {
throw new Error('Invalid wallet protocol format.');
}
const [security, protocolString] = parsed;
// Validate that the security level is one of the allowed numbers.
if (![0, 1, 2].includes(security)) {
throw new Error('Invalid security level.');
}
// Validate that the protocol string is a string and its length is within the allowed bounds.
if (typeof protocolString !== 'string') {
throw new Error('Invalid protocolID');
}
return [security, protocolString];
}
exports.deserializeWalletProtocol = deserializeWalletProtocol;
//# sourceMappingURL=RegistryClient.js.map