diff --git a/src/services/storage/client.ts b/src/services/storage/client.ts index 896951b5..21ca0bb9 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'; @@ -7,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, @@ -34,15 +34,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. @@ -443,7 +445,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)); } @@ -1175,6 +1176,14 @@ export class KeetaStorageAnchorProvider extends KeetaStorageAnchorBase { const session = this.beginSession(config); return(await fn(session)); } + + /** + * Get a contacts client bound to the given account. + */ + getContactsClient(config: ContactsClientConfig): StorageContactsClient { + const session = this.beginSession({ account: config.account, workingDirectory: config.basePath }); + return(new StorageContactsClient(session)); + } } class KeetaStorageAnchorClient extends KeetaStorageAnchorBase { @@ -1242,6 +1251,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(config: ContactsClientConfig): Promise { + const providers = await this.getProviders(); + const provider = providers?.[0]; + if (!provider) { + return(null); + } + + return(provider.getContactsClient(config)); + } + /** @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..a0fec46f --- /dev/null +++ b/src/services/storage/clients/contacts.test.ts @@ -0,0 +1,387 @@ +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, basePath: `/user/${pubkey}/contacts/` }); + await testFunction({ contactsClient, account, storageClient }); +} + +// #endregion + +// #region Test Fixtures + +const keetaAddress: ContactAddress = { + recipient: 'keeta1a2b3c', + location: 'chain:keeta:1' +}; + +const evmAddress: ContactAddress = { + recipient: '0x4d5e6f7a8b9c', + location: 'chain:evm:1' +}; + +const wireAddress: ContactAddress = { + recipient: { + type: 'bank-account', + accountType: 'us', + accountNumber: '123456789', + routingNumber: '021000021', + accountTypeDetail: 'checking', + accountOwner: { type: 'individual', firstName: 'Alice', lastName: 'Smith' } + } +}; + +const bitcoinAddress: ContactAddress = { + recipient: 'bc1q0a1b2c3d4e5f', + location: 'chain:bitcoin:f9beb4d9' +}; + +const solanaAddress: ContactAddress = { + recipient: '9a8b7c6d5e4f', + location: 'chain:solana:1' +}; + +const tronAddress: ContactAddress = { + recipient: 'T1a2b3c4d5e6', + location: 'chain:tron:mainnet' +}; + +const achAddress: ContactAddress = { + recipient: { + type: 'bank-account', + accountType: 'us', + accountNumber: '987654321', + routingNumber: '021000021', + accountTypeDetail: 'checking', + accountOwner: { type: 'individual', firstName: 'Bob', lastName: 'Jones' } + } +}; + +const sepaAddress: ContactAddress = { + recipient: { + type: 'bank-account', + accountType: 'iban-swift', + iban: 'DE89370400440532013000', + bic: 'COBADEFFXXX', // cspell:disable-line + accountOwner: { type: 'individual', firstName: 'Hans', lastName: 'Mueller' } + } +}; + +const persistentAddress: ContactAddress = { + recipient: { type: 'persistent-address', persistentAddressId: 'pa-123' }, + location: 'chain:evm:1' +}; + +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: { + name: string; + initial: { label: string; address: ContactAddress }; + update: { label?: string; address?: ContactAddress }; + expected: { label: string; address: ContactAddress }; + changesId: boolean; +}[] = [ + { + name: 'label only preserves address and id', + initial: { label: 'Original', address: keetaAddress }, + update: { label: 'Renamed' }, + expected: { label: 'Renamed', address: keetaAddress }, + changesId: false + }, + { + name: 'address only preserves label and changes id', + 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: keetaAddress }, + update: { label: 'New', address: evmAddress }, + expected: { label: 'New', address: evmAddress }, + changesId: true + } +]; + +// #endregion + +// #region Tests + +describe('Contacts Client - CRUD per address type', function() { + 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)); + 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, 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(contactsClient.deriveId(expected.address)); + expect(updated.label).toBe(expected.label); + expect(updated.address).toEqual(expected.address); + + const retrieved = await contactsClient.get(updated.id); + expect(retrieved).toEqual(updated); + + if (changesId) { + const oldRetrieved = await contactsClient.get(created.id); + expect(oldRetrieved).toBeNull(); + } + })); + }); + + 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: keetaAddress + }); + + 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 { name, address } of sampleAddresses) { + created.push(await contactsClient.create({ label: `Contact ${name}`, address })); + } + + const listed = await contactsClient.list(); + expect(listed).toHaveLength(sampleAddresses.length); + expect(listed.sort(sortById)).toEqual(created.sort(sortById)); + })); + }); + + 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 { address } of fixtures) { + await contactsClient.create({ label: 'Location Test', address }); + } + + 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).toEqual(fixture.address); + } + })); + }); +}); + +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(); + })); + }); + + 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: keetaAddress }); + const second = await contactsClient.create({ label: 'Second', address: keetaAddress }); + 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, 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)); + } + })); + }); + + test('deriveId is stable regardless of key order', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const reordered: ContactAddress = { + location: 'chain:evm:1', + recipient: '0x4d5e6f7a8b9c' + }; + expect(contactsClient.deriveId(evmAddress)).toBe(contactsClient.deriveId(reordered)); + })); + }); + + test('deriveId ignores undefined optional fields', function() { + return(withContacts(randomSeed(), async function({ contactsClient }) { + const withUndefined = { + ...wireAddress, + location: undefined + }; + expect(contactsClient.deriveId(withUndefined as unknown as ContactAddress)).toBe(contactsClient.deriveId(wireAddress)); // 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': ['template'] } + }; + expect(contactsClient.deriveId(withProvider)).toBe(contactsClient.deriveId(evmAddress)); + })); + }); +}); + +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, basePath: `/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..6c1b5fef --- /dev/null +++ b/src/services/storage/clients/contacts.ts @@ -0,0 +1,282 @@ +import type { AssetTransferInstructions, RecipientResolved, KeetaNetAccount } from '../../asset-movement/common.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'; +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; + +/** + * The structural shape of a transfer instruction, excluding transaction-specific fields. + */ +export type TransferInstructionShape = DistributiveOmit< + AssetTransferInstructions, + 'value' | 'assetFee' | 'totalReceiveAmount' | 'persistentAddressId' +>; + +/** + * The type of provider information allowed for a contact address. + */ +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[]; +} + +export type KeetaAssetLocation = PickChainLocation<'keeta'> | `chain:keeta:${bigint}`; + +/** + * A contact address for a Keeta account. + */ +export type KeetaContactAddress = ContactAddressBase; + +/** + * A contact address for a persistent address template. + */ +export type TemplateContactAddress = ContactAddressBase; + +/** + * A contact address for a non-Keeta, non-persistent-address recipient. + */ +export type OtherContactAddress = ContactAddressBase< + Exclude, + Exclude, + 'template' +>; + +export type ContactAddress = KeetaContactAddress | TemplateContactAddress | OtherContactAddress; + +/** + * 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 { + deriveId(address: ContactAddress): string; + + 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?: { + location?: AssetLocationLike; + }): Promise; +} + +// #endregion + +// #region Storage Implementation + +/** + * MIME type for contact data. + */ +const MIME_TYPE = 'application/json'; + +/** + * 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 identity fields. + */ +function canonicalizeContactAddress(address: ContactAddress): string { + 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(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`. + */ +export class StorageContactsClient implements ContactsClient { + readonly #session: KeetaStorageAnchorSession; + + constructor(session: KeetaStorageAnchorSession) { + this.#session = session; + } + + deriveId(address: ContactAddress): string { + return(hash(canonicalizeContactAddress(address))); + } + + #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 = this.deriveId(options.address); + const contact: Contact = { + id, + label: options.label, + address: options.address + }; + + await this.#session.put(id, this.#serialize(contact), { + mimeType: MIME_TYPE, + tags: contactTags(options.address) + }); + + return(contact); + } + + async get(id: string): Promise { + const result = await this.#session.get(id); + 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 newAddress = options.address ?? existing.address; + const newId = this.deriveId(newAddress); + + const updated: Contact = { + id: newId, + label: options.label ?? existing.label, + address: newAddress + }; + + await this.#session.put(newId, this.#serialize(updated), { + mimeType: MIME_TYPE, + tags: contactTags(updated.address) + }); + + if (newId !== id) { + try { + await this.#session.delete(id); + } catch { + // Put succeeded; old contact is now orphaned + } + } + + return(updated); + } + + async delete(id: string): Promise { + return(await this.#session.delete(id)); + } + + async list(options?: { + location?: AssetLocationLike; + }): Promise { + 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) { + const result = await this.#session.get(metadata.path); + if (result) { + contacts.push(this.#deserialize(result.data)); + } + } + + return(contacts); + } +} + +// #endregion 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. */