Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 38 additions & 38 deletions docs/reference/package-api-migrations.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions governance/package-release-notes.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions packages/wallet/wallet-toolbox/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ attention to changes that materially alter behavior or extend functionality.

## wallet-toolbox (unreleased)

- Keep cold raw-transaction reads on the caller's transaction, including SQLite
with one connection; do not cache uncommitted settings or start background
work. Accept missing optional inputBEEF when returning stored raw bytes.
- Reject unresolved output-basket mappings before sync writes. Apply valid newer
basket assignments/removals, preserving equal/older local relinquishment and
transaction rollback/retry. Included in unpublished 2.13.2; no schema migration.
- Restore transactional SQLite migrations in the unpublished 2.13.2 candidate.
DDL, migration journal and lock changes roll back after an interrupted attempt;
foreign-key enforcement is restored after success or failure. Existing stores
with unjournaled partial schema need operator-reviewed recovery; this change
does not delete or automatically reconcile historical wallet data.

- Implement `BHServiceClient.findChainTipHash()` by delegating to its existing
`findChainTipHeader()` call against `/api/v1/chain/tip/longest`, instead of
throwing `Not implemented`. `ChaintracksChainTracker.getVerificationContextToken()`
Expand Down
28 changes: 28 additions & 0 deletions packages/wallet/wallet-toolbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ Timing compares successive candidates, not a controlled comparison against upstr
`main`. Byte verification was sampled, not database-wide. See
[test methods and limits](#sync-performance-and-recovery) for details.

### SQLite migration recovery

The unpublished 2.13.2 candidate runs SQLite migration DDL and the migration
journal update transactionally. Foreign-key enforcement is disabled before the
migration transaction for table rebuilds and restored after success or failure.
Failed migrations can be retried after reopening the database without partial
schema objects from that attempt. MySQL's existing transaction configuration
is unchanged.

This prevents future partial migrations. It does not automatically repair a
store already left with unjournaled schema objects by an older version. Preserve
the database and verified backups and reconcile the exact schema and migration
journal before recovery; do not delete journal rows or wallet data blindly.

## Overview

The Wallet Toolbox is the reference implementation of the BRC-100 wallet interface. It connects the BSV SDK's cryptographic primitives to real storage backends, network services, and signing flows so that application developers don't have to wire these layers together themselves.
Expand Down Expand Up @@ -223,6 +237,20 @@ serialized RPC response exceeds the service ceiling, remote clients retry the
read-only request with a smaller chunk budget and remember the working limit
for the rest of the session.

Output synchronization requires a local mapping for every non-null source basket
ID. A missing mapping rejects the page so its transaction and checkpoint can roll
back; retry after transferring the missing basket. Newer source updates apply
basket changes, including explicit removal. Same-time or older updates preserve
local relinquishment. Previously unbasketed records require a verified newer
source update to repair; no heuristic rewrites existing wallet state.

`StorageKnex.getRawTxOfKnownValidTransaction()` can read a cold store while the
caller holds a transaction, including SQLite's single-connection pool. Settings
are read through that transaction without entering the shared cache or starting
prepared-BEEF background work; ordinary `makeAvailable()` remains the explicit
store-wide startup operation. An absent optional `inputBEEF` does not prevent
returning stored raw transaction bytes.

IndexedDB schema version 6 adds a non-unique transaction-ID/user index. Sync
identity lookups, commissions, and relation maps use selective indexes or exact
keys instead of scanning the growing wallet for each row. Proof batch checks
Expand Down
34 changes: 23 additions & 11 deletions packages/wallet/wallet-toolbox/src/storage/StorageKnex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide
)
if (reqRawTx != null) {
r.rawTx = Array.from(reqRawTx.rawTx)
r.inputBEEF = Array.from(reqRawTx.inputBEEF)
r.inputBEEF = reqRawTx.inputBEEF == null ? undefined : Array.from(reqRawTx.inputBEEF)
}
}
return r
Expand Down Expand Up @@ -440,19 +440,20 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide
await this.preparedBeefCoordinator.stop()
}

