@bsv/sdk
Version:
BSV Blockchain Software Development Kit
337 lines • 15.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContactsManager = void 0;
const index_js_1 = require("../wallet/index.js");
const index_js_2 = require("../primitives/index.js");
const index_js_3 = require("../script/index.js");
const index_js_4 = require("../transaction/index.js");
const CONTACT_PROTOCOL_ID = [2, 'contact'];
// In-memory cache for cross-platform compatibility
class MemoryCache {
constructor() {
this.cache = new Map();
}
getItem(key) {
return this.cache.get(key) ?? null;
}
setItem(key, value) {
this.cache.set(key, value);
}
removeItem(key) {
this.cache.delete(key);
}
clear() {
this.cache.clear();
}
}
class ContactsManager {
constructor(wallet) {
this.cache = new MemoryCache();
this.CONTACTS_CACHE_KEY = 'metanet-contacts';
this.wallet = wallet ?? new index_js_1.WalletClient();
}
/**
* Load all records from the contacts basket
* @param identityKey Optional specific identity key to fetch
* @param forceRefresh Whether to force a check for new contact data
* @param limit Maximum number of contacts to return
* @returns A promise that resolves with an array of contacts
*/
async getContacts(identityKey, forceRefresh = false, limit = 1000) {
// Check in-memory cache first unless forcing refresh
if (!forceRefresh) {
const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
if (cached != null && cached !== '') {
try {
const cachedContacts = JSON.parse(cached);
return identityKey != null
? cachedContacts.filter(c => c.identityKey === identityKey)
: cachedContacts;
}
catch (e) {
console.warn('Invalid cached contacts JSON; will reload from chain', e);
}
}
}
const tags = [];
if (identityKey != null) {
// Hash the identity key to use as a tag for quick lookup
const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
protocolID: CONTACT_PROTOCOL_ID,
keyID: identityKey,
counterparty: 'self',
data: index_js_2.Utils.toArray(identityKey, 'utf8')
});
tags.push(`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`);
}
// Get all contact outputs from the contacts basket
const outputs = await this.wallet.listOutputs({
basket: 'contacts',
include: 'locking scripts',
includeCustomInstructions: true,
tags,
limit
});
if (outputs.outputs == null || outputs.outputs.length === 0) {
this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify([]));
return [];
}
const contacts = [];
// Process each contact output
for (const output of outputs.outputs) {
try {
if (output.lockingScript == null)
continue;
// Decode the PushDrop data
const decoded = index_js_3.PushDrop.decode(index_js_3.LockingScript.fromHex(output.lockingScript));
if (output.customInstructions == null)
continue;
const keyID = JSON.parse(output.customInstructions).keyID;
// Decrypt the contact data
const { plaintext } = await this.wallet.decrypt({
ciphertext: decoded.fields[0],
protocolID: CONTACT_PROTOCOL_ID,
keyID,
counterparty: 'self'
});
// Parse the contact data
const contactData = JSON.parse(index_js_2.Utils.toUTF8(plaintext));
contacts.push(contactData);
}
catch (error) {
console.warn('ContactsManager: Failed to decode contact output:', error);
// Skip this contact and continue with others
}
}
// Cache the loaded contacts
this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts));
const filteredContacts = identityKey != null
? contacts.filter(c => c.identityKey === identityKey)
: contacts;
return filteredContacts;
}
/**
* Save or update a Metanet contact
* @param contact The displayable identity information for the contact
* @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
*/
async saveContact(contact, metadata) {
// Get current contacts from cache or blockchain
const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
let contacts;
if (cached != null && cached !== '') {
contacts = JSON.parse(cached);
}
else {
// If cache is empty, get current data from blockchain
contacts = await this.getContacts();
}
const existingIndex = contacts.findIndex(c => c.identityKey === contact.identityKey);
const contactToStore = {
...contact,
metadata
};
if (existingIndex >= 0) {
contacts[existingIndex] = contactToStore;
}
else {
contacts.push(contactToStore);
}
const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
protocolID: CONTACT_PROTOCOL_ID,
keyID: contact.identityKey,
counterparty: 'self',
data: index_js_2.Utils.toArray(contact.identityKey, 'utf8')
});
// Check if this contact already exists (to update it)
const outputs = await this.wallet.listOutputs({
basket: 'contacts',
include: 'entire transactions',
includeCustomInstructions: true,
tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`],
limit: 100 // Should only be one contact!
});
let existingOutput = null;
let keyID = index_js_2.Utils.toBase64((0, index_js_2.Random)(32));
if (outputs.outputs != null) {
// Find output by trying to decrypt and checking identityKey in payload
for (const output of outputs.outputs) {
try {
const [txid, outputIndex] = output.outpoint.split('.');
const tx = index_js_4.Transaction.fromBEEF(outputs.BEEF, txid);
const decoded = index_js_3.PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript);
if (output.customInstructions == null)
continue;
keyID = JSON.parse(output.customInstructions).keyID;
const { plaintext } = await this.wallet.decrypt({
ciphertext: decoded.fields[0],
protocolID: CONTACT_PROTOCOL_ID,
keyID,
counterparty: 'self'
});
const storedContact = JSON.parse(index_js_2.Utils.toUTF8(plaintext));
if (storedContact.identityKey === contact.identityKey) {
// Found the right output
existingOutput = output;
break;
}
}
catch (e) {
// Skip malformed or undecryptable outputs
}
}
}
// Encrypt the contact data directly
const contactWithMetadata = {
...contact,
metadata
};
const { ciphertext } = await this.wallet.encrypt({
plaintext: index_js_2.Utils.toArray(JSON.stringify(contactWithMetadata), 'utf8'),
protocolID: CONTACT_PROTOCOL_ID,
keyID,
counterparty: 'self'
});
// Create locking script for the new contact token
const lockingScript = await new index_js_3.PushDrop(this.wallet).lock([ciphertext], CONTACT_PROTOCOL_ID, keyID, 'self');
if (existingOutput != null) {
// Update existing contact by spending its output
const [txid, outputIndex] = String(existingOutput.outpoint).split('.');
const prevOutpoint = `${txid}.${outputIndex}`;
const pushdrop = new index_js_3.PushDrop(this.wallet);
const { signableTransaction } = await this.wallet.createAction({
description: 'Update Contact',
inputBEEF: outputs.BEEF,
inputs: [{
outpoint: prevOutpoint,
unlockingScriptLength: 74,
inputDescription: 'Spend previous contact output'
}],
outputs: [{
basket: 'contacts',
satoshis: 1,
lockingScript: lockingScript.toHex(),
outputDescription: `Updated Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`],
customInstructions: JSON.stringify({ keyID })
}],
options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
});
if (signableTransaction == null)
throw new Error('Unable to update contact');
const unlocker = pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self');
const unlockingScript = await unlocker.sign(index_js_4.Transaction.fromBEEF(signableTransaction.tx), 0);
const { tx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: { 0: { unlockingScript: unlockingScript.toHex() } }
});
if (tx == null)
throw new Error('Failed to update contact output');
}
else {
// Create new contact output
const { tx } = await this.wallet.createAction({
description: 'Add Contact',
outputs: [{
basket: 'contacts',
satoshis: 1,
lockingScript: lockingScript.toHex(),
outputDescription: `Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
tags: [`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`],
customInstructions: JSON.stringify({ keyID })
}],
options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
});
if (tx == null)
throw new Error('Failed to create contact output');
}
this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts));
}
/**
* Remove a contact from the contacts basket
* @param identityKey The identity key of the contact to remove
*/
async removeContact(identityKey) {
// Update in-memory cache
const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY);
if (cached != null && cached !== '') {
try {
const contacts = JSON.parse(cached);
const filteredContacts = contacts.filter(c => c.identityKey !== identityKey);
this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(filteredContacts));
}
catch (e) {
console.warn('Failed to update cache after contact removal:', e);
}
}
// Hash the identity key to use as a tag for quick lookup
const tags = [];
const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
protocolID: CONTACT_PROTOCOL_ID,
keyID: identityKey,
counterparty: 'self',
data: index_js_2.Utils.toArray(identityKey, 'utf8')
});
tags.push(`identityKey ${index_js_2.Utils.toHex(hashedIdentityKey)}`);
// Find and spend the contact's output
const outputs = await this.wallet.listOutputs({
basket: 'contacts',
include: 'entire transactions',
includeCustomInstructions: true,
tags,
limit: 100 // Should only be one contact!
});
if (outputs.outputs == null)
return;
// Find the output for this specific contact by decrypting and checking identityKey
for (const output of outputs.outputs) {
try {
const [txid, outputIndex] = String(output.outpoint).split('.');
const tx = index_js_4.Transaction.fromBEEF(outputs.BEEF, txid);
const decoded = index_js_3.PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript);
if (output.customInstructions == null)
continue;
const keyID = JSON.parse(output.customInstructions).keyID;
const { plaintext } = await this.wallet.decrypt({
ciphertext: decoded.fields[0],
protocolID: CONTACT_PROTOCOL_ID,
keyID,
counterparty: 'self'
});
const storedContact = JSON.parse(index_js_2.Utils.toUTF8(plaintext));
if (storedContact.identityKey === identityKey) {
// Found the contact's output, spend it without creating a new one
const prevOutpoint = `${txid}.${outputIndex}`;
const pushdrop = new index_js_3.PushDrop(this.wallet);
const { signableTransaction } = await this.wallet.createAction({
description: 'Delete Contact',
inputBEEF: outputs.BEEF,
inputs: [{
outpoint: prevOutpoint,
unlockingScriptLength: 74,
inputDescription: 'Spend contact output to delete'
}],
outputs: [],
options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
});
if (signableTransaction == null)
throw new Error('Unable to delete contact');
const unlocker = pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self');
const unlockingScript = await unlocker.sign(index_js_4.Transaction.fromBEEF(signableTransaction.tx), 0);
const { tx: deleteTx } = await this.wallet.signAction({
reference: signableTransaction.reference,
spends: { 0: { unlockingScript: unlockingScript.toHex() } }
});
if (deleteTx == null)
throw new Error('Failed to delete contact output');
return;
}
}
catch (e) {
// Skip malformed or undecryptable outputs
}
}
}
}
exports.ContactsManager = ContactsManager;
//# sourceMappingURL=ContactsManager.js.map