UNPKG

@bsv/wallet-toolbox

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

580 lines (538 loc) 17.9 kB
import { Knex, knex as makeKnex } from 'knex' import { KeyPairAddress, SetupWallet, SetupWalletClient } from './SetupWallet' import { BEEF, CachedKeyDeriver, CreateActionArgs, CreateActionOptions, CreateActionOutput, CreateActionResult, KeyDeriver, KeyDeriverApi, LockingScript, P2PKH, PrivateKey, PublicKey, ScriptTemplateUnlock, WalletInterface } from '@bsv/sdk' import { Chain } from './sdk/types' import { verifyTruthy } from './utility/utilityHelpers' import { WERR_INVALID_OPERATION } from './sdk/WERR_errors' import { WalletStorageManager } from './storage/WalletStorageManager' import { Services } from './services/Services' import { Monitor } from './monitor/Monitor' import { PrivilegedKeyManager } from './sdk/PrivilegedKeyManager' import { Wallet } from './Wallet' import { StorageClient } from './storage/remoting/StorageClient' import { StorageKnex } from './storage/StorageKnex' import { WalletStorageProvider } from './sdk/WalletStorage.interfaces' // To rely on your own headers service, uncomment the following line: // import { BHServiceClient } from './services/chaintracker' import * as dotenv from 'dotenv' dotenv.config() /** * The 'Setup` class provides static setup functions to construct BRC-100 compatible * wallets in a variety of configurations. * * It serves as a starting point for experimentation and customization. */ export abstract class Setup { /** * @param chain * @returns true if .env is not valid for chain */ static noEnv(chain: Chain): boolean { try { Setup.getEnv(chain) return false } catch { return true } } /** * Creates content for .env file with some private keys, identity keys, sample API keys, and sample MySQL connection string. * * Two new, random private keys are generated each time, with their associated public identity keys. * * Loading secrets from a .env file is intended only for experimentation and getting started. * Private keys should never be included directly in your source code. * * @publicbody */ static makeEnv(): string { const testPrivKey1 = PrivateKey.fromRandom() const testIdentityKey1 = testPrivKey1.toPublicKey().toString() const testPrivKey2 = PrivateKey.fromRandom() const testIdentityKey2 = testPrivKey2.toPublicKey().toString() const mainPrivKey1 = PrivateKey.fromRandom() const mainIdentityKey1 = mainPrivKey1.toPublicKey().toString() const mainPrivKey2 = PrivateKey.fromRandom() const mainIdentityKey2 = mainPrivKey2.toPublicKey().toString() const log = ` # .env file template for working with wallet-toolbox Setup functions. MY_TEST_IDENTITY = '${testIdentityKey1}' MY_TEST_IDENTITY2 = '${testIdentityKey2}' MY_MAIN_IDENTITY = '${mainIdentityKey1}' MY_MAIN_IDENTITY2 = '${mainIdentityKey2}' MAIN_TAAL_API_KEY='mainnet_9596de07e92300c6287e4393594ae39c' TEST_TAAL_API_KEY='testnet_0e6cf72133b43ea2d7861da2a38684e3' MYSQL_CONNECTION='{"port":3306,"host":"127.0.0.1","user":"root","password":"your_password","database":"your_database", "timezone": "Z"}' DEV_KEYS = '{ "${testIdentityKey1}": "${testPrivKey1.toString()}", "${testIdentityKey2}": "${testPrivKey2.toString()}", "${mainIdentityKey1}": "${mainPrivKey1.toString()}", "${mainIdentityKey2}": "${mainPrivKey2.toString()}" }' ` console.log(log) return log } /** * Reads a .env file of the format created by `makeEnv`. * * Returns values for designated `chain`. * * Access private keys through the `devKeys` object: `devKeys[identityKey]` * * @param chain Which chain to use: 'test' or 'main' * @returns {SetupEnv} with configuration environment secrets used by `Setup` functions. * * @publicbody */ static getEnv(chain: Chain): SetupEnv { // Identity keys of the lead maintainer of this repo... const identityKey = chain === 'main' ? process.env.MY_MAIN_IDENTITY : process.env.MY_TEST_IDENTITY const identityKey2 = chain === 'main' ? process.env.MY_MAIN_IDENTITY2 : process.env.MY_TEST_IDENTITY2 const filePath = chain === 'main' ? process.env.MY_MAIN_FILEPATH : process.env.MY_TEST_FILEPATH const DEV_KEYS = process.env.DEV_KEYS || '{}' const mySQLConnection = process.env.MYSQL_CONNECTION || '{}' const taalApiKey = verifyTruthy( chain === 'main' ? process.env.MAIN_TAAL_API_KEY : process.env.TEST_TAAL_API_KEY, `.env value for '${chain.toUpperCase()}_TAAL_API_KEY' is required.` ) if (!identityKey || !identityKey2) throw new WERR_INVALID_OPERATION('.env is not a valid SetupEnv configuration.') return { chain, identityKey, identityKey2, filePath, taalApiKey, devKeys: JSON.parse(DEV_KEYS) as Record<string, string>, mySQLConnection } } /** * Create a `Wallet`. Storage can optionally be provided or configured later. * * The following components are configured: KeyDeriver, WalletStorageManager, WalletService, WalletStorage. * Optionally, PrivilegedKeyManager is also configured. * * @publicbody */ static async createWallet(args: SetupWalletArgs): Promise<SetupWallet> { const chain = args.env.chain args.rootKeyHex ||= args.env.devKeys[args.env.identityKey] const rootKey = PrivateKey.fromHex(args.rootKeyHex) const identityKey = rootKey.toPublicKey().toString() const keyDeriver = new CachedKeyDeriver(rootKey) const storage = new WalletStorageManager(identityKey, args.active, args.backups) if (storage.canMakeAvailable()) await storage.makeAvailable() const serviceOptions = Services.createDefaultOptions(chain) serviceOptions.taalApiKey = args.env.taalApiKey // To rely on your own headers service, uncomment the following line, updating the url and apiKey to your own values. // serviceOptions.chaintracks = new BHServiceClient('main', 'https://headers.spv.money', 'fC42F069YJs30FaWBAgikfDFEfIW1j4q') const services = new Services(serviceOptions) const monopts = Monitor.createDefaultWalletMonitorOptions(chain, storage, services) const monitor = new Monitor(monopts) monitor.addDefaultTasks() const privilegedKeyManager = args.privilegedKeyGetter ? new PrivilegedKeyManager(args.privilegedKeyGetter) : undefined const wallet = new Wallet({ chain, keyDeriver, storage, services, monitor, privilegedKeyManager }) const r: SetupWallet = { rootKey, identityKey, keyDeriver, chain, storage, services, monitor, wallet } return r } /** * Setup a new `Wallet` without requiring a .env file. * * @param args.chain - 'main' or 'test' * @param args.rootKeyHex - Root private key for wallet's key deriver. * @param args.storageUrl - Optional. `StorageClient` and `chain` compatible endpoint URL. * @param args.privilegedKeyGetter - Optional. Method that will return the privileged `PrivateKey`, on demand. */ static async createWalletClientNoEnv(args: { chain: Chain rootKeyHex: string storageUrl?: string privilegedKeyGetter?: () => Promise<PrivateKey> }): Promise<Wallet> { const chain = args.chain const endpointUrl = args.storageUrl || `https://${args.chain !== 'main' ? 'staging-' : ''}storage.babbage.systems` const rootKey = PrivateKey.fromHex(args.rootKeyHex) const keyDeriver = new CachedKeyDeriver(rootKey) const storage = new WalletStorageManager(keyDeriver.identityKey) const services = new Services(chain) const privilegedKeyManager = args.privilegedKeyGetter ? new PrivilegedKeyManager(args.privilegedKeyGetter) : undefined const wallet = new Wallet({ chain, keyDeriver, storage, services, privilegedKeyManager }) const client = new StorageClient(wallet, endpointUrl) await storage.addWalletStorageProvider(client) await storage.makeAvailable() return wallet } /** * @publicbody */ static async createWalletClient(args: SetupWalletClientArgs): Promise<SetupWalletClient> { const wo = await Setup.createWallet(args) const endpointUrl = args.endpointUrl || `https://${args.env.chain !== 'main' ? 'staging-' : ''}storage.babbage.systems` const client = new StorageClient(wo.wallet, endpointUrl) await wo.storage.addWalletStorageProvider(client) await wo.storage.makeAvailable() return { ...wo, endpointUrl } } /** * @publicbody */ static getKeyPair(priv?: string | PrivateKey): KeyPairAddress { 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 } } /** * @publicbody */ static getLockP2PKH(address: string): LockingScript { const p2pkh = new P2PKH() const lock = p2pkh.lock(address) return lock } /** * @publicbody */ static getUnlockP2PKH(priv: PrivateKey, satoshis: number): ScriptTemplateUnlock { const p2pkh = new P2PKH() const lock = Setup.getLockP2PKH(Setup.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 } /** * @publicbody */ static createP2PKHOutputs( outputs: { address: string satoshis: number outputDescription?: string basket?: string tags?: string[] }[] ): CreateActionOutput[] { const os: CreateActionOutput[] = [] const count = outputs.length for (let i = 0; i < count; i++) { const o = outputs[i] os.push({ basket: o.basket, tags: o.tags, satoshis: o.satoshis, lockingScript: Setup.getLockP2PKH(o.address).toHex(), outputDescription: o.outputDescription || `p2pkh ${i}` }) } return os } /** * @publicbody */ static async createP2PKHOutputsAction( wallet: WalletInterface, outputs: { address: string satoshis: number outputDescription?: string basket?: string tags?: string[] }[], options?: CreateActionOptions ): Promise<{ cr: CreateActionResult outpoints: string[] | undefined }> { const os = Setup.createP2PKHOutputs(outputs) const createArgs: CreateActionArgs = { description: `createP2PKHOutputs`, outputs: os, options: { ...options, // Don't randomize so we can simplify outpoint creation randomizeOutputs: false } } const cr = await wallet.createAction(createArgs) let outpoints: string[] | undefined if (cr.txid) { outpoints = os.map((o, i) => `${cr.txid}.${i}`) } return { cr, outpoints } } /** * @publicbody */ static async fundWalletFromP2PKHOutpoints( wallet: WalletInterface, outpoints: string[], p2pkhKey: KeyPairAddress, inputBEEF?: BEEF ) { // TODO } /** * Adds `Knex` based storage to a `Wallet` configured by `Setup.createWalletOnly` * * @param args.knex `Knex` object configured for either MySQL or SQLite database access. * Schema will be created and migrated as needed. * For MySQL, a schema corresponding to databaseName must exist with full access permissions. * @param args.databaseName Name for this storage. For MySQL, the schema name within the MySQL instance. * @param args.chain Which chain this wallet is on: 'main' or 'test'. Defaults to 'test'. * @param args.rootKeyHex * * @publicbody */ static async createWalletKnex(args: SetupWalletKnexArgs): Promise<SetupWalletKnex> { const wo = await Setup.createWallet(args) const activeStorage = await Setup.createStorageKnex(args) await wo.storage.addWalletStorageProvider(activeStorage) const { user, isNew } = await activeStorage.findOrInsertUser(wo.identityKey) const userId = user.userId const r: SetupWalletKnex = { ...wo, activeStorage, userId } return r } /** * @returns {StorageKnex} - `Knex` based storage provider for a wallet. May be used for either active storage or backup storage. */ static async createStorageKnex(args: SetupWalletKnexArgs): Promise<StorageKnex> { // Create a temporary wallet setup to consistently resolve optional args. const wo = await Setup.createWallet(args) const storage = new StorageKnex({ chain: wo.chain, knex: args.knex, commissionSatoshis: 0, commissionPubKeyHex: undefined, feeModel: { model: 'sat/kb', value: 1 } }) await storage.migrate(args.databaseName, randomBytesHex(33)) await storage.makeAvailable() await wo.wallet.destroy() return storage } /** * @publicbody */ static createSQLiteKnex(filename: string): Knex { const config: Knex.Config = { client: 'sqlite3', connection: { filename }, useNullAsDefault: true } const knex = makeKnex(config) return knex } /** * @publicbody */ static createMySQLKnex(connection: string, database?: string): Knex { const c: Knex.MySql2ConnectionConfig = JSON.parse(connection) if (database) { c.database = database } const config: Knex.Config = { client: 'mysql2', connection: c, useNullAsDefault: true, pool: { min: 0, max: 7, idleTimeoutMillis: 15000 } } const knex = makeKnex(config) return knex } /** * @publicbody */ static async createWalletMySQL(args: SetupWalletMySQLArgs): Promise<SetupWalletKnex> { return await this.createWalletKnex({ ...args, knex: Setup.createMySQLKnex(args.env.mySQLConnection, args.databaseName) }) } /** * @publicbody */ static async createWalletSQLite(args: SetupWalletSQLiteArgs): Promise<SetupWalletKnex> { return await this.createWalletKnex({ ...args, knex: Setup.createSQLiteKnex(args.filePath) }) } } /** * Arguments used by `createWallet` to construct a `SetupWallet`. * * Extension `SetupWalletClientArgs` used by `createWalletClient` to construct a `SetupWalletClient`. * * Extension `SetupWalletKnexArgs` used by `createWalletKnex` to construct a `SetupWalletKnex`. * * Extension `SetupWalletMySQLArgs` used by `createWalletMySQL` to construct a `SetupWalletKnex`. * * Extension `SetupWalletSQLiteArgs` used by `createWalletSQLite` to construct a `SetupWalletKnex`. */ export interface SetupWalletArgs { /** * Configuration "secrets" typically obtained by `Setup.makeEnv` and `Setup.getEnv` functions. */ env: SetupEnv /** * Optional. The non-privileged private key used to initialize the `KeyDeriver` and determine the `identityKey`. * Defaults to `env.devKeys[env.identityKey] */ rootKeyHex?: string /** * Optional. The privileged private key getter used to initialize the `PrivilegedKeyManager`. * Defaults to undefined. */ privilegedKeyGetter?: () => Promise<PrivateKey> /** * Optional. Active wallet storage. Can be added later. */ active?: WalletStorageProvider /** * Optional. One or more storage providers managed as backup destinations. Can be added later. */ backups?: WalletStorageProvider[] } /** * */ export interface SetupWalletKnexArgs extends SetupWalletArgs { knex: Knex<any, any[]> databaseName: string } /** * */ export interface SetupWalletMySQLArgs extends SetupWalletArgs { databaseName: string } /** * */ export interface SetupWalletSQLiteArgs extends SetupWalletArgs { filePath: string databaseName: string } /** * */ export interface SetupWalletKnex extends SetupWallet { activeStorage: StorageKnex userId: number rootKey: PrivateKey identityKey: string keyDeriver: KeyDeriverApi chain: Chain storage: WalletStorageManager services: Services monitor: Monitor wallet: Wallet } /** * `SetupEnv` provides a starting point for managing secrets that * must not appear in source code. * * The `makeEnv` and `getEnv` functions of the `Setup` and `SetupClient` classes * provide an easy way to create and import these secrets and related properties. */ export interface SetupEnv { /** * The chan being accessed: 'main' for mainnet, 'test' for 'testnet'. */ chain: Chain /** * The user's primary identity key (public key). */ identityKey: string /** * A secondary identity key (public key), used to test exchanges with other users. */ identityKey2: string /** * Filepath to sqlite file to be used for identityKey wallet. */ filePath: string | undefined /** * A vaild TAAL API key for use by `Services` */ taalApiKey: string /** * A map of public keys (identity keys, hex strings) to private keys (hex strings). */ devKeys: Record<string, string> /** * A MySQL connection string including user and password properties. * Must be valid to make use of MySQL `Setup` class support. */ mySQLConnection: string } /** * Extension `SetupWalletClientArgs` of `SetupWalletArgs` is used by `createWalletClient` * to construct a `SetupWalletClient`. */ export interface SetupWalletClientArgs extends SetupWalletArgs { /** * The endpoint URL of a service hosting the `StorageServer` JSON-RPC service to * which a `StorageClient` instance should connect to function as * the active storage provider of the newly created wallet. */ endpointUrl?: string } function randomBytesHex(arg0: number): string { throw new Error('Function not implemented.') }