private rawTxSliceExpression(offset: number, length: number): Knex.Raw<Buffer> {
const sql = this.dbtype === 'MySQL' ? 'substring(?? from ? for ?)' : 'substr(??, ?, ?)'
private rawTxSliceExpression(offset: number, length: number, dbtype: DBType): Knex.Raw<Buffer> {
const sql = dbtype === 'MySQL' ? 'substring(?? from ? for ?)' : 'substr(??, ?, ?)'
return this.knex.raw(sql, ['rawTx', offset + 1, length])
}

private async getRawTxSlice(
txid: string,
offset: number,
length: number,
dbtype: DBType,
trx?: TrxToken
): Promise<number[] | undefined> {
const k = this.toDb(trx)
const slice = this.rawTxSliceExpression(offset, length)
const slice = this.rawTxSliceExpression(offset, length, dbtype)
const proven = verifyOneOrNone(await k('proven_txs').select({ rawTx: slice }).where({ txid })) as
{ rawTx: Buffer | null } | undefined
if (proven?.rawTx != null) return Array.from(proven.rawTx)
Expand Down Expand Up @@ -491,9 +492,15 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide
'a hexadecimal transaction id and non-negative safe slice integers with a safe sum'
)
}
if (!this.isAvailable()) await this.makeAvailable()
// A cold caller may already own SQLite's only connection. Read settings
// through that transaction without caching uncommitted state or starting
// the background backfill before the caller commits.
let settings: TableSettings
if (this.isAvailable()) settings = this.getSettings()
else if (trx != null) settings = await this.readSettings(trx)
else settings = await this.makeAvailable()
if (hasOffset) {
return await this.getRawTxSlice(txid, offset as number, length as number, trx)
return await this.getRawTxSlice(txid, offset as number, length as number, settings.dbtype, trx)
}
const r = await this.getProvenOrRawTx(txid, trx)
return r.proven != null ? r.proven.rawTx : r.rawTx
Expand Down Expand Up @@ -1598,17 +1605,22 @@ export class StorageKnex extends StorageProvider implements WalletStorageProvide
const clientName = (this.knex.client as { config?: { client?: string } }).config?.client ?? ''
const isSQLite = clientName.includes('sqlite')

