UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

767 lines (679 loc) 27.2 kB
import { AbortActionArgs, AbortActionResult, InternalizeActionArgs, InternalizeActionResult, ListActionsResult, ListCertificatesResult, ListOutputsResult, RelinquishCertificateArgs, RelinquishOutputArgs } from '@bsv/sdk' import { EntitySyncState } from '../storage/schema/entities' import * as sdk from '../sdk' import { TableCertificate, TableCertificateX, TableOutput, TableOutputBasket, TableProvenTxReq, TableSettings, TableUser } from '../storage/schema/tables' import { wait } from '../utility/utilityHelpers' import { StorageProvider } from './StorageProvider' import { StorageClient } from './remoting/StorageClient' class ManagedStorage { isAvailable: boolean isStorageProvider: boolean settings?: TableSettings user?: TableUser constructor(public storage: sdk.WalletStorageProvider) { this.isStorageProvider = storage.isStorageProvider() this.isAvailable = false } } /** * The `WalletStorageManager` class delivers authentication checking storage access to the wallet. * * If manages multiple `StorageBase` derived storage services: one actice, the rest as backups. * * Of the storage services, one is 'active' at any one time. * On startup, and whenever triggered by the wallet, `WalletStorageManager` runs a syncrhonization sequence: * * 1. While synchronizing, all other access to storage is blocked waiting. * 2. The active service is confirmed, potentially triggering a resolution process if there is disagreement. * 3. Changes are pushed from the active storage service to each inactive, backup service. * * Some storage services do not support multiple writers. `WalletStorageManager` manages wait-blocking write requests * for these services. */ export class WalletStorageManager implements sdk.WalletStorage { /** * All configured stores including current active, backups, and conflicting actives. */ _stores: ManagedStorage[] = [] /** * True if makeAvailable has been run and access to managed stores (active) is allowed */ _isAvailable: boolean = false /** * The current active store which is only enabled if the store's user record activeStorage property matches its settings record storageIdentityKey property */ _active?: ManagedStorage /** * Stores to which state is pushed by updateBackups. */ _backups?: ManagedStorage[] /** * Stores whose user record activeStorage property disagrees with the active store's user record activeStorage property. */ _conflictingActives?: ManagedStorage[] /** * identityKey is always valid, userId and isActive are valid only if _isAvailable */ _authId: sdk.AuthId /** * Configured services if any. If valid, shared with stores (which may ignore it). */ _services?: sdk.WalletServices /** * Creates a new WalletStorageManager with the given identityKey and optional active and backup storage providers. * * @param identityKey The identity key of the user for whom this wallet is being managed. * @param active An optional active storage provider. If not provided, no active storage will be set. * @param backups An optional array of backup storage providers. If not provided, no backups will be set. */ constructor(identityKey: string, active?: sdk.WalletStorageProvider, backups?: sdk.WalletStorageProvider[]) { const stores = [...(backups || [])] if (active) stores.unshift(active) this._stores = stores.map(s => new ManagedStorage(s)) this._authId = { identityKey } } isStorageProvider(): boolean { return false } isAvailable(): boolean { return this._isAvailable } /** * The active storage is "enabled" only if its `storageIdentityKey` matches the user's currently selected `activeStorage`, * and only if there are no stores with conflicting `activeStorage` selections. * * A wallet may be created without including the user's currently selected active storage. This allows readonly access to their wallet data. * * In addition, if there are conflicting `activeStorage` selections among backup storage providers then the active remains disabled. */ get isActiveEnabled(): boolean { return ( this._active !== undefined && this._active.settings!.storageIdentityKey === this._active.user!.activeStorage && this._conflictingActives !== undefined && this._conflictingActives.length === 0 ) } /** * @returns true if at least one WalletStorageProvider has been added. */ canMakeAvailable(): boolean { return this._stores.length > 0 } /** * This async function must be called after construction and before * any other async function can proceed. * * Runs through `_stores` validating all properties and partitioning across `_active`, `_backups`, `_conflictingActives`. * * @throws WERR_INVALID_PARAMETER if canMakeAvailable returns false. * * @returns {TableSettings} from the active storage. */ async makeAvailable(): Promise<TableSettings> { if (this._isAvailable) return this._active!.settings! this._active = undefined this._backups = [] this._conflictingActives = [] if (this._stores.length < 1) throw new sdk.WERR_INVALID_PARAMETER('active', 'valid. Must add active storage provider to wallet.') // Initial backups. conflictingActives will be removed. const backups: ManagedStorage[] = [] let i = -1 for (const store of this._stores) { i++ if (!store.isAvailable || !store.settings || !store.user) { // Validate all ManagedStorage properties. store.settings = await store.storage.makeAvailable() const r = await store.storage.findOrInsertUser(this._authId.identityKey) store.user = r.user store.isAvailable = true } if (!this._active) // _stores[0] becomes the default active store. It may be replaced if it is not the user's "enabled" activeStorage and that store is found among the remainder (backups). this._active = store else { const ua = store.user!.activeStorage const si = store.settings!.storageIdentityKey if (ua === si && !this.isActiveEnabled) { // This store's user record selects it as an enabled active storage... // swap the current not-enabled active for this storeage. backups.push(this._active!) this._active = store } else { // This store is a backup: Its user record selects some other storage as active. backups.push(store) } } } // Review backups, partition out conflicting actives. const si = this._active!.settings?.storageIdentityKey for (const store of backups) { if (store.user!.activeStorage !== si) this._conflictingActives.push(store) else this._backups.push(store) } this._isAvailable = true this._authId.userId = this._active!.user!.userId this._authId.isActive = this.isActiveEnabled return this._active!.settings! } private verifyActive(): ManagedStorage { if (!this._active || !this._isAvailable) throw new sdk.WERR_INVALID_OPERATION( 'An active WalletStorageProvider must be added to this WalletStorageManager and makeAvailable must be called.' ) return this._active } async getAuth(mustBeActive?: boolean): Promise<sdk.AuthId> { if (!this.isAvailable()) await this.makeAvailable() if (mustBeActive && !this._authId.isActive) throw new sdk.WERR_NOT_ACTIVE() return this._authId } async getUserId(): Promise<number> { return (await this.getAuth()).userId! } getActive(): sdk.WalletStorageProvider { return this.verifyActive().storage } getActiveSettings(): TableSettings { return this.verifyActive().settings! } getActiveUser(): TableUser { return this.verifyActive().user! } getActiveStore(): string { return this.verifyActive().settings!.storageIdentityKey } getActiveStoreName(): string { return this.verifyActive().settings!.storageName } getBackupStores(): string[] { this.verifyActive() return this._backups!.map(b => b.settings!.storageIdentityKey) } getConflictingStores(): string[] { this.verifyActive() return this._conflictingActives!.map(b => b.settings!.storageIdentityKey) } getAllStores(): string[] { this.verifyActive() return this._stores.map(b => b.settings!.storageIdentityKey) } private readonly readerLocks: Array<(value: void | PromiseLike<void>) => void> = [] private readonly writerLocks: Array<(value: void | PromiseLike<void>) => void> = [] private readonly syncLocks: Array<(value: void | PromiseLike<void>) => void> = [] private readonly spLocks: Array<(value: void | PromiseLike<void>) => void> = [] private async getActiveLock(lockQueue: Array<(value: void | PromiseLike<void>) => void>): Promise<void> { if (!this.isAvailable()) await this.makeAvailable() let resolveNewLock: () => void = () => {} const newLock = new Promise<void>(resolve => { resolveNewLock = resolve lockQueue.push(resolve) }) if (lockQueue.length === 1) { resolveNewLock() } await newLock } private releaseActiveLock(queue: Array<(value: void | PromiseLike<void>) => void>): void { queue.shift() // Remove the current lock from the queue if (queue.length > 0) { queue[0]() } } private async getActiveForReader(): Promise<sdk.WalletStorageReader> { await this.getActiveLock(this.readerLocks) return this.getActive() } private releaseActiveForReader(): void { this.releaseActiveLock(this.readerLocks) } private async getActiveForWriter(): Promise<sdk.WalletStorageWriter> { await this.getActiveLock(this.readerLocks) await this.getActiveLock(this.writerLocks) return this.getActive() } private releaseActiveForWriter(): void { this.releaseActiveLock(this.writerLocks) this.releaseActiveLock(this.readerLocks) } private async getActiveForSync(): Promise<sdk.WalletStorageSync> { await this.getActiveLock(this.readerLocks) await this.getActiveLock(this.writerLocks) await this.getActiveLock(this.syncLocks) return this.getActive() } private releaseActiveForSync(): void { this.releaseActiveLock(this.syncLocks) this.releaseActiveLock(this.writerLocks) this.releaseActiveLock(this.readerLocks) } private async getActiveForStorageProvider(): Promise<StorageProvider> { await this.getActiveLock(this.readerLocks) await this.getActiveLock(this.writerLocks) await this.getActiveLock(this.syncLocks) await this.getActiveLock(this.spLocks) const active = this.getActive() // We can finally confirm that active storage is still able to support `StorageProvider` if (!active.isStorageProvider()) throw new sdk.WERR_INVALID_OPERATION( 'Active "WalletStorageProvider" does not support "StorageProvider" interface.' ) // Allow the sync to proceed on the active store. return active as unknown as StorageProvider } private releaseActiveForStorageProvider(): void { this.releaseActiveLock(this.spLocks) this.releaseActiveLock(this.syncLocks) this.releaseActiveLock(this.writerLocks) this.releaseActiveLock(this.readerLocks) } async runAsWriter<R>(writer: (active: sdk.WalletStorageWriter) => Promise<R>): Promise<R> { try { const active = await this.getActiveForWriter() const r = await writer(active) return r } finally { this.releaseActiveForWriter() } } async runAsReader<R>(reader: (active: sdk.WalletStorageReader) => Promise<R>): Promise<R> { try { const active = await this.getActiveForReader() const r = await reader(active) return r } finally { this.releaseActiveForReader() } } /** * * @param sync the function to run with sync access lock * @param activeSync from chained sync functions, active storage already held under sync access lock. * @returns */ async runAsSync<R>( sync: (active: sdk.WalletStorageSync) => Promise<R>, activeSync?: sdk.WalletStorageSync ): Promise<R> { try { const active = activeSync || (await this.getActiveForSync()) const r = await sync(active) return r } finally { if (!activeSync) this.releaseActiveForSync() } } async runAsStorageProvider<R>(sync: (active: StorageProvider) => Promise<R>): Promise<R> { try { const active = await this.getActiveForStorageProvider() const r = await sync(active) return r } finally { this.releaseActiveForStorageProvider() } } /** * * @returns true if the active `WalletStorageProvider` also implements `StorageProvider` */ isActiveStorageProvider(): boolean { return this.getActive().isStorageProvider() } async addWalletStorageProvider(provider: sdk.WalletStorageProvider): Promise<void> { await provider.makeAvailable() if (this._services) provider.setServices(this._services) this._stores.push(new ManagedStorage(provider)) this._isAvailable = false await this.makeAvailable() } setServices(v: sdk.WalletServices) { this._services = v for (const store of this._stores) store.storage.setServices(v) } getServices(): sdk.WalletServices { if (!this._services) throw new sdk.WERR_INVALID_OPERATION('Must setServices first.') return this._services } getSettings(): TableSettings { return this.getActive().getSettings() } async migrate(storageName: string, storageIdentityKey: string): Promise<string> { return await this.runAsWriter(async writer => { return writer.migrate(storageName, storageIdentityKey) }) } async destroy(): Promise<void> { if (this._stores.length < 1) return return await this.runAsWriter(async writer => { for (const store of this._stores) await store.storage.destroy() }) } async findOrInsertUser(identityKey: string): Promise<{ user: TableUser; isNew: boolean }> { const auth = await this.getAuth() if (identityKey != auth.identityKey) throw new sdk.WERR_UNAUTHORIZED() return await this.runAsWriter(async writer => { const r = await writer.findOrInsertUser(identityKey) if (auth.userId && auth.userId !== r.user.userId) throw new sdk.WERR_INTERNAL('userId may not change for given identityKey') this._authId.userId = r.user.userId return r }) } async abortAction(args: AbortActionArgs): Promise<AbortActionResult> { sdk.validateAbortActionArgs(args) return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.abortAction(auth, args) }) } async createAction(vargs: sdk.ValidCreateActionArgs): Promise<sdk.StorageCreateActionResult> { return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.createAction(auth, vargs) }) } async internalizeAction(args: InternalizeActionArgs): Promise<sdk.StorageInternalizeActionResult> { sdk.validateInternalizeActionArgs(args) return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.internalizeAction(auth, args) }) } async relinquishCertificate(args: RelinquishCertificateArgs): Promise<number> { sdk.validateRelinquishCertificateArgs(args) return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.relinquishCertificate(auth, args) }) } async relinquishOutput(args: RelinquishOutputArgs): Promise<number> { sdk.validateRelinquishOutputArgs(args) return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.relinquishOutput(auth, args) }) } async processAction(args: sdk.StorageProcessActionArgs): Promise<sdk.StorageProcessActionResults> { return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.processAction(auth, args) }) } async insertCertificate(certificate: TableCertificate): Promise<number> { return await this.runAsWriter(async writer => { const auth = await this.getAuth(true) return await writer.insertCertificateAuth(auth, certificate) }) } async listActions(vargs: sdk.ValidListActionsArgs): Promise<ListActionsResult> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.listActions(auth, vargs) }) } async listCertificates(args: sdk.ValidListCertificatesArgs): Promise<ListCertificatesResult> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.listCertificates(auth, args) }) } async listOutputs(vargs: sdk.ValidListOutputsArgs): Promise<ListOutputsResult> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.listOutputs(auth, vargs) }) } async findCertificates(args: sdk.FindCertificatesArgs): Promise<TableCertificateX[]> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.findCertificatesAuth(auth, args) }) } async findOutputBaskets(args: sdk.FindOutputBasketsArgs): Promise<TableOutputBasket[]> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.findOutputBasketsAuth(auth, args) }) } async findOutputs(args: sdk.FindOutputsArgs): Promise<TableOutput[]> { const auth = await this.getAuth() return await this.runAsReader(async reader => { return await reader.findOutputsAuth(auth, args) }) } async findProvenTxReqs(args: sdk.FindProvenTxReqsArgs): Promise<TableProvenTxReq[]> { return await this.runAsReader(async reader => { return await reader.findProvenTxReqs(args) }) } async syncFromReader( identityKey: string, reader: sdk.WalletStorageSyncReader, activeSync?: sdk.WalletStorageSync, log: string = '' ): Promise<{ inserts: number; updates: number; log: string }> { const auth = await this.getAuth() if (identityKey !== auth.identityKey) throw new sdk.WERR_UNAUTHORIZED() const readerSettings = await reader.makeAvailable() let inserts = 0, updates = 0 log = await this.runAsSync(async sync => { const writer = sync const writerSettings = this.getSettings() log += `syncFromReader from ${readerSettings.storageName} to ${writerSettings.storageName}\n` let i = -1 for (;;) { i++ const ss = await EntitySyncState.fromStorage(writer, identityKey, readerSettings) const args = ss.makeRequestSyncChunkArgs(identityKey, writerSettings.storageIdentityKey) const chunk = await reader.getSyncChunk(args) if (chunk.user) { // Merging state from a reader cannot update activeStorage chunk.user.activeStorage = this._active!.user!.activeStorage } const r = await writer.processSyncChunk(args, chunk) inserts += r.inserts updates += r.updates log += `chunk ${i} inserted ${r.inserts} updated ${r.updates} ${r.maxUpdated_at}\n` if (r.done) break } log += `syncFromReader complete: ${inserts} inserts, ${updates} updates\n` return log }, activeSync) return { inserts, updates, log } } async syncToWriter( auth: sdk.AuthId, writer: sdk.WalletStorageProvider, activeSync?: sdk.WalletStorageSync, log: string = '', progLog?: (s: string) => string ): Promise<{ inserts: number; updates: number; log: string }> { progLog ||= s => s const identityKey = auth.identityKey const writerSettings = await writer.makeAvailable() let inserts = 0, updates = 0 log = await this.runAsSync(async sync => { const reader = sync const readerSettings = reader.getSettings() log += progLog(`syncToWriter from ${readerSettings.storageName} to ${writerSettings.storageName}\n`) let i = -1 for (;;) { i++ const ss = await EntitySyncState.fromStorage(writer, identityKey, readerSettings) const args = ss.makeRequestSyncChunkArgs(identityKey, writerSettings.storageIdentityKey) const chunk = await reader.getSyncChunk(args) log += EntitySyncState.syncChunkSummary(chunk) const r = await writer.processSyncChunk(args, chunk) inserts += r.inserts updates += r.updates log += progLog(`chunk ${i} inserted ${r.inserts} updated ${r.updates} ${r.maxUpdated_at}\n`) if (r.done) break } log += progLog(`syncToWriter complete: ${inserts} inserts, ${updates} updates\n`) return log }, activeSync) return { inserts, updates, log } } async updateBackups(activeSync?: sdk.WalletStorageSync, progLog?: (s: string) => string): Promise<string> { progLog ||= s => s const auth = await this.getAuth(true) return await this.runAsSync(async sync => { let log = progLog(`BACKUP CURRENT ACTIVE TO ${this._backups!.length} STORES\n`) for (const backup of this._backups!) { const stwr = await this.syncToWriter(auth, backup.storage, sync, undefined, progLog) log += stwr.log } return log }, activeSync) } /** * Updates backups and switches to new active storage provider from among current backup providers. * * Also resolves conflicting actives. * * @param storageIdentityKey of current backup storage provider that is to become the new active provider. */ async setActive(storageIdentityKey: string, progLog?: (s: string) => string): Promise<string> { progLog ||= s => s if (!this.isAvailable()) await this.makeAvailable() // Confirm a valid storageIdentityKey: must match one of the _stores. const newActiveIndex = this._stores.findIndex(s => s.settings!.storageIdentityKey === storageIdentityKey) if (newActiveIndex < 0) throw new sdk.WERR_INVALID_PARAMETER( 'storageIdentityKey', `registered with this "WalletStorageManager". ${storageIdentityKey} does not match any managed store.` ) const identityKey = (await this.getAuth()).identityKey const newActive = this._stores[newActiveIndex] let log = progLog(`setActive to ${newActive.settings!.storageName}`) if (storageIdentityKey === this.getActiveStore() && this.isActiveEnabled) /** Setting the current active as the new active is a permitted no-op. */ return log + progLog(` unchanged\n`) log += progLog('\n') log += await this.runAsSync(async sync => { let log = '' if (this._conflictingActives!.length > 0) { // Merge state from conflicting actives into `newActive`. // Handle case where new active is current active to resolve conflicts. // And where new active is one of the current conflict actives. this._conflictingActives!.push(this._active!) // Remove the new active from conflicting actives and // set new active as the conflicting active that matches the target `storageIdentityKey` this._conflictingActives = this._conflictingActives!.filter(ca => { const isNewActive = ca.settings!.storageIdentityKey === storageIdentityKey return !isNewActive }) // Merge state from conflicting actives into `newActive`. for (const conflict of this._conflictingActives) { log += progLog('MERGING STATE FROM CONFLICTING ACTIVES:\n') const sfr = await this.syncToWriter( { identityKey, userId: newActive.user!.userId, isActive: false }, newActive.storage, conflict.storage, undefined, progLog ) log += sfr.log } log += progLog('PROPAGATE MERGED ACTIVE STATE TO NON-ACTIVES\n') } else { log += progLog('BACKUP CURRENT ACTIVE STATE THEN SET NEW ACTIVE\n') } // If there were conflicting actives, // Push state merged from all merged actives into newActive to all stores other than the now single active. // Otherwise, // Push state from current active to all other stores. const backupSource = this._conflictingActives!.length > 0 ? newActive : this._active! // Update the backupSource's user record with the new activeStorage // which will propagate to all other stores in the following backup loop. await backupSource.storage.setActive({ identityKey, userId: backupSource.user!.userId }, storageIdentityKey) for (const store of this._stores) { // Update cached user.activeStorage of all stores store.user!.activeStorage = storageIdentityKey if (store.settings!.storageIdentityKey !== backupSource.settings!.storageIdentityKey) { // If this store is not the backupSource store push state from backupSource to this store. const stwr = await this.syncToWriter( { identityKey, userId: store.user!.userId, isActive: false }, store.storage, backupSource.storage, undefined, progLog ) log += stwr.log } } this._isAvailable = false await this.makeAvailable() return log }) return log } getStoreEndpointURL(store: ManagedStorage): string | undefined { if (store.storage.constructor.name === 'StorageClient') return (store.storage as StorageClient).endpointUrl return undefined } getStores(): sdk.WalletStorageInfo[] { const stores: sdk.WalletStorageInfo[] = [] if (this._active) { stores.push({ isActive: true, isEnabled: this.isActiveEnabled, isBackup: false, isConflicting: false, userId: this._active.user!.userId, storageIdentityKey: this._active.settings!.storageIdentityKey, storageName: this._active.settings!.storageName, storageClass: this._active.storage.constructor.name, endpointURL: this.getStoreEndpointURL(this._active) }) } for (const store of this._conflictingActives || []) { stores.push({ isActive: true, isEnabled: false, isBackup: false, isConflicting: true, userId: store.user!.userId, storageIdentityKey: store.settings!.storageIdentityKey, storageName: store.settings!.storageName, storageClass: store.storage.constructor.name, endpointURL: this.getStoreEndpointURL(store) }) } for (const store of this._backups || []) { stores.push({ isActive: false, isEnabled: false, isBackup: true, isConflicting: false, userId: store.user!.userId, storageIdentityKey: store.settings!.storageIdentityKey, storageName: store.settings!.storageName, storageClass: store.storage.constructor.name, endpointURL: this.getStoreEndpointURL(store) }) } return stores } }