@bsv/sdk 1.6.24 → 1.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/identity/ContactsManager.js +334 -0
  3. package/dist/cjs/src/identity/ContactsManager.js.map +1 -0
  4. package/dist/cjs/src/identity/IdentityClient.js +26 -0
  5. package/dist/cjs/src/identity/IdentityClient.js.map +1 -1
  6. package/dist/cjs/src/transaction/Transaction.js +3 -0
  7. package/dist/cjs/src/transaction/Transaction.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/identity/ContactsManager.js +329 -0
  10. package/dist/esm/src/identity/ContactsManager.js.map +1 -0
  11. package/dist/esm/src/identity/IdentityClient.js +27 -0
  12. package/dist/esm/src/identity/IdentityClient.js.map +1 -1
  13. package/dist/esm/src/transaction/Transaction.js +3 -0
  14. package/dist/esm/src/transaction/Transaction.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/identity/ContactsManager.d.ts +30 -0
  17. package/dist/types/src/identity/ContactsManager.d.ts.map +1 -0
  18. package/dist/types/src/identity/IdentityClient.d.ts +21 -1
  19. package/dist/types/src/identity/IdentityClient.d.ts.map +1 -1
  20. package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
  21. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  22. package/dist/umd/bundle.js +3 -3
  23. package/dist/umd/bundle.js.map +1 -1
  24. package/docs/reference/identity.md +144 -4
  25. package/package.json +1 -1
  26. package/src/identity/ContactsManager.ts +372 -0
  27. package/src/identity/IdentityClient.ts +31 -0
  28. package/src/identity/__tests/IdentityClient.test.ts +360 -2
  29. package/src/transaction/Transaction.ts +2 -0
@@ -48,6 +48,80 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
48
48
  ---
49
49
  ## Classes
50
50
 