// For SQLite, disable transactions during migrations and turn off foreign keys.
// PRAGMA foreign_keys is silently ignored inside transactions, so we must
// disable transactions for the migration to allow the PRAGMA to take effect.
// See: https://github.com/knex/knex/issues/4155
// For SQLite, turn foreign keys off for the duration of the migration.
// PRAGMA foreign_keys is silently ignored *when executed inside* a
// transaction (https://github.com/knex/knex/issues/4155), so it is issued
// here, outside migrate.latest(). SQLite's single-connection pool means
// knex's per-migration transaction runs on this same connection and
// inherits the setting, and knex's own SQLite alter-table rebuild leaves an
// ambient pragma alone while transacting (sqlite3/schema/ddl.js: alter()
// uses `enforceForeignCheck = this.client.transacting ? null : false`).
if (isSQLite) {
await this.knex.raw('PRAGMA foreign_keys = OFF;')
}
try {
const config = {
migrationSource: new KnexMigrations(this.chain, storageName, storageIdentityKey, 1024),
disableTransactions: isSQLite
// Keep DDL and its migration journal entry in the same transaction.
disableTransactions: false
}
await this.knex.migrate.latest(config)
return await this.knex.migrate.currentVersion(config)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { knex, type Knex } from 'knex'
import { StorageKnex } from '../StorageKnex'

const txid = 'ab'.repeat(32)
const bytes = [0, 1, 2, 128, 255]

async function coldStorage(proven = true): Promise<StorageKnex> {
const storage = new StorageKnex({
...StorageKnex.defaultOptions(),
chain: 'test',
knex: knex({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
pool: { min: 1, max: 1 },
acquireConnectionTimeout: 500
})
})
await storage.migrate('committed', '1'.repeat(64))
if (proven) {
await storage.knex('proven_txs').insert({
txid,
rawTx: Buffer.from(bytes),
height: 1,
index: 0,
merklePath: Buffer.from([0]),
blockHash: '11'.repeat(32),
merkleRoot: '22'.repeat(32)
})
} else {
await storage.knex('proven_tx_reqs').insert({ txid, rawTx: Buffer.from(bytes), status: 'completed' })
}
expect(storage.isAvailable()).toBe(false)
return storage
}

describe('cold transaction-owned raw transaction reads', () => {
test('compiles MySQL slices from transaction-local settings without populating the global cache', async () => {
const database = knex({ client: 'mysql2' })
const storage = new StorageKnex({ ...StorageKnex.defaultOptions(), chain: 'test', knex: database })
const queries: Knex.Sql[] = []
jest.spyOn(database.client, 'runner').mockImplementation(query => ({
run: async () => {
const compiled = (query as Knex.QueryBuilder).toSQL()
queries.push(compiled)
if (compiled.sql.includes('`settings`')) {
return [
{
created_at: new Date('2026-09-01'),
updated_at: new Date('2026-09-01'),
storageName: 'transaction-local',
storageIdentityKey: '1'.repeat(64),
chain: 'test',
dbtype: 'MySQL',
maxOutputScript: 1000
}
]
}
return [{ rawTx: Buffer.from([1, 2, 128]) }]
}
}))
try {
await expect(storage.getRawTxOfKnownValidTransaction(txid, 1, 3, database)).resolves.toEqual([1, 2, 128])
expect(queries).toHaveLength(2)
expect(queries[0].sql).toBe('select * from `settings`')
expect(queries[1].sql).toBe(
'select substring(`rawTx` from ? for ?) as `rawTx` from `proven_txs` where `txid` = ?'
)
expect(queries[1].bindings).toEqual([2, 3, txid])
expect(storage.isAvailable()).toBe(false)
} finally {
await storage.destroy()
}
})

test.each([true, false])('reads full and sliced bytes from proven=%s without a second connection', async proven => {
const storage = await coldStorage(proven)
const background = jest.spyOn(storage, 'startPreparedBeefBackfill')
try {
await storage.transaction(async trx => {
await expect(storage.getRawTxOfKnownValidTransaction(txid, undefined, undefined, trx)).resolves.toEqual(bytes)
await expect(storage.getRawTxOfKnownValidTransaction(txid, 1, 3, trx)).resolves.toEqual([1, 2, 128])
await expect(storage.getRawTxOfKnownValidTransaction(txid, 0, 0, trx)).resolves.toEqual([])
await expect(storage.getRawTxOfKnownValidTransaction(txid, 99, 2, trx)).resolves.toEqual([])
await expect(
storage.getRawTxOfKnownValidTransaction('cd'.repeat(32), undefined, undefined, trx)
).resolves.toBeUndefined()
await expect(storage.getRawTxOfKnownValidTransaction('cd'.repeat(32), 0, 1, trx)).resolves.toBeUndefined()
expect(storage.isAvailable()).toBe(false)
})
expect(background).not.toHaveBeenCalled()
await expect(storage.getRawTxOfKnownValidTransaction(txid)).resolves.toEqual(bytes)
expect(storage.isAvailable()).toBe(true)
expect(background).toHaveBeenCalledTimes(1)
await storage.transaction(async trx => {
await expect(storage.getRawTxOfKnownValidTransaction(txid, 2, 2, trx)).resolves.toEqual([2, 128])
})
} finally {
await storage.destroy()
}
})

test.each([true, false])('keeps transaction-local settings out of the global cache (rollback=%s)', async rollback => {
const storage = await coldStorage()
const background = jest.spyOn(storage, 'startPreparedBeefBackfill')
try {
const result = storage.knex.transaction(async trx => {
await trx('settings').update({ storageName: 'uncommitted' })
await expect(storage.getRawTxOfKnownValidTransaction(txid, 1, 1, trx)).resolves.toEqual([1])
expect(storage.isAvailable()).toBe(false)
expect(background).not.toHaveBeenCalled()
if (rollback) throw new Error('caller rollback')
})
if (rollback) await expect(result).rejects.toThrow('caller rollback')
else await result
expect(storage.isAvailable()).toBe(false)
const settings = await storage.makeAvailable()
expect(settings.storageName).toBe(rollback ? 'committed' : 'uncommitted')
} finally {
await storage.destroy()
}
})
})
Loading
Loading