@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
436 lines (415 loc) • 18.8 kB
text/typescript
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Knex } from 'knex'
import { sdk, StorageKnex } from '../../index.all'
import { DBType } from '../StorageReader'
interface Migration {
up: (knex: Knex) => Promise<void>
down?: (knex: Knex) => Promise<void>
config?: object
}
interface MigrationSource<TMigrationSpec> {
getMigrations(loadExtensions: readonly string[]): Promise<TMigrationSpec[]>
getMigrationName(migration: TMigrationSpec): string
getMigration(migration: TMigrationSpec): Promise<Migration>
}
export class KnexMigrations implements MigrationSource<string> {
migrations: Record<string, Migration> = {}
/**
* @param chain
* @param storageName human readable name for this storage instance
* @param maxOutputScriptLength limit for scripts kept in outputs table, longer scripts will be pulled from rawTx
*/
constructor(
public chain: sdk.Chain,
public storageName: string,
public storageIdentityKey: string,
public maxOutputScriptLength: number
) {
this.migrations = this.setupMigrations(chain, storageName, storageIdentityKey, maxOutputScriptLength)
}
async getMigrations(): Promise<string[]> {
return Object.keys(this.migrations).sort()
}
getMigrationName(migration: string) {
return migration
}
async getMigration(migration: string): Promise<Migration> {
return this.migrations[migration]
}
async getLatestMigration(): Promise<string> {
const ms = await this.getMigrations()
return ms[ms.length - 1]
}
static async latestMigration(): Promise<string> {
const km = new KnexMigrations('test', 'dummy', '1'.repeat(64), 100)
return await km.getLatestMigration()
}
setupMigrations(
chain: string,
storageName: string,
storageIdentityKey: string,
maxOutputScriptLength: number
): Record<string, Migration> {
const migrations: Record<string, Migration> = {}
const addTimeStamps = (knex: Knex<any, any[]>, table: Knex.CreateTableBuilder, dbtype: DBType) => {
if (dbtype === 'MySQL') {
table.timestamp('created_at', { precision: 3 }).defaultTo(knex.fn.now(3)).notNullable()
table.timestamp('updated_at', { precision: 3 }).defaultTo(knex.fn.now(3)).notNullable()
} else {
table.timestamp('created_at', { precision: 3 }).defaultTo(knex.fn.now()).notNullable()
table.timestamp('updated_at', { precision: 3 }).defaultTo(knex.fn.now()).notNullable()
}
}
migrations['2025-05-13-001 add monitor events event index'] = {
async up(knex) {
await knex.schema.alterTable('monitor_events', table => {
table.index('event')
})
},
async down(knex) {
await knex.schema.alterTable('monitor_events', table => {
table.dropIndex('event')
})
}
}
migrations['2025-03-03-001 descriptions to 2000'] = {
async up(knex) {
await knex.schema.alterTable('transactions', table => {
table.string('description', 2048).alter()
})
await knex.schema.alterTable('outputs', table => {
table.string('outputDescription', 2048).alter()
table.string('spendingDescription', 2048).alter()
})
},
async down(knex) {}
}
migrations['2025-03-01-001 reset req history'] = {
async up(knex) {
const storage = new StorageKnex({
...StorageKnex.defaultOptions(),
chain: <sdk.Chain>chain,
knex
})
const settings = await storage.makeAvailable()
await knex.raw(`update proven_tx_reqs set history = '{}'`)
},
async down(knex) {
// No way back...
}
}
migrations['2025-02-28-001 derivations to 200'] = {
async up(knex) {
await knex.schema.alterTable('outputs', table => {
table.string('derivationPrefix', 200).alter()
table.string('derivationSuffix', 200).alter()
})
},
async down(knex) {
await knex.schema.alterTable('outputs', table => {
table.string('derivationPrefix', 32).alter()
table.string('derivationSuffix', 32).alter()
})
}
}
migrations['2025-02-22-001 nonNULL activeStorage'] = {
async up(knex) {
const storage = new StorageKnex({
...StorageKnex.defaultOptions(),
chain: <sdk.Chain>chain,
knex
})
const settings = await storage.makeAvailable()
await knex.raw(`update users set activeStorage = '${settings.storageIdentityKey}' where activeStorage is NULL`)
await knex.schema.alterTable('users', table => {
table.string('activeStorage').notNullable().alter()
})
},
async down(knex) {
await knex.schema.alterTable('users', table => {
table.string('activeStorage').nullable().alter()
})
}
}
migrations['2025-01-21-001 add activeStorage to users'] = {
async up(knex) {
await knex.schema.alterTable('users', table => {
table.string('activeStorage', 130).nullable().defaultTo(null)
})
},
async down(knex) {
await knex.schema.alterTable('users', table => {
table.dropColumn('activeStorage')
})
}
}
migrations['2024-12-26-001 initial migration'] = {
async up(knex) {
const dbtype = await KnexMigrations.dbtype(knex)
await knex.schema.createTable('proven_txs', table => {
addTimeStamps(knex, table, dbtype)
table.increments('provenTxId').notNullable()
table.string('txid', 64).notNullable().unique()
table.integer('height').unsigned().notNullable()
table.integer('index').unsigned().notNullable()
table.binary('merklePath').notNullable()
table.binary('rawTx').notNullable()
table.string('blockHash', 64).notNullable()
table.string('merkleRoot', 64).notNullable()
})
await knex.schema.createTable('proven_tx_reqs', table => {
addTimeStamps(knex, table, dbtype)
table.increments('provenTxReqId')
table.integer('provenTxId').unsigned().references('provenTxId').inTable('proven_txs')
table.string('status', 16).notNullable().defaultTo('unknown')
table.integer('attempts').unsigned().defaultTo(0).notNullable()
table.boolean('notified').notNullable().defaultTo(false)
table.string('txid', 64).notNullable().unique()
table.string('batch', 64).nullable()
table.text('history', 'longtext').notNullable().defaultTo('{}')
table.text('notify', 'longtext').notNullable().defaultTo('{}')
table.binary('rawTx').notNullable()
table.binary('inputBEEF')
table.index('status')
table.index('batch')
})
await knex.schema.createTable('users', table => {
addTimeStamps(knex, table, dbtype)
table.increments('userId')
table.string('identityKey', 130).notNullable().unique()
})
await knex.schema.createTable('certificates', table => {
addTimeStamps(knex, table, dbtype)
table.increments('certificateId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.string('serialNumber', 100).notNullable()
table.string('type', 100).notNullable()
table.string('certifier', 100).notNullable()
table.string('subject', 100).notNullable()
table.string('verifier', 100).nullable()
table.string('revocationOutpoint', 100).notNullable()
table.string('signature', 255).notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['userId', 'type', 'certifier', 'serialNumber'])
})
await knex.schema.createTable('certificate_fields', table => {
addTimeStamps(knex, table, dbtype)
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.integer('certificateId').unsigned().references('certificateId').inTable('certificates').notNullable()
table.string('fieldName', 100).notNullable()
table.string('fieldValue').notNullable()
table.string('masterKey', 255).defaultTo('').notNullable()
table.unique(['fieldName', 'certificateId'])
})
await knex.schema.createTable('output_baskets', table => {
addTimeStamps(knex, table, dbtype)
table.increments('basketId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.string('name', 300).notNullable()
table.integer('numberOfDesiredUTXOs', 6).defaultTo(6).notNullable()
table.integer('minimumDesiredUTXOValue', 15).defaultTo(10000).notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['name', 'userId'])
})
await knex.schema.createTable('transactions', table => {
addTimeStamps(knex, table, dbtype)
table.increments('transactionId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.integer('provenTxId').unsigned().references('provenTxId').inTable('proven_txs')
table.string('status', 64).notNullable()
table.string('reference', 64).notNullable().unique()
table.boolean('isOutgoing').notNullable()
table.bigint('satoshis').defaultTo(0).notNullable()
table.integer('version').unsigned().nullable()
table.integer('lockTime').unsigned().nullable()
table.string('description', 500).notNullable()
table.string('txid', 64)
table.binary('inputBEEF')
table.binary('rawTx')
table.index('status')
})
await knex.schema.createTable('commissions', table => {
addTimeStamps(knex, table, dbtype)
table.increments('commissionId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table
.integer('transactionId')
.unsigned()
.references('transactionId')
.inTable('transactions')
.notNullable()
.unique()
table.integer('satoshis', 15).notNullable()
table.string('keyOffset', 130).notNullable()
table.boolean('isRedeemed').defaultTo(false).notNullable()
table.binary('lockingScript').notNullable()
table.index('transactionId')
})
await knex.schema.createTable('outputs', table => {
addTimeStamps(knex, table, dbtype)
table.increments('outputId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.integer('transactionId').unsigned().references('transactionId').inTable('transactions').notNullable()
table.integer('basketId').unsigned().references('basketId').inTable('output_baskets')
table.boolean('spendable').defaultTo(false).notNullable()
table.boolean('change').defaultTo(false).notNullable()
table.integer('vout', 10).notNullable()
table.bigint('satoshis').notNullable()
table.string('providedBy', 130).notNullable()
table.string('purpose', 20).notNullable()
table.string('type', 50).notNullable()
table.string('outputDescription', 300) // allow extra room for encryption and imports
table.string('txid', 64)
table.string('senderIdentityKey', 130)
table.string('derivationPrefix', 32)
table.string('derivationSuffix', 32)
table.string('customInstructions', 2500)
table.integer('spentBy').unsigned().references('transactionId').inTable('transactions')
table.integer('sequenceNumber').unsigned().nullable()
table.string('spendingDescription')
table.bigint('scriptLength').unsigned().nullable()
table.bigint('scriptOffset').unsigned().nullable()
table.binary('lockingScript')
table.unique(['transactionId', 'vout', 'userId'])
})
await knex.schema.createTable('output_tags', table => {
addTimeStamps(knex, table, dbtype)
table.increments('outputTagId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.string('tag', 150).notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['tag', 'userId'])
})
await knex.schema.createTable('output_tags_map', table => {
addTimeStamps(knex, table, dbtype)
table.integer('outputTagId').unsigned().references('outputTagId').inTable('output_tags').notNullable()
table.integer('outputId').unsigned().references('outputId').inTable('outputs').notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['outputTagId', 'outputId'])
table.index('outputId')
})
await knex.schema.createTable('tx_labels', table => {
addTimeStamps(knex, table, dbtype)
table.increments('txLabelId')
table.integer('userId').unsigned().references('userId').inTable('users').notNullable()
table.string('label', 300).notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['label', 'userId'])
})
await knex.schema.createTable('tx_labels_map', table => {
addTimeStamps(knex, table, dbtype)
table.integer('txLabelId').unsigned().references('txLabelId').inTable('tx_labels').notNullable()
table.integer('transactionId').unsigned().references('transactionId').inTable('transactions').notNullable()
table.boolean('isDeleted').notNullable().defaultTo(false)
table.unique(['txLabelId', 'transactionId'])
table.index('transactionId')
})
await knex.schema.createTable('monitor_events', table => {
addTimeStamps(knex, table, dbtype)
table.increments('id')
table.string('event', 64).notNullable()
table.text('details', 'longtext').nullable()
})
await knex.schema.createTable('settings', table => {
addTimeStamps(knex, table, dbtype)
table.string('storageIdentityKey', 130).notNullable()
table.string('storageName', 128).notNullable()
table.string('chain', 10).notNullable()
table.string('dbtype', 10).notNullable()
table.integer('maxOutputScript', 15).notNullable()
})
await knex.schema.createTable('sync_states', table => {
addTimeStamps(knex, table, dbtype)
table.increments('syncStateId')
table.integer('userId').unsigned().notNullable().references('userId').inTable('users')
table.string('storageIdentityKey', 130).notNullable().defaultTo('')
table.string('storageName').notNullable()
table.string('status').notNullable().defaultTo('unknown')
table.boolean('init').notNullable().defaultTo(false)
table.string('refNum', 100).notNullable().unique()
table.text('syncMap', 'longtext').notNullable()
table.dateTime('when')
table.bigint('satoshis')
table.text('errorLocal', 'longtext')
table.text('errorOther', 'longtext')
table.index('status')
table.index('refNum')
})
if (dbtype === 'MySQL') {
await knex.raw('ALTER TABLE proven_tx_reqs MODIFY COLUMN rawTx LONGBLOB')
await knex.raw('ALTER TABLE proven_tx_reqs MODIFY COLUMN inputBEEF LONGBLOB')
await knex.raw('ALTER TABLE proven_txs MODIFY COLUMN rawTx LONGBLOB')
await knex.raw('ALTER TABLE transactions MODIFY COLUMN rawTx LONGBLOB')
await knex.raw('ALTER TABLE transactions MODIFY COLUMN inputBEEF LONGBLOB')
await knex.raw('ALTER TABLE outputs MODIFY COLUMN lockingScript LONGBLOB')
} else {
await knex.schema.alterTable('proven_tx_reqs', table => {
table.binary('rawTx', 10000000).alter()
table.binary('beef', 10000000).alter()
})
await knex.schema.alterTable('outputs', table => {
table.binary('lockingScript', 10000000).alter()
})
await knex.schema.alterTable('proven_txs', table => {
table.binary('rawTx', 10000000).alter()
})
await knex.schema.alterTable('transactions', table => {
table.binary('rawTx', 10000000).alter()
table.binary('beef', 10000000).alter()
})
}
await knex('settings').insert({
storageIdentityKey,
storageName,
chain,
dbtype,
maxOutputScript: maxOutputScriptLength
})
},
async down(knex) {
await knex.schema.dropTable('sync_states')
await knex.schema.dropTable('settings')
await knex.schema.dropTable('monitor_events')
await knex.schema.dropTable('certificate_fields')
await knex.schema.dropTable('certificates')
await knex.schema.dropTable('commissions')
await knex.schema.dropTable('output_tags_map')
await knex.schema.dropTable('output_tags')
await knex.schema.dropTable('outputs')
await knex.schema.dropTable('output_baskets')
await knex.schema.dropTable('tx_labels_map')
await knex.schema.dropTable('tx_labels')
await knex.schema.dropTable('transactions')
await knex.schema.dropTable('users')
await knex.schema.dropTable('proven_tx_reqs')
await knex.schema.dropTable('proven_txs')
}
}
return migrations
}
/**
* @param knex
* @returns {DBType} connected database engine variant
*/
static async dbtype(knex: Knex<any, any[]>): Promise<DBType> {
try {
const q = `SELECT
CASE
WHEN (SELECT VERSION() LIKE '%MariaDB%') = 1 THEN 'Unknown'
WHEN (SELECT VERSION()) IS NOT NULL THEN 'MySQL'
ELSE 'Unknown'
END AS database_type;`
let r = await knex.raw(q)
if (!r[0]['database_type']) r = r[0]
if (r['rows']) r = r.rows
const dbtype: 'SQLite' | 'MySQL' | 'Unknown' = r[0].database_type
if (dbtype === 'Unknown')
throw new sdk.WERR_NOT_IMPLEMENTED(`Attempting to create database on unsuported engine.`)
return dbtype
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
if (e.code === 'SQLITE_ERROR') return 'SQLite'
throw new sdk.WERR_NOT_IMPLEMENTED(`Attempting to create database on unsuported engine.`)
}
}
}