UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

337 lines 15.5 kB
"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