From 9350aa57d0fab081f5f91325f5421af1473e6a8d Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Tue, 24 Feb 2026 13:33:39 -0800 Subject: [PATCH 01/10] Contacts client. --- src/services/storage/client.ts | 22 ++ .../storage/clients/contacts.generated.ts | 4 + src/services/storage/clients/contacts.test.ts | 315 ++++++++++++++++++ src/services/storage/clients/contacts.ts | 194 +++++++++++ 4 files changed, 535 insertions(+) create mode 100644 src/services/storage/clients/contacts.generated.ts create mode 100644 src/services/storage/clients/contacts.test.ts create mode 100644 src/services/storage/clients/contacts.ts diff --git a/src/services/storage/client.ts b/src/services/storage/client.ts index 09f3c1e4..eefc893b 100644 --- a/src/services/storage/client.ts +++ b/src/services/storage/client.ts @@ -43,6 +43,7 @@ import { addSignatureToURL } from '../../lib/http-server/common.js'; import { SignData } from '../../lib/utils/signing.js'; import { KeetaAnchorError } from '../../lib/error.js'; import { arrayBufferLikeToBuffer } from '../../lib/utils/buffer.js'; +import { StorageContactsClient } from './clients/contacts.js'; /** * The configuration options for the Storage Anchor client. @@ -1175,6 +1176,13 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { const session = this.beginSession(config); return(await fn(session)); } + + /** + * Get a contacts client bound to the given account. + */ + getContactsClient(account: InstanceType, basePath: string): StorageContactsClient { + return(new StorageContactsClient(this, account, basePath)); + } } class KeetaStorageAnchorClient extends KeetaStorageAnchorBase { @@ -1242,6 +1250,20 @@ class KeetaStorageAnchorClient extends KeetaStorageAnchorBase { return(provider ?? null); } + /** + * Get a contacts client bound to the given account. + * Resolves the first available provider and constructs a StorageContactsClient. + */ + async getContactsClient(account: InstanceType, basePath: string): Promise { + const providers = await this.getProviders(); + const provider = providers?.[0]; + if (!provider) { + return(null); + } + + return(provider.getContactsClient(account, basePath)); + } + /** @internal */ _internals(accessToken: symbol) { if (accessToken !== KeetaStorageAnchorClientAccessToken) { diff --git a/src/services/storage/clients/contacts.generated.ts b/src/services/storage/clients/contacts.generated.ts new file mode 100644 index 00000000..094150af --- /dev/null +++ b/src/services/storage/clients/contacts.generated.ts @@ -0,0 +1,4 @@ +import { createAssert } from 'typia'; +import type { Contact } from './contacts.ts'; + +export const assertContact: (input: unknown) => Contact = createAssert(); diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts new file mode 100644 index 00000000..4ebfbd4d --- /dev/null +++ b/src/services/storage/clients/contacts.test.ts @@ -0,0 +1,315 @@ +import { test, expect, describe } from 'vitest'; +import { KeetaNet } from '../../../client/index.js'; +import { createNodeAndClient, setResolverInfo } from '../../../lib/utils/tests/node.js'; +import KeetaAnchorResolver from '../../../lib/resolver.js'; +import { KeetaNetStorageAnchorHTTPServer } from '../server.js'; +import KeetaStorageAnchorClient from '../client.js'; +import { MemoryStorageBackend, testPathPolicy } from '../test-utils.js'; +import type { Contact, ContactAddress } from './contacts.js'; +import { StorageContactsClient } from './contacts.js'; +import { Errors } from '../common.js'; + +// #region Test Harness + +type Account = InstanceType; + +function randomSeed() { + return(KeetaNet.lib.Account.generateRandomSeed()); +} + +interface ContactsTestContext { + contactsClient: StorageContactsClient; + account: Account; + storageClient: KeetaStorageAnchorClient; +} + +async function withContacts( + seed: string | ArrayBuffer, + testFunction: (ctx: ContactsTestContext) => Promise +): Promise { + const account = KeetaNet.lib.Account.fromSeed(seed, 0); + const anchorAccount = KeetaNet.lib.Account.fromSeed(seed, 50); + + await using nodeAndClient = await createNodeAndClient(account); + + const userClient = nodeAndClient.userClient; + nodeAndClient.fees.disable(); + + const backend = new MemoryStorageBackend(); + + await using server = new KeetaNetStorageAnchorHTTPServer({ + backend, + anchorAccount, + pathPolicies: [testPathPolicy] + }); + + await server.start(); + + const rootAccount = KeetaNet.lib.Account.fromSeed(seed, 100); + const serviceMetadata = await server.serviceMetadata(); + + await setResolverInfo(rootAccount, userClient, { + version: 1, + currencyMap: {}, + services: { + storage: { + 'test-provider': serviceMetadata + } + } + }); + + const resolver = new KeetaAnchorResolver({ + root: rootAccount, + client: userClient, + trustedCAs: [] + }); + + const storageClient = new KeetaStorageAnchorClient(userClient, { resolver }); + const maybeProvider = await storageClient.getProviderByID('test-provider'); + if (!maybeProvider) { + throw(new Error('Provider not found')); + } + + const pubkey = account.publicKeyString.get(); + const contactsClient = maybeProvider.getContactsClient(account, `/user/${pubkey}/contacts/`); + await testFunction({ contactsClient, account, storageClient }); +} + +// #endregion + +// #region Test Fixtures + +const keetaSendAddress: ContactAddress = { + type: 'KEETA_SEND', + location: 'chain:keeta:1', + sendToAddress: 'keeta1a2b3c', + tokenAddress: '0x1a2b3c4d' +}; + +const evmSendAddress: ContactAddress = { + type: 'EVM_SEND', + location: 'chain:evm:1', + sendToAddress: '0x4d5e6f7a8b9c', + tokenAddress: '0x1a2b3c4d' +}; + +const wireAddress: ContactAddress = { + type: 'WIRE', + account: { + type: 'bank-account', + accountType: 'us', + accountNumber: '123456789', + routingNumber: '021000021', + accountTypeDetail: 'checking', + accountOwner: { type: 'individual', firstName: 'Alice', lastName: 'Smith' } + } +}; + +const bitcoinSendAddress: ContactAddress = { + type: 'BITCOIN_SEND', + location: 'chain:bitcoin:f9beb4d9', + sendToAddress: 'bc1q0a1b2c3d4e5f' +}; + +const solanaSendAddress: ContactAddress = { + type: 'SOLANA_SEND', + location: 'chain:solana:1', + sendToAddress: '9a8b7c6d5e4f' +}; + +const tronSendAddress: ContactAddress = { + type: 'TRON_SEND', + location: 'chain:tron:mainnet', + sendToAddress: 'T1a2b3c4d5e6' +}; + +const evmCallAddress: ContactAddress = { + type: 'EVM_CALL', + location: 'chain:evm:1', + contractAddress: '0x9c0d1e2f', + contractMethodName: 'deposit(uint256)', + contractMethodArgs: ['1000'] +}; + +const achAddress: ContactAddress = { + type: 'ACH', + account: { + type: 'bank-account', + accountType: 'us', + accountNumber: '987654321', + routingNumber: '021000021', + accountTypeDetail: 'checking', + accountOwner: { type: 'individual', firstName: 'Bob', lastName: 'Jones' } + } +}; + +const sepaPushAddress: ContactAddress = { + type: 'SEPA_PUSH', + account: { + type: 'bank-account', + accountType: 'iban-swift', + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', // cspell:disable-line + accountOwner: { type: 'individual', firstName: 'Hans', lastName: 'Mueller' } + } +}; + +const sampleAddresses: { type: ContactAddress['type']; address: ContactAddress }[] = [ + { type: 'KEETA_SEND', address: keetaSendAddress }, + { type: 'EVM_SEND', address: evmSendAddress }, + { type: 'EVM_CALL', address: evmCallAddress }, + { type: 'WIRE', address: wireAddress }, + { type: 'ACH', address: achAddress }, + { type: 'SEPA_PUSH', address: sepaPushAddress }, + { type: 'BITCOIN_SEND', address: bitcoinSendAddress }, + { type: 'SOLANA_SEND', address: solanaSendAddress }, + { type: 'TRON_SEND', address: tronSendAddress } +]; + +const updateCases: { + name: string; + initial: { label: string; address: ContactAddress }; + update: { label?: string; address?: ContactAddress }; + expected: { label: string; address: ContactAddress }; +}[] = [ + { + name: 'label only preserves address', + initial: { label: 'Original', address: keetaSendAddress }, + update: { label: 'Renamed' }, + expected: { label: 'Renamed', address: keetaSendAddress } + }, + { + name: 'address only preserves label', + initial: { label: 'Keep This', address: keetaSendAddress }, + update: { address: bitcoinSendAddress }, + expected: { label: 'Keep This', address: bitcoinSendAddress } + }, + { + name: 'both label and address', + initial: { label: 'Old', address: keetaSendAddress }, + update: { label: 'New', address: evmSendAddress }, + expected: { label: 'New', address: evmSendAddress } + } +]; + +// #endregion + +// #region Tests + +describe('Contacts Client - CRUD per address type', function() { + test.each(sampleAddresses)('create and get contact with $type address', function({ address }) { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const created = await contactsClient.create({ label: 'Test Contact', address }); + expect(created.id).toBeDefined(); + expect(created.label).toBe('Test Contact'); + expect(created.address).toEqual(address); + + const retrieved = await contactsClient.get(created.id); + expect(retrieved).toEqual(created); + })); + }); +}); + +describe('Contacts Client - Update', function() { + test.each(updateCases)('update $name', function({ initial, update, expected }) { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const created = await contactsClient.create(initial); + const updated = await contactsClient.update(created.id, update); + expect(updated.id).toBe(created.id); + expect(updated.label).toBe(expected.label); + expect(updated.address).toEqual(expected.address); + + const retrieved = await contactsClient.get(created.id); + expect(retrieved).toEqual(updated); + })); + }); + + test('update non-existent contact throws DocumentNotFound', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + await expect(contactsClient.update('no-such-id', { label: 'X' })) + .rejects.toSatisfy(function(e: unknown) { return(Errors.DocumentNotFound.isInstance(e)); }); + })); + }); +}); + +describe('Contacts Client - Delete', function() { + test('delete existing contact returns true', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const created = await contactsClient.create({ + label: 'To Delete', + address: keetaSendAddress + }); + + const deleted = await contactsClient.delete(created.id); + expect(deleted).toBe(true); + + const retrieved = await contactsClient.get(created.id); + expect(retrieved).toBeNull(); + })); + }); + + test('delete non-existent contact returns false', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const deleted = await contactsClient.delete('non-existent-id'); + expect(deleted).toBe(false); + })); + }); +}); + +describe('Contacts Client - List', function() { + test('list returns all created contacts', function() { + const sortById = function(a: Contact, b: Contact) { return(a.id.localeCompare(b.id)); }; + + return(withContacts(randomSeed(), async function({ contactsClient }) { + const created: Contact[] = []; + for (const { address } of sampleAddresses) { + created.push(await contactsClient.create({ label: `Contact ${address.type}`, address })); + } + + const listed = await contactsClient.list(); + expect(listed).toHaveLength(sampleAddresses.length); + expect(listed.sort(sortById)).toEqual(created.sort(sortById)); + })); + }); + + test('list filtered by type returns only matching contacts', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + await contactsClient.create({ label: 'Wire Contact', address: wireAddress }); + await contactsClient.create({ label: 'Bitcoin Contact', address: bitcoinSendAddress }); + await contactsClient.create({ label: 'Another Wire', address: wireAddress }); + + const wireContacts = await contactsClient.list({ type: 'WIRE' }); + expect(wireContacts).toHaveLength(2); + for (const contact of wireContacts) { + expect(contact.address.type).toBe('WIRE'); + } + + const btcContacts = await contactsClient.list({ type: 'BITCOIN_SEND' }); + expect(btcContacts).toHaveLength(1); + for (const contact of btcContacts) { + expect(contact.address.type).toBe('BITCOIN_SEND'); + } + })); + }); +}); + +describe('Contacts Client - Edge Cases', function() { + test('get non-existent id returns null', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const result = await contactsClient.get('does-not-exist'); + expect(result).toBeNull(); + })); + }); +}); + +describe('Contacts Client - Factory Methods', function() { + test('getContactsClient via storage client resolves provider', function() { + return(withContacts(randomSeed(), async function({ storageClient, account }) { + const pubkey = account.publicKeyString.get(); + const contactsClient = await storageClient.getContactsClient(account, `/user/${pubkey}/contacts/`); + expect(contactsClient).toBeInstanceOf(StorageContactsClient); + })); + }); +}); + +// #endregion diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts new file mode 100644 index 00000000..bbd5e556 --- /dev/null +++ b/src/services/storage/clients/contacts.ts @@ -0,0 +1,194 @@ +import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; +import type { AssetTransferInstructions } from '../../asset-movement/common.js'; +import type { KeetaStorageAnchorProvider } from '../client.js'; +import type { SearchCriteria } from '../common.js'; +import crypto from '../../../lib/utils/crypto.js'; +import { Errors } from '../common.js'; +import { Buffer } from '../../../lib/utils/buffer.js'; +import { assertContact } from './contacts.generated.js'; + +// #region Types + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** + * A contact address derived from `AssetTransferInstructions` + */ +export type ContactAddress = DistributiveOmit< + AssetTransferInstructions, + 'value' | 'assetFee' | 'totalReceiveAmount' | 'persistentAddressId' +>; + +/** + * A stored contact with metadata and an address. + */ +export type Contact = { + id: string; + label: string; + address: ContactAddress; +}; + +// #endregion + +// #region Interface + +/** + * Generic contacts client interface + */ +export interface ContactsClient { + create(options: { + label: string; + address: ContactAddress; + }): Promise; + + get(id: string): Promise; + + update(id: string, options: { + label?: string; + address?: ContactAddress; + }): Promise; + + delete(id: string): Promise; + + list(options?: { + type?: ContactAddress['type']; + }): Promise; +} + +// #endregion + +// #region Storage Implementation + +const MIME_TYPE = 'application/json'; + +/** + * Storage Anchor-backed implementation of `ContactsClient`. + * Stores contacts as encrypted JSON objects via `KeetaStorageAnchorProvider`. + */ +export class StorageContactsClient implements ContactsClient { + readonly #provider: KeetaStorageAnchorProvider; + readonly #account: InstanceType; + readonly #basePath: string; + + constructor(provider: KeetaStorageAnchorProvider, account: InstanceType, basePath: string) { + this.#provider = provider; + this.#account = account; + this.#basePath = basePath; + } + + #contactPath(id: string): string { + return(`${this.#basePath}${id}`); + } + + #contactsPathPrefix(): string { + return(this.#basePath); + } + + #serialize(contact: Contact): Buffer { + return(Buffer.from(JSON.stringify(contact))); + } + + #deserialize(data: Buffer): Contact { + return(assertContact(JSON.parse(data.toString()))); + } + + async create(options: { + label: string; + address: ContactAddress; + }): Promise { + const id = crypto.randomUUID(); + const contact: Contact = { + id, + label: options.label, + address: options.address + }; + + await this.#provider.put({ + path: this.#contactPath(id), + data: this.#serialize(contact), + mimeType: MIME_TYPE, + tags: [options.address.type], + account: this.#account + }); + + return(contact); + } + + async get(id: string): Promise { + const result = await this.#provider.get({ + path: this.#contactPath(id), + account: this.#account + }); + if (!result) { + return(null); + } + + return(this.#deserialize(result.data)); + } + + async update(id: string, options: { + label?: string; + address?: ContactAddress; + }): Promise { + const existing = await this.get(id); + if (!existing) { + throw(new Errors.DocumentNotFound(`Contact not found: ${id}`)); + } + + const updated: Contact = { + id: existing.id, + label: options.label ?? existing.label, + address: options.address ?? existing.address + }; + + await this.#provider.put({ + path: this.#contactPath(id), + data: this.#serialize(updated), + mimeType: MIME_TYPE, + tags: [updated.address.type], + account: this.#account + }); + + return(updated); + } + + async delete(id: string): Promise { + return(await this.#provider.delete({ + path: this.#contactPath(id), + account: this.#account + })); + } + + async list(options?: { + type?: ContactAddress['type']; + }): Promise { + const criteria: SearchCriteria = { + pathPrefix: this.#contactsPathPrefix(), + owner: this.#account.publicKeyString.get() + }; + + if (options?.type) { + criteria.tags = [options.type]; + } + + const searchResult = await this.#provider.search({ + criteria, + account: this.#account + }); + + const contacts: Contact[] = []; + for (const metadata of searchResult.results) { + const result = await this.#provider.get({ + path: metadata.path, + account: this.#account + }); + if (result) { + contacts.push(this.#deserialize(result.data)); + } + } + + return(contacts); + } +} + +// #endregion From 62d2f9647cee478efab2aff5eaf6acca51051466 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 26 Feb 2026 11:18:32 -0800 Subject: [PATCH 02/10] Use hashes --- src/services/storage/client.ts | 11 ++-- src/services/storage/clients/contacts.test.ts | 65 +++++++++++++++---- src/services/storage/clients/contacts.ts | 47 ++++++++++++-- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/services/storage/client.ts b/src/services/storage/client.ts index 7ba3b670..994cd655 100644 --- a/src/services/storage/client.ts +++ b/src/services/storage/client.ts @@ -1,5 +1,4 @@ import type { UserClient as KeetaNetUserClient } from '@keetanetwork/keetanet-client'; -import { KeetaNet } from '../../client/index.js'; import type { Logger } from '../../lib/log/index.ts'; import type { HTTPSignedField } from '../../lib/http-server/common.js'; import type { ServiceMetadata, ServiceMetadataAuthenticationType, ServiceMetadataEndpoint } from '../../lib/resolver.ts'; @@ -34,16 +33,17 @@ import { CONTENT_TYPE_OCTET_STREAM, DEFAULT_SIGNED_URL_TTL_SECONDS } from './common.js'; +import { KeetaNet } from '../../client/index.js'; import { getDefaultResolver } from '../../config.js'; import { EncryptedContainer } from '../../lib/encrypted-container.js'; -import Resolver from '../../lib/resolver.js'; -import crypto from '../../lib/utils/crypto.js'; import { createAssertEquals } from 'typia'; import { addSignatureToURL } from '../../lib/http-server/common.js'; import { SignData } from '../../lib/utils/signing.js'; import { KeetaAnchorError } from '../../lib/error.js'; import { arrayBufferLikeToBuffer } from '../../lib/utils/buffer.js'; import { StorageContactsClient } from './clients/contacts.js'; +import Resolver from '../../lib/resolver.js'; +import crypto from '../../lib/utils/crypto.js'; /** * The configuration options for the Storage Anchor client. @@ -444,7 +444,6 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { if (endpoint === undefined) { throw(new Errors.OperationNotSupported(operationName)); } - if (endpoint.options.authentication.method !== 'keeta-account') { throw(new Errors.UnsupportedAuthMethod(endpoint.options.authentication.method)); } @@ -1180,7 +1179,7 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { /** * Get a contacts client bound to the given account. */ - getContactsClient(account: InstanceType, basePath: string): StorageContactsClient { + getContactsClient(account: KeetaNetAccount, basePath: string): StorageContactsClient { return(new StorageContactsClient(this, account, basePath)); } } @@ -1254,7 +1253,7 @@ class KeetaStorageAnchorClient extends KeetaStorageAnchorBase { * Get a contacts client bound to the given account. * Resolves the first available provider and constructs a StorageContactsClient. */ - async getContactsClient(account: InstanceType, basePath: string): Promise { + async getContactsClient(account: KeetaNetAccount, basePath: string): Promise { const providers = await this.getProviders(); const provider = providers?.[0]; if (!provider) { diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts index 4ebfbd4d..8f146d07 100644 --- a/src/services/storage/clients/contacts.test.ts +++ b/src/services/storage/clients/contacts.test.ts @@ -171,24 +171,28 @@ const updateCases: { initial: { label: string; address: ContactAddress }; update: { label?: string; address?: ContactAddress }; expected: { label: string; address: ContactAddress }; + changesId: boolean; }[] = [ { - name: 'label only preserves address', + name: 'label only preserves address and id', initial: { label: 'Original', address: keetaSendAddress }, update: { label: 'Renamed' }, - expected: { label: 'Renamed', address: keetaSendAddress } + expected: { label: 'Renamed', address: keetaSendAddress }, + changesId: false }, { - name: 'address only preserves label', + name: 'address only preserves label and changes id', initial: { label: 'Keep This', address: keetaSendAddress }, update: { address: bitcoinSendAddress }, - expected: { label: 'Keep This', address: bitcoinSendAddress } + expected: { label: 'Keep This', address: bitcoinSendAddress }, + changesId: true }, { - name: 'both label and address', + name: 'both label and address changes id', initial: { label: 'Old', address: keetaSendAddress }, update: { label: 'New', address: evmSendAddress }, - expected: { label: 'New', address: evmSendAddress } + expected: { label: 'New', address: evmSendAddress }, + changesId: true } ]; @@ -200,7 +204,7 @@ describe('Contacts Client - CRUD per address type', function() { test.each(sampleAddresses)('create and get contact with $type address', function({ address }) { return(withContacts(randomSeed(), async function({ contactsClient }) { const created = await contactsClient.create({ label: 'Test Contact', address }); - expect(created.id).toBeDefined(); + expect(created.id).toBe(contactsClient.deriveId(address)); expect(created.label).toBe('Test Contact'); expect(created.address).toEqual(address); @@ -211,16 +215,22 @@ describe('Contacts Client - CRUD per address type', function() { }); describe('Contacts Client - Update', function() { - test.each(updateCases)('update $name', function({ initial, update, expected }) { + test.each(updateCases)('update $name', function({ initial, update, expected, changesId }) { return(withContacts(randomSeed(), async function({ contactsClient }) { const created = await contactsClient.create(initial); const updated = await contactsClient.update(created.id, update); - expect(updated.id).toBe(created.id); + + expect(updated.id).toBe(contactsClient.deriveId(expected.address)); expect(updated.label).toBe(expected.label); expect(updated.address).toEqual(expected.address); - const retrieved = await contactsClient.get(created.id); + const retrieved = await contactsClient.get(updated.id); expect(retrieved).toEqual(updated); + + if (changesId) { + const oldRetrieved = await contactsClient.get(created.id); + expect(oldRetrieved).toBeNull(); + } })); }); @@ -276,14 +286,20 @@ describe('Contacts Client - List', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { await contactsClient.create({ label: 'Wire Contact', address: wireAddress }); await contactsClient.create({ label: 'Bitcoin Contact', address: bitcoinSendAddress }); - await contactsClient.create({ label: 'Another Wire', address: wireAddress }); + await contactsClient.create({ label: 'ACH Contact', address: achAddress }); const wireContacts = await contactsClient.list({ type: 'WIRE' }); - expect(wireContacts).toHaveLength(2); + expect(wireContacts).toHaveLength(1); for (const contact of wireContacts) { expect(contact.address.type).toBe('WIRE'); } + const achContacts = await contactsClient.list({ type: 'ACH' }); + expect(achContacts).toHaveLength(1); + for (const contact of achContacts) { + expect(contact.address.type).toBe('ACH'); + } + const btcContacts = await contactsClient.list({ type: 'BITCOIN_SEND' }); expect(btcContacts).toHaveLength(1); for (const contact of btcContacts) { @@ -300,6 +316,31 @@ describe('Contacts Client - Edge Cases', function() { expect(result).toBeNull(); })); }); + + test('creating the same address twice is idempotent and updates the label', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const first = await contactsClient.create({ label: 'First', address: keetaSendAddress }); + const second = await contactsClient.create({ label: 'Second', address: keetaSendAddress }); + expect(second.id).toBe(first.id); + expect(second.label).toBe('Second'); + + const retrieved = await contactsClient.get(first.id); + expect(retrieved).toEqual(second); + + const listed = await contactsClient.list(); + expect(listed).toHaveLength(1); + })); + }); + + test('deriveId is deterministic across client instances', function() { + return(withContacts(randomSeed(), async function({ contactsClient, storageClient, account }) { + const pubkey = account.publicKeyString.get(); + const otherClient = (await storageClient.getProviderByID('test-provider'))!.getContactsClient(account, `/user/${pubkey}/contacts/`); // eslint-disable-line @typescript-eslint/no-non-null-assertion + for (const { address } of sampleAddresses) { + expect(contactsClient.deriveId(address)).toBe(otherClient.deriveId(address)); + } + })); + }); }); describe('Contacts Client - Factory Methods', function() { diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index bbd5e556..d129c931 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -2,7 +2,7 @@ import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; import type { AssetTransferInstructions } from '../../asset-movement/common.js'; import type { KeetaStorageAnchorProvider } from '../client.js'; import type { SearchCriteria } from '../common.js'; -import crypto from '../../../lib/utils/crypto.js'; +import { hash } from '../../../lib/utils/tests/hash.js'; import { Errors } from '../common.js'; import { Buffer } from '../../../lib/utils/buffer.js'; import { assertContact } from './contacts.generated.js'; @@ -36,6 +36,8 @@ export type Contact = { * Generic contacts client interface */ export interface ContactsClient { + deriveId(address: ContactAddress): string; + create(options: { label: string; address: ContactAddress; @@ -61,6 +63,27 @@ export interface ContactsClient { const MIME_TYPE = 'application/json'; +function canonicalizeValue(value: unknown): string { + if (value === null || typeof value !== 'object') { + return(JSON.stringify(value)); + } + if (Array.isArray(value)) { + return('[' + value.map(canonicalizeValue).join(',') + ']'); + } + + const keys = Object.keys(value).sort(); + const pairs: string[] = []; + for (const key of keys) { + pairs.push(JSON.stringify(key) + ':' + canonicalizeValue((value as { [k: string]: unknown })[key])); + } + + return('{' + pairs.join(',') + '}'); +} + +function canonicalizeContactAddress(address: ContactAddress): string { + return(canonicalizeValue(address)); +} + /** * Storage Anchor-backed implementation of `ContactsClient`. * Stores contacts as encrypted JSON objects via `KeetaStorageAnchorProvider`. @@ -76,6 +99,10 @@ export class StorageContactsClient implements ContactsClient { this.#basePath = basePath; } + deriveId(address: ContactAddress): string { + return(hash(canonicalizeContactAddress(address))); + } + #contactPath(id: string): string { return(`${this.#basePath}${id}`); } @@ -96,7 +123,7 @@ export class StorageContactsClient implements ContactsClient { label: string; address: ContactAddress; }): Promise { - const id = crypto.randomUUID(); + const id = this.deriveId(options.address); const contact: Contact = { id, label: options.label, @@ -135,14 +162,24 @@ export class StorageContactsClient implements ContactsClient { throw(new Errors.DocumentNotFound(`Contact not found: ${id}`)); } + const newAddress = options.address ?? existing.address; + const newId = this.deriveId(newAddress); + const updated: Contact = { - id: existing.id, + id: newId, label: options.label ?? existing.label, - address: options.address ?? existing.address + address: newAddress }; + if (newId !== id) { + await this.#provider.delete({ + path: this.#contactPath(id), + account: this.#account + }); + } + await this.#provider.put({ - path: this.#contactPath(id), + path: this.#contactPath(newId), data: this.#serialize(updated), mimeType: MIME_TYPE, tags: [updated.address.type], From 16d098eca05bd31847ebf22af65ac42215f5eff4 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 26 Feb 2026 11:18:38 -0800 Subject: [PATCH 03/10] Use sessions --- src/services/storage/client.ts | 3 +- src/services/storage/clients/contacts.ts | 75 ++++++------------------ 2 files changed, 19 insertions(+), 59 deletions(-) diff --git a/src/services/storage/client.ts b/src/services/storage/client.ts index 994cd655..0ae769b6 100644 --- a/src/services/storage/client.ts +++ b/src/services/storage/client.ts @@ -1180,7 +1180,8 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { * Get a contacts client bound to the given account. */ getContactsClient(account: KeetaNetAccount, basePath: string): StorageContactsClient { - return(new StorageContactsClient(this, account, basePath)); + const session = this.beginSession({ account, workingDirectory: basePath }); + return(new StorageContactsClient(session)); } } diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index d129c931..4b51035c 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -1,7 +1,5 @@ -import type { lib as KeetaNetLib } from '@keetanetwork/keetanet-client'; import type { AssetTransferInstructions } from '../../asset-movement/common.js'; -import type { KeetaStorageAnchorProvider } from '../client.js'; -import type { SearchCriteria } from '../common.js'; +import type { KeetaStorageAnchorSession } from '../client.js'; import { hash } from '../../../lib/utils/tests/hash.js'; import { Errors } from '../common.js'; import { Buffer } from '../../../lib/utils/buffer.js'; @@ -86,31 +84,19 @@ function canonicalizeContactAddress(address: ContactAddress): string { /** * Storage Anchor-backed implementation of `ContactsClient`. - * Stores contacts as encrypted JSON objects via `KeetaStorageAnchorProvider`. + * Stores contacts as encrypted JSON objects via a `KeetaStorageAnchorSession`. */ export class StorageContactsClient implements ContactsClient { - readonly #provider: KeetaStorageAnchorProvider; - readonly #account: InstanceType; - readonly #basePath: string; - - constructor(provider: KeetaStorageAnchorProvider, account: InstanceType, basePath: string) { - this.#provider = provider; - this.#account = account; - this.#basePath = basePath; + readonly #session: KeetaStorageAnchorSession; + + constructor(session: KeetaStorageAnchorSession) { + this.#session = session; } deriveId(address: ContactAddress): string { return(hash(canonicalizeContactAddress(address))); } - #contactPath(id: string): string { - return(`${this.#basePath}${id}`); - } - - #contactsPathPrefix(): string { - return(this.#basePath); - } - #serialize(contact: Contact): Buffer { return(Buffer.from(JSON.stringify(contact))); } @@ -130,22 +116,16 @@ export class StorageContactsClient implements ContactsClient { address: options.address }; - await this.#provider.put({ - path: this.#contactPath(id), - data: this.#serialize(contact), + await this.#session.put(id, this.#serialize(contact), { mimeType: MIME_TYPE, - tags: [options.address.type], - account: this.#account + tags: [options.address.type] }); return(contact); } async get(id: string): Promise { - const result = await this.#provider.get({ - path: this.#contactPath(id), - account: this.#account - }); + const result = await this.#session.get(id); if (!result) { return(null); } @@ -172,53 +152,32 @@ export class StorageContactsClient implements ContactsClient { }; if (newId !== id) { - await this.#provider.delete({ - path: this.#contactPath(id), - account: this.#account - }); + await this.#session.delete(id); } - await this.#provider.put({ - path: this.#contactPath(newId), - data: this.#serialize(updated), + await this.#session.put(newId, this.#serialize(updated), { mimeType: MIME_TYPE, - tags: [updated.address.type], - account: this.#account + tags: [updated.address.type] }); return(updated); } async delete(id: string): Promise { - return(await this.#provider.delete({ - path: this.#contactPath(id), - account: this.#account - })); + return(await this.#session.delete(id)); } async list(options?: { type?: ContactAddress['type']; }): Promise { - const criteria: SearchCriteria = { - pathPrefix: this.#contactsPathPrefix(), - owner: this.#account.publicKeyString.get() - }; - - if (options?.type) { - criteria.tags = [options.type]; - } - - const searchResult = await this.#provider.search({ - criteria, - account: this.#account + const searchResult = await this.#session.search({ + pathPrefix: this.#session.workingDirectory, + ...(options?.type ? { tags: [options.type] } : {}) }); const contacts: Contact[] = []; for (const metadata of searchResult.results) { - const result = await this.#provider.get({ - path: metadata.path, - account: this.#account - }); + const result = await this.#session.get(metadata.path); if (result) { contacts.push(this.#deserialize(result.data)); } From 50797ecfacea1191796eea259d1ed55c005fd6ad Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Thu, 26 Feb 2026 11:20:25 -0800 Subject: [PATCH 04/10] Lint --- src/services/storage/clients/contacts.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index 4b51035c..3a04ee4d 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -72,6 +72,8 @@ function canonicalizeValue(value: unknown): string { const keys = Object.keys(value).sort(); const pairs: string[] = []; for (const key of keys) { + // Assertion required: `value` is a non-null, non-array object (guarded above) but typed as `unknown` + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions pairs.push(JSON.stringify(key) + ':' + canonicalizeValue((value as { [k: string]: unknown })[key])); } From 10be1470966609b205822d409a6571b8c2d3f599 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Fri, 27 Feb 2026 11:34:38 -0800 Subject: [PATCH 05/10] Put before delete on update. --- src/services/storage/clients/contacts.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index 3a04ee4d..d7a9e160 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -153,15 +153,19 @@ export class StorageContactsClient implements ContactsClient { address: newAddress }; - if (newId !== id) { - await this.#session.delete(id); - } - await this.#session.put(newId, this.#serialize(updated), { mimeType: MIME_TYPE, tags: [updated.address.type] }); + if (newId !== id) { + try { + await this.#session.delete(id); + } catch { + // Put succeeded; old contact is now orphaned + } + } + return(updated); } From 2729ac4bd32d6ed6b512328793ffa0763fe18834 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Fri, 27 Feb 2026 11:46:20 -0800 Subject: [PATCH 06/10] Filter undefined values in `canonicalizeValue`. --- src/services/storage/clients/contacts.test.ts | 53 ++++++++++++------- src/services/storage/clients/contacts.ts | 36 ++++++------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts index 8f146d07..557496ff 100644 --- a/src/services/storage/clients/contacts.test.ts +++ b/src/services/storage/clients/contacts.test.ts @@ -283,27 +283,21 @@ describe('Contacts Client - List', function() { }); test('list filtered by type returns only matching contacts', function() { - return(withContacts(randomSeed(), async function({ contactsClient }) { - await contactsClient.create({ label: 'Wire Contact', address: wireAddress }); - await contactsClient.create({ label: 'Bitcoin Contact', address: bitcoinSendAddress }); - await contactsClient.create({ label: 'ACH Contact', address: achAddress }); - - const wireContacts = await contactsClient.list({ type: 'WIRE' }); - expect(wireContacts).toHaveLength(1); - for (const contact of wireContacts) { - expect(contact.address.type).toBe('WIRE'); - } + const fixtures: { type: ContactAddress['type']; address: ContactAddress }[] = [ + { type: 'WIRE', address: wireAddress }, + { type: 'ACH', address: achAddress }, + { type: 'BITCOIN_SEND', address: bitcoinSendAddress } + ]; - const achContacts = await contactsClient.list({ type: 'ACH' }); - expect(achContacts).toHaveLength(1); - for (const contact of achContacts) { - expect(contact.address.type).toBe('ACH'); + return(withContacts(randomSeed(), async function({ contactsClient }) { + for (const { type, address } of fixtures) { + await contactsClient.create({ label: `${type} Contact`, address }); } - const btcContacts = await contactsClient.list({ type: 'BITCOIN_SEND' }); - expect(btcContacts).toHaveLength(1); - for (const contact of btcContacts) { - expect(contact.address.type).toBe('BITCOIN_SEND'); + for (const { type } of fixtures) { + const filtered = await contactsClient.list({ type }); + expect(filtered).toHaveLength(1); + expect(filtered[0]?.address.type).toBe(type); } })); }); @@ -341,6 +335,29 @@ describe('Contacts Client - Edge Cases', function() { } })); }); + + test('deriveId is stable regardless of key order', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const reordered: ContactAddress = { + tokenAddress: '0x1a2b3c4d', + sendToAddress: '0x4d5e6f7a8b9c', + type: 'EVM_SEND', + location: 'chain:evm:1' + }; + expect(contactsClient.deriveId(evmSendAddress)).toBe(contactsClient.deriveId(reordered)); + })); + }); + + test('deriveId ignores undefined optional fields', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + // Simulates runtime scenario where an optional field is explicitly set to undefined + const withUndefined = { + ...wireAddress, + depositMessage: undefined + }; + expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(wireAddress)); // eslint-disable-line @typescript-eslint/consistent-type-assertions + })); + }); }); describe('Contacts Client - Factory Methods', function() { diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index d7a9e160..fa4db8de 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -61,27 +61,23 @@ export interface ContactsClient { const MIME_TYPE = 'application/json'; -function canonicalizeValue(value: unknown): string { - if (value === null || typeof value !== 'object') { - return(JSON.stringify(value)); - } - if (Array.isArray(value)) { - return('[' + value.map(canonicalizeValue).join(',') + ']'); - } - - const keys = Object.keys(value).sort(); - const pairs: string[] = []; - for (const key of keys) { - // Assertion required: `value` is a non-null, non-array object (guarded above) but typed as `unknown` - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - pairs.push(JSON.stringify(key) + ':' + canonicalizeValue((value as { [k: string]: unknown })[key])); - } - - return('{' + pairs.join(',') + '}'); -} - +/** + * Canonicalize a contact address for use as a storage path. + * + * @param address - The contact address to canonicalize. + * + * @returns The canonicalized string representation of the contact address. + */ function canonicalizeContactAddress(address: ContactAddress): string { - return(canonicalizeValue(address)); + return(JSON.stringify(address, function(_key: string, value: unknown): unknown { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const obj = value as { [k: string]: unknown }; + return(Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)))); + } + + return(value); + })); } /** From 3a501ee64e0410cfaa5ae0845432b6fcec9e2039 Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Fri, 27 Feb 2026 11:57:30 -0800 Subject: [PATCH 07/10] Use config for `getContactsClient`. --- src/services/storage/client.ts | 9 +++++---- src/services/storage/clients/contacts.test.ts | 6 +++--- src/services/storage/common.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/services/storage/client.ts b/src/services/storage/client.ts index 0ae769b6..21ca0bb9 100644 --- a/src/services/storage/client.ts +++ b/src/services/storage/client.ts @@ -6,6 +6,7 @@ import type { Signable } from '../../lib/utils/signing.js'; import type { Buffer } from '../../lib/utils/buffer.js'; import type { KeetaNetAccount, + ContactsClientConfig, StorageObjectMetadata, SearchCriteria, SearchPagination, @@ -1179,8 +1180,8 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { /** * Get a contacts client bound to the given account. */ - getContactsClient(account: KeetaNetAccount, basePath: string): StorageContactsClient { - const session = this.beginSession({ account, workingDirectory: basePath }); + getContactsClient(config: ContactsClientConfig): StorageContactsClient { + const session = this.beginSession({ account: config.account, workingDirectory: config.basePath }); return(new StorageContactsClient(session)); } } @@ -1254,14 +1255,14 @@ class KeetaStorageAnchorClient extends KeetaStorageAnchorBase { * Get a contacts client bound to the given account. * Resolves the first available provider and constructs a StorageContactsClient. */ - async getContactsClient(account: KeetaNetAccount, basePath: string): Promise { + async getContactsClient(config: ContactsClientConfig): Promise { const providers = await this.getProviders(); const provider = providers?.[0]; if (!provider) { return(null); } - return(provider.getContactsClient(account, basePath)); + return(provider.getContactsClient(config)); } /** @internal */ diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts index 557496ff..cdb07580 100644 --- a/src/services/storage/clients/contacts.test.ts +++ b/src/services/storage/clients/contacts.test.ts @@ -71,7 +71,7 @@ async function withContacts( } const pubkey = account.publicKeyString.get(); - const contactsClient = maybeProvider.getContactsClient(account, `/user/${pubkey}/contacts/`); + const contactsClient = maybeProvider.getContactsClient({ account, basePath: `/user/${pubkey}/contacts/` }); await testFunction({ contactsClient, account, storageClient }); } @@ -329,7 +329,7 @@ describe('Contacts Client - Edge Cases', function() { test('deriveId is deterministic across client instances', function() { return(withContacts(randomSeed(), async function({ contactsClient, storageClient, account }) { const pubkey = account.publicKeyString.get(); - const otherClient = (await storageClient.getProviderByID('test-provider'))!.getContactsClient(account, `/user/${pubkey}/contacts/`); // eslint-disable-line @typescript-eslint/no-non-null-assertion + const otherClient = (await storageClient.getProviderByID('test-provider'))!.getContactsClient({ account, basePath: `/user/${pubkey}/contacts/` }); // eslint-disable-line @typescript-eslint/no-non-null-assertion for (const { address } of sampleAddresses) { expect(contactsClient.deriveId(address)).toBe(otherClient.deriveId(address)); } @@ -364,7 +364,7 @@ describe('Contacts Client - Factory Methods', function() { test('getContactsClient via storage client resolves provider', function() { return(withContacts(randomSeed(), async function({ storageClient, account }) { const pubkey = account.publicKeyString.get(); - const contactsClient = await storageClient.getContactsClient(account, `/user/${pubkey}/contacts/`); + const contactsClient = await storageClient.getContactsClient({ account, basePath: `/user/${pubkey}/contacts/` }); expect(contactsClient).toBeInstanceOf(StorageContactsClient); })); }); diff --git a/src/services/storage/common.ts b/src/services/storage/common.ts index e7e3ae87..d34756c9 100644 --- a/src/services/storage/common.ts +++ b/src/services/storage/common.ts @@ -243,6 +243,21 @@ export type KeetaStorageAnchorPutRequest = { visibility?: StorageObjectVisibility; }; +/** + * Configuration for a contacts client. + * Contacts are stored in a subdirectory of the account's working directory. + */ +export type ContactsClientConfig = { + /** + * The account to use for the contacts client. + */ + account: KeetaNetAccount; + /** + * The base path for the contacts client. + */ + basePath: string; +}; + /** * Generic response type for storage operations. */ From 9610b35d8ff7b5627b3197a45bfdfa9952939a8f Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Sun, 1 Mar 2026 14:48:59 -0800 Subject: [PATCH 08/10] Update contact approach. --- src/services/storage/clients/contacts.test.ts | 175 ++++++++++-------- src/services/storage/clients/contacts.ts | 84 +++++++-- 2 files changed, 164 insertions(+), 95 deletions(-) diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts index cdb07580..2a06746b 100644 --- a/src/services/storage/clients/contacts.test.ts +++ b/src/services/storage/clients/contacts.test.ts @@ -79,23 +79,18 @@ async function withContacts( // #region Test Fixtures -const keetaSendAddress: ContactAddress = { - type: 'KEETA_SEND', - location: 'chain:keeta:1', - sendToAddress: 'keeta1a2b3c', - tokenAddress: '0x1a2b3c4d' +const keetaAddress: ContactAddress = { + recipient: 'keeta1a2b3c', + location: 'chain:keeta:1' }; -const evmSendAddress: ContactAddress = { - type: 'EVM_SEND', - location: 'chain:evm:1', - sendToAddress: '0x4d5e6f7a8b9c', - tokenAddress: '0x1a2b3c4d' +const evmAddress: ContactAddress = { + recipient: '0x4d5e6f7a8b9c', + location: 'chain:evm:1' }; const wireAddress: ContactAddress = { - type: 'WIRE', - account: { + recipient: { type: 'bank-account', accountType: 'us', accountNumber: '123456789', @@ -105,35 +100,23 @@ const wireAddress: ContactAddress = { } }; -const bitcoinSendAddress: ContactAddress = { - type: 'BITCOIN_SEND', - location: 'chain:bitcoin:f9beb4d9', - sendToAddress: 'bc1q0a1b2c3d4e5f' +const bitcoinAddress: ContactAddress = { + recipient: 'bc1q0a1b2c3d4e5f', + location: 'chain:bitcoin:f9beb4d9' }; -const solanaSendAddress: ContactAddress = { - type: 'SOLANA_SEND', - location: 'chain:solana:1', - sendToAddress: '9a8b7c6d5e4f' +const solanaAddress: ContactAddress = { + recipient: '9a8b7c6d5e4f', + location: 'chain:solana:1' }; -const tronSendAddress: ContactAddress = { - type: 'TRON_SEND', - location: 'chain:tron:mainnet', - sendToAddress: 'T1a2b3c4d5e6' -}; - -const evmCallAddress: ContactAddress = { - type: 'EVM_CALL', - location: 'chain:evm:1', - contractAddress: '0x9c0d1e2f', - contractMethodName: 'deposit(uint256)', - contractMethodArgs: ['1000'] +const tronAddress: ContactAddress = { + recipient: 'T1a2b3c4d5e6', + location: 'chain:tron:mainnet' }; const achAddress: ContactAddress = { - type: 'ACH', - account: { + recipient: { type: 'bank-account', accountType: 'us', accountNumber: '987654321', @@ -143,9 +126,8 @@ const achAddress: ContactAddress = { } }; -const sepaPushAddress: ContactAddress = { - type: 'SEPA_PUSH', - account: { +const sepaAddress: ContactAddress = { + recipient: { type: 'bank-account', accountType: 'iban-swift', iban: 'DE89370400440532013000', @@ -154,16 +136,22 @@ const sepaPushAddress: ContactAddress = { } }; -const sampleAddresses: { type: ContactAddress['type']; address: ContactAddress }[] = [ - { type: 'KEETA_SEND', address: keetaSendAddress }, - { type: 'EVM_SEND', address: evmSendAddress }, - { type: 'EVM_CALL', address: evmCallAddress }, - { type: 'WIRE', address: wireAddress }, - { type: 'ACH', address: achAddress }, - { type: 'SEPA_PUSH', address: sepaPushAddress }, - { type: 'BITCOIN_SEND', address: bitcoinSendAddress }, - { type: 'SOLANA_SEND', address: solanaSendAddress }, - { type: 'TRON_SEND', address: tronSendAddress } +const persistentAddress: ContactAddress = { + recipient: { type: 'persistent-address', persistentAddressId: 'pa-123' }, + location: 'chain:evm:1', + asset: 'evm:0x1a2b3c4d' +}; + +const sampleAddresses: { name: string; address: ContactAddress }[] = [ + { name: 'keeta crypto', address: keetaAddress }, + { name: 'evm crypto', address: evmAddress }, + { name: 'wire bank', address: wireAddress }, + { name: 'bitcoin crypto', address: bitcoinAddress }, + { name: 'solana crypto', address: solanaAddress }, + { name: 'tron crypto', address: tronAddress }, + { name: 'ach bank', address: achAddress }, + { name: 'sepa bank', address: sepaAddress }, + { name: 'persistent address', address: persistentAddress } ]; const updateCases: { @@ -175,23 +163,23 @@ const updateCases: { }[] = [ { name: 'label only preserves address and id', - initial: { label: 'Original', address: keetaSendAddress }, + initial: { label: 'Original', address: keetaAddress }, update: { label: 'Renamed' }, - expected: { label: 'Renamed', address: keetaSendAddress }, + expected: { label: 'Renamed', address: keetaAddress }, changesId: false }, { name: 'address only preserves label and changes id', - initial: { label: 'Keep This', address: keetaSendAddress }, - update: { address: bitcoinSendAddress }, - expected: { label: 'Keep This', address: bitcoinSendAddress }, + initial: { label: 'Keep This', address: keetaAddress }, + update: { address: bitcoinAddress }, + expected: { label: 'Keep This', address: bitcoinAddress }, changesId: true }, { name: 'both label and address changes id', - initial: { label: 'Old', address: keetaSendAddress }, - update: { label: 'New', address: evmSendAddress }, - expected: { label: 'New', address: evmSendAddress }, + initial: { label: 'Old', address: keetaAddress }, + update: { label: 'New', address: evmAddress }, + expected: { label: 'New', address: evmAddress }, changesId: true } ]; @@ -201,7 +189,7 @@ const updateCases: { // #region Tests describe('Contacts Client - CRUD per address type', function() { - test.each(sampleAddresses)('create and get contact with $type address', function({ address }) { + test.each(sampleAddresses)('create and get contact: $name', function({ address }) { return(withContacts(randomSeed(), async function({ contactsClient }) { const created = await contactsClient.create({ label: 'Test Contact', address }); expect(created.id).toBe(contactsClient.deriveId(address)); @@ -247,7 +235,7 @@ describe('Contacts Client - Delete', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { const created = await contactsClient.create({ label: 'To Delete', - address: keetaSendAddress + address: keetaAddress }); const deleted = await contactsClient.delete(created.id); @@ -272,8 +260,8 @@ describe('Contacts Client - List', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { const created: Contact[] = []; - for (const { address } of sampleAddresses) { - created.push(await contactsClient.create({ label: `Contact ${address.type}`, address })); + for (const { name, address } of sampleAddresses) { + created.push(await contactsClient.create({ label: `Contact ${name}`, address })); } const listed = await contactsClient.list(); @@ -282,22 +270,27 @@ describe('Contacts Client - List', function() { })); }); - test('list filtered by type returns only matching contacts', function() { - const fixtures: { type: ContactAddress['type']; address: ContactAddress }[] = [ - { type: 'WIRE', address: wireAddress }, - { type: 'ACH', address: achAddress }, - { type: 'BITCOIN_SEND', address: bitcoinSendAddress } + test('list filtered by location returns only matching contacts', function() { + const fixtures: { address: ContactAddress }[] = [ + { address: evmAddress }, + { address: bitcoinAddress }, + { address: solanaAddress } ]; return(withContacts(randomSeed(), async function({ contactsClient }) { - for (const { type, address } of fixtures) { - await contactsClient.create({ label: `${type} Contact`, address }); + for (const { address } of fixtures) { + await contactsClient.create({ label: 'Location Test', address }); } - for (const { type } of fixtures) { - const filtered = await contactsClient.list({ type }); + for (const fixture of fixtures) { + const location = fixture.address.location; + expect(location).toBeDefined(); + if (!location) { + continue; + } + const filtered = await contactsClient.list({ location }); expect(filtered).toHaveLength(1); - expect(filtered[0]?.address.type).toBe(type); + expect(filtered[0]?.address).toEqual(fixture.address); } })); }); @@ -313,8 +306,8 @@ describe('Contacts Client - Edge Cases', function() { test('creating the same address twice is idempotent and updates the label', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { - const first = await contactsClient.create({ label: 'First', address: keetaSendAddress }); - const second = await contactsClient.create({ label: 'Second', address: keetaSendAddress }); + const first = await contactsClient.create({ label: 'First', address: keetaAddress }); + const second = await contactsClient.create({ label: 'Second', address: keetaAddress }); expect(second.id).toBe(first.id); expect(second.label).toBe('Second'); @@ -339,23 +332,45 @@ describe('Contacts Client - Edge Cases', function() { test('deriveId is stable regardless of key order', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { const reordered: ContactAddress = { - tokenAddress: '0x1a2b3c4d', - sendToAddress: '0x4d5e6f7a8b9c', - type: 'EVM_SEND', - location: 'chain:evm:1' + location: 'chain:evm:1', + recipient: '0x4d5e6f7a8b9c' }; - expect(contactsClient.deriveId(evmSendAddress)).toBe(contactsClient.deriveId(reordered)); + expect(contactsClient.deriveId(evmAddress)).toBe(contactsClient.deriveId(reordered)); })); }); test('deriveId ignores undefined optional fields', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { - // Simulates runtime scenario where an optional field is explicitly set to undefined const withUndefined = { - ...wireAddress, - depositMessage: undefined + ...keetaAddress, + asset: undefined + }; + expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(keetaAddress)); // eslint-disable-line @typescript-eslint/consistent-type-assertions + })); + }); + + test('deriveId excludes pastInstructions from identity', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const withInstructions: ContactAddress = { + ...evmAddress, + pastInstructions: [{ + type: 'EVM_SEND', + location: 'chain:evm:1', + sendToAddress: '0x4d5e6f7a8b9c', + tokenAddress: '0x1a2b3c4d' + }] + }; + expect(contactsClient.deriveId(withInstructions)).toBe(contactsClient.deriveId(evmAddress)); + })); + }); + + test('deriveId excludes providerInformation from identity', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const withProvider: ContactAddress = { + ...evmAddress, + providerInformation: { 'provider-abc': { usingProvider: true }} }; - expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(wireAddress)); // eslint-disable-line @typescript-eslint/consistent-type-assertions + expect(contactsClient.deriveId(withProvider)).toBe(contactsClient.deriveId(evmAddress)); })); }); }); diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index fa4db8de..c33aa9bd 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -1,5 +1,7 @@ -import type { AssetTransferInstructions } from '../../asset-movement/common.js'; +import type { AssetTransferInstructions, RecipientResolved, MovableAsset } from '../../asset-movement/common.js'; +import type { AssetLocationLike } from '../../asset-movement/lib/location.js'; import type { KeetaStorageAnchorSession } from '../client.js'; +import { convertAssetLocationToString } from '../../asset-movement/lib/location.js'; import { hash } from '../../../lib/utils/tests/hash.js'; import { Errors } from '../common.js'; import { Buffer } from '../../../lib/utils/buffer.js'; @@ -10,13 +12,24 @@ import { assertContact } from './contacts.generated.js'; type DistributiveOmit = T extends unknown ? Omit : never; /** - * A contact address derived from `AssetTransferInstructions` + * The structural shape of a transfer instruction, excluding transaction-specific fields. */ -export type ContactAddress = DistributiveOmit< +export type TransferInstructionShape = DistributiveOmit< AssetTransferInstructions, 'value' | 'assetFee' | 'totalReceiveAmount' | 'persistentAddressId' >; +/** + * A contact address with decomposed recipient, location, asset, and metadata. + */ +export type ContactAddress = { + recipient: RecipientResolved; + location?: AssetLocationLike; + asset?: MovableAsset; + providerInformation?: { [providerId: string]: true | { usingProvider: true; usingForUsername?: boolean }}; + pastInstructions?: TransferInstructionShape[]; +}; + /** * A stored contact with metadata and an address. */ @@ -51,7 +64,7 @@ export interface ContactsClient { delete(id: string): Promise; list(options?: { - type?: ContactAddress['type']; + location?: AssetLocationLike; }): Promise; } @@ -59,27 +72,63 @@ export interface ContactsClient { // #region Storage Implementation +/** + * MIME type for contact data. + */ const MIME_TYPE = 'application/json'; /** - * Canonicalize a contact address for use as a storage path. + * Canonicalize a contact address for use in ID derivation. + * Excludes metadata fields (`providerInformation`, `pastInstructions`) that are not part of contact identity. * * @param address - The contact address to canonicalize. * - * @returns The canonicalized string representation of the contact address. + * @returns The canonicalized string representation of the contact address identity fields. */ function canonicalizeContactAddress(address: ContactAddress): string { - return(JSON.stringify(address, function(_key: string, value: unknown): unknown { + const { providerInformation: _, pastInstructions: __, ...identity } = address; // eslint-disable-line @typescript-eslint/no-unused-vars + return(JSON.stringify(identity, function(_key: string, value: unknown): unknown { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const obj = value as { [k: string]: unknown }; - return(Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)))); + return(Object.fromEntries( + Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)) + )); } return(value); })); } +/** + * Convert an asset location to a tag for use in storage. + * + * @param location - The asset location to convert to a tag. + * + * @returns The tag string. + */ +function locationToTag(location: AssetLocationLike): string { + const str = convertAssetLocationToString(location); + + const parts = str.split(':'); + return(parts.join('-')); +} + +/** + * Convert a contact address to a list of tags for use in storage. + * + * @param address - The contact address to convert to tags. + * + * @returns The list of tags. + */ +function contactTags(address: ContactAddress): string[] { + if (address.location) { + return([locationToTag(address.location)]); + } + + return([]); +} + /** * Storage Anchor-backed implementation of `ContactsClient`. * Stores contacts as encrypted JSON objects via a `KeetaStorageAnchorSession`. @@ -116,7 +165,7 @@ export class StorageContactsClient implements ContactsClient { await this.#session.put(id, this.#serialize(contact), { mimeType: MIME_TYPE, - tags: [options.address.type] + tags: contactTags(options.address) }); return(contact); @@ -151,7 +200,7 @@ export class StorageContactsClient implements ContactsClient { await this.#session.put(newId, this.#serialize(updated), { mimeType: MIME_TYPE, - tags: [updated.address.type] + tags: contactTags(updated.address) }); if (newId !== id) { @@ -170,12 +219,17 @@ export class StorageContactsClient implements ContactsClient { } async list(options?: { - type?: ContactAddress['type']; + location?: AssetLocationLike; }): Promise { - const searchResult = await this.#session.search({ - pathPrefix: this.#session.workingDirectory, - ...(options?.type ? { tags: [options.type] } : {}) - }); + const criteria: { pathPrefix: string; tags?: string[] } = { + pathPrefix: this.#session.workingDirectory + }; + + if (options?.location) { + criteria.tags = [locationToTag(options.location)]; + } + + const searchResult = await this.#session.search(criteria); const contacts: Contact[] = []; for (const metadata of searchResult.results) { From 68ce2514f83e18919a9564947ec9d5d18daeb74a Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 4 Mar 2026 11:55:07 -0800 Subject: [PATCH 09/10] Narrow types. --- src/services/storage/clients/contacts.test.ts | 11 ++-- src/services/storage/clients/contacts.ts | 51 ++++++++++++++++--- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/services/storage/clients/contacts.test.ts b/src/services/storage/clients/contacts.test.ts index 2a06746b..a0fec46f 100644 --- a/src/services/storage/clients/contacts.test.ts +++ b/src/services/storage/clients/contacts.test.ts @@ -138,8 +138,7 @@ const sepaAddress: ContactAddress = { const persistentAddress: ContactAddress = { recipient: { type: 'persistent-address', persistentAddressId: 'pa-123' }, - location: 'chain:evm:1', - asset: 'evm:0x1a2b3c4d' + location: 'chain:evm:1' }; const sampleAddresses: { name: string; address: ContactAddress }[] = [ @@ -342,10 +341,10 @@ describe('Contacts Client - Edge Cases', function() { test('deriveId ignores undefined optional fields', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { const withUndefined = { - ...keetaAddress, - asset: undefined + ...wireAddress, + location: undefined }; - expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(keetaAddress)); // eslint-disable-line @typescript-eslint/consistent-type-assertions + expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(wireAddress)); // eslint-disable-line @typescript-eslint/consistent-type-assertions })); }); @@ -368,7 +367,7 @@ describe('Contacts Client - Edge Cases', function() { return(withContacts(randomSeed(), async function({ contactsClient }) { const withProvider: ContactAddress = { ...evmAddress, - providerInformation: { 'provider-abc': { usingProvider: true }} + providerInformation: { 'provider-abc': ['template'] } }; expect(contactsClient.deriveId(withProvider)).toBe(contactsClient.deriveId(evmAddress)); })); diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index c33aa9bd..f48e4c4b 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -1,4 +1,4 @@ -import type { AssetTransferInstructions, RecipientResolved, MovableAsset } from '../../asset-movement/common.js'; +import type { AssetTransferInstructions, RecipientResolved, KeetaNetAccount } from '../../asset-movement/common.js'; import type { AssetLocationLike } from '../../asset-movement/lib/location.js'; import type { KeetaStorageAnchorSession } from '../client.js'; import { convertAssetLocationToString } from '../../asset-movement/lib/location.js'; @@ -20,15 +20,50 @@ export type TransferInstructionShape = DistributiveOmit< >; /** - * A contact address with decomposed recipient, location, asset, and metadata. + * The type of provider information allowed for a contact address. */ -export type ContactAddress = { - recipient: RecipientResolved; - location?: AssetLocationLike; - asset?: MovableAsset; - providerInformation?: { [providerId: string]: true | { usingProvider: true; usingForUsername?: boolean }}; +export type ProviderInformationType = 'username' | 'template'; + +/** + * The type of recipient for a persistent address template. + */ +export type PersistentAddressTemplateRecipient = Extract; + +/** + * Base interface for contact addresses with narrowed generic parameters. + */ +export interface ContactAddressBase< + RecipientType extends RecipientResolved, + Location extends AssetLocationLike, + ProviderInformationAllowedTypes extends ProviderInformationType +> { + recipient: RecipientType; + location?: Location; + providerInformation?: { [providerId: string]: ProviderInformationAllowedTypes[] }; pastInstructions?: TransferInstructionShape[]; -}; +} + +/** + * A contact address for a Keeta account. + * The recipient is a Keeta account public key string. It is a string in `RecipientResolved`. + */ +export type KeetaContactAddress = ContactAddressBase; + +/** + * A contact address for a persistent address template. + */ +export type TemplateContactAddress = ContactAddressBase; + +/** + * A contact address for a non-Keeta account or non-persistent address template. + */ +export type OtherContactAddress = ContactAddressBase< + Exclude, + Exclude, + 'template' +>; + +export type ContactAddress = KeetaContactAddress | TemplateContactAddress | OtherContactAddress; /** * A stored contact with metadata and an address. From 8085ac27538f0702d083a8fdf494fc320c852c6c Mon Sep 17 00:00:00 2001 From: Tanveer Wahid Date: Wed, 4 Mar 2026 12:40:08 -0800 Subject: [PATCH 10/10] Improve typing. --- src/services/storage/clients/contacts.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/services/storage/clients/contacts.ts b/src/services/storage/clients/contacts.ts index f48e4c4b..6c1b5fef 100644 --- a/src/services/storage/clients/contacts.ts +++ b/src/services/storage/clients/contacts.ts @@ -1,5 +1,5 @@ import type { AssetTransferInstructions, RecipientResolved, KeetaNetAccount } from '../../asset-movement/common.js'; -import type { AssetLocationLike } from '../../asset-movement/lib/location.js'; +import type { AssetLocationLike, PickChainLocation } from '../../asset-movement/lib/location.js'; import type { KeetaStorageAnchorSession } from '../client.js'; import { convertAssetLocationToString } from '../../asset-movement/lib/location.js'; import { hash } from '../../../lib/utils/tests/hash.js'; @@ -43,11 +43,12 @@ export interface ContactAddressBase< pastInstructions?: TransferInstructionShape[]; } +export type KeetaAssetLocation = PickChainLocation<'keeta'> | `chain:keeta:${bigint}`; + /** * A contact address for a Keeta account. - * The recipient is a Keeta account public key string. It is a string in `RecipientResolved`. */ -export type KeetaContactAddress = ContactAddressBase; +export type KeetaContactAddress = ContactAddressBase; /** * A contact address for a persistent address template. @@ -55,11 +56,11 @@ export type KeetaContactAddress = ContactAddressBase; /** - * A contact address for a non-Keeta account or non-persistent address template. + * A contact address for a non-Keeta, non-persistent-address recipient. */ export type OtherContactAddress = ContactAddressBase< Exclude, - Exclude, + Exclude, 'template' >;