51
+ | |
52
+ | --- |
53
+ | [ContactsManager](#class-contactsmanager) |
54
+ | [IdentityClient](#class-identityclient) |
55
+
56
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
57
+
58
+ ---
59
+
60
+ ### Class: ContactsManager
61
+
62
+ ```ts
63
+ export class ContactsManager {
64
+ constructor(wallet?: WalletInterface)
65
+ async getContacts(identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]>
66
+ async saveContact(contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void>
67
+ async removeContact(identityKey: string): Promise<void>
68
+ }
69
+ ```
70
+
71
+ See also: [Contact](./identity.md#type-contact), [DisplayableIdentity](./identity.md#interface-displayableidentity), [PubKeyHex](./wallet.md#type-pubkeyhex), [WalletInterface](./wallet.md#interface-walletinterface)
72
+
73
+ #### Method getContacts
74
+
75
+ Load all records from the contacts basket
76
+
77
+ ```ts
78
+ async getContacts(identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]>
79
+ ```
80
+ See also: [Contact](./identity.md#type-contact), [PubKeyHex](./wallet.md#type-pubkeyhex)
81
+
82
+ Returns
83
+
84
+ A promise that resolves with an array of contacts
85
+
86
+ Argument Details
87
+
88
+ + **identityKey**
89
+ + Optional specific identity key to fetch
90
+ + **forceRefresh**
91
+ + Whether to force a check for new contact data
92
+
93
+ #### Method removeContact
94
+
95
+ Remove a contact from the contacts basket
96
+
97
+ ```ts
98
+ async removeContact(identityKey: string): Promise<void>
99
+ ```
100
+
101
+ Argument Details
102
+
103
+ + **identityKey**
104
+ + The identity key of the contact to remove
105
+
106
+ #### Method saveContact
107
+
108
+ Save or update a Metanet contact
109
+
110
+ ```ts
111
+ async saveContact(contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void>
112
+ ```
113
+ See also: [DisplayableIdentity](./identity.md#interface-displayableidentity)
114
+
115
+ Argument Details
116
+
117
+ + **contact**
118
+ + The displayable identity information for the contact
119
+ + **metadata**
120
+ + Optional metadata to store with the contact (ex. notes, aliases, etc)
121
+
122
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
123
+
124
+ ---
51
125
  ### Class: IdentityClient
52
126
 
53
127
  IdentityClient lets you discover who others are, and let the world know who you are.
@@ -58,17 +132,42 @@ export class IdentityClient {
58
132
  async publiclyRevealAttributes(certificate: WalletCertificate, fieldsToReveal: CertificateFieldNameUnder50Bytes[]): Promise<BroadcastResponse | BroadcastFailure>
59
133
  async resolveByIdentityKey(args: DiscoverByIdentityKeyArgs): Promise<DisplayableIdentity[]>
60
134
  async resolveByAttributes(args: DiscoverByAttributesArgs): Promise<DisplayableIdentity[]>
135
+ public async getContacts(identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]>
136
+ public async saveContact(contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void>
137
+ public async removeContact(identityKey: PubKeyHex): Promise<void>
61
138
  static parseIdentity(identityToParse: IdentityCertificate): DisplayableIdentity
62
139
  }
63
140
  ```
64
141
 
65
- See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [DEFAULT_IDENTITY_CLIENT_OPTIONS](./identity.md#variable-default_identity_client_options), [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [IdentityCertificate](./wallet.md#interface-identitycertificate), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [WalletCertificate](./wallet.md#interface-walletcertificate), [WalletInterface](./wallet.md#interface-walletinterface)
142
+ See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [CertificateFieldNameUnder50Bytes](./wallet.md#type-certificatefieldnameunder50bytes), [Contact](./identity.md#type-contact), [DEFAULT_IDENTITY_CLIENT_OPTIONS](./identity.md#variable-default_identity_client_options), [DiscoverByAttributesArgs](./wallet.md#interface-discoverbyattributesargs), [DiscoverByIdentityKeyArgs](./wallet.md#interface-discoverbyidentitykeyargs), [DisplayableIdentity](./identity.md#interface-displayableidentity), [IdentityCertificate](./wallet.md#interface-identitycertificate), [OriginatorDomainNameStringUnder250Bytes](./wallet.md#type-originatordomainnamestringunder250bytes), [PubKeyHex](./wallet.md#type-pubkeyhex), [WalletCertificate](./wallet.md#interface-walletcertificate), [WalletInterface](./wallet.md#interface-walletinterface)
66
143
 
67
- #### Method parseIdentity
144
+ #### Method getContacts
68
145
 
69
146
  TODO: Implement once revocation overlay is created
70
147
  Remove public certificate revelation from overlay services by spending the identity token
71
148
 
149
+ Load all records from the contacts basket
150
+
151
+ ```ts
152
+ public async getContacts(identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]>
153
+ ```
154
+ See also: [Contact](./identity.md#type-contact), [PubKeyHex](./wallet.md#type-pubkeyhex)
155
+
156
+ Returns
157
+
158
+ A promise that resolves with an array of contacts
159
+
160
+ Argument Details
161
+
162
+ + **serialNumber**
163
+ + Unique serial number of the certificate to revoke revelation
164
+ + **identityKey**
165
+ + Optional specific identity key to fetch
166
+ + **forceRefresh**
167
+ + Whether to force a check for new contact data
168
+
169
+ #### Method parseIdentity
170
+
72
171
  Parse out identity and certifier attributes to display from an IdentityCertificate
73
172
 
74
173
  ```ts
@@ -82,8 +181,6 @@ Returns
82
181
 
83
182
  Argument Details
84
183
 
85
- + **serialNumber**
86
- + Unique serial number of the certificate to revoke revelation
87
184
  + **identityToParse**
88
185
  + The Identity Certificate to parse
89
186
 
@@ -112,6 +209,20 @@ Throws
112
209
 
113
210
  Throws an error if the certificate is invalid, the fields cannot be revealed, or if the broadcast fails.
114
211
 
212
+ #### Method removeContact
213
+
214
+ Remove a contact from the contacts basket
215
+
216
+ ```ts
217
+ public async removeContact(identityKey: PubKeyHex): Promise<void>
218
+ ```
219
+ See also: [PubKeyHex](./wallet.md#type-pubkeyhex)
220
+
221
+ Argument Details
222
+
223
+ + **identityKey**
224
+ + The identity key of the contact to remove
225
+
115
226
  #### Method resolveByAttributes
116
227
 
117
228
  Resolves displayable identity certificates by specific identity attributes, issued by a trusted entity.
@@ -148,6 +259,22 @@ Argument Details
148
259
  + **args**
149
260
  + Arguments for requesting the discovery based on the identity key.
150
261
 
262
+ #### Method saveContact
263
+
264
+ Save or update a Metanet contact
265
+
266
+ ```ts
267
+ public async saveContact(contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void>
268
+ ```
269
+ See also: [DisplayableIdentity](./identity.md#interface-displayableidentity)
270
+
271
+ Argument Details
272
+
273
+ + **contact**
274
+ + The displayable identity information for the contact
275
+ + **metadata**
276
+ + Optional metadata to store with the contact (ex. notes, aliases, etc)
277
+
151
278
  Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
152
279
 
153
280
  ---
@@ -155,6 +282,19 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](
155
282
 
156
283
  ## Types
157
284
 
285
+ ### Type: Contact
286
+
287
+ ```ts
288
+ export type Contact = DisplayableIdentity & {
289
+ metadata?: Record<string, any>;
290
+ }
291
+ ```
292
+
293
+ See also: [DisplayableIdentity](./identity.md#interface-displayableidentity)
294
+
295
+ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables)
296
+
297
+ ---
158
298
  ## Enums
159
299
 
160
300
  ## Variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.6.24",
3
+ "version": "1.6.26",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -0,0 +1,372 @@
1
+ import { PubKeyHex, WalletClient, WalletInterface, WalletProtocol } from '../wallet/index.js'
2
+ import { Utils, Random } from '../primitives/index.js'
3
+ import { DisplayableIdentity } from './types/index.js'
4
+ import { PushDrop } from '../script/index.js'
5
+ import { Transaction } from '../transaction/index.js'
6
+ export type Contact = DisplayableIdentity & { metadata?: Record<string, any> }
7
+
8
+ const CONTACT_PROTOCOL_ID: WalletProtocol = [2, 'contact']
9
+
10
+ // In-memory cache for cross-platform compatibility
11
+ class MemoryCache {
12
+ private readonly cache = new Map<string, string>()
13
+
14
+ getItem (key: string): string | null {
15
+ return this.cache.get(key) ?? null
16
+ }
17
+
18
+ setItem (key: string, value: string): void {
19
+ this.cache.set(key, value)
20
+ }
21
+
22
+ removeItem (key: string): void {
23
+ this.cache.delete(key)
24
+ }
25
+
26
+ clear (): void {
27
+ this.cache.clear()
28
+ }
29
+ }
30
+
31
+ export class ContactsManager {
32
+ private readonly wallet: WalletInterface
33
+ private readonly cache = new MemoryCache()
34
+ private readonly CONTACTS_CACHE_KEY = 'metanet-contacts'
35
+
36
+ constructor (wallet?: WalletInterface) {
37
+ this.wallet = wallet ?? new WalletClient()
38
+ }
39
+
40
+ /**
41
+ * Load all records from the contacts basket
42
+ * @param identityKey Optional specific identity key to fetch
43
+ * @param forceRefresh Whether to force a check for new contact data
44
+ * @returns A promise that resolves with an array of contacts
45
+ */
46
+ async getContacts (identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]> {
47
+ // Check in-memory cache first unless forcing refresh
48
+ if (!forceRefresh) {
49
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
50
+ if (cached != null && cached !== '') {
51
+ try {
52
+ const cachedContacts: Contact[] = JSON.parse(cached)
53
+ return identityKey != null
54
+ ? cachedContacts.filter(c => c.identityKey === identityKey)
55
+ : cachedContacts
56
+ } catch (e) {
57
+ console.warn('Invalid cached contacts JSON; will reload from chain', e)
58
+ }
59
+ }
60
+ }
61
+
62
+ const tags: string[] = []
63
+ if (identityKey != null) {
64
+ // Hash the identity key to use as a tag for quick lookup
65
+ const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
66
+ protocolID: CONTACT_PROTOCOL_ID,
67
+ keyID: identityKey,
68
+ counterparty: 'self',
69
+ data: Utils.toArray(identityKey, 'utf8')
70
+ })
71
+ tags.push(`identityKey ${Utils.toHex(hashedIdentityKey)}`)
72
+ }
73
+
74
+ // Get all contact outputs from the contacts basket
75
+ const outputs = await this.wallet.listOutputs({
76
+ basket: 'contacts',
77
+ include: 'entire transactions',
78
+ includeCustomInstructions: true,
79
+ tags
80
+ })
81
+
82
+ if (outputs.outputs == null || outputs.outputs.length === 0) {
83
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify([]))
84
+ return []
85
+ }
86
+
87
+ const contacts: Contact[] = []
88
+
89
+ // Process each contact output
90
+ for (const output of outputs.outputs) {
91
+ try {
92
+ const [txid, outputIndex] = output.outpoint.split('.')
93
+ const tx = Transaction.fromBEEF(outputs.BEEF as number[], txid)
94
+ const lockingScript = tx.outputs[Number(outputIndex)].lockingScript
95
+
96
+ // Decode the PushDrop data
97
+ const decoded = PushDrop.decode(lockingScript)
98
+ if (output.customInstructions == null) continue
99
+ const keyID = JSON.parse(output.customInstructions).keyID
100
+
101
+ // Decrypt the contact data
102
+ const { plaintext } = await this.wallet.decrypt({
103
+ ciphertext: decoded.fields[0],
104
+ protocolID: CONTACT_PROTOCOL_ID,
105
+ keyID,
106
+ counterparty: 'self'
107
+ })
108
+
109
+ // Parse the contact data
110
+ const contactData: Contact = JSON.parse(Utils.toUTF8(plaintext))
111
+
112
+ contacts.push(contactData)
113
+ } catch (error) {
114
+ console.warn('ContactsManager: Failed to decode contact output:', error)
115
+ // Skip this contact and continue with others
116
+ }
117
+ }
118
+
119
+ // Cache the loaded contacts
120
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
121
+ const filteredContacts = identityKey != null
122
+ ? contacts.filter(c => c.identityKey === identityKey)
123
+ : contacts
124
+ return filteredContacts
125
+ }
126
+
127
+ /**
128
+ * Save or update a Metanet contact
129
+ * @param contact The displayable identity information for the contact
130
+ * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
131
+ */
132
+ async saveContact (contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void> {
133
+ // Get current contacts from cache or blockchain
134
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
135
+ let contacts: Contact[]
136
+ if (cached != null && cached !== '') {
137
+ contacts = JSON.parse(cached)
138
+ } else {
139
+ // If cache is empty, get current data from blockchain
140
+ contacts = await this.getContacts()
141
+ }
142
+
143
+ const existingIndex = contacts.findIndex(c => c.identityKey === contact.identityKey)
144
+ const contactToStore: Contact = {
145
+ ...contact,
146
+ metadata
147
+ }
148
+
149
+ if (existingIndex >= 0) {
150
+ contacts[existingIndex] = contactToStore
151
+ } else {
152
+ contacts.push(contactToStore)
153
+ }
154
+
155
+ const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
156
+ protocolID: CONTACT_PROTOCOL_ID,
157
+ keyID: contact.identityKey,
158
+ counterparty: 'self',
159
+ data: Utils.toArray(contact.identityKey, 'utf8')
160
+ })
161
+
162
+ // Check if this contact already exists (to update it)
163
+ const outputs = await this.wallet.listOutputs({
164
+ basket: 'contacts',
165
+ include: 'entire transactions',
166
+ includeCustomInstructions: true,
167
+ tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`]
168
+ })
169
+
170
+ let existingOutput: any = null
171
+ let keyID = Utils.toBase64(Random(32))
172
+ if (outputs.outputs != null) {
173
+ // Find output by trying to decrypt and checking identityKey in payload
174
+ for (const output of outputs.outputs) {
175
+ try {
176
+ const [txid, outputIndex] = output.outpoint.split('.')
177
+ const tx = Transaction.fromBEEF(outputs.BEEF as number[], txid)
178
+ const decoded = PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript)
179
+ if (output.customInstructions == null) continue
180
+ keyID = JSON.parse(output.customInstructions).keyID
181
+
182
+ const { plaintext } = await this.wallet.decrypt({
183
+ ciphertext: decoded.fields[0],
184
+ protocolID: CONTACT_PROTOCOL_ID,
185
+ keyID,
186
+ counterparty: 'self'
187
+ })
188
+
189
+ const storedContact: Contact = JSON.parse(Utils.toUTF8(plaintext))
190
+ if (storedContact.identityKey === contact.identityKey) {
191
+ // Found the right output
192
+ existingOutput = output
193
+ break
194
+ }
195
+ } catch (e) {
196
+ // Skip malformed or undecryptable outputs
197
+ }
198
+ }
199
+ }
200
+
201
+ // Encrypt the contact data directly
202
+ const contactWithMetadata: Contact = {
203
+ ...contact,
204
+ metadata
205
+ }
206
+ const { ciphertext } = await this.wallet.encrypt({
207
+ plaintext: Utils.toArray(JSON.stringify(contactWithMetadata), 'utf8'),
208
+ protocolID: CONTACT_PROTOCOL_ID,
209
+ keyID,
210
+ counterparty: 'self'
211
+ })
212
+
213
+ // Create locking script for the new contact token
214
+ const lockingScript = await new PushDrop(this.wallet).lock(
215
+ [ciphertext],
216
+ CONTACT_PROTOCOL_ID,
217
+ keyID,
218
+ 'self'
219
+ )
220
+
221
+ if (existingOutput != null) {
222
+ // Update existing contact by spending its output
223
+ const [txid, outputIndex] = String(existingOutput.outpoint).split('.')
224
+ const prevOutpoint = `${txid}.${outputIndex}` as const
225
+
226
+ const pushdrop = new PushDrop(this.wallet)
227
+ const { signableTransaction } = await this.wallet.createAction({
228
+ description: 'Update Contact',
229
+ inputBEEF: outputs.BEEF as number[],
230
+ inputs: [{
231
+ outpoint: prevOutpoint,
232
+ unlockingScriptLength: 74,
233
+ inputDescription: 'Spend previous contact output'
234
+ }],
235
+ outputs: [{
236
+ basket: 'contacts',
237
+ satoshis: 1,
238
+ lockingScript: lockingScript.toHex(),
239
+ outputDescription: `Updated Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
240
+ tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`],
241
+ customInstructions: JSON.stringify({ keyID })
242
+ }],
243
+ options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
244
+ })
245
+
246
+ if (signableTransaction == null) throw new Error('Unable to update contact')
247
+
248
+ const unlocker = pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self')
249
+ const unlockingScript = await unlocker.sign(
250
+ Transaction.fromBEEF(signableTransaction.tx),
251
+ 0
252
+ )
253
+
254
+ const { tx } = await this.wallet.signAction({
255
+ reference: signableTransaction.reference,
256
+ spends: { 0: { unlockingScript: unlockingScript.toHex() } }
257
+ })
258
+
259
+ if (tx == null) throw new Error('Failed to update contact output')
260
+ } else {
261
+ // Create new contact output
262
+ const { tx } = await this.wallet.createAction({
263
+ description: 'Add Contact',
264
+ outputs: [{
265
+ basket: 'contacts',
266
+ satoshis: 1,
267
+ lockingScript: lockingScript.toHex(),
268
+ outputDescription: `Contact: ${contact.name ?? contact.identityKey.slice(0, 10)}`,
269
+ tags: [`identityKey ${Utils.toHex(hashedIdentityKey)}`],
270
+ customInstructions: JSON.stringify({ keyID })
271
+ }],
272
+ options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
273
+ })
274
+
275
+ if (tx == null) throw new Error('Failed to create contact output')
276
+ }
277
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(contacts))
278
+ }
279
+
280
+ /**
281
+ * Remove a contact from the contacts basket
282
+ * @param identityKey The identity key of the contact to remove
283
+ */
284
+ async removeContact (identityKey: string): Promise<void> {
285
+ // Update in-memory cache
286
+ const cached = this.cache.getItem(this.CONTACTS_CACHE_KEY)
287
+ if (cached != null && cached !== '') {
288
+ try {
289
+ const contacts: Contact[] = JSON.parse(cached)
290
+ const filteredContacts = contacts.filter(c => c.identityKey !== identityKey)
291
+ this.cache.setItem(this.CONTACTS_CACHE_KEY, JSON.stringify(filteredContacts))
292
+ } catch (e) {
293
+ console.warn('Failed to update cache after contact removal:', e)
294
+ }
295
+ }
296
+
297
+ // Hash the identity key to use as a tag for quick lookup
298
+ const tags: string[] = []
299
+ const { hmac: hashedIdentityKey } = await this.wallet.createHmac({
300
+ protocolID: CONTACT_PROTOCOL_ID,
301
+ keyID: identityKey,
302
+ counterparty: 'self',
303
+ data: Utils.toArray(identityKey, 'utf8')
304
+ })
305
+ tags.push(`identityKey ${Utils.toHex(hashedIdentityKey)}`)
306
+
307
+ // Find and spend the contact's output
308
+ const outputs = await this.wallet.listOutputs({
309
+ basket: 'contacts',
310
+ include: 'entire transactions',
311
+ includeCustomInstructions: true,
312
+ tags
313
+ })
314
+
315
+ if (outputs.outputs == null) return
316
+
317
+ // Find the output for this specific contact by decrypting and checking identityKey
318
+ for (const output of outputs.outputs) {
319
+ try {
320
+ const [txid, outputIndex] = String(output.outpoint).split('.')
321
+ const tx = Transaction.fromBEEF(outputs.BEEF as number[], txid)
322
+ const decoded = PushDrop.decode(tx.outputs[Number(outputIndex)].lockingScript)
323
+ if (output.customInstructions == null) continue
324
+ const keyID = JSON.parse(output.customInstructions).keyID
325
+
326
+ const { plaintext } = await this.wallet.decrypt({
327
+ ciphertext: decoded.fields[0],
328
+ protocolID: CONTACT_PROTOCOL_ID,
329
+ keyID,
330
+ counterparty: 'self'
331
+ })
332
+
333
+ const storedContact: Contact = JSON.parse(Utils.toUTF8(plaintext))
334
+ if (storedContact.identityKey === identityKey) {
335
+ // Found the contact's output, spend it without creating a new one
336
+ const prevOutpoint = `${txid}.${outputIndex}` as const
337
+
338
+ const pushdrop = new PushDrop(this.wallet)
339
+ const { signableTransaction } = await this.wallet.createAction({
340
+ description: 'Delete Contact',
341
+ inputBEEF: outputs.BEEF as number[],
342
+ inputs: [{
343
+ outpoint: prevOutpoint,
344
+ unlockingScriptLength: 74,
345
+ inputDescription: 'Spend contact output to delete'
346
+ }],
347
+ outputs: [], // No outputs = deletion
348
+ options: { acceptDelayedBroadcast: false, randomizeOutputs: false } // TODO: Support custom config as needed.
349
+ })
350
+
351
+ if (signableTransaction == null) throw new Error('Unable to delete contact')
352
+
353
+ const unlocker = pushdrop.unlock(CONTACT_PROTOCOL_ID, keyID, 'self')
354
+ const unlockingScript = await unlocker.sign(
355
+ Transaction.fromBEEF(signableTransaction.tx),
356
+ 0
357
+ )
358
+
359
+ const { tx: deleteTx } = await this.wallet.signAction({
360
+ reference: signableTransaction.reference,
361
+ spends: { 0: { unlockingScript: unlockingScript.toHex() } }
362
+ })
363
+
364
+ if (deleteTx == null) throw new Error('Failed to delete contact output')
365
+ return
366
+ }
367
+ } catch (e) {
368
+ // Skip malformed or undecryptable outputs
369
+ }
370
+ }
371
+ }
372
+ }
@@ -6,6 +6,7 @@ import {
6
6
  DiscoverByIdentityKeyArgs,
7
7
  IdentityCertificate,
8
8
  OriginatorDomainNameStringUnder250Bytes,
9
+ PubKeyHex,
9
10
  WalletCertificate,
10
11
  WalletClient,
11
12
  WalletInterface
@@ -15,6 +16,7 @@ import Certificate from '../auth/certificates/Certificate.js'
15
16
  import { PushDrop } from '../script/index.js'
16
17
  import { PrivateKey, Utils } from '../primitives/index.js'
17
18
  import { TopicBroadcaster } from '../overlay-tools/index.js'
19
+ import { ContactsManager, Contact } from './ContactsManager.js'
18
20
 
19
21
  /**
20
22
  * IdentityClient lets you discover who others are, and let the world know who you are.
@@ -22,6 +24,7 @@ import { TopicBroadcaster } from '../overlay-tools/index.js'
22
24
  export class IdentityClient {
23
25
  private readonly authClient: AuthFetch
24
26
  private readonly wallet: WalletInterface
27
+ private readonly contactsManager: ContactsManager
25
28
  constructor (
26
29
  wallet?: WalletInterface,
27
30
  private readonly options = DEFAULT_IDENTITY_CLIENT_OPTIONS,
@@ -29,6 +32,7 @@ export class IdentityClient {
29
32
  ) {
30
33
  this.wallet = wallet ?? new WalletClient()
31
34
  this.authClient = new AuthFetch(this.wallet)
35
+ this.contactsManager = new ContactsManager(this.wallet)
32
36
  }
33
37
 
34
38
  /**
@@ -213,6 +217,33 @@ export class IdentityClient {
213
217
  // return await broadcaster.broadcast(Transaction.fromAtomicBEEF(signedTx as number[]))
214
218
  // }
215
219
 
220
+ /**
221
+ * Load all records from the contacts basket
222
+ * @param identityKey Optional specific identity key to fetch
223
+ * @param forceRefresh Whether to force a check for new contact data
224
+ * @returns A promise that resolves with an array of contacts
225
+ */
226
+ public async getContacts (identityKey?: PubKeyHex, forceRefresh = false): Promise<Contact[]> {
227
+ return await this.contactsManager.getContacts(identityKey, forceRefresh)
228
+ }
229
+
230
+ /**
231
+ * Save or update a Metanet contact
232
+ * @param contact The displayable identity information for the contact
233
+ * @param metadata Optional metadata to store with the contact (ex. notes, aliases, etc)
234
+ */
235
+ public async saveContact (contact: DisplayableIdentity, metadata?: Record<string, any>): Promise<void> {
236
+ return await this.contactsManager.saveContact(contact, metadata)
237
+ }
238
+
239
+ /**
240
+ * Remove a contact from the contacts basket
241
+ * @param identityKey The identity key of the contact to remove
242
+ */
243
+ public async removeContact (identityKey: PubKeyHex): Promise<void> {
244
+ return await this.contactsManager.removeContact(identityKey)
245
+ }
246
+
216
247
  /**
217
248
  * Parse out identity and certifier attributes to display from an IdentityCertificate
218
249
  * @param identityToParse - The Identity Certificate to parse