UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

1,570 lines (1,449 loc) 83.1 kB
import { Beef, CachedKeyDeriver, CreateActionArgs, CreateActionOutput, CreateActionResult, HexString, KeyDeriver, KeyDeriverApi, P2PKH, PrivateKey, PublicKey, SatoshiValue, SignActionArgs, SignActionResult, Transaction, Utils, WalletAction, WalletActionInput, WalletActionOutput, WalletCertificate, WalletInterface } from '@bsv/sdk' import path from 'path' import { promises as fsp } from 'fs' import { asArray, randomBytesBase64, randomBytesHex, sdk, StorageProvider, StorageKnex, StorageSyncReader, verifyTruthy, Wallet, Monitor, Services, WalletStorageManager, verifyOne, StorageClient, TableOutputBasket, TableOutput, TableMonitorEvent, TableProvenTxReq, TableProvenTx, TableCommission, TableTransaction, TableTxLabelMap, TableTxLabel, TableUser, TableSyncState, TableCertificate, TableCertificateField, TableOutputTagMap, TableOutputTag, ScriptTemplateBRC29, Setup } from '../../src/index.all' import { Knex, knex as makeKnex } from 'knex' import * as dotenv from 'dotenv' import { TrxToken, WalletServicesOptions } from '../../src/sdk' import { StorageIdb } from '../../src/storage/StorageIdb' dotenv.config() const localMySqlConnection = process.env.MYSQL_CONNECTION || '' export interface TuEnvFlags { chain: sdk.Chain runMySQL: boolean runSlowTests: boolean logTests: boolean } export interface TuEnv extends TuEnvFlags { chain: sdk.Chain identityKey: string identityKey2: string taalApiKey: string bitailsApiKey: string whatsonchainApiKey: string devKeys: Record<string, string> /** * file path to local sqlite file for identityKey */ filePath?: string /** * identityKey for automated test wallet on this chain */ testIdentityKey?: string /** * file path to local sqlite file for testIdentityKey */ testFilePath?: string cloudMySQLConnection?: string } export abstract class TestUtilsWalletStorage { /** * @param chain * @returns true if .env has truthy idenityKey, idenityKey2 values for chain */ static noEnv(chain: sdk.Chain): boolean { try { Setup.getEnv(chain) return false } catch { return true } } /** * @param chain * @returns true if .env is not valid for chain or testIdentityKey or testFilePath are undefined or empty. */ static noTestEnv(chain: sdk.Chain): boolean { try { const env = _tu.getEnv(chain) return !env.testIdentityKey || !env.testFilePath } catch { return true } } static getEnvFlags(chain: sdk.Chain): TuEnvFlags { const logTests = !!process.env.LOGTESTS const runMySQL = !!process.env.RUNMYSQL const runSlowTests = !!process.env.RUNSLOWTESTS return { chain, runMySQL, runSlowTests, logTests } } static getEnv(chain: sdk.Chain): TuEnv { const flagsEnv = _tu.getEnvFlags(chain) // Identity keys of the lead maintainer of this repo... const identityKey = (chain === 'main' ? process.env.MY_MAIN_IDENTITY : process.env.MY_TEST_IDENTITY) || '' const filePath = chain === 'main' ? process.env.MY_MAIN_FILEPATH : process.env.MY_TEST_FILEPATH const identityKey2 = (chain === 'main' ? process.env.MY_MAIN_IDENTITY2 : process.env.MY_TEST_IDENTITY2) || '' const testIdentityKey = chain === 'main' ? process.env.TEST_MAIN_IDENTITY : process.env.TEST_TEST_IDENTITY const testFilePath = chain === 'main' ? process.env.TEST_MAIN_FILEPATH : process.env.TEST_TEST_FILEPATH const cloudMySQLConnection = chain === 'main' ? process.env.MAIN_CLOUD_MYSQL_CONNECTION : process.env.TEST_CLOUD_MYSQL_CONNECTION const DEV_KEYS = process.env.DEV_KEYS || '{}' const taalApiKey = (chain === 'main' ? process.env.MAIN_TAAL_API_KEY : process.env.TEST_TAAL_API_KEY) || '' const bitailsApiKey = (chain === 'main' ? process.env.MAIN_BITAILS_API_KEY : process.env.TEST_BITAILS_API_KEY) || '' const whatsonchainApiKey = (chain === 'main' ? process.env.MAIN_WHATSONCHAIN_API_KEY : process.env.TEST_WHATSONCHAIN_API_KEY) || '' return { ...flagsEnv, identityKey, identityKey2, taalApiKey, bitailsApiKey, whatsonchainApiKey, devKeys: JSON.parse(DEV_KEYS) as Record<string, string>, filePath, testIdentityKey, testFilePath, cloudMySQLConnection } } static async createMainReviewSetup(): Promise<{ env: TuEnv storage: StorageKnex services: Services }> { const env = _tu.getEnv('main') if (!env.cloudMySQLConnection) throw new sdk.WERR_INVALID_PARAMETER('env.cloudMySQLConnection', 'valid') const knex = Setup.createMySQLKnex(env.cloudMySQLConnection) const storage = new StorageKnex({ chain: env.chain, knex: knex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) const servicesOptions = Services.createDefaultOptions(env.chain) if (env.whatsonchainApiKey) servicesOptions.whatsOnChainApiKey = env.whatsonchainApiKey const services = new Services(servicesOptions) storage.setServices(services) await storage.makeAvailable() return { env, storage, services } } static async createNoSendP2PKHTestOutpoint( address: string, satoshis: number, noSendChange: string[] | undefined, wallet: WalletInterface ): Promise<{ noSendChange: string[] txid: string cr: CreateActionResult sr: SignActionResult }> { return await _tu.createNoSendP2PKHTestOutpoints(1, address, satoshis, noSendChange, wallet) } static async createNoSendP2PKHTestOutpoints( count: number, address: string, satoshis: number, noSendChange: string[] | undefined, wallet: WalletInterface ): Promise<{ noSendChange: string[] txid: string cr: CreateActionResult sr: SignActionResult }> { const outputs: CreateActionOutput[] = [] for (let i = 0; i < count; i++) { outputs.push({ basket: `test-p2pkh-output-${i}`, satoshis, lockingScript: _tu.getLockP2PKH(address).toHex(), outputDescription: `p2pkh ${i}` }) } const createArgs: CreateActionArgs = { description: `to ${address}`, outputs, options: { noSendChange, randomizeOutputs: false, signAndProcess: false, noSend: true } } const cr = await wallet.createAction(createArgs) noSendChange = cr.noSendChange expect(cr.noSendChange).toBeTruthy() expect(cr.sendWithResults).toBeUndefined() expect(cr.tx).toBeUndefined() expect(cr.txid).toBeUndefined() expect(cr.signableTransaction).toBeTruthy() const st = cr.signableTransaction! expect(st.reference).toBeTruthy() // const tx = Transaction.fromAtomicBEEF(st.tx) // Transaction doesn't support V2 Beef yet. const atomicBeef = Beef.fromBinary(st.tx) const tx = atomicBeef.txs[atomicBeef.txs.length - 1].tx! for (const input of tx.inputs) { expect(atomicBeef.findTxid(input.sourceTXID!)).toBeTruthy() } // Spending authorization check happens here... //expect(st.amount > 242 && st.amount < 300).toBe(true) // sign and complete const signArgs: SignActionArgs = { reference: st.reference, spends: {}, options: { returnTXIDOnly: true, noSend: true } } const sr = await wallet.signAction(signArgs) let txid = sr.txid! // Update the noSendChange txid to final signed value. noSendChange = noSendChange!.map(op => `${txid}.${op.split('.')[1]}`) return { noSendChange, txid, cr, sr } } static getKeyPair(priv?: string | PrivateKey): TestKeyPair { if (priv === undefined) priv = PrivateKey.fromRandom() else if (typeof priv === 'string') priv = new PrivateKey(priv, 'hex') const pub = PublicKey.fromPrivateKey(priv) const address = pub.toAddress() return { privateKey: priv, publicKey: pub, address } } static getLockP2PKH(address: string) { const p2pkh = new P2PKH() const lock = p2pkh.lock(address) return lock } static getUnlockP2PKH(priv: PrivateKey, satoshis: number) { const p2pkh = new P2PKH() const lock = _tu.getLockP2PKH(_tu.getKeyPair(priv).address) // Prepare to pay with SIGHASH_ALL and without ANYONE_CAN_PAY. // In otherwords: // - all outputs must remain in the current order, amount and locking scripts. // - all inputs must remain from the current outpoints and sequence numbers. // (unlock scripts are never signed) const unlock = p2pkh.unlock(priv, 'all', false, satoshis, lock) return unlock } static async createWalletOnly(args: { chain?: sdk.Chain rootKeyHex?: string active?: sdk.WalletStorageProvider backups?: sdk.WalletStorageProvider[] privKeyHex?: string }): Promise<TestWalletOnly> { args.chain ||= 'test' args.rootKeyHex ||= '1'.repeat(64) const rootKey = PrivateKey.fromHex(args.rootKeyHex) const identityKey = rootKey.toPublicKey().toString() const keyDeriver = new CachedKeyDeriver(rootKey) const chain = args.chain const storage = new WalletStorageManager(identityKey, args.active, args.backups) if (storage.canMakeAvailable()) await storage.makeAvailable() const env = _tu.getEnv(args.chain!) const serviceOptions: WalletServicesOptions = Services.createDefaultOptions(env.chain) serviceOptions.taalApiKey = env.taalApiKey serviceOptions.arcConfig.apiKey = env.taalApiKey serviceOptions.bitailsApiKey = env.bitailsApiKey serviceOptions.whatsOnChainApiKey = env.whatsonchainApiKey const services = new Services(serviceOptions) const monopts = Monitor.createDefaultWalletMonitorOptions(chain, storage, services) const monitor = new Monitor(monopts) monitor.addDefaultTasks() let privilegedKeyManager: sdk.PrivilegedKeyManager | undefined = undefined if (args.privKeyHex) { const privKey = PrivateKey.fromString(args.privKeyHex) privilegedKeyManager = new sdk.PrivilegedKeyManager(async () => privKey) } const wallet = new Wallet({ chain, keyDeriver, storage, services, monitor, privilegedKeyManager }) const r: TestWalletOnly = { rootKey, identityKey, keyDeriver, chain, storage, services, monitor, wallet } return r } /** * Creates a wallet with both local sqlite and cloud stores, the local store is left active. * * Requires a valid .env file with chain matching testIdentityKey and testFilePath properties valid. * Or `args` with those properties. * * Verifies wallet has at least 1000 satoshis in at least 10 change utxos. * * @param chain * * @returns {TestWalletNoSetup} */ static async createTestWallet(args: sdk.Chain | CreateTestWalletArgs): Promise<TestWalletNoSetup> { let chain: sdk.Chain let rootKeyHex: string let filePath: string let addLocalBackup = false let setActiveClient = false let useMySQLConnectionForClient = false if (typeof args === 'string') { chain = args const env = _tu.getEnv(chain) if (!env.testIdentityKey || !env.testFilePath) { throw new sdk.WERR_INVALID_PARAMETER('env.testIdentityKey and env.testFilePath', 'valid') } rootKeyHex = env.devKeys[env.testIdentityKey!] filePath = env.testFilePath } else { chain = args.chain rootKeyHex = args.rootKeyHex filePath = args.filePath addLocalBackup = args.addLocalBackup === true setActiveClient = args.setActiveClient === true useMySQLConnectionForClient = args.useMySQLConnectionForClient === true } const databaseName = path.parse(filePath).name const setup = await _tu.createSQLiteTestWallet({ filePath, rootKeyHex, databaseName, chain }) setup.localStorageIdentityKey = setup.storage.getActiveStore() let client: sdk.WalletStorageProvider if (useMySQLConnectionForClient) { const env = _tu.getEnv(chain) if (!env.cloudMySQLConnection) throw new sdk.WERR_INVALID_PARAMETER('env.cloundMySQLConnection', 'valid') const connection = JSON.parse(env.cloudMySQLConnection) client = new StorageKnex({ ...StorageKnex.defaultOptions(), knex: _tu.createMySQLFromConnection(connection), chain: env.chain }) } else { const endpointUrl = chain === 'main' ? 'https://storage.babbage.systems' : 'https://staging-storage.babbage.systems' client = new StorageClient(setup.wallet, endpointUrl) } setup.clientStorageIdentityKey = (await client.makeAvailable()).storageIdentityKey await setup.wallet.storage.addWalletStorageProvider(client) if (addLocalBackup) { const backupName = `${databaseName}_backup` const backupPath = filePath.replace(databaseName, backupName) const localBackup = new StorageKnex({ ...StorageKnex.defaultOptions(), knex: _tu.createLocalSQLite(backupPath), chain }) await localBackup.migrate(backupName, randomBytesHex(33)) setup.localBackupStorageIdentityKey = (await localBackup.makeAvailable()).storageIdentityKey await setup.wallet.storage.addWalletStorageProvider(localBackup) } // SETTING ACTIVE // SETTING ACTIVE // SETTING ACTIVE const log = await setup.storage.setActive( setActiveClient ? setup.clientStorageIdentityKey : setup.localStorageIdentityKey ) logger(log) let needsBackup = false if (setup.storage.getActiveStore() === setup.localStorageIdentityKey) { const basket = verifyOne( await setup.activeStorage.findOutputBaskets({ partial: { userId: setup.storage.getActiveUser().userId, name: 'default' } }) ) if (basket.minimumDesiredUTXOValue !== 5 || basket.numberOfDesiredUTXOs < 32) { needsBackup = true await setup.activeStorage.updateOutputBasket(basket.basketId, { minimumDesiredUTXOValue: 5, numberOfDesiredUTXOs: 32 }) } } const balance = await setup.wallet.balanceAndUtxos() if (balance.total < 1000) { throw new sdk.WERR_INSUFFICIENT_FUNDS(1000, 1000 - balance.total) } if (balance.utxos.length <= 10) { const args: CreateActionArgs = { description: 'spread change' } await setup.wallet.createAction(args) needsBackup = true } if (needsBackup) { const log2 = await setup.storage.updateBackups() console.log(log2) } return setup } static async createTestWalletWithStorageClient(args: { rootKeyHex?: string endpointUrl?: string chain?: sdk.Chain }): Promise<TestWalletOnly> { args.chain ||= 'test' const wo = await _tu.createWalletOnly({ chain: args.chain, rootKeyHex: args.rootKeyHex }) args.endpointUrl ||= args.chain === 'main' ? 'https://storage.babbage.systems' : 'https://staging-storage.babbage.systems' const client = new StorageClient(wo.wallet, args.endpointUrl) await wo.storage.addWalletStorageProvider(client) return wo } static async createKnexTestWalletWithSetup<T>(args: { knex: Knex<any, any[]> databaseName: string chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean privKeyHex?: string insertSetup: (storage: StorageKnex, identityKey: string) => Promise<T> }): Promise<TestWallet<T>> { const wo = await _tu.createWalletOnly({ chain: args.chain, rootKeyHex: args.rootKeyHex, privKeyHex: args.privKeyHex }) const activeStorage = new StorageKnex({ chain: wo.chain, knex: args.knex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) if (args.dropAll) await activeStorage.dropAllData() await activeStorage.migrate(args.databaseName, randomBytesHex(33)) await activeStorage.makeAvailable() const setup = await args.insertSetup(activeStorage, wo.identityKey) await wo.storage.addWalletStorageProvider(activeStorage) const { user, isNew } = await activeStorage.findOrInsertUser(wo.identityKey) const userId = user.userId const r: TestWallet<T> = { ...wo, activeStorage, setup, userId } return r } /** * Returns path to temporary file in project's './test/data/tmp/' folder. * * Creates any missing folders. * * Optionally tries to delete any existing file. This may fail if the file file is locked * by another process. * * Optionally copies filename (or if filename has no dir component, a file of the same filename from the project's './test/data' folder) to initialize file's contents. * * CAUTION: returned file path will include four random hex digits unless tryToDelete is true. Files must be purged periodically. * * @param filename target filename without path, optionally just extension in which case random name is used * @param tryToDelete true to attempt to delete an existing file at the returned file path. * @param copyToTmp true to copy file of same filename from './test/data' (or elsewhere if filename has path) to tmp folder * @param reuseExisting true to use existing file if found, otherwise a random string is added to filename. * @returns path in './test/data/tmp' folder. */ static async newTmpFile( filename = '', tryToDelete = false, copyToTmp = false, reuseExisting = false ): Promise<string> { const tmpFolder = './test/data/tmp/' const p = path.parse(filename) const dstDir = tmpFolder const dstName = `${p.name}${tryToDelete || reuseExisting ? '' : randomBytesHex(6)}` const dstExt = p.ext || 'tmp' const dstPath = path.resolve(`${dstDir}${dstName}${dstExt}`) await fsp.mkdir(tmpFolder, { recursive: true }) if (!reuseExisting && (tryToDelete || copyToTmp)) try { await fsp.unlink(dstPath) } catch (eu: unknown) { const e = sdk.WalletError.fromUnknown(eu) if (e.name !== 'ENOENT') { throw e } } if (copyToTmp) { const srcPath = p.dir ? path.resolve(filename) : path.resolve(`./test/data/${filename}`) await fsp.copyFile(srcPath, dstPath) } return dstPath } static async copyFile(srcPath: string, dstPath: string): Promise<void> { await fsp.copyFile(srcPath, dstPath) } static async existingDataFile(filename: string): Promise<string> { const folder = './test/data/' return folder + filename } static createLocalSQLite(filename: string): Knex { const config: Knex.Config = { client: 'sqlite3', connection: { filename }, useNullAsDefault: true } const knex = makeKnex(config) return knex } static createMySQLFromConnection(connection: object): Knex { const config: Knex.Config = { client: 'mysql2', connection, useNullAsDefault: true, pool: { min: 0, max: 7, idleTimeoutMillis: 15000 } } const knex = makeKnex(config) return knex } static createLocalMySQL(database: string): Knex { const connection = JSON.parse(localMySqlConnection || '{}') connection['database'] = database const config: Knex.Config = { client: 'mysql2', connection, useNullAsDefault: true, pool: { min: 0, max: 7, idleTimeoutMillis: 15000 } } const knex = makeKnex(config) return knex } static async createMySQLTestWallet(args: { databaseName: string chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean }): Promise<TestWallet<{}>> { return await this.createKnexTestWallet({ ...args, knex: _tu.createLocalMySQL(args.databaseName) }) } static async createMySQLTestSetup1Wallet(args: { databaseName: string chain?: sdk.Chain rootKeyHex?: string }): Promise<TestWallet<TestSetup1>> { return await this.createKnexTestSetup1Wallet({ ...args, dropAll: true, knex: _tu.createLocalMySQL(args.databaseName) }) } static async createMySQLTestSetup2Wallet(args: { databaseName: string mockData: MockData chain?: sdk.Chain rootKeyHex?: string }): Promise<TestWallet<TestSetup2>> { return await this.createKnexTestSetup2Wallet({ ...args, dropAll: true, knex: _tu.createLocalMySQL(args.databaseName) }) } static async createSQLiteTestWallet(args: { filePath?: string databaseName: string chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean privKeyHex?: string }): Promise<TestWalletNoSetup> { const localSQLiteFile = args.filePath || (await _tu.newTmpFile(`${args.databaseName}.sqlite`, false, false, true)) return await this.createKnexTestWallet({ ...args, knex: _tu.createLocalSQLite(localSQLiteFile) }) } static async createSQLiteTestSetup1Wallet(args: { databaseName: string chain?: sdk.Chain rootKeyHex?: string }): Promise<TestWallet<TestSetup1>> { const localSQLiteFile = await _tu.newTmpFile(`${args.databaseName}.sqlite`, false, false, true) return await this.createKnexTestSetup1Wallet({ ...args, dropAll: true, knex: _tu.createLocalSQLite(localSQLiteFile) }) } static async createSQLiteTestSetup2Wallet(args: { databaseName: string mockData: MockData chain?: sdk.Chain rootKeyHex?: string }): Promise<TestWallet<TestSetup2>> { const localSQLiteFile = await _tu.newTmpFile(`${args.databaseName}.sqlite`, false, false, true) return await this.createKnexTestSetup2Wallet({ ...args, dropAll: true, knex: _tu.createLocalSQLite(localSQLiteFile) }) } static async createKnexTestWallet(args: { knex: Knex<any, any[]> databaseName: string chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean privKeyHex?: string }): Promise<TestWalletNoSetup> { return await _tu.createKnexTestWalletWithSetup({ ...args, insertSetup: insertEmptySetup }) } static async createKnexTestSetup1Wallet(args: { knex: Knex<any, any[]> databaseName: string chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean }): Promise<TestWallet<TestSetup1>> { return await _tu.createKnexTestWalletWithSetup({ ...args, insertSetup: _tu.createTestSetup1 }) } static async createKnexTestSetup2Wallet(args: { knex: Knex<any, any[]> databaseName: string mockData: MockData chain?: sdk.Chain rootKeyHex?: string dropAll?: boolean }): Promise<TestWallet<TestSetup2>> { return await _tu.createKnexTestWalletWithSetup({ ...args, insertSetup: async (storage: StorageKnex, identityKey: string) => { return _tu.createTestSetup2(storage, identityKey, args.mockData) } }) } static async fileExists(file: string): Promise<boolean> { try { const f = await fsp.open(file, 'r') await f.close() return true } catch (eu: unknown) { return false } } //if (await _tu.fileExists(walletFile)) static async createLegacyWalletSQLiteCopy(databaseName: string): Promise<TestWalletNoSetup> { const walletFile = await _tu.newTmpFile(`${databaseName}.sqlite`, false, false, true) const walletKnex = _tu.createLocalSQLite(walletFile) return await _tu.createLegacyWalletCopy(databaseName, walletKnex, walletFile) } static async createLegacyWalletMySQLCopy(databaseName: string): Promise<TestWalletNoSetup> { const walletKnex = _tu.createLocalMySQL(databaseName) return await _tu.createLegacyWalletCopy(databaseName, walletKnex) } static async createLiveWalletSQLiteWARNING( databaseFullPath: string = './test/data/walletLiveTestData.sqlite' ): Promise<TestWalletNoSetup> { return await this.createKnexTestWallet({ chain: 'test', rootKeyHex: _tu.legacyRootKeyHex, databaseName: 'walletLiveTestData', knex: _tu.createLocalSQLite(databaseFullPath) }) } static async createWalletSQLite( databaseFullPath: string = './test/data/tmp/walletNewTestData.sqlite', databaseName: string = 'walletNewTestData' ): Promise<TestWalletNoSetup> { return await this.createSQLiteTestWallet({ filePath: databaseFullPath, databaseName, chain: 'test', rootKeyHex: '1'.repeat(64), dropAll: true }) } static legacyRootKeyHex = '153a3df216' + '686f55b253991c' + '7039da1f648' + 'ffc5bfe93d6ac2c25ac' + '2d4070918d' static async createLegacyWalletCopy( databaseName: string, walletKnex: Knex<any, any[]>, tryCopyToPath?: string ): Promise<TestWalletNoSetup> { const readerFile = await _tu.existingDataFile(`walletLegacyTestData.sqlite`) let useReader = true if (tryCopyToPath) { await _tu.copyFile(readerFile, tryCopyToPath) //console.log('USING FILE COPY INSTEAD OF SOURCE DB SYNC') useReader = false } const chain: sdk.Chain = 'test' const rootKeyHex = _tu.legacyRootKeyHex const identityKey = '03ac2d10bdb0023f4145cc2eba2fcd2ad3070cb2107b0b48170c46a9440e4cc3fe' const rootKey = PrivateKey.fromHex(rootKeyHex) const keyDeriver = new CachedKeyDeriver(rootKey) const activeStorage = new StorageKnex({ chain, knex: walletKnex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) if (useReader) await activeStorage.dropAllData() await activeStorage.migrate(databaseName, randomBytesHex(33)) await activeStorage.makeAvailable() const storage = new WalletStorageManager(identityKey, activeStorage) await storage.makeAvailable() if (useReader) { const readerKnex = _tu.createLocalSQLite(readerFile) const reader = new StorageKnex({ chain, knex: readerKnex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) await reader.makeAvailable() await storage.syncFromReader(identityKey, new StorageSyncReader({ identityKey }, reader)) await reader.destroy() } const services = new Services(chain) const monopts = Monitor.createDefaultWalletMonitorOptions(chain, storage, services) const monitor = new Monitor(monopts) const wallet = new Wallet({ chain, keyDeriver, storage, services, monitor }) const userId = verifyTruthy(await activeStorage.findUserByIdentityKey(identityKey)).userId const r: TestWallet<{}> = { rootKey, identityKey, keyDeriver, chain, activeStorage, storage, setup: {}, services, monitor, wallet, userId } return r } static wrapProfiling(o: Object, name: string): Record<string, { count: number; totalMsecs: number }> { const getFunctionsNames = (obj: Object) => { let fNames: string[] = [] do { fNames = fNames.concat( Object.getOwnPropertyNames(obj).filter(p => p !== 'constructor' && typeof obj[p] === 'function') ) } while ((obj = Object.getPrototypeOf(obj)) && obj !== Object.prototype) return fNames } const notifyPerformance = (fn, performanceDetails) => { setTimeout(() => { let { functionName, args, startTime, endTime } = performanceDetails let _args = args if (Array.isArray(args)) { _args = args.map(arg => { if (typeof arg === 'function') { let fName = arg.name if (!fName) { fName = 'function' } else if (fName === 'callbackWrapper') { fName = 'callback' } arg = `[${fName} Function]` } return arg }) } fn({ functionName, args: _args, startTime, endTime }) }, 0) } const stats: Record<string, { count: number; totalMsecs: number }> = {} function logger(args: { functionName: string; args: any; startTime: number; endTime: number }) { let s = stats[args.functionName] if (!s) { s = { count: 0, totalMsecs: 0 } stats[args.functionName] = s } s.count++ s.totalMsecs += args.endTime - args.startTime } const performanceWrapper = (obj: Object, objectName: string, performanceNotificationCallback: any) => { let _notifyPerformance = notifyPerformance.bind(null, performanceNotificationCallback) let fNames = getFunctionsNames(obj) for (let fName of fNames) { let originalFunction = obj[fName] let wrapperFunction = (...args) => { let callbackFnIndex = -1 let startTime = Date.now() let _callBack = args.filter((arg, i) => { let _isFunction = typeof arg === 'function' if (_isFunction) { callbackFnIndex = i } return _isFunction })[0] if (_callBack) { let callbackWrapper = (...callbackArgs) => { let endTime = Date.now() _notifyPerformance({ functionName: `${objectName}.${fName}`, args, startTime, endTime }) _callBack.apply(null, callbackArgs) } args[callbackFnIndex] = callbackWrapper } let originalReturnObject = originalFunction.apply(obj, args) let isPromiseType = originalReturnObject && typeof originalReturnObject.then === 'function' && typeof originalReturnObject.catch === 'function' if (isPromiseType) { return originalReturnObject .then(resolveArgs => { let endTime = Date.now() _notifyPerformance({ functionName: `${objectName}.${fName}`, args, startTime, endTime }) return Promise.resolve(resolveArgs) }) .catch((...rejectArgs) => { let endTime = Date.now() _notifyPerformance({ functionName: `${objectName}.${fName}`, args, startTime, endTime }) return Promise.reject(...rejectArgs) }) } if (!_callBack && !isPromiseType) { let endTime = Date.now() _notifyPerformance({ functionName: `${objectName}.${fName}`, args, startTime, endTime }) } return originalReturnObject } obj[fName] = wrapperFunction } return obj } const functionNames = getFunctionsNames(o) performanceWrapper(o, name, logger) return stats } static async createIdbLegacyWalletCopy(databaseName: string): Promise<TestWalletProviderNoSetup> { const chain: sdk.Chain = 'test' const readerFile = await _tu.existingDataFile(`walletLegacyTestData.sqlite`) const readerKnex = _tu.createLocalSQLite(readerFile) const reader = new StorageKnex({ chain, knex: readerKnex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) await reader.makeAvailable() const rootKeyHex = _tu.legacyRootKeyHex const identityKey = '03ac2d10bdb0023f4145cc2eba2fcd2ad3070cb2107b0b48170c46a9440e4cc3fe' const rootKey = PrivateKey.fromHex(rootKeyHex) const keyDeriver = new CachedKeyDeriver(rootKey) const activeStorage = new StorageIdb({ chain, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) await activeStorage.dropAllData() await activeStorage.migrate(databaseName, randomBytesHex(33)) await activeStorage.makeAvailable() const storage = new WalletStorageManager(identityKey, activeStorage) await storage.makeAvailable() await storage.syncFromReader(identityKey, new StorageSyncReader({ identityKey }, reader)) await reader.destroy() const services = new Services(chain) const monopts = Monitor.createDefaultWalletMonitorOptions(chain, storage, services) const monitor = new Monitor(monopts) const wallet = new Wallet({ chain, keyDeriver, storage, services, monitor }) const userId = verifyTruthy(await activeStorage.findUserByIdentityKey(identityKey)).userId const r: TestWalletProvider<{}> = { rootKey, identityKey, keyDeriver, chain, activeStorage, storage, setup: {}, services, monitor, wallet, userId } return r } static makeSampleCert(subject?: string): { cert: WalletCertificate subject: string certifier: PrivateKey } { subject ||= PrivateKey.fromRandom().toPublicKey().toString() const certifier = PrivateKey.fromRandom() const verifier = PrivateKey.fromRandom() const cert: WalletCertificate = { type: Utils.toBase64(new Array(32).fill(1)), serialNumber: Utils.toBase64(new Array(32).fill(2)), revocationOutpoint: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef.1', subject, certifier: certifier.toPublicKey().toString(), fields: { name: 'Alice', email: 'alice@example.com', organization: 'Example Corp' }, signature: '' } return { cert, subject, certifier } } static async insertTestProvenTx(storage: StorageProvider, txid?: string, trx?: TrxToken) { const now = new Date() const ptx: TableProvenTx = { created_at: now, updated_at: now, provenTxId: 0, txid: txid || randomBytesHex(32), height: 1, index: 0, merklePath: [1, 2, 3, 4, 5, 6, 7, 8], rawTx: [4, 5, 6], blockHash: randomBytesHex(32), merkleRoot: randomBytesHex(32) } await storage.insertProvenTx(ptx, trx) return ptx } static async insertTestProvenTxReq( storage: StorageProvider, txid?: string, provenTxId?: number, onlyRequired?: boolean ) { const now = new Date() const ptxreq: TableProvenTxReq = { // Required: created_at: now, updated_at: now, provenTxReqId: 0, txid: txid || randomBytesHex(32), status: 'nosend', attempts: 0, notified: false, history: '{}', notify: '{}', rawTx: [4, 5, 6], // Optional: provenTxId: provenTxId || undefined, batch: onlyRequired ? undefined : randomBytesBase64(10), inputBEEF: onlyRequired ? undefined : [1, 2, 3] } await storage.insertProvenTxReq(ptxreq) return ptxreq } static async insertTestUser(storage: StorageProvider, identityKey?: string) { const now = new Date() const e: TableUser = { created_at: now, updated_at: now, userId: 0, identityKey: identityKey || randomBytesHex(33), activeStorage: storage.getSettings().storageIdentityKey } await storage.insertUser(e) return e } static async insertTestCertificate(storage: StorageProvider, u?: TableUser) { const now = new Date() u ||= await _tu.insertTestUser(storage) const e: TableCertificate = { created_at: now, updated_at: now, certificateId: 0, userId: u.userId, type: randomBytesBase64(33), serialNumber: randomBytesBase64(33), certifier: randomBytesHex(33), subject: randomBytesHex(33), verifier: undefined, revocationOutpoint: `${randomBytesHex(32)}.999`, signature: randomBytesHex(50), isDeleted: false } await storage.insertCertificate(e) return e } static async insertTestCertificateField(storage: StorageProvider, c: TableCertificate, name: string, value: string) { const now = new Date() const e: TableCertificateField = { created_at: now, updated_at: now, certificateId: c.certificateId, userId: c.userId, fieldName: name, fieldValue: value, masterKey: randomBytesBase64(40) } await storage.insertCertificateField(e) return e } static async insertTestOutputBasket( storage: StorageProvider, u?: TableUser | number, partial?: Partial<TableOutputBasket> ) { const now = new Date() let user: TableUser if (u === undefined) { user = await _tu.insertTestUser(storage) } else if (typeof u === 'number') { user = verifyOne(await storage.findUsers({ partial: { userId: u } })) as TableUser } else { user = u } const e: TableOutputBasket = { created_at: now, updated_at: now, basketId: 0, userId: user.userId, name: randomBytesHex(6), numberOfDesiredUTXOs: 42, minimumDesiredUTXOValue: 1642, isDeleted: false, ...(partial || {}) } await storage.insertOutputBasket(e) return e } static async insertTestTransaction( storage: StorageProvider, u?: TableUser, onlyRequired?: boolean, partial?: Partial<TableTransaction> ) { const now = new Date() u ||= await _tu.insertTestUser(storage) const e: TableTransaction = { // Required: created_at: now, updated_at: now, transactionId: 0, userId: u.userId, status: 'nosend', reference: randomBytesBase64(10), isOutgoing: true, satoshis: 9999, description: 'buy me a river', // Optional: version: onlyRequired ? undefined : 0, lockTime: onlyRequired ? undefined : 500000000, txid: onlyRequired ? undefined : randomBytesHex(32), inputBEEF: onlyRequired ? undefined : new Beef().toBinary(), rawTx: onlyRequired ? undefined : [1, 2, 3], ...(partial || {}) } await storage.insertTransaction(e) return { tx: e, user: u } } static async insertTestOutput( storage: StorageProvider, t: TableTransaction, vout: number, satoshis: number, basket?: TableOutputBasket, requiredOnly?: boolean, partial?: Partial<TableOutput> ) { const now = new Date() const e: TableOutput = { created_at: now, updated_at: now, outputId: 0, userId: t.userId, transactionId: t.transactionId, basketId: basket ? basket.basketId : undefined, spendable: true, change: true, outputDescription: 'not mutch to say', vout, satoshis, providedBy: 'you', purpose: 'secret', type: 'custom', txid: requiredOnly ? undefined : randomBytesHex(32), senderIdentityKey: requiredOnly ? undefined : randomBytesHex(32), derivationPrefix: requiredOnly ? undefined : randomBytesHex(16), derivationSuffix: requiredOnly ? undefined : randomBytesHex(16), spentBy: undefined, // must be a valid transactionId sequenceNumber: requiredOnly ? undefined : 42, spendingDescription: requiredOnly ? undefined : randomBytesHex(16), scriptLength: requiredOnly ? undefined : 36, scriptOffset: requiredOnly ? undefined : 12, lockingScript: requiredOnly ? undefined : asArray(randomBytesHex(36)), ...(partial || {}) } await storage.insertOutput(e) return e } static async insertTestOutputTag(storage: StorageProvider, u: TableUser, partial?: Partial<TableOutputTag>) { const now = new Date() const e: TableOutputTag = { created_at: now, updated_at: now, outputTagId: 0, userId: u.userId, tag: randomBytesHex(6), isDeleted: false, ...(partial || {}) } await storage.insertOutputTag(e) return e } static async insertTestOutputTagMap(storage: StorageProvider, o: TableOutput, tag: TableOutputTag) { const now = new Date() const e: TableOutputTagMap = { created_at: now, updated_at: now, outputTagId: tag.outputTagId, outputId: o.outputId, isDeleted: false } await storage.insertOutputTagMap(e) return e } static async insertTestTxLabel(storage: StorageProvider, u: TableUser, partial?: Partial<TableTxLabel>) { const now = new Date() const e: TableTxLabel = { created_at: now, updated_at: now, txLabelId: 0, userId: u.userId, label: randomBytesHex(6), isDeleted: false, ...(partial || {}) } await storage.insertTxLabel(e) return e } static async insertTestTxLabelMap( storage: StorageProvider, tx: TableTransaction, label: TableTxLabel, partial?: Partial<TableTxLabelMap> ) { const now = new Date() const e: TableTxLabelMap = { created_at: now, updated_at: now, txLabelId: label.txLabelId, transactionId: tx.transactionId, isDeleted: false, ...(partial || {}) } await storage.insertTxLabelMap(e) return e } static async insertTestSyncState(storage: StorageProvider, u: TableUser) { const now = new Date() const settings = await storage.getSettings() const e: TableSyncState = { created_at: now, updated_at: now, syncStateId: 0, userId: u.userId, storageIdentityKey: settings.storageIdentityKey, storageName: settings.storageName, status: 'unknown', init: false, refNum: randomBytesBase64(10), syncMap: '{}' } await storage.insertSyncState(e) return e } static async insertTestMonitorEvent(storage: StorageProvider) { const now = new Date() const e: TableMonitorEvent = { created_at: now, updated_at: now, id: 0, event: 'nothing much happened' } await storage.insertMonitorEvent(e) return e } static async insertTestCommission(storage: StorageProvider, t: TableTransaction) { const now = new Date() const e: TableCommission = { created_at: now, updated_at: now, commissionId: 0, userId: t.userId, transactionId: t.transactionId, satoshis: 200, keyOffset: randomBytesBase64(32), isRedeemed: false, lockingScript: [1, 2, 3] } await storage.insertCommission(e) return e } static async createTestSetup1(storage: StorageProvider, u1IdentityKey?: string): Promise<TestSetup1> { const u1 = await _tu.insertTestUser(storage, u1IdentityKey) const u1basket1 = await _tu.insertTestOutputBasket(storage, u1) const u1basket2 = await _tu.insertTestOutputBasket(storage, u1) const u1label1 = await _tu.insertTestTxLabel(storage, u1) const u1label2 = await _tu.insertTestTxLabel(storage, u1) const u1tag1 = await _tu.insertTestOutputTag(storage, u1) const u1tag2 = await _tu.insertTestOutputTag(storage, u1) const { tx: u1tx1 } = await _tu.insertTestTransaction(storage, u1) const u1comm1 = await _tu.insertTestCommission(storage, u1tx1) const u1tx1label1 = await _tu.insertTestTxLabelMap(storage, u1tx1, u1label1) const u1tx1label2 = await _tu.insertTestTxLabelMap(storage, u1tx1, u1label2) const u1tx1o0 = await _tu.insertTestOutput(storage, u1tx1, 0, 101, u1basket1) const u1o0tag1 = await _tu.insertTestOutputTagMap(storage, u1tx1o0, u1tag1) const u1o0tag2 = await _tu.insertTestOutputTagMap(storage, u1tx1o0, u1tag2) const u1tx1o1 = await _tu.insertTestOutput(storage, u1tx1, 1, 111, u1basket2) const u1o1tag1 = await _tu.insertTestOutputTagMap(storage, u1tx1o1, u1tag1) const u1cert1 = await _tu.insertTestCertificate(storage, u1) const u1cert1field1 = await _tu.insertTestCertificateField(storage, u1cert1, 'bob', 'your uncle') const u1cert1field2 = await _tu.insertTestCertificateField(storage, u1cert1, 'name', 'alice') const u1cert2 = await _tu.insertTestCertificate(storage, u1) const u1cert2field1 = await _tu.insertTestCertificateField(storage, u1cert2, 'name', 'alice') const u1cert3 = await _tu.insertTestCertificate(storage, u1) const u1sync1 = await _tu.insertTestSyncState(storage, u1) const u2 = await _tu.insertTestUser(storage) const u2basket1 = await _tu.insertTestOutputBasket(storage, u2) const u2label1 = await _tu.insertTestTxLabel(storage, u2) const { tx: u2tx1 } = await _tu.insertTestTransaction(storage, u2, true) const u2comm1 = await _tu.insertTestCommission(storage, u2tx1) const u2tx1label1 = await _tu.insertTestTxLabelMap(storage, u2tx1, u2label1) const u2tx1o0 = await _tu.insertTestOutput(storage, u2tx1, 0, 101, u2basket1) const { tx: u2tx2 } = await _tu.insertTestTransaction(storage, u2, true) const u2comm2 = await _tu.insertTestCommission(storage, u2tx2) const proven1 = await _tu.insertTestProvenTx(storage) const req1 = await _tu.insertTestProvenTxReq(storage, undefined, undefined, true) const req2 = await _tu.insertTestProvenTxReq(storage, proven1.txid, proven1.provenTxId) const we1 = await _tu.insertTestMonitorEvent(storage) return { u1, u1basket1, u1basket2, u1label1, u1label2, u1tag1, u1tag2, u1tx1, u1comm1, u1tx1label1, u1tx1label2, u1tx1o0, u1o0tag1, u1o0tag2, u1tx1o1, u1o1tag1, u1cert1, u1cert1field1, u1cert1field2, u1cert2, u1cert2field1, u1cert3, u1sync1, u2, u2basket1, u2label1, u2tx1, u2comm1, u2tx1label1, u2tx1o0, u2tx2, u2comm2, proven1, req1, req2, we1 } } static async createTestSetup2( storage: StorageProvider, identityKey: string, mockData: MockData = { actions: [] } ): Promise<TestSetup2> { if (!mockData || !mockData.actions) { throw new Error('mockData.actions is required') } const now = new Date() const inputTxMap: Record<string, any> = {} const outputMap: Record<string, any> = {} // only one user const user = await _tu.insertTestUser(storage, identityKey) // First create your output that represent your inputs for (const action of mockData.actions) { for (const input of action.inputs || []) { let prevOutput = outputMap[input.sourceOutpoint] if (!prevOutput) { const { tx: transaction } = await _tu.insertTestTransaction(storage, user, false, { txid: input.sourceOutpoint.split('.')[0], satoshis: input.sourceSatoshis, status: 'confirmed' as sdk.TransactionStatus, description: 'Generated transaction for input', lockTime: 0, version: 1, inputBEEF: [1, 2, 3, 4], rawTx: [4, 3, 2, 1] }) const basket = await _tu.insertTestOutputBasket(storage, user, { name: randomBytesHex(6) }) // Need to convert const lockingScriptValue = input.sourceLockingScript ? Utils.toArray(input.sourceLockingScript, 'hex') : undefined prevOutput = await _tu.insertTestOutput( storage, transaction, 0, input.sourceSatoshis, basket, true, // Needs to be spendable { outputDescription: input.inputDescription, spendable: true, vout: Number(input.sourceOutpoint.split('.')[1]), lockingScript: lockingScriptValue, txid: transaction.txid } ) // Store in maps for later use inputTxMap[input.sourceOutpoint] = transaction outputMap[input.sourceOutpoint] = prevOutput } } } // Process transactions that spend those previous outputs for (const action of mockData.actions) { const { tx: transaction } = await _tu.insertTestTransaction(storage, user, false, { txid: `${action.txid}` || `tx_${action.satoshis}_${Date.now()}`, satoshis: action.satoshis, status: action.status as sdk.TransactionStatus, description: action.description, lockTime: action.lockTime, version: action.version, inputBEEF: [1, 2, 3, 4], rawTx: [4, 3, 2, 1] }) // Loop through action inputs and update chosen outputs for (const input of action.inputs || []) { // Output must exist before updating const prevOutput = outputMap[input.sourceOutpoint] if (!prevOutput) { throw new Error(`UTXO not found in outputMap for sourceOutpoint: ${input.sourceOutpoint}`) } // Set correct output fields as per input fields await storage.updateOutput(prevOutput.outputId, { spendable: false, // Mark output as spent spentBy: transaction.transactionId, // Reference the new transaction spendingDescription: input.inputDescription, // Store description sequenceNumber: input.sequenceNumber // Store sequence number }) } // Insert any new outputs for the transaction if (action.outputs) { for (const output of action.outputs) { const basket = await _tu.insertTestOutputBasket(storage, user, { name: output.basket }) const insertedOutput = await _tu.insertTestOutput( storage, transaction, output.outputIndex, output.satoshis, basket, false, { outputDescription: output.outputDescription, spendable: output.spendable, txid: transaction.txid } ) // Store this output in the map for future transactions to reference outputMap[`${action.txid}.${output.outputIndex}`] = insertedOutput } } // Labels inserted if (action.labels) { for (const label of action.labels) { const l = await _tu.insertTestTxLabel(storage, user, { label, isDeleted: false, created_at: now, updated_at: now, txLabelId: 0, userId: user.userId }) await _tu.insertTestTxLabelMap(storage, transaction, l) }