import type { XOInvitation, XOTemplate } from '@xo-cash/types'; import { DatabaseClosedError, DatabaseNotInitializedError, QueryFieldsLimitExceededError, QueryInvalidError, UnspentOutputNotFoundInStoreError, UnspentOutputReservationConflictError, DatabaseState, EventData, EventDataQuery, ScriptHashData, ScriptHashDataQuery, UnspentOutputData, UnspentOutputDataFilter, UnspentOutputDataQuery, UnspentOutputStatus, XOStorage, } from '@xo-cash/state'; import { generateTemplateIdentifier } from '@xo-cash/engine'; /** * Returns the names of all fields in a query object that have a defined value. * * @param query - The query object to inspect * @returns Array of field names whose values are not undefined */ export const getDefinedQueryFields = (query: Record): string[] => { const definedFields: string[] = []; for (const [ field, value ] of Object.entries(query)) { if (value !== undefined) { definedFields.push(field); } } return definedFields; }; /** * In-memory storage adapter implementation. * * This adapter mirrors the behavior of the IndexedDB implementation while keeping * all data in process memory. It is useful for tests and ephemeral sessions where * persistence across process restarts is not required. */ export class InMemoryStorage implements XOStorage { /** * Lifecycle state for this in-memory adapter instance. */ private databaseState: DatabaseState = DatabaseState.Uninitialized; /** * Pending initialization promise used to deduplicate concurrent initialize calls. */ private databaseInitializationPromise: Promise | null = null; /** * Template records keyed by template identifier. */ private templates = new Map(); /** * Invitation records keyed by invitation identifier. */ private invitations = new Map(); /** * Script hash records keyed by script hash. */ private scriptHashes = new Map(); /** * Unspent output records keyed by outpoint hash:index. */ private unspentOutputs = new Map(); /** * Event records keyed by event identifier. */ private events = new Map(); /** * Initializes the storage adapter. * * @returns Promise that resolves when initialization is complete * @throws {DatabaseClosedError} if the adapter has already been closed */ async initialize(): Promise { if (this.databaseState === DatabaseState.Ready) { return; } if (this.databaseState === DatabaseState.Closed) { throw new DatabaseClosedError(); } if (this.databaseInitializationPromise !== null) { return this.databaseInitializationPromise; } this.databaseInitializationPromise = Promise.resolve() .then(() => { this.databaseState = DatabaseState.Ready; }) .finally(() => { this.databaseInitializationPromise = null; }); return this.databaseInitializationPromise; } /** * Retrieves a template from storage by its identifier. * * @param templateIdentifier - Identifier for the template * @returns Promise resolving to the template data, or undefined if not found */ async getTemplate(templateIdentifier: string): Promise { this.ensureReady(); const template = this.templates.get(templateIdentifier); return template === undefined ? undefined : this.clone(template); } /** * Stores a template in storage. * * @param template - The template data to store * @returns Promise that resolves when the template is stored */ async storeTemplate(template: XOTemplate): Promise { this.ensureReady(); const templateIdentifier = generateTemplateIdentifier(template); this.templates.set(templateIdentifier, this.clone(template)); } /** * Lists all templates stored in storage. * * @returns Promise resolving to all stored templates */ async listTemplates(): Promise { this.ensureReady(); return Array.from(this.templates.values(), (template) => this.clone(template)); } /** * Retrieves an invitation from storage by its identifier. * * @param invitationIdentifier - Identifier for the invitation * @returns Promise resolving to the invitation data, or undefined if not found */ async getInvitation(invitationIdentifier: string): Promise { this.ensureReady(); const invitation = this.invitations.get(invitationIdentifier); return invitation === undefined ? undefined : this.clone(invitation); } /** * Stores an invitation in storage. * * @param invitationIdentifier - Identifier for the invitation * @param invitation - The invitation data to store * @returns Promise that resolves when the invitation is stored */ async storeInvitation(invitationIdentifier: string, invitation: XOInvitation): Promise { this.ensureReady(); this.invitations.set(invitationIdentifier, this.clone(invitation)); } /** * Retrieves script hash data from storage by script hash. * * @param scriptHash - Script hash to retrieve * @returns Promise resolving to script hash data, or undefined if not found */ async getScriptHashData(scriptHash: string): Promise { this.ensureReady(); const scriptHashData = this.scriptHashes.get(scriptHash); return scriptHashData === undefined ? undefined : this.clone(scriptHashData); } /** * Stores script hash data in storage. * * @param data - Script hash data to store * @returns Promise that resolves when data is stored */ async storeScriptHashData(data: ScriptHashData): Promise { this.ensureReady(); this.scriptHashes.set(data.scriptHash, this.clone(data)); } /** * Gets the last derivative index used for standard locking bytecodes. * Filters by templateIdentifier and outputIdentifier, and optionally by invitationIdentifier. * * @param templateIdentifier - The template identifier to filter by * @param outputIdentifier - The output identifier to filter by * @param invitationIdentifier - Optional invitation identifier to filter by * @returns Promise resolving to the last derivative index as a string, or null if none found */ async getLastDerivativeIndexForP2PKH( templateIdentifier: string, outputIdentifier: string, invitationIdentifier?: string, ): Promise { this.ensureReady(); const allLockingBytecodes = await this.listScriptHashData({ templateIdentifier }); const filtered = allLockingBytecodes.filter((lockingBytecodeData) => lockingBytecodeData.derivationSource?.lockingType === 'standard' && lockingBytecodeData.outputIdentifier === outputIdentifier && lockingBytecodeData.derivationSource?.derivativeIndex !== undefined && (invitationIdentifier === undefined || lockingBytecodeData.invitationIdentifier === invitationIdentifier)); if (filtered.length === 0) { return null; } return filtered.reduce((maximumDerivativeIndex, lockingBytecodeData) => { const currentDerivativeIndex = lockingBytecodeData.derivationSource?.derivativeIndex ?? '0'; const currentNum = parseInt(currentDerivativeIndex, 10); const maxNum = parseInt(maximumDerivativeIndex, 10); return currentNum > maxNum ? currentDerivativeIndex : maximumDerivativeIndex; }, '0'); } /** * Retrieves unspent output data from storage. * * @param outpointTransactionHash - Outpoint transaction hash * @param outpointIndex - Outpoint index * @returns Promise resolving to unspent output data, or undefined if not found */ async getUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise { this.ensureReady(); const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex); const output = this.unspentOutputs.get(key); return output === undefined ? undefined : this.clone(output); } /** * Stores unspent output data in storage. * * @param data - Unspent output data to store * @returns Promise that resolves when data is stored */ async storeUnspentOutputData(data: UnspentOutputData): Promise { this.ensureReady(); const key = this.getUnspentOutputKey(data.outpointTransactionHash, data.outpointIndex); this.unspentOutputs.set(key, this.clone(data)); } /** * Updates the status of an existing unspent output. * * @param outpointTransactionHash - Outpoint transaction hash * @param outpointIndex - Outpoint index * @param status - New status to set * @returns Promise that resolves when the update is complete * @throws {UnspentOutputNotFoundInStoreError} if the output doesn't exist */ async updateUnspentOutputDataStatus(outpointTransactionHash: string, outpointIndex: number, status: UnspentOutputStatus): Promise { this.ensureReady(); const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex); const existing = this.unspentOutputs.get(key); if (existing === undefined) { throw new UnspentOutputNotFoundInStoreError(key); } this.unspentOutputs.set(key, { ...existing, status, }); } /** * Sets reservation status of unspent outputs in bulk. * * @param outpoints - The outpoints to reserve or unreserve * @param shouldBeReserved - Whether outputs should be reserved * @param invitationIdentifier - Invitation identifier owning the reservation * @returns Promise that resolves when reservation updates complete * @throws {UnspentOutputNotFoundInStoreError} if any output doesn't exist * @throws {UnspentOutputReservationConflictError} if any output is held by another invitation */ async executeBulkUnspentOutputReservation( outpoints: Array<{ outpointTransactionHash: string; outpointIndex: number }>, shouldBeReserved: boolean, invitationIdentifier: string, ): Promise { this.ensureReady(); const outputsToUpdate: Array<{ storageKey: string; updatedOutput: UnspentOutputData }> = []; for (const { outpointTransactionHash, outpointIndex } of outpoints) { const storageKey = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex); const existingOutput = this.unspentOutputs.get(storageKey); if (existingOutput === undefined) { throw new UnspentOutputNotFoundInStoreError(storageKey); } if (shouldBeReserved) { if (existingOutput.reservedBy === invitationIdentifier) { continue; } if (existingOutput.reservedBy !== undefined) { throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy); } } else { if (existingOutput.reservedBy === undefined) { continue; } if (existingOutput.reservedBy !== invitationIdentifier) { throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy); } } outputsToUpdate.push({ storageKey, updatedOutput: { ...existingOutput, reservedBy: shouldBeReserved ? invitationIdentifier : undefined, }, }); } for (const { storageKey, updatedOutput } of outputsToUpdate) { this.unspentOutputs.set(storageKey, updatedOutput); } } /** * Deletes unspent output data from storage. * * @param outpointTransactionHash - Outpoint transaction hash * @param outpointIndex - Outpoint index * @returns Promise that resolves when deletion is complete */ async deleteUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise { this.ensureReady(); const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex); this.unspentOutputs.delete(key); } /** * Lists script hash data matching the provided query. * * @param query - Query to apply * @returns Promise resolving to matching script hash records * @throws {QueryFieldsLimitExceededError} if more than one query field is provided * @throws {QueryInvalidError} if the query contains no recognized field */ async listScriptHashData(query: ScriptHashDataQuery): Promise { this.ensureReady(); const definedFields = getDefinedQueryFields(query); if (definedFields.length > 1) { throw new QueryFieldsLimitExceededError('scriptHashes', definedFields); } if (query.templateIdentifier !== undefined) { return this.filterScriptHashes((item) => item.templateIdentifier === query.templateIdentifier); } if (query.invitationIdentifier !== undefined) { return this.filterScriptHashes((item) => item.invitationIdentifier === query.invitationIdentifier); } if (query.scriptHash !== undefined) { return this.filterScriptHashes((item) => item.scriptHash === query.scriptHash); } throw new QueryInvalidError('scriptHashes'); } /** * Lists unspent output data matching query and optional in-memory filter. * * @param query - Query for selecting unspent outputs * @param filter - Optional filter for reserved status * @returns Promise resolving to matching unspent outputs * @throws {QueryFieldsLimitExceededError} if more than one query field is provided */ async listUnspentOutputs(query: UnspentOutputDataQuery = {}, filter?: UnspentOutputDataFilter): Promise { this.ensureReady(); const definedFields = getDefinedQueryFields(query); if (definedFields.length > 1) { throw new QueryFieldsLimitExceededError('unspentOutputs', definedFields); } let unspentOutputs = Array.from(this.unspentOutputs.values(), (output) => this.clone(output)); if (query.templateIdentifier !== undefined) { unspentOutputs = unspentOutputs.filter((output) => output.templateIdentifier === query.templateIdentifier); } else if (query.reservedBy !== undefined) { unspentOutputs = unspentOutputs.filter((output) => output.reservedBy === query.reservedBy); } else if (query.scriptHash !== undefined) { unspentOutputs = unspentOutputs.filter((output) => output.scriptHash === query.scriptHash); } else if (query.status !== undefined) { unspentOutputs = unspentOutputs.filter((output) => output.status === query.status); } if (filter?.reserved !== undefined) { unspentOutputs = unspentOutputs.filter((unspentOutput) => (unspentOutput.reservedBy !== undefined) === filter.reserved); } return unspentOutputs; } /** * Stores an event in storage. * * @param eventId - Event identifier * @param eventType - Event type * @param data - Event payload * @param timestamp - Optional timestamp, defaults to Date.now() * @returns Promise that resolves when event is stored */ async storeEvent(eventId: string, eventType: string, data: unknown, timestamp?: number): Promise { this.ensureReady(); this.events.set(eventId, { eventId, timestamp: timestamp ?? Date.now(), eventType, data: this.clone(data), }); } /** * Lists event data matching the provided query. * * @param query - Query to apply * @returns Promise resolving to matching events * @throws {QueryFieldsLimitExceededError} if more than one query field is provided * @throws {QueryInvalidError} if the query contains no recognized field */ async listEvents(query: EventDataQuery): Promise { this.ensureReady(); const definedFields = getDefinedQueryFields(query); if (definedFields.length > 1) { throw new QueryFieldsLimitExceededError('events', definedFields); } if (query.eventType !== undefined) { return Array.from(this.events.values()) .filter((event) => event.eventType === query.eventType) .map((event) => this.clone(event)); } throw new QueryInvalidError('events'); } /** * Closes the storage adapter and releases in-memory resources. * * @returns Promise that resolves when close operation finishes */ async close(): Promise { this.templates.clear(); this.invitations.clear(); this.scriptHashes.clear(); this.unspentOutputs.clear(); this.events.clear(); this.databaseState = DatabaseState.Closed; this.databaseInitializationPromise = null; } /** * Ensures the adapter is in a ready state before handling any operation. * Mirrors the behavior of the IndexedDB implementation where calls after its been closed throw an error. * * @throws {DatabaseClosedError} if the adapter has already been closed * @throws {DatabaseNotInitializedError} if initialize has not been called */ private ensureReady(): void { if (this.databaseState === DatabaseState.Closed) { throw new DatabaseClosedError(); } } /** * Builds the canonical storage key for unspent outputs. * * @param outpointTransactionHash - Outpoint transaction hash * @param outpointIndex - Outpoint index * @returns Canonical key in hash:index format */ private getUnspentOutputKey(outpointTransactionHash: string, outpointIndex: number): string { return `${outpointTransactionHash}:${outpointIndex}`; } /** * Returns script hash records matching the given predicate. * * @param predicate - Predicate used to filter script hash records * @returns Matching script hash records */ private filterScriptHashes(predicate: (item: ScriptHashData) => boolean): ScriptHashData[] { return Array.from(this.scriptHashes.values()) .filter(predicate) .map((item) => this.clone(item)); } /** * Creates a detached clone to preserve storage value semantics. * * IndexedDB deserializes values when reading, so callers cannot mutate data that is * still stored in the database. We clone in-memory values to keep that same behavior. * * @param value - Value to clone * @returns Detached clone of the provided value */ private clone(value: T): T { return structuredClone(value); } }