taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 5bc7c2d2e65c21c2f87f9634e545c44ae5a5e36e
parent bbed91fb176f78fe41086cfb32b95a24a37e6396
Author: Florian Dold <dold@taler.net>
Date:   Sun, 19 Jul 2026 13:53:24 +0200

db: store keys, hashes and signatures as BLOBs

Smaller on disk and in indexes.  Record types still expose Crockford
strings, converted at the field mapping.

Diffstat:
Mpackages/taler-wallet-core/src/db-sqlite-schema.ts | 314++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-wallet-core/src/dbtx-bench.ts | 27++++++++++++++-------------
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 417++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Apackages/taler-wallet-core/src/dbtx-sqlite.test.ts | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 560+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
5 files changed, 942 insertions(+), 483 deletions(-)

diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -42,7 +42,7 @@ * * Bump this when adding a migration to {@link schemaMigrations}. */ -export const SQLITE_SCHEMA_VERSION = 1; +export const SQLITE_SCHEMA_VERSION = 3; /** * A single, ordered schema evolution step. @@ -74,6 +74,140 @@ export interface SchemaMigration { * grows as dbtx-sqlite.ts does, rather than being written speculatively for * all 43 tables at once. */ + +/** + * Columns stored as BLOB whose record fields are Crockford base32 strings. + * + * Single source of truth: the mappers convert according to this, and a test + * asserts that a populated database matches it exactly. Both matter, because + * neither the type checker nor sqlite will complain if they diverge — sqlite + * happily stores a string in a BLOB-declared column, and a TEXT value never + * compares equal to a BLOB one, so a half-converted column returns no rows + * and raises no error. + * + * Phase 1 of SQLITE_BLOB_PLAN.md: the hot, high-row-count tables. Columns in + * other tables holding the same kind of value (for example + * refund_items.coin_pub) are still TEXT. That is safe because no query joins + * across them — they are separate tables compared only against bound + * parameters — but it is why the phases have to be finished rather than left + * half-done. + */ +export const BLOB_COLUMNS: Readonly<Record<string, readonly string[]>> = { + coin_availability: ["denom_pub_hash", "exchange_master_pub"], + coin_history: ["coin_pub"], + coins: [ + "blinding_key", + "coin_ev_hash", + "coin_priv", + "coin_pub", + "denom_pub_hash", + ], + denomination_families: ["exchange_master_pub"], + denominations: ["denom_pub_hash", "exchange_master_pub", "master_sig"], + deposit_groups: [ + "contract_terms_hash", + "merchant_priv", + "merchant_pub", + "nonce_priv", + "nonce_pub", + ], + donation_planchets: [ + "bks", + "donation_unit_pub_hash", + "donor_tax_id_hash", + "udi_nonce", + ], + donation_receipts: [ + "donation_unit_pub_hash", + "donor_tax_id_hash", + "udi_nonce", + ], + exchange_details: ["master_public_key"], + exchange_sign_keys: ["master_sig", "signkey_pub"], + exchanges: [ + "current_account_priv", + "current_account_pub", + "details_pointer_master_pub", + ], + global_currency_auditors: ["auditor_pub"], + global_currency_exchanges: ["exchange_master_pub"], + peer_pull_credit: [ + "contract_enc_nonce", + "contract_priv", + "contract_pub", + "contract_terms_hash", + "kyc_payto_hash", + "merge_priv", + "merge_pub", + "purse_priv", + "purse_pub", + ], + peer_pull_debit: ["contract_priv", "contract_terms_hash", "purse_pub"], + peer_push_credit: [ + "contract_priv", + "contract_terms_hash", + "kyc_payto_hash", + "merge_priv", + "purse_pub", + ], + peer_push_debit: [ + "contract_enc_nonce", + "contract_priv", + "contract_pub", + "contract_terms_hash", + "merge_priv", + "merge_pub", + "purse_priv", + "purse_pub", + ], + planchets: [ + "blinding_key", + "coin_ev_hash", + "coin_priv", + "coin_pub", + "denom_pub_hash", + "withdraw_sig", + ], + purchases: [ + "donau_tax_id_hash", + "merchant_pay_sig", + "nonce_priv", + "nonce_pub", + "secret_seed", + ], + refresh_sessions: ["session_public_seed"], + refund_items: ["coin_pub"], + reserves: ["reserve_priv", "reserve_pub"], + slates: [ + "blinding_key", + "token_ev_hash", + "token_family_hash", + "token_issue_pub_hash", + "token_use_priv", + "token_use_pub", + ], + tokens: [ + "blinding_key", + "token_ev_hash", + "token_family_hash", + "token_issue_pub_hash", + "token_use_priv", + "token_use_pub", + ], + withdrawal_groups: [ + "contract_priv", + "kyc_payto_hash", + "reserve_priv", + "reserve_pub", + "secret_seed", + ], +}; + +/** True when the column is stored as a BLOB. */ +export function isBlobColumn(table: string, column: string): boolean { + return BLOB_COLUMNS[table]?.includes(column) ?? false; +} + export const SQLITE_BASELINE_SCHEMA = ` CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, @@ -135,8 +269,8 @@ CREATE TABLE IF NOT EXISTS operation_retries ( -- callers store it on the exchange entry, so ids must not be reused. CREATE TABLE IF NOT EXISTS reserves ( row_id INTEGER PRIMARY KEY AUTOINCREMENT, - reserve_pub TEXT NOT NULL, - reserve_priv TEXT NOT NULL, + reserve_pub BLOB NOT NULL, + reserve_priv BLOB NOT NULL, status INTEGER, requirement_row INTEGER, threshold_requested TEXT, @@ -154,9 +288,9 @@ CREATE INDEX IF NOT EXISTS reserves_by_reserve_pub -- wrong lookup. CREATE TABLE IF NOT EXISTS denominations ( exchange_base_url TEXT NOT NULL, - denom_pub_hash TEXT NOT NULL, + denom_pub_hash BLOB NOT NULL, denom_pub TEXT NOT NULL, - exchange_master_pub TEXT NOT NULL, + exchange_master_pub BLOB NOT NULL, currency TEXT NOT NULL, value TEXT NOT NULL, denomination_family_serial INTEGER, @@ -171,7 +305,7 @@ CREATE TABLE IF NOT EXISTS denominations ( is_offered INTEGER NOT NULL, is_revoked INTEGER NOT NULL, is_lost INTEGER, - master_sig TEXT NOT NULL, + master_sig BLOB NOT NULL, verification_status INTEGER NOT NULL, PRIMARY KEY (exchange_base_url, denom_pub_hash) ); @@ -192,7 +326,7 @@ CREATE TABLE IF NOT EXISTS global_currency_exchanges ( id INTEGER PRIMARY KEY AUTOINCREMENT, currency TEXT NOT NULL, exchange_base_url TEXT NOT NULL, - exchange_master_pub TEXT NOT NULL + exchange_master_pub BLOB NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS global_currency_exchanges_by_cur_url_pub ON global_currency_exchanges (currency, exchange_base_url, @@ -202,7 +336,7 @@ CREATE TABLE IF NOT EXISTS global_currency_auditors ( id INTEGER PRIMARY KEY AUTOINCREMENT, currency TEXT NOT NULL, auditor_base_url TEXT NOT NULL, - auditor_pub TEXT NOT NULL + auditor_pub BLOB NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS global_currency_auditors_by_cur_url_pub ON global_currency_auditors (currency, auditor_base_url, auditor_pub); @@ -218,8 +352,8 @@ CREATE INDEX IF NOT EXISTS bank_accounts_by_payto_uri ON bank_accounts (payto_uri); CREATE TABLE IF NOT EXISTS tokens ( - token_use_pub TEXT PRIMARY KEY, - token_use_priv TEXT NOT NULL, + token_use_pub BLOB PRIMARY KEY, + token_use_priv BLOB NOT NULL, purchase_id TEXT NOT NULL, transaction_id TEXT, choice_index INTEGER, @@ -227,15 +361,15 @@ CREATE TABLE IF NOT EXISTS tokens ( repeat_index INTEGER, merchant_base_url TEXT NOT NULL, kind TEXT NOT NULL, - token_issue_pub_hash TEXT NOT NULL, - token_family_hash TEXT, + token_issue_pub_hash BLOB NOT NULL, + token_family_hash BLOB, valid_after INTEGER NOT NULL, valid_before INTEGER NOT NULL, token_issue_sig TEXT NOT NULL, token_use_sig TEXT, token_ev TEXT NOT NULL, - token_ev_hash TEXT NOT NULL, - blinding_key TEXT NOT NULL, + token_ev_hash BLOB NOT NULL, + blinding_key BLOB NOT NULL, -- Inherited from TokenFamilyInfo. slug TEXT NOT NULL, name TEXT NOT NULL, @@ -252,8 +386,8 @@ CREATE INDEX IF NOT EXISTS tokens_by_family_hash ON tokens (token_family_hash); CREATE TABLE IF NOT EXISTS slates ( - token_use_pub TEXT PRIMARY KEY, - token_use_priv TEXT NOT NULL, + token_use_pub BLOB PRIMARY KEY, + token_use_priv BLOB NOT NULL, purchase_id TEXT NOT NULL, transaction_id TEXT, choice_index INTEGER, @@ -261,14 +395,14 @@ CREATE TABLE IF NOT EXISTS slates ( repeat_index INTEGER, merchant_base_url TEXT NOT NULL, kind TEXT NOT NULL, - token_issue_pub_hash TEXT NOT NULL, - token_family_hash TEXT, + token_issue_pub_hash BLOB NOT NULL, + token_family_hash BLOB, valid_after INTEGER NOT NULL, valid_before INTEGER NOT NULL, token_use_sig TEXT, token_ev TEXT NOT NULL, - token_ev_hash TEXT NOT NULL, - blinding_key TEXT NOT NULL, + token_ev_hash BLOB NOT NULL, + blinding_key BLOB NOT NULL, -- Inherited from TokenFamilyInfo. slug TEXT NOT NULL, name TEXT NOT NULL, @@ -287,7 +421,7 @@ CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat CREATE TABLE IF NOT EXISTS refresh_sessions ( refresh_group_id TEXT NOT NULL, coin_index INTEGER NOT NULL, - session_public_seed TEXT, + session_public_seed BLOB, amount_refresh_output TEXT NOT NULL, new_denoms TEXT NOT NULL, noreveal_index INTEGER, @@ -321,31 +455,31 @@ CREATE TABLE IF NOT EXISTS donation_summaries ( ); CREATE TABLE IF NOT EXISTS donation_planchets ( - udi_nonce TEXT PRIMARY KEY, + udi_nonce BLOB PRIMARY KEY, donau_base_url TEXT NOT NULL, - donor_tax_id_hash TEXT NOT NULL, + donor_tax_id_hash BLOB NOT NULL, donor_hash_salt TEXT NOT NULL, donor_tax_id TEXT NOT NULL, donation_year INTEGER NOT NULL, proposal_id TEXT NOT NULL, udi_index INTEGER NOT NULL, blinded_udi TEXT NOT NULL, - bks TEXT NOT NULL, - donation_unit_pub_hash TEXT NOT NULL, + bks BLOB NOT NULL, + donation_unit_pub_hash BLOB NOT NULL, value TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS donation_planchets_by_proposal ON donation_planchets (proposal_id); CREATE TABLE IF NOT EXISTS donation_receipts ( - udi_nonce TEXT PRIMARY KEY, + udi_nonce BLOB PRIMARY KEY, status INTEGER NOT NULL, donau_base_url TEXT NOT NULL, proposal_id TEXT NOT NULL, donation_year INTEGER NOT NULL, - donation_unit_pub_hash TEXT NOT NULL, + donation_unit_pub_hash BLOB NOT NULL, donation_unit_sig TEXT NOT NULL, - donor_tax_id_hash TEXT NOT NULL, + donor_tax_id_hash BLOB NOT NULL, donor_hash_salt TEXT NOT NULL, donor_tax_id TEXT NOT NULL, value TEXT NOT NULL, @@ -367,10 +501,10 @@ CREATE TABLE IF NOT EXISTS purchases ( abort_refresh_group_id TEXT, abort_reason TEXT, fail_reason TEXT, - nonce_priv TEXT NOT NULL, - nonce_pub TEXT NOT NULL, + nonce_priv BLOB NOT NULL, + nonce_pub BLOB NOT NULL, choice_index INTEGER, - secret_seed TEXT, + secret_seed BLOB, download TEXT, -- The ONLY copy of download.fulfillmentUrl: stripped from the JSON on -- write and re-inserted on read, so the indexed value cannot drift from @@ -379,12 +513,12 @@ CREATE TABLE IF NOT EXISTS purchases ( pay_info TEXT, pending_removed_coin_pubs TEXT, timestamp_first_successful_pay INTEGER, - merchant_pay_sig TEXT, + merchant_pay_sig BLOB, pos_confirmation TEXT, donau_output_index INTEGER, donau_base_url TEXT, donau_amount TEXT, - donau_tax_id_hash TEXT, + donau_tax_id_hash BLOB, donau_tax_id_salt TEXT, donau_tax_id TEXT, donau_year INTEGER, @@ -425,12 +559,12 @@ CREATE TABLE IF NOT EXISTS deposit_groups ( currency TEXT NOT NULL, amount TEXT NOT NULL, wire_transfer_deadline INTEGER NOT NULL, - merchant_pub TEXT NOT NULL, - merchant_priv TEXT NOT NULL, - nonce_priv TEXT NOT NULL, - nonce_pub TEXT NOT NULL, + merchant_pub BLOB NOT NULL, + merchant_priv BLOB NOT NULL, + nonce_priv BLOB NOT NULL, + nonce_pub BLOB NOT NULL, wire TEXT NOT NULL, - contract_terms_hash TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, pay_coin_selection TEXT, pay_coin_selection_uid TEXT, total_pay_cost TEXT NOT NULL, @@ -489,19 +623,19 @@ CREATE INDEX IF NOT EXISTS denom_loss_events_by_status ON denom_loss_events (status); CREATE TABLE IF NOT EXISTS peer_push_debit ( - purse_pub TEXT PRIMARY KEY, + purse_pub BLOB PRIMARY KEY, exchange_base_url TEXT NOT NULL, restrict_scope TEXT, amount TEXT NOT NULL, total_cost TEXT NOT NULL, coin_sel TEXT, - contract_terms_hash TEXT NOT NULL, - purse_priv TEXT NOT NULL, - merge_pub TEXT NOT NULL, - merge_priv TEXT NOT NULL, - contract_priv TEXT NOT NULL, - contract_pub TEXT NOT NULL, - contract_enc_nonce TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, + purse_priv BLOB NOT NULL, + merge_pub BLOB NOT NULL, + merge_priv BLOB NOT NULL, + contract_priv BLOB NOT NULL, + contract_pub BLOB NOT NULL, + contract_enc_nonce BLOB NOT NULL, purse_expiration INTEGER NOT NULL, timestamp_created INTEGER NOT NULL, abort_refresh_group_id TEXT, @@ -515,18 +649,18 @@ CREATE INDEX IF NOT EXISTS peer_push_debit_by_status CREATE TABLE IF NOT EXISTS peer_push_credit ( peer_push_credit_id TEXT PRIMARY KEY, exchange_base_url TEXT NOT NULL, - purse_pub TEXT NOT NULL, - merge_priv TEXT NOT NULL, - contract_priv TEXT NOT NULL, + purse_pub BLOB NOT NULL, + merge_priv BLOB NOT NULL, + contract_priv BLOB NOT NULL, timestamp INTEGER NOT NULL, estimated_amount_effective TEXT NOT NULL, - contract_terms_hash TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, status INTEGER NOT NULL, abort_reason TEXT, fail_reason TEXT, withdrawal_group_id TEXT, currency TEXT, - kyc_payto_hash TEXT, + kyc_payto_hash BLOB, kyc_access_token TEXT, kyc_last_check_status INTEGER, kyc_last_check_code INTEGER, @@ -545,12 +679,12 @@ CREATE INDEX IF NOT EXISTS peer_push_credit_by_withdrawal_group CREATE TABLE IF NOT EXISTS peer_pull_debit ( peer_pull_debit_id TEXT PRIMARY KEY, - purse_pub TEXT NOT NULL, + purse_pub BLOB NOT NULL, exchange_base_url TEXT NOT NULL, amount TEXT NOT NULL, - contract_terms_hash TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, timestamp_created INTEGER NOT NULL, - contract_priv TEXT NOT NULL, + contract_priv BLOB NOT NULL, status INTEGER NOT NULL, total_cost_estimated TEXT NOT NULL, abort_refresh_group_id TEXT, @@ -566,21 +700,21 @@ CREATE INDEX IF NOT EXISTS peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv); CREATE TABLE IF NOT EXISTS peer_pull_credit ( - purse_pub TEXT PRIMARY KEY, + purse_pub BLOB PRIMARY KEY, exchange_base_url TEXT NOT NULL, amount TEXT NOT NULL, estimated_amount_effective TEXT NOT NULL, - purse_priv TEXT NOT NULL, - contract_terms_hash TEXT NOT NULL, - merge_pub TEXT NOT NULL, - merge_priv TEXT NOT NULL, - contract_pub TEXT NOT NULL, - contract_priv TEXT NOT NULL, - contract_enc_nonce TEXT NOT NULL, + purse_priv BLOB NOT NULL, + contract_terms_hash BLOB NOT NULL, + merge_pub BLOB NOT NULL, + merge_priv BLOB NOT NULL, + contract_pub BLOB NOT NULL, + contract_priv BLOB NOT NULL, + contract_enc_nonce BLOB NOT NULL, merge_timestamp INTEGER NOT NULL, merge_reserve_row_id INTEGER NOT NULL, status INTEGER NOT NULL, - kyc_payto_hash TEXT, + kyc_payto_hash BLOB, kyc_access_token TEXT, kyc_last_check_status INTEGER, kyc_last_check_code INTEGER, @@ -621,7 +755,7 @@ CREATE TABLE IF NOT EXISTS exchanges ( -- WalletExchangeDetailsPointer or undefined, i.e. a required key, so the -- mapper always sets it and uses the master pub being NULL as the signal -- that there is no pointer. - details_pointer_master_pub TEXT, + details_pointer_master_pub BLOB, details_pointer_currency TEXT, details_pointer_update_clock INTEGER, entry_status INTEGER NOT NULL, @@ -636,8 +770,8 @@ CREATE TABLE IF NOT EXISTS exchanges ( last_keys_etag TEXT, next_refresh_check_stamp INTEGER NOT NULL, current_merge_reserve_row_id INTEGER, - current_account_priv TEXT, - current_account_pub TEXT, + current_account_priv BLOB, + current_account_pub BLOB, peer_payments_disabled INTEGER, direct_deposit_disabled INTEGER, no_fees INTEGER @@ -646,7 +780,7 @@ CREATE TABLE IF NOT EXISTS exchanges ( CREATE TABLE IF NOT EXISTS exchange_details ( row_id INTEGER PRIMARY KEY AUTOINCREMENT, exchange_base_url TEXT NOT NULL, - master_public_key TEXT NOT NULL, + master_public_key BLOB NOT NULL, currency TEXT NOT NULL, auditors TEXT NOT NULL, protocol_version_range TEXT NOT NULL, @@ -673,11 +807,11 @@ CREATE TABLE IF NOT EXISTS exchange_sign_keys ( REFERENCES exchange_details(row_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, - signkey_pub TEXT NOT NULL, + signkey_pub BLOB NOT NULL, stamp_start INTEGER NOT NULL, stamp_expire INTEGER NOT NULL, stamp_end INTEGER NOT NULL, - master_sig TEXT NOT NULL, + master_sig BLOB NOT NULL, PRIMARY KEY (exchange_details_row_id, signkey_pub) ); @@ -686,7 +820,7 @@ CREATE TABLE IF NOT EXISTS exchange_sign_keys ( CREATE TABLE IF NOT EXISTS denomination_families ( denomination_family_serial INTEGER PRIMARY KEY AUTOINCREMENT, exchange_base_url TEXT NOT NULL, - exchange_master_pub TEXT NOT NULL, + exchange_master_pub BLOB NOT NULL, value TEXT NOT NULL, fee_withdraw TEXT NOT NULL, fee_deposit TEXT NOT NULL, @@ -727,11 +861,11 @@ CREATE TABLE IF NOT EXISTS withdrawal_groups ( -- would let the indexed column and the payload disagree. taler_withdraw_uri TEXT, -- Promoted so the whole PeerPullCredit variant needs no JSON at all. - contract_priv TEXT, + contract_priv BLOB, bank_info TEXT, exchange_credit_accounts TEXT, is_foreign_account INTEGER, - kyc_payto_hash TEXT, + kyc_payto_hash BLOB, kyc_access_token TEXT, kyc_last_check_status INTEGER, kyc_last_check_code INTEGER, @@ -739,9 +873,9 @@ CREATE TABLE IF NOT EXISTS withdrawal_groups ( kyc_last_aml_review INTEGER, kyc_last_deny INTEGER, kyc_withdrawal_delay TEXT, - secret_seed TEXT NOT NULL, - reserve_pub TEXT NOT NULL, - reserve_priv TEXT NOT NULL, + secret_seed BLOB NOT NULL, + reserve_pub BLOB NOT NULL, + reserve_priv BLOB NOT NULL, exchange_base_url TEXT, timestamp_start INTEGER NOT NULL, timestamp_finish INTEGER, @@ -766,17 +900,17 @@ CREATE INDEX IF NOT EXISTS withdrawal_groups_by_taler_withdraw_uri ON withdrawal_groups (taler_withdraw_uri); CREATE TABLE IF NOT EXISTS planchets ( - coin_pub TEXT PRIMARY KEY, - coin_priv TEXT NOT NULL, + coin_pub BLOB PRIMARY KEY, + coin_priv BLOB NOT NULL, withdrawal_group_id TEXT NOT NULL, coin_idx INTEGER NOT NULL, planchet_status INTEGER NOT NULL, last_error TEXT, - denom_pub_hash TEXT NOT NULL, - blinding_key TEXT NOT NULL, - withdraw_sig TEXT NOT NULL, + denom_pub_hash BLOB NOT NULL, + blinding_key BLOB NOT NULL, + withdraw_sig BLOB NOT NULL, coin_ev TEXT NOT NULL, - coin_ev_hash TEXT NOT NULL, + coin_ev_hash BLOB NOT NULL, age_commitment_proof TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS planchets_by_group_and_index @@ -787,13 +921,13 @@ CREATE INDEX IF NOT EXISTS planchets_by_coin_ev ON planchets (coin_ev_hash); CREATE TABLE IF NOT EXISTS coins ( - coin_pub TEXT PRIMARY KEY, - coin_priv TEXT NOT NULL, + coin_pub BLOB PRIMARY KEY, + coin_priv BLOB NOT NULL, exchange_base_url TEXT NOT NULL, - denom_pub_hash TEXT NOT NULL, + denom_pub_hash BLOB NOT NULL, denom_sig TEXT NOT NULL, - blinding_key TEXT NOT NULL, - coin_ev_hash TEXT NOT NULL, + blinding_key BLOB NOT NULL, + coin_ev_hash BLOB NOT NULL, -- TEXT, not INTEGER: CoinStatus is a string enum ("fresh", "denom-loss", -- ...), unlike every other status column in this schema. status TEXT NOT NULL, @@ -820,11 +954,11 @@ CREATE INDEX IF NOT EXISTS coins_by_exchange_denom_age_status CREATE TABLE IF NOT EXISTS coin_availability ( exchange_base_url TEXT NOT NULL, - denom_pub_hash TEXT NOT NULL, + denom_pub_hash BLOB NOT NULL, max_age INTEGER NOT NULL, currency TEXT NOT NULL, value TEXT NOT NULL, - exchange_master_pub TEXT, + exchange_master_pub BLOB, fresh_coin_count INTEGER NOT NULL, visible_coin_count INTEGER NOT NULL, pending_refresh_output_count INTEGER, @@ -838,7 +972,7 @@ CREATE INDEX IF NOT EXISTS coin_availability_by_exchange_age_fresh ON coin_availability (exchange_base_url, max_age, fresh_coin_count); CREATE TABLE IF NOT EXISTS coin_history ( - coin_pub TEXT PRIMARY KEY, + coin_pub BLOB PRIMARY KEY, history TEXT NOT NULL ); @@ -871,7 +1005,7 @@ CREATE TABLE IF NOT EXISTS refund_items ( execution_time INTEGER NOT NULL, obtained_time INTEGER NOT NULL, refund_amount TEXT NOT NULL, - coin_pub TEXT NOT NULL, + coin_pub BLOB NOT NULL, rtxid INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS refund_items_by_group diff --git a/packages/taler-wallet-core/src/dbtx-bench.ts b/packages/taler-wallet-core/src/dbtx-bench.ts @@ -35,6 +35,7 @@ import { AmountString, CoinStatus, DenomKeyType, + encodeCrock, Logger, } from "@gnu-taler/taler-util"; import { @@ -93,16 +94,16 @@ function median(xs: number[]): number { } /** - * Deterministic pseudo-Crockford value of the right length. + * Deterministic Crockford value of the right length. * - * Fixed length matters: the point of the benchmark is partly to compare - * against a schema that stores these as BLOBs, so the strings have to be the - * size the real ones are (52 characters for a 32-byte key, 103 for a 64-byte - * hash) or the comparison measures the wrong thing. + * Built by encoding bytes rather than by picking characters. Real wallet + * values are always canonical encodings, and picking characters produces + * non-canonical ones: 52 Crockford characters carry 260 bits while a key is + * 256, so only about one random string in 16 is a spelling the database will + * hand back. Generating those measured a workload the wallet never runs. */ -function crockLike(seed: string, len: number): string { - const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - let out = ""; +function crockLike(seed: string, numBytes: number): string { + const bytes = new Uint8Array(numBytes); // Math.imul, not `*`: 32-bit hash multiplication overflows a double and // silently loses the low bits, which collapses distinct seeds onto the same // output. An earlier version did that and produced 4 distinct coin public @@ -112,16 +113,16 @@ function crockLike(seed: string, len: number): string { for (let i = 0; i < seed.length; i++) { h = Math.imul(h ^ seed.charCodeAt(i), 16777619); } - for (let i = 0; i < len; i++) { + for (let i = 0; i < numBytes; i++) { h = Math.imul(h ^ (h >>> 15), 2246822519); h = (h ^ (h >>> 13)) >>> 0; - out += alphabet[h % 32]; + bytes[i] = h & 0xff; } - return out; + return encodeCrock(bytes); } -const key = (s: string) => crockLike(s, 52); -const hash = (s: string) => crockLike(s, 103); +const key = (s: string) => crockLike(s, 32); +const hash = (s: string) => crockLike(s, 64); function exchangeUrl(i: number): string { return `https://exchange-${i}.test/`; diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -26,6 +26,8 @@ import { AmountString, CoinStatus, + decodeCrock, + encodeCrock, MerchantContractTokenKind, RefreshReason, DenomKeyType, @@ -91,6 +93,62 @@ import { } from "./db-common.js"; import { ConformanceCase } from "./dbtx-conformance.js"; +/** + * Deterministic Crockford base32 value derived from a readable label. + * + * The columns holding keys, hashes and signatures are BLOBs in the native + * schema, so the DAL decodes them on write and re-encodes on read: a fixture + * value like "dph-a" is not decodable, and would throw. Labels stay readable + * at the call sites and this maps them, so a record written as ck("coin-1") + * is found by a query for ck("coin-1"). + * + * Built by encoding bytes rather than by picking characters, because not + * every 52-character Crockford string is a canonical encoding of 32 bytes: + * 52 characters carry 260 bits and a key is 256, so the spare bits do not + * survive a decode/encode round trip. Generating characters directly + * produced values that came back differing in the last character. + */ +function ckBytes(label: string, numBytes: number): Uint8Array { + const out = new Uint8Array(numBytes); + let h = 2166136261; + for (let i = 0; i < label.length; i++) { + h = Math.imul(h ^ label.charCodeAt(i), 16777619); + } + for (let i = 0; i < numBytes; i++) { + h = Math.imul(h ^ (h >>> 15), 2246822519); + h = (h ^ (h >>> 13)) >>> 0; + out[i] = h & 0xff; + } + return out; +} + +/** + * Idempotent: a value that is already a canonical encoding of the right size + * is returned unchanged. + * + * These labels flow through fixtures, query arguments and assertions, and all + * three have to agree. Idempotence means a site can be wrapped without + * checking whether it was wrapped already, which is what makes converting + * them mechanically safe. + */ +function ckOfSize(label: string, numBytes: number): string { + try { + const decoded = decodeCrock(label); + if (decoded.length === numBytes && encodeCrock(decoded) === label) { + return label; + } + } catch (e) { + // Not Crockford at all; fall through and derive one. + } + return encodeCrock(ckBytes(label, numBytes)); +} + +/** A key-sized (32-byte) Crockford value. */ +const ck = (label: string): string => ckOfSize(label, 32); + +/** A hash-sized (64-byte) Crockford value. */ +const ckh = (label: string): string => ckOfSize(label, 64); + const ts = (seconds: number): DbProtocolTimestamp => timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(seconds)); @@ -115,13 +173,13 @@ function makeDenomination( // second implementation tried to persist it. const denom: WalletDenomination = { exchangeBaseUrl, - denomPubHash, + denomPubHash: ckh(denomPubHash), denomPub: { cipher: DenomKeyType.Rsa, rsa_public_key: "dummy", age_mask: 0, }, - exchangeMasterPub: "master-pub", + exchangeMasterPub: ck("master-pub"), currency: "TESTKUDOS", value: amt("TESTKUDOS:1"), denominationFamilySerial: opts.familySerial ?? 1, @@ -138,7 +196,7 @@ function makeDenomination( isOffered: opts.isOffered ?? true, isRevoked: false, isLost: false, - masterSig: "master-sig", + masterSig: ckh("master-sig"), verificationStatus: DenominationVerificationStatus.VerifiedGood, }; return denom; @@ -146,16 +204,16 @@ function makeDenomination( function makeCoin(coinPub: string): WalletCoin { const coin: WalletCoin = { - coinPub, - coinPriv: `priv-${coinPub}`, + coinPub: ck(coinPub), + coinPriv: ck(`priv-${coinPub}`), exchangeBaseUrl: "https://exchange.test/", - denomPubHash: "dph-default", + denomPubHash: ckh("dph-default"), denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: "sig-blob", }, - blindingKey: "bk-1", - coinEvHash: `evh-${coinPub}`, + blindingKey: ck("bk-1"), + coinEvHash: ckh(`evh-${coinPub}`), status: CoinStatus.Fresh, maxAge: 0, ageCommitmentProof: undefined, @@ -163,7 +221,7 @@ function makeCoin(coinPub: string): WalletCoin { type: CoinSourceType.Withdraw, withdrawalGroupId: "wg-default", coinIndex: 0, - reservePub: "rp-default", + reservePub: ck("rp-default"), }, }; return coin; @@ -176,7 +234,7 @@ function makeAvail( ): WalletCoinAvailability { const rec: WalletCoinAvailability = { exchangeBaseUrl, - denomPubHash, + denomPubHash: ckh(denomPubHash), maxAge, currency: "TESTKUDOS", value: amt("TESTKUDOS:1"), @@ -209,7 +267,7 @@ function makeExchangeDetails( ): WalletExchangeDetails { const det: WalletExchangeDetails = { exchangeBaseUrl, - masterPublicKey, + masterPublicKey: ck(masterPublicKey), currency: "TESTKUDOS", auditors: [], protocolVersionRange: "18:0:1", @@ -232,11 +290,11 @@ function makeSignKey( ): WalletExchangeSignkeys { const k: WalletExchangeSignkeys = { exchangeDetailsRowId, - signkeyPub, + signkeyPub: ck(signkeyPub), stampStart: ts(100), stampExpire: ts(200), stampEnd: ts(300), - masterSig: "sig-1", + masterSig: ckh("sig-1"), }; return k; } @@ -247,7 +305,7 @@ function makeFamilyParams( ): WalletDenomFamilyParams { const p: WalletDenomFamilyParams = { exchangeBaseUrl, - exchangeMasterPub: "mpk-fam", + exchangeMasterPub: ck("mpk-fam"), value: amt(value), feeWithdraw: amt("TESTKUDOS:0.01"), feeDeposit: amt("TESTKUDOS:0.01"), @@ -273,9 +331,9 @@ function makeWithdrawalGroup(withdrawalGroupId: string): WalletWithdrawalGroup { const wg: WalletWithdrawalGroup = { withdrawalGroupId, wgInfo: { withdrawalType: WithdrawalRecordType.BankManual }, - secretSeed: `seed-${withdrawalGroupId}`, - reservePub: `rpub-${withdrawalGroupId}`, - reservePriv: `rpriv-${withdrawalGroupId}`, + secretSeed: ck(`seed-${withdrawalGroupId}`), + reservePub: ck(`rpub-${withdrawalGroupId}`), + reservePriv: ck(`rpriv-${withdrawalGroupId}`), timestampStart: tsPrecise(1000), status: WithdrawalGroupStatus.PendingRegisteringBank, }; @@ -288,20 +346,20 @@ function makePlanchet( coinIdx: number, ): WalletPlanchet { const pl: WalletPlanchet = { - coinPub, - coinPriv: `priv-${coinPub}`, + coinPub: ck(coinPub), + coinPriv: ck(`priv-${coinPub}`), withdrawalGroupId, coinIdx, planchetStatus: PlanchetStatus.Pending, lastError: undefined, - denomPubHash: "dph-pl", - blindingKey: "bk-pl", - withdrawSig: "sig-pl", + denomPubHash: ckh("dph-pl"), + blindingKey: ck("bk-pl"), + withdrawSig: ckh("sig-pl"), coinEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded", }, - coinEvHash: `evh-${coinPub}`, + coinEvHash: ckh(`evh-${coinPub}`), }; return pl; } @@ -310,7 +368,7 @@ function makeDownloadInfo( fulfillmentUrl: string | undefined, ): WalletProposalDownloadInfo { const dl: WalletProposalDownloadInfo = { - contractTermsHash: "cth-1", + contractTermsHash: ckh("cth-1"), currency: "TESTKUDOS", contractTermsMerchantSig: "sig-1", ...(fulfillmentUrl !== undefined ? { fulfillmentUrl } : undefined), @@ -327,8 +385,8 @@ function makePurchase(proposalId: string): WalletPurchase { downloadSessionId: undefined, repurchaseProposalId: undefined, purchaseStatus: PurchaseStatus.PendingDownloadingProposal, - noncePriv: `npriv-${proposalId}`, - noncePub: `npub-${proposalId}`, + noncePriv: ck(`npriv-${proposalId}`), + noncePub: ck(`npub-${proposalId}`), secretSeed: undefined, download: undefined, payInfo: undefined, @@ -393,18 +451,18 @@ const tokenFamilyFields = () => ({ function makeToken(tokenUsePub: string): WalletToken { const tok: WalletToken = { ...tokenFamilyFields(), - tokenUsePub, - tokenUsePriv: `priv-${tokenUsePub}`, + tokenUsePub: ck(tokenUsePub), + tokenUsePriv: ck(`priv-${tokenUsePub}`), purchaseId: "pur-tok", merchantBaseUrl: "https://merchant.test/", kind: MerchantContractTokenKind.Subscription, - tokenIssuePubHash: "tiph-1", + tokenIssuePubHash: ckh("tiph-1"), validAfter: ts(100), validBefore: ts(200), tokenIssueSig: { cipher: DenomKeyType.Rsa, rsa_signature: "isig" }, tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" }, - tokenEvHash: `evh-${tokenUsePub}`, - blindingKey: "bk-tok", + tokenEvHash: ckh(`evh-${tokenUsePub}`), + blindingKey: ck("bk-tok"), }; return tok; } @@ -418,20 +476,20 @@ function makeSlate( ): WalletSlate { const sl: WalletSlate = { ...tokenFamilyFields(), - tokenUsePub, - tokenUsePriv: `priv-${tokenUsePub}`, + tokenUsePub: ck(tokenUsePub), + tokenUsePriv: ck(`priv-${tokenUsePub}`), purchaseId, choiceIndex, outputIndex, repeatIndex, merchantBaseUrl: "https://merchant.test/", kind: MerchantContractTokenKind.Subscription, - tokenIssuePubHash: "tiph-1", + tokenIssuePubHash: ckh("tiph-1"), validAfter: ts(100), validBefore: ts(200), tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" }, - tokenEvHash: `evh-${tokenUsePub}`, - blindingKey: "bk-slate", + tokenEvHash: ckh(`evh-${tokenUsePub}`), + blindingKey: ck("bk-slate"), }; return sl; } @@ -442,12 +500,12 @@ function makeDepositGroup(depositGroupId: string): WalletDepositGroup { currency: "TESTKUDOS", amount: amt("TESTKUDOS:5"), wireTransferDeadline: ts(9999), - merchantPub: "mpub", - merchantPriv: "mpriv", - noncePriv: "npriv", - noncePub: "npub", + merchantPub: ck("mpub"), + merchantPriv: ck("mpriv"), + noncePriv: ck("npriv"), + noncePub: ck("npub"), wire: { payto_uri: "payto://iban/DE1", salt: "salt-1" }, - contractTermsHash: "cth", + contractTermsHash: ckh("cth"), totalPayCost: amt("TESTKUDOS:5.1"), counterpartyEffectiveDepositAmount: amt("TESTKUDOS:5"), timestampCreated: tsPrecise(1000), @@ -483,7 +541,7 @@ function makeRefreshSession( refreshGroupId, coinIndex, amountRefreshOutput: amt("TESTKUDOS:1"), - newDenoms: [{ denomPubHash: "dph-1", count: 2 }], + newDenoms: [{ denomPubHash: ckh("dph-1"), count: 2 }], }; return rs; } @@ -507,17 +565,17 @@ function makeRecoupGroup( function makePeerPushDebit(pursePub: string): WalletPeerPushDebit { const rec: WalletPeerPushDebit = { - pursePub, + pursePub: ck(pursePub), exchangeBaseUrl: "https://exchange.test/", amount: amt("TESTKUDOS:3"), totalCost: amt("TESTKUDOS:3.1"), - contractTermsHash: "cth", - pursePriv: "ppriv", - mergePub: "mpub", - mergePriv: "mpriv", - contractPriv: "cpriv", - contractPub: "cpub", - contractEncNonce: "nonce", + contractTermsHash: ckh("cth"), + pursePriv: ck("ppriv"), + mergePub: ck("mpub"), + mergePriv: ck("mpriv"), + contractPriv: ck("cpriv"), + contractPub: ck("cpub"), + contractEncNonce: ck("nonce"), purseExpiration: ts(9999), timestampCreated: tsPrecise(1000), status: PeerPushDebitStatus.PendingCreatePurse, @@ -529,12 +587,12 @@ function makePeerPushCredit(peerPushCreditId: string): WalletPeerPushCredit { const rec: WalletPeerPushCredit = { peerPushCreditId, exchangeBaseUrl: "https://exchange.test/", - pursePub: `purse-${peerPushCreditId}`, - mergePriv: "mpriv", - contractPriv: "cpriv", + pursePub: ck(`purse-${peerPushCreditId}`), + mergePriv: ck("mpriv"), + contractPriv: ck("cpriv"), timestamp: tsPrecise(1000), estimatedAmountEffective: amt("TESTKUDOS:2"), - contractTermsHash: "cth", + contractTermsHash: ckh("cth"), status: PeerPushCreditStatus.PendingMerge, withdrawalGroupId: undefined, currency: undefined, @@ -545,12 +603,12 @@ function makePeerPushCredit(peerPushCreditId: string): WalletPeerPushCredit { function makePeerPullDebit(peerPullDebitId: string): WalletPeerPullDebit { const rec: WalletPeerPullDebit = { peerPullDebitId, - pursePub: `purse-${peerPullDebitId}`, + pursePub: ck(`purse-${peerPullDebitId}`), exchangeBaseUrl: "https://exchange.test/", amount: amt("TESTKUDOS:4"), - contractTermsHash: "cth", + contractTermsHash: ckh("cth"), timestampCreated: tsPrecise(1000), - contractPriv: "cpriv", + contractPriv: ck("cpriv"), status: PeerPullDebitRecordStatus.PendingDeposit, totalCostEstimated: amt("TESTKUDOS:4.1"), }; @@ -559,17 +617,17 @@ function makePeerPullDebit(peerPullDebitId: string): WalletPeerPullDebit { function makePeerPullCredit(pursePub: string): WalletPeerPullCredit { const rec: WalletPeerPullCredit = { - pursePub, + pursePub: ck(pursePub), exchangeBaseUrl: "https://exchange.test/", amount: amt("TESTKUDOS:6"), estimatedAmountEffective: amt("TESTKUDOS:6"), - pursePriv: "ppriv", - contractTermsHash: "cth", - mergePub: "mpub", - mergePriv: "mpriv", - contractPub: "cpub", - contractPriv: "cpriv", - contractEncNonce: "nonce", + pursePriv: ck("ppriv"), + contractTermsHash: ckh("cth"), + mergePub: ck("mpub"), + mergePriv: ck("mpriv"), + contractPub: ck("cpub"), + contractPriv: ck("cpriv"), + contractEncNonce: ck("nonce"), mergeTimestamp: tsPrecise(1000), mergeReserveRowId: 1, status: PeerPullPaymentCreditStatus.PendingCreatePurse, @@ -580,8 +638,8 @@ function makePeerPullCredit(pursePub: string): WalletPeerPullCredit { function makeReserve(reservePub: string): WalletReserve { const r: WalletReserve = { - reservePub, - reservePriv: `priv-of-${reservePub}`, + reservePub: ck(reservePub), + reservePriv: ck(`priv-of-${reservePub}`), }; return r; } @@ -612,7 +670,7 @@ function makeRefundItem( executionTime: ts(5000), obtainedTime: tsPrecise(5000), refundAmount: amt("TESTKUDOS:1"), - coinPub, + coinPub: ck(coinPub), rtxid, }; } @@ -640,7 +698,9 @@ export const conformanceCases: ConformanceCase[] = [ { name: "get on a missing key returns undefined, not a throw", async run(t, runner) { - const got = await runner.runTx((tx) => tx.getCoin("no-such-coin-pub")); + const got = await runner.runTx((tx) => + tx.getCoin(ck("no-such-coin-pub")), + ); t.equal(got, undefined); }, }, @@ -671,8 +731,8 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertDenomination(makeDenomination("https://e2/", "dph-a")); }); const [d1, d2] = await runner.runTx(async (tx) => [ - await tx.getDenomination("https://e1/", "dph-a"), - await tx.getDenomination("https://e2/", "dph-a"), + await tx.getDenomination("https://e1/", ckh("dph-a")), + await tx.getDenomination("https://e2/", ckh("dph-a")), ]); t.ok(d1, "same hash under a different exchange must be a distinct row"); t.ok(d2); @@ -721,12 +781,12 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertRefundGroup(makeRefundGroup("grp-3")); }); const got = await runner.runTx((tx) => - tx.getRefundItemByCoinAndRtxid("coin-9", 42), + tx.getRefundItemByCoinAndRtxid(ck("coin-9"), 42), ); t.ok(got, "compound index lookup must find the item"); t.equal(got?.refundGroupId, "grp-3"); const miss = await runner.runTx((tx) => - tx.getRefundItemByCoinAndRtxid("coin-9", 43), + tx.getRefundItemByCoinAndRtxid(ck("coin-9"), 43), ); t.equal(miss, undefined, "wrong rtxid must not match"); }, @@ -742,16 +802,26 @@ export const conformanceCases: ConformanceCase[] = [ // exchange entry silently. async run(t, runner) { const id1 = await runner.runTx((tx) => - tx.upsertReserve({ reservePub: "rp-1", reservePriv: "rv-1" } as any), + tx.upsertReserve({ + reservePub: ck("rp-1"), + reservePriv: ck("rv-1"), + } as any), ); const id2 = await runner.runTx((tx) => - tx.upsertReserve({ reservePub: "rp-2", reservePriv: "rv-2" } as any), + tx.upsertReserve({ + reservePub: ck("rp-2"), + reservePriv: ck("rv-2"), + } as any), ); t.equal(typeof id1, "number"); t.equal(typeof id2, "number"); t.ok(id1 !== id2, "generated ids must be distinct"); const back = await runner.runTx((tx) => tx.getReserve(id1)); - t.equal(back?.reservePub, "rp-1", "the returned id must address the row"); + t.equal( + back?.reservePub, + ck("rp-1"), + "the returned id must address the row", + ); }, }, @@ -789,7 +859,7 @@ export const conformanceCases: ConformanceCase[] = [ ); t.equal( found?.denomPubHash, - "d-3", + ckh("d-3"), "must return the first match in order", ); }, @@ -884,7 +954,11 @@ export const conformanceCases: ConformanceCase[] = [ const found = await runner.runTx((tx) => tx.findDenominationByFamilyFromExpiry(111, ts(10000), () => true), ); - t.equal(found?.denomPubHash, "new", "must skip records before the bound"); + t.equal( + found?.denomPubHash, + ckh("new"), + "must skip records before the bound", + ); }, }, @@ -977,7 +1051,7 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { await runner.runTx(async (tx) => { await tx.deletePurchase("no-such-proposal"); - await tx.deleteDenomination("https://nope/", "no-such-hash"); + await tx.deleteDenomination("https://nope/", ckh("no-such-hash")); await tx.deleteRefundGroup("no-such-group"); }); t.ok(true, "deleting a missing row must not throw"); @@ -1035,9 +1109,9 @@ export const conformanceCases: ConformanceCase[] = [ t.ok(typeof id1 === "number", "upsertReserve must return the row id"); t.ok(id1 !== id2, "row ids must be distinct"); const got = await runner.runTx((tx) => - tx.getReserveByReservePub("rpub-2"), + tx.getReserveByReservePub(ck("rpub-2")), ); - t.equal(got?.reservePub, "rpub-2"); + t.equal(got?.reservePub, ck("rpub-2")); t.equal(got?.rowId, id2, "the id returned must be the id stored"); }, }, @@ -1164,14 +1238,14 @@ export const conformanceCases: ConformanceCase[] = [ tx.getRefundItemsByGroup("rg-del"), ); t.equal(items.length, 2); - const victim = items.find((i) => i.coinPub === "coin-d1"); + const victim = items.find((i) => i.coinPub === ck("coin-d1")); t.ok(victim?.id !== undefined, "stored items must carry their row id"); await runner.runTx((tx) => tx.deleteRefundItem(victim!.id!)); const left = await runner.runTx((tx) => tx.getRefundItemsByGroup("rg-del"), ); t.equal(left.length, 1); - t.equal(left[0].coinPub, "coin-d2"); + t.equal(left[0].coinPub, ck("coin-d2")); }, }, @@ -1192,7 +1266,7 @@ export const conformanceCases: ConformanceCase[] = [ ), ); t.equal(unverified.length, 1, "must filter on the status"); - t.equal(unverified[0].denomPubHash, "vs-a"); + t.equal(unverified[0].denomPubHash, ckh("vs-a")); }, }, // ---------------------------------------------------------------- coins @@ -1202,7 +1276,7 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const coin = makeCoin("cp-1"); await runner.runTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runTx((tx) => tx.getCoin("cp-1")); + const got = await runner.runTx((tx) => tx.getCoin(ck("cp-1"))); t.ok(got, "coin should exist"); t.deepEqual(got, coin, "every field must survive the round trip"); t.ok( @@ -1220,10 +1294,10 @@ export const conformanceCases: ConformanceCase[] = [ type: CoinSourceType.Withdraw, withdrawalGroupId: "wg-1", coinIndex: 3, - reservePub: "rp-1", + reservePub: ck("rp-1"), }; await runner.runTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runTx((tx) => tx.getCoin("cp-src")); + const got = await runner.runTx((tx) => tx.getCoin(ck("cp-src"))); t.deepEqual(got?.coinSource, coin.coinSource); t.deepEqual(got?.denomSig, coin.denomSig); }, @@ -1235,7 +1309,7 @@ export const conformanceCases: ConformanceCase[] = [ const coin = makeCoin("cp-status"); coin.status = CoinStatus.Dormant; await runner.runTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runTx((tx) => tx.getCoin("cp-status")); + const got = await runner.runTx((tx) => tx.getCoin(ck("cp-status"))); t.equal(got?.status, CoinStatus.Dormant); t.equal(typeof got?.status, "string", "CoinStatus must not be coerced"); }, @@ -1250,7 +1324,7 @@ export const conformanceCases: ConformanceCase[] = [ coin.visible = 1; await runner.runTx((tx) => tx.upsertCoin(coin)); const all = await runner.runTx((tx) => tx.listAllCoins()); - const mine = all.filter((c) => c.coinPub === "cp-up"); + const mine = all.filter((c) => c.coinPub === ck("cp-up")); t.equal(mine.length, 1, "a second upsert must replace, not append"); t.equal(mine[0].status, CoinStatus.Dormant); t.equal(mine[0].visible, 1); @@ -1263,15 +1337,15 @@ export const conformanceCases: ConformanceCase[] = [ await runner.runTx(async (tx) => { const a = makeCoin("cq-1"); a.exchangeBaseUrl = "https://ex-a/"; - a.denomPubHash = "dq-1"; + a.denomPubHash = ckh("dq-1"); a.sourceTransactionId = "txn-1"; const b = makeCoin("cq-2"); b.exchangeBaseUrl = "https://ex-a/"; - b.denomPubHash = "dq-2"; + b.denomPubHash = ckh("dq-2"); b.sourceTransactionId = "txn-2"; const c = makeCoin("cq-3"); c.exchangeBaseUrl = "https://ex-b/"; - c.denomPubHash = "dq-1"; + c.denomPubHash = ckh("dq-1"); await tx.upsertCoin(a); await tx.upsertCoin(b); await tx.upsertCoin(c); @@ -1285,14 +1359,14 @@ export const conformanceCases: ConformanceCase[] = [ ); t.equal(count, 2, "count must agree with the list"); const byDenom = await runner.runTx((tx) => - tx.getCoinsByDenomPubHash("dq-1"), + tx.getCoinsByDenomPubHash(ckh("dq-1")), ); t.equal(byDenom.length, 2, "denom hash spans exchanges"); const bySrc = await runner.runTx((tx) => tx.getCoinsBySourceTransaction("txn-1"), ); t.equal(bySrc.length, 1); - t.equal(bySrc[0].coinPub, "cq-1"); + t.equal(bySrc[0].coinPub, ck("cq-1")); }, }, @@ -1304,11 +1378,11 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertCoin(makeCoin("cb-2")); }); const got = await runner.runTx((tx) => - tx.getCoinsByPubs(["cb-2", "cb-missing", "cb-1"]), + tx.getCoinsByPubs([ck("cb-2"), ck("cb-missing"), ck("cb-1")]), ); t.deepEqual( got.map((c) => c.coinPub), - ["cb-2", "cb-1"], + [ck("cb-2"), ck("cb-1")], "missing pubs are dropped, not returned as holes", ); }, @@ -1321,18 +1395,18 @@ export const conformanceCases: ConformanceCase[] = [ for (let i = 0; i < 4; i++) { const c = makeCoin(`cf-${i}`); c.exchangeBaseUrl = "https://ex-f/"; - c.denomPubHash = "df-1"; + c.denomPubHash = ckh("df-1"); c.maxAge = 21; c.status = i === 3 ? CoinStatus.Dormant : CoinStatus.Fresh; await tx.upsertCoin(c); } }); const all = await runner.runTx((tx) => - tx.getFreshCoinsByDenomAndAge("https://ex-f/", "df-1", 21, 100), + tx.getFreshCoinsByDenomAndAge("https://ex-f/", ckh("df-1"), 21, 100), ); t.equal(all.length, 3, "the dormant coin must be excluded"); const limited = await runner.runTx((tx) => - tx.getFreshCoinsByDenomAndAge("https://ex-f/", "df-1", 21, 2), + tx.getFreshCoinsByDenomAndAge("https://ex-f/", ckh("df-1"), 21, 2), ); t.equal(limited.length, 2, "the limit must be applied"); }, @@ -1344,16 +1418,19 @@ export const conformanceCases: ConformanceCase[] = [ await runner.runTx(async (tx) => { await tx.upsertCoin(makeCoin("cd-1")); await tx.upsertCoinHistory({ - coinPub: "cd-1", + coinPub: ck("cd-1"), history: [{ type: "withdraw", transactionId: txnId("txn:cd-1") }], }); }); - await runner.runTx((tx) => tx.deleteCoin("cd-1")); - t.equal(await runner.runTx((tx) => tx.getCoin("cd-1")), undefined); - const hist = await runner.runTx((tx) => tx.getCoinHistory("cd-1")); + await runner.runTx((tx) => tx.deleteCoin(ck("cd-1"))); + t.equal(await runner.runTx((tx) => tx.getCoin(ck("cd-1"))), undefined); + const hist = await runner.runTx((tx) => tx.getCoinHistory(ck("cd-1"))); t.ok(hist, "deleteCoin must not delete the history record"); - await runner.runTx((tx) => tx.deleteCoinHistory("cd-1")); - t.equal(await runner.runTx((tx) => tx.getCoinHistory("cd-1")), undefined); + await runner.runTx((tx) => tx.deleteCoinHistory(ck("cd-1"))); + t.equal( + await runner.runTx((tx) => tx.getCoinHistory(ck("cd-1"))), + undefined, + ); }, }, @@ -1374,9 +1451,9 @@ export const conformanceCases: ConformanceCase[] = [ }, ]; await runner.runTx((tx) => - tx.upsertCoinHistory({ coinPub: "ch-1", history }), + tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }), ); - const got = await runner.runTx((tx) => tx.getCoinHistory("ch-1")); + const got = await runner.runTx((tx) => tx.getCoinHistory(ck("ch-1"))); t.deepEqual(got?.history, history); }, }, @@ -1391,10 +1468,10 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertCoinAvailability(makeAvail("https://ea/", "da", 21)); }); const a = await runner.runTx((tx) => - tx.getCoinAvailability("https://ea/", "da", 0), + tx.getCoinAvailability("https://ea/", ckh("da"), 0), ); const b = await runner.runTx((tx) => - tx.getCoinAvailability("https://ea/", "da", 21), + tx.getCoinAvailability("https://ea/", ckh("da"), 21), ); t.ok(a && b, "differing maxAge must be distinct rows"); t.equal(a?.maxAge, 0); @@ -1412,7 +1489,7 @@ export const conformanceCases: ConformanceCase[] = [ rec.pendingRefreshOutputCount = 2; await runner.runTx((tx) => tx.upsertCoinAvailability(rec)); const got = await runner.runTx((tx) => - tx.getCoinAvailability("https://eu/", "du", 0), + tx.getCoinAvailability("https://eu/", ckh("du"), 0), ); t.equal(got?.freshCoinCount, 9); t.equal(got?.visibleCoinCount, 4); @@ -1451,7 +1528,7 @@ export const conformanceCases: ConformanceCase[] = [ const hashes = got.map((a) => a.denomPubHash).sort(); t.deepEqual( hashes, - ["d-hi", "d-lf"], + [ckh("d-hi"), ckh("d-lf")].sort(), "excludes only the zero-fresh row at the lower bound", ); }, @@ -1465,7 +1542,7 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertCoinAvailability(makeAvail("https://ed/", "dd", 21)); }); await runner.runTx((tx) => - tx.deleteCoinAvailability("https://ed/", "dd", 0), + tx.deleteCoinAvailability("https://ed/", ckh("dd"), 0), ); const left = await runner.runTx((tx) => tx.getCoinAvailabilityByExchange("https://ed/"), @@ -1481,7 +1558,7 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const ex = makeExchange("https://ex-rt/"); ex.detailsPointer = { - masterPublicKey: "mpk-1", + masterPublicKey: ck("mpk-1"), currency: "TESTKUDOS", updateClock: tsPrecise(4242), }; @@ -1556,7 +1633,11 @@ export const conformanceCases: ConformanceCase[] = [ const rowId = await runner.runTx((tx) => tx.upsertExchangeDetails(det)); t.ok(typeof rowId === "number", "must return the generated row id"); const got = await runner.runTx((tx) => - tx.getExchangeDetailsByPointer("https://ed-1/", "TESTKUDOS", "mpk-a"), + tx.getExchangeDetailsByPointer( + "https://ed-1/", + "TESTKUDOS", + ck("mpk-a"), + ), ); t.equal(got?.rowId, rowId); t.deepEqual(got?.wireInfo, det.wireInfo, "nested JSON must survive"); @@ -1590,7 +1671,7 @@ export const conformanceCases: ConformanceCase[] = [ await tx.upsertExchangeDetails(det); const ex = makeExchange("https://ep/"); ex.detailsPointer = { - masterPublicKey: "mpk-p", + masterPublicKey: ck("mpk-p"), currency: "TESTKUDOS", updateClock: tsPrecise(1), }; @@ -1599,7 +1680,7 @@ export const conformanceCases: ConformanceCase[] = [ const got = await runner.runTx((tx) => tx.getExchangeDetails("https://ep/"), ); - t.equal(got?.masterPublicKey, "mpk-p"); + t.equal(got?.masterPublicKey, ck("mpk-p")); }, }, @@ -1647,13 +1728,16 @@ export const conformanceCases: ConformanceCase[] = [ tx.getExchangeSignKeysByDetailsRowId(rowId), ); t.equal(keys.length, 2); - t.deepEqual(keys.map((k) => k.signkeyPub).sort(), ["sk-1", "sk-2"]); - await runner.runTx((tx) => tx.deleteExchangeSignKey(rowId, "sk-1")); + t.deepEqual( + keys.map((k) => k.signkeyPub).sort(), + [ck("sk-1"), ck("sk-2")].sort(), + ); + await runner.runTx((tx) => tx.deleteExchangeSignKey(rowId, ck("sk-1"))); const left = await runner.runTx((tx) => tx.getExchangeSignKeysByDetailsRowId(rowId), ); t.equal(left.length, 1); - t.equal(left[0].signkeyPub, "sk-2"); + t.equal(left[0].signkeyPub, ck("sk-2")); }, }, @@ -1665,13 +1749,13 @@ export const conformanceCases: ConformanceCase[] = [ ); const key = makeSignKey(rowId, "sk-dup"); await runner.runTx((tx) => tx.upsertExchangeSignKey(key)); - key.masterSig = "sig-updated"; + key.masterSig = ckh("sig-updated"); await runner.runTx((tx) => tx.upsertExchangeSignKey(key)); const keys = await runner.runTx((tx) => tx.getExchangeSignKeysByDetailsRowId(rowId), ); t.equal(keys.length, 1, "must replace, not append"); - t.equal(keys[0].masterSig, "sig-updated"); + t.equal(keys[0].masterSig, ckh("sig-updated")); }, }, @@ -1904,7 +1988,7 @@ export const conformanceCases: ConformanceCase[] = [ }, { withdrawalType: WithdrawalRecordType.PeerPullCredit, - contractPriv: "cpriv-1", + contractPriv: ck("cpriv-1"), }, { withdrawalType: WithdrawalRecordType.PeerPushCredit }, { withdrawalType: WithdrawalRecordType.Recoup }, @@ -2022,8 +2106,8 @@ export const conformanceCases: ConformanceCase[] = [ const byIdx = await runner.runTx((tx) => tx.getPlanchetByGroupAndIndex("wg-p", 1), ); - t.equal(byIdx?.coinPub, "pl-2"); - const direct = await runner.runTx((tx) => tx.getPlanchet("pl-1")); + t.equal(byIdx?.coinPub, ck("pl-2")); + const direct = await runner.runTx((tx) => tx.getPlanchet(ck("pl-1"))); t.deepEqual(direct, makePlanchet("pl-1", "wg-p", 0)); }, }, @@ -2034,7 +2118,7 @@ export const conformanceCases: ConformanceCase[] = [ const pl = makePlanchet("pl-err", "wg-e", 0); pl.lastError = undefined; await runner.runTx((tx) => tx.upsertPlanchet(pl)); - const got = await runner.runTx((tx) => tx.getPlanchet("pl-err")); + const got = await runner.runTx((tx) => tx.getPlanchet(ck("pl-err"))); t.ok( got !== undefined && "lastError" in got, "lastError is declared as a required key", @@ -2067,7 +2151,7 @@ export const conformanceCases: ConformanceCase[] = [ 0, ); t.ok( - await runner.runTx((tx) => tx.getPlanchet("pl-keep")), + await runner.runTx((tx) => tx.getPlanchet(ck("pl-keep"))), "another group must be untouched", ); }, @@ -2518,21 +2602,21 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const rec = makePeerPushDebit("ppd-1"); await runner.runTx((tx) => tx.upsertPeerPushDebit(rec)); - const got = await runner.runTx((tx) => tx.getPeerPushDebit("ppd-1")); + const got = await runner.runTx((tx) => tx.getPeerPushDebit(ck("ppd-1"))); t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); const done = makePeerPushDebit("ppd-2"); done.status = PeerPushDebitStatus.Done; await runner.runTx((tx) => tx.upsertPeerPushDebit(done)); const active = await runner.runTx((tx) => tx.getActivePeerPushDebits()); - t.ok(active.map((r) => r.pursePub).includes("ppd-1")); - t.ok(!active.map((r) => r.pursePub).includes("ppd-2")); + t.ok(active.map((r) => r.pursePub).includes(ck("ppd-1"))); + t.ok(!active.map((r) => r.pursePub).includes(ck("ppd-2"))); t.equal( (await runner.runTx((tx) => tx.listAllPeerPushDebits())).length, 2, ); - await runner.runTx((tx) => tx.deletePeerPushDebit("ppd-1")); + await runner.runTx((tx) => tx.deletePeerPushDebit(ck("ppd-1"))); t.equal( - await runner.runTx((tx) => tx.getPeerPushDebit("ppd-1")), + await runner.runTx((tx) => tx.getPeerPushDebit(ck("ppd-1"))), undefined, ); }, @@ -2542,21 +2626,21 @@ export const conformanceCases: ConformanceCase[] = [ name: "peer push credit: round trip, contract-priv lookup, active range", async run(t, runner) { const rec = makePeerPushCredit("ppc-1"); - rec.contractPriv = "cpriv-find-me"; + rec.contractPriv = ck("cpriv-find-me"); await runner.runTx((tx) => tx.upsertPeerPushCredit(rec)); const got = await runner.runTx((tx) => tx.getPeerPushCredit("ppc-1")); t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); const byPriv = await runner.runTx((tx) => tx.getPeerPushCreditByExchangeAndContractPriv( rec.exchangeBaseUrl, - "cpriv-find-me", + ck("cpriv-find-me"), ), ); t.equal(byPriv?.peerPushCreditId, "ppc-1"); const wrongExchange = await runner.runTx((tx) => tx.getPeerPushCreditByExchangeAndContractPriv( "https://other/", - "cpriv-find-me", + ck("cpriv-find-me"), ), ); t.equal(wrongExchange, undefined, "both components must match"); @@ -2578,14 +2662,14 @@ export const conformanceCases: ConformanceCase[] = [ name: "peer pull debit: round trip, contract-priv lookup, active range", async run(t, runner) { const rec = makePeerPullDebit("ppld-1"); - rec.contractPriv = "cpriv-pull"; + rec.contractPriv = ck("cpriv-pull"); await runner.runTx((tx) => tx.upsertPeerPullDebit(rec)); const got = await runner.runTx((tx) => tx.getPeerPullDebit("ppld-1")); t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); const byPriv = await runner.runTx((tx) => tx.getPeerPullDebitByExchangeAndContractPriv( rec.exchangeBaseUrl, - "cpriv-pull", + ck("cpriv-pull"), ), ); t.equal(byPriv?.peerPullDebitId, "ppld-1"); @@ -2612,21 +2696,23 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { const rec = makePeerPullCredit("pplc-1"); await runner.runTx((tx) => tx.upsertPeerPullCredit(rec)); - const got = await runner.runTx((tx) => tx.getPeerPullCredit("pplc-1")); + const got = await runner.runTx((tx) => + tx.getPeerPullCredit(ck("pplc-1")), + ); t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); const done = makePeerPullCredit("pplc-2"); done.status = PeerPullPaymentCreditStatus.Done; await runner.runTx((tx) => tx.upsertPeerPullCredit(done)); const active = await runner.runTx((tx) => tx.getActivePeerPullCredits()); - t.ok(active.map((r) => r.pursePub).includes("pplc-1")); - t.ok(!active.map((r) => r.pursePub).includes("pplc-2")); + t.ok(active.map((r) => r.pursePub).includes(ck("pplc-1"))); + t.ok(!active.map((r) => r.pursePub).includes(ck("pplc-2"))); t.equal( (await runner.runTx((tx) => tx.listAllPeerPullCredits())).length, 2, ); - await runner.runTx((tx) => tx.deletePeerPullCredit("pplc-1")); + await runner.runTx((tx) => tx.deletePeerPullCredit(ck("pplc-1"))); t.equal( - await runner.runTx((tx) => tx.getPeerPullCredit("pplc-1")), + await runner.runTx((tx) => tx.getPeerPullCredit(ck("pplc-1"))), undefined, ); }, @@ -2642,7 +2728,7 @@ export const conformanceCases: ConformanceCase[] = [ // its own body once cost five silently dropped columns here. const tok = makeToken("tk-1"); await runner.runTx((tx) => tx.upsertToken(tok)); - const got = await runner.runTx((tx) => tx.getToken("tk-1")); + const got = await runner.runTx((tx) => tx.getToken(ck("tk-1"))); t.deepEqual(withoutUndefined(got), withoutUndefined(tok)); t.equal(got?.slug, tok.slug, "inherited fields must persist"); t.deepEqual(got?.tokenIssuePub, tok.tokenIssuePub); @@ -2657,25 +2743,28 @@ export const conformanceCases: ConformanceCase[] = [ async run(t, runner) { await runner.runTx(async (tx) => { const a = makeToken("tk-a"); - a.tokenIssuePubHash = "tiph-shared"; + a.tokenIssuePubHash = ckh("tiph-shared"); const b = makeToken("tk-b"); - b.tokenIssuePubHash = "tiph-shared"; + b.tokenIssuePubHash = ckh("tiph-shared"); const c = makeToken("tk-c"); - c.tokenIssuePubHash = "tiph-other"; + c.tokenIssuePubHash = ckh("tiph-other"); await tx.upsertToken(a); await tx.upsertToken(b); await tx.upsertToken(c); }); const byHash = await runner.runTx((tx) => - tx.getTokensByIssuePubHash("tiph-shared"), + tx.getTokensByIssuePubHash(ckh("tiph-shared")), ); t.equal(byHash.length, 2); t.equal((await runner.runTx((tx) => tx.listTokens())).length, 3); - await runner.runTx((tx) => tx.deleteToken("tk-a")); - t.equal(await runner.runTx((tx) => tx.getToken("tk-a")), undefined); + await runner.runTx((tx) => tx.deleteToken(ck("tk-a"))); + t.equal(await runner.runTx((tx) => tx.getToken(ck("tk-a"))), undefined); t.equal( - (await runner.runTx((tx) => tx.getTokensByIssuePubHash("tiph-shared"))) - .length, + ( + await runner.runTx((tx) => + tx.getTokensByIssuePubHash(ckh("tiph-shared")), + ) + ).length, 1, ); }, @@ -2693,14 +2782,14 @@ export const conformanceCases: ConformanceCase[] = [ const one = await runner.runTx((tx) => tx.getSlate("pur-1", 0, 0, 1)); t.equal( one?.tokenUsePub, - "sl-2", + ck("sl-2"), "all four components must select the slate", ); const byChoice = await runner.runTx((tx) => tx.getSlatesByPurchaseAndChoice("pur-1", 0), ); t.equal(byChoice.length, 3, "choice 1 must not be included"); - await runner.runTx((tx) => tx.deleteSlate("sl-2")); + await runner.runTx((tx) => tx.deleteSlate(ck("sl-2"))); t.equal( await runner.runTx((tx) => tx.getSlate("pur-1", 0, 0, 1)), undefined, @@ -2732,4 +2821,28 @@ export const conformanceCases: ConformanceCase[] = [ t.deepEqual(gotSigned?.tokenUseSig, signed.tokenUseSig); }, }, + { + name: "coin availability: exchange master pub round trips", + async run(t, runner) { + // Exercises a column that no other case populates; the sqlite + // storage-class test can only verify columns that have rows. + const rec = makeAvail("https://emp/", "d-emp", 0); + rec.exchangeMasterPub = ck("master-emp"); + await runner.runTx((tx) => tx.upsertCoinAvailability(rec)); + const got = await runner.runTx((tx) => + tx.getCoinAvailability("https://emp/", ckh("d-emp"), 0), + ); + t.equal(got?.exchangeMasterPub, ck("master-emp")); + const unset = makeAvail("https://emp2/", "d-emp2", 0); + await runner.runTx((tx) => tx.upsertCoinAvailability(unset)); + const got2 = await runner.runTx((tx) => + tx.getCoinAvailability("https://emp2/", ckh("d-emp2"), 0), + ); + t.equal( + got2?.exchangeMasterPub, + undefined, + "an unset optional key must stay unset", + ); + }, + }, ]; diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.test.ts b/packages/taler-wallet-core/src/dbtx-sqlite.test.ts @@ -0,0 +1,107 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Tests specific to the native sqlite backend, as opposed to the + * backend-neutral conformance suite. + * + * The storage-class check here is the one test that catches a write path + * which stored TEXT into a BLOB column. Nothing else can: sqlite accepts a + * string in a BLOB-declared column, the value reads back fine through the + * same code that wrote it, and only a *lookup* against a correctly-encoded + * parameter fails — silently, by matching nothing. + */ + +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import assert from "node:assert"; +import { test } from "node:test"; + +import { BLOB_COLUMNS } from "./db-sqlite-schema.js"; +import { conformanceCases } from "./dbtx-conformance-cases.js"; +import { makeSqliteRunner } from "./dbtx-runners.js"; + +/** + * Run every conformance case against one database, then inspect how the + * values actually landed. + * + * Reusing the conformance cases as the workload means this covers whatever + * write paths the suite covers, rather than a hand-written sample that would + * drift away from it. + */ +test("sqlite: BLOB columns really hold blobs", async (t) => { + const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await impl.open(":memory:"); + const runner = await makeSqliteRunner(":memory:"); + + // Populate through the DAL, using the same cases the conformance suite + // runs, so every write path they exercise is represented here. + const asserts = { + equal: () => {}, + deepEqual: () => {}, + ok: () => {}, + fail: () => { + throw Error("unreachable"); + }, + }; + for (const c of conformanceCases) { + try { + await c.run(asserts as any, runner); + } catch (e) { + // A case that fails its own assertions is the conformance suite's + // problem, not this test's. What matters here is what got written. + } + } + + const offenders: string[] = []; + const missing: string[] = []; + + for (const [table, columns] of Object.entries(BLOB_COLUMNS)) { + for (const column of columns) { + const rows = await runner.runTx(async (tx: any) => { + // Reach past the DAL deliberately: the point is to see the storage + // class, which the DAL exists to hide. + return await (tx as any).all( + `SELECT typeof("${column}") AS t, COUNT(*) AS n FROM "${table}"` + + ` WHERE "${column}" IS NOT NULL GROUP BY typeof("${column}")`, + ); + }); + if (rows.length === 0) { + missing.push(`${table}.${column}`); + continue; + } + for (const r of rows) { + if (r.t !== "blob") { + offenders.push(`${table}.${column} has ${r.n} row(s) of ${r.t}`); + } + } + } + } + + await runner.close(); + await db.close(); + + assert.deepStrictEqual( + offenders, + [], + `columns declared BLOB that hold something else:\n${offenders.join("\n")}`, + ); + + // Not a failure — a column with no rows simply was not exercised — but + // worth surfacing, because an unexercised column is an unverified one. + if (missing.length > 0) { + t.diagnostic(`BLOB columns with no rows to check: ${missing.join(", ")}`); + } +}); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -33,6 +33,8 @@ import { import { AmountString, CoinStatus, + decodeCrock, + encodeCrock, MerchantContractTokenKind, stringifyScopeInfo, DenomLossEventType, @@ -304,6 +306,51 @@ function optStr(v: Sqlite3Value | undefined): string | undefined { return v == null ? undefined : (v as string); } +/** + * Encode a Crockford base32 record field for a BLOB column. + * + * Applied at the individual field mapping, never folded into a shared row + * builder: when a record field eventually becomes Uint8Array end to end, that + * one line loses its call and nothing else moves. See SQLITE_BLOB_PLAN.md. + */ +function crockToDb(v: string): Uint8Array { + return decodeCrock(v); +} + +function optCrockToDb(v: string | undefined | null): Uint8Array | null { + return v == null ? null : decodeCrock(v); +} + +/** + * Decode a BLOB column back into the Crockford string the record exposes. + * + * Throws rather than coercing if the column came back as TEXT: that means a + * write path stored a string into a BLOB column, which otherwise stays + * invisible until a lookup silently matches nothing. + */ +function dbToCrock(v: Sqlite3Value | undefined): string { + if (!(v instanceof Uint8Array)) { + throw Error( + `expected a BLOB column, got ${typeof v}; a write path is storing` + + ` TEXT into a BLOB column`, + ); + } + return encodeCrock(v); +} + +function dbToOptCrock(v: Sqlite3Value | undefined): string | undefined { + return v == null ? undefined : dbToCrock(v); +} + +/** Stable map key for a BLOB value. */ +function blobKey(v: Uint8Array): string { + let out = ""; + for (let i = 0; i < v.length; i++) { + out += v[i].toString(16).padStart(2, "0"); + } + return out; +} + function jsonToDb(v: unknown): string { return JSON.stringify(v); } @@ -476,8 +523,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async upsertReserve(rec: WalletReserve): Promise<number> { const cols = { - pub: rec.reservePub, - priv: rec.reservePriv, + pub: crockToDb(rec.reservePub), + priv: crockToDb(rec.reservePriv), status: rec.status ?? null, requirement_row: rec.requirementRow ?? null, threshold_requested: rec.thresholdRequested ?? null, @@ -531,7 +578,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletReserve | undefined> { const row = await this.first( "SELECT * FROM reserves" + " WHERE reserve_pub = $pub", - { pub: reservePub }, + { pub: crockToDb(reservePub) }, ); return row ? this.rowToReserve(row) : undefined; } @@ -539,8 +586,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToReserve(row: ResultRow): WalletReserve { return { rowId: num(row.row_id), - reservePub: str(row.reserve_pub), - reservePriv: str(row.reserve_priv), + reservePub: dbToCrock(row.reserve_pub), + reservePriv: dbToCrock(row.reserve_priv), ...(row.status != null ? { status: num(row.status) } : undefined), ...(row.requirement_row != null ? { requirementRow: num(row.requirement_row) } @@ -605,9 +652,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { verification_status = excluded.verification_status`, { exchange_base_url: rec.exchangeBaseUrl, - denom_pub_hash: rec.denomPubHash, + denom_pub_hash: crockToDb(rec.denomPubHash), denom_pub: jsonToDb(rec.denomPub), - exchange_master_pub: rec.exchangeMasterPub ?? null, + exchange_master_pub: crockToDb(rec.exchangeMasterPub), currency: rec.currency, value: rec.value, family_serial: rec.denominationFamilySerial ?? null, @@ -622,7 +669,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { is_offered: boolToDb(rec.isOffered), is_revoked: boolToDb(rec.isRevoked), is_lost: boolToDb(rec.isLost), - master_sig: rec.masterSig, + master_sig: crockToDb(rec.masterSig), verification_status: rec.verificationStatus, }, ); @@ -635,7 +682,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const row = await this.first( "SELECT * FROM denominations" + " WHERE exchange_base_url = $url AND denom_pub_hash = $hash", - { url: exchangeBaseUrl, hash: denomPubHash }, + { url: exchangeBaseUrl, hash: crockToDb(denomPubHash) }, ); return row ? this.rowToDenomination(row) : undefined; } @@ -667,7 +714,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( "DELETE FROM denominations" + " WHERE exchange_base_url = $url AND denom_pub_hash = $hash", - { url: exchangeBaseUrl, hash: denomPubHash }, + { url: exchangeBaseUrl, hash: crockToDb(denomPubHash) }, ); } @@ -691,7 +738,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletDenomination | undefined> { const batchSize = 1; let afterExpiry: number = minStampExpireWithdraw; - let afterHash: string | undefined = undefined; + // The cursor stays in the column's own representation (a BLOB now) and + // is fed straight back into the next query. Decoding it to a string and + // re-encoding would be pointless work, and binding the string form would + // silently match nothing. + let afterHash: Uint8Array | undefined = undefined; while (true) { const rows: ResultRow[] = afterHash === undefined @@ -731,16 +782,20 @@ export class SqliteWalletTransaction implements WalletDbTransaction { } const last = rows[rows.length - 1]; afterExpiry = num(last.stamp_expire_withdraw); - afterHash = str(last.denom_pub_hash); + const lastHash = last.denom_pub_hash; + if (!(lastHash instanceof Uint8Array)) { + throw Error("denominations.denom_pub_hash must be a BLOB column"); + } + afterHash = lastHash; } } private rowToDenomination(row: ResultRow): WalletDenomination { const denom: WalletDenomination = { exchangeBaseUrl: str(row.exchange_base_url), - denomPubHash: str(row.denom_pub_hash), + denomPubHash: dbToCrock(row.denom_pub_hash), denomPub: dbToJson(row.denom_pub), - exchangeMasterPub: str(row.exchange_master_pub), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), currency: str(row.currency), value: dbAmount(row.value), denominationFamilySerial: num(row.denomination_family_serial), @@ -757,7 +812,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { isOffered: dbToBool(row.is_offered), isRevoked: dbToBool(row.is_revoked), isLost: dbToOptBool(row.is_lost), - masterSig: str(row.master_sig), + masterSig: dbToCrock(row.master_sig), verificationStatus: num(row.verification_status), }; return denom; @@ -884,7 +939,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { exec: rec.executionTime, obt: rec.obtainedTime, amt: rec.refundAmount, - coin: rec.coinPub, + coin: crockToDb(rec.coinPub), rtxid: rec.rtxid, }; if (id !== undefined) { @@ -903,7 +958,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletRefundItem | undefined> { const row = await this.first( "SELECT * FROM refund_items WHERE coin_pub = $coin AND rtxid = $rtxid", - { coin: coinPub, rtxid }, + { coin: crockToDb(coinPub), rtxid }, ); return row ? this.rowToRefundItem(row) : undefined; } @@ -917,7 +972,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { executionTime: dbTimestamp(row.execution_time), obtainedTime: dbTimestamp(row.obtained_time), refundAmount: dbAmount(row.refund_amount), - coinPub: str(row.coin_pub), + coinPub: dbToCrock(row.coin_pub), rtxid: num(row.rtxid), }; } @@ -926,13 +981,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToCoin(row: ResultRow): WalletCoin { return { - coinPub: str(row.coin_pub), - coinPriv: str(row.coin_priv), + coinPub: dbToCrock(row.coin_pub), + coinPriv: dbToCrock(row.coin_priv), exchangeBaseUrl: str(row.exchange_base_url), - denomPubHash: str(row.denom_pub_hash), + denomPubHash: dbToCrock(row.denom_pub_hash), denomSig: dbToJson(row.denom_sig), - blindingKey: str(row.blinding_key), - coinEvHash: str(row.coin_ev_hash), + blindingKey: dbToCrock(row.blinding_key), + coinEvHash: dbToCrock(row.coin_ev_hash), status: str(row.status) as CoinStatus, maxAge: num(row.max_age), // Always set, even when absent: the field is declared as @@ -948,7 +1003,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async getCoin(coinPub: string): Promise<WalletCoin | undefined> { const row = await this.first("SELECT * FROM coins WHERE coin_pub = $pub", { - pub: coinPub, + pub: crockToDb(coinPub), }); return row ? this.rowToCoin(row) : undefined; } @@ -977,13 +1032,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { coin_source = excluded.coin_source, source_transaction_id = excluded.source_transaction_id`, { - pub: coin.coinPub, - priv: coin.coinPriv, + pub: crockToDb(coin.coinPub), + priv: crockToDb(coin.coinPriv), url: coin.exchangeBaseUrl, - dph: coin.denomPubHash, + dph: crockToDb(coin.denomPubHash), sig: jsonToDb(coin.denomSig), - bk: coin.blindingKey, - ceh: coin.coinEvHash, + bk: crockToDb(coin.blindingKey), + ceh: crockToDb(coin.coinEvHash), status: coin.status, visible: coin.visible ?? null, age: coin.maxAge, @@ -1021,7 +1076,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]> { const rows = await this.all( "SELECT * FROM coins WHERE denom_pub_hash = $dph", - { dph: denomPubHash }, + { dph: crockToDb(denomPubHash) }, ); return rows.map((r) => this.rowToCoin(r)); } @@ -1045,23 +1100,33 @@ export class SqliteWalletTransaction implements WalletDbTransaction { if (coinPubs.length === 0) { return []; } - const params: Record<string, string> = {}; - const placeholders = coinPubs.map((pub, i) => { - params[`p${i}`] = pub; + const params: Record<string, Uint8Array> = {}; + const blobs = coinPubs.map((pub) => crockToDb(pub)); + const placeholders = blobs.map((blob, i) => { + params[`p${i}`] = blob; return `$p${i}`; }); const rows = await this.all( `SELECT * FROM coins WHERE coin_pub IN (${placeholders.join(", ")})`, params, ); - const byPub = new Map<string, WalletCoin>(); + // Keyed on the stored bytes, not on the caller's string. Several + // distinct strings can decode to the same key -- 52 Crockford characters + // carry 260 bits and a key is 256 -- so re-encoding a row yields the + // canonical spelling, which need not be the spelling the caller passed. + // Matching on the string dropped every coin whose argument was not + // canonical, silently and without error. + const byKey = new Map<string, WalletCoin>(); for (const row of rows) { - const coin = this.rowToCoin(row); - byPub.set(coin.coinPub, coin); + const raw = row.coin_pub; + if (!(raw instanceof Uint8Array)) { + throw Error("coins.coin_pub must be a BLOB column"); + } + byKey.set(blobKey(raw), this.rowToCoin(row)); } const coins: WalletCoin[] = []; - for (const pub of coinPubs) { - const coin = byPub.get(pub); + for (const blob of blobs) { + const coin = byKey.get(blobKey(blob)); if (coin) { coins.push(coin); } @@ -1082,7 +1147,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { " LIMIT $limit", { url: exchangeBaseUrl, - dph: denomPubHash, + dph: crockToDb(denomPubHash), age: maxAge, status: CoinStatus.Fresh, limit, @@ -1092,7 +1157,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { } async deleteCoin(coinPub: string): Promise<void> { - await this.run("DELETE FROM coins WHERE coin_pub = $pub", { pub: coinPub }); + await this.run("DELETE FROM coins WHERE coin_pub = $pub", { + pub: crockToDb(coinPub), + }); } // ------------------------------------------------------ coin history @@ -1102,13 +1169,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletCoinHistory | undefined> { const row = await this.first( "SELECT * FROM coin_history WHERE coin_pub = $pub", - { pub: coinPub }, + { pub: crockToDb(coinPub) }, ); if (!row) { return undefined; } return { - coinPub: str(row.coin_pub), + coinPub: dbToCrock(row.coin_pub), history: dbToJson(row.history), }; } @@ -1117,13 +1184,13 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( "INSERT INTO coin_history (coin_pub, history) VALUES ($pub, $h)" + " ON CONFLICT(coin_pub) DO UPDATE SET history = excluded.history", - { pub: rec.coinPub, h: jsonToDb(rec.history) }, + { pub: crockToDb(rec.coinPub), h: jsonToDb(rec.history) }, ); } async deleteCoinHistory(coinPub: string): Promise<void> { await this.run("DELETE FROM coin_history WHERE coin_pub = $pub", { - pub: coinPub, + pub: crockToDb(coinPub), }); } @@ -1132,14 +1199,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToCoinAvailability(row: ResultRow): WalletCoinAvailability { return { exchangeBaseUrl: str(row.exchange_base_url), - denomPubHash: str(row.denom_pub_hash), + denomPubHash: dbToCrock(row.denom_pub_hash), maxAge: num(row.max_age), currency: str(row.currency), value: dbAmount(row.value), freshCoinCount: num(row.fresh_coin_count), visibleCoinCount: num(row.visible_coin_count), ...(row.exchange_master_pub != null - ? { exchangeMasterPub: str(row.exchange_master_pub) } + ? { exchangeMasterPub: dbToCrock(row.exchange_master_pub) } : undefined), ...(row.pending_refresh_output_count != null ? { pendingRefreshOutputCount: num(row.pending_refresh_output_count) } @@ -1156,7 +1223,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "SELECT * FROM coin_availability" + " WHERE exchange_base_url = $url AND denom_pub_hash = $dph" + " AND max_age = $age", - { url: exchangeBaseUrl, dph: denomPubHash, age: maxAge }, + { url: exchangeBaseUrl, dph: crockToDb(denomPubHash), age: maxAge }, ); return row ? this.rowToCoinAvailability(row) : undefined; } @@ -1178,11 +1245,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { excluded.pending_refresh_output_count`, { url: rec.exchangeBaseUrl, - dph: rec.denomPubHash, + dph: crockToDb(rec.denomPubHash), age: rec.maxAge, cur: rec.currency, val: rec.value, - emp: rec.exchangeMasterPub ?? null, + emp: optCrockToDb(rec.exchangeMasterPub), fresh: rec.freshCoinCount, vis: rec.visibleCoinCount, pend: rec.pendingRefreshOutputCount ?? null, @@ -1240,7 +1307,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "DELETE FROM coin_availability" + " WHERE exchange_base_url = $url AND denom_pub_hash = $dph" + " AND max_age = $age", - { url: exchangeBaseUrl, dph: denomPubHash, age: maxAge }, + { url: exchangeBaseUrl, dph: crockToDb(denomPubHash), age: maxAge }, ); } @@ -1254,7 +1321,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { row.details_pointer_master_pub == null ? undefined : { - masterPublicKey: str(row.details_pointer_master_pub), + masterPublicKey: dbToCrock(row.details_pointer_master_pub), currency: str(row.details_pointer_currency), updateClock: dbTimestamp(row.details_pointer_update_clock), }, @@ -1293,10 +1360,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? { currentMergeReserveRowId: num(row.current_merge_reserve_row_id) } : undefined), ...(row.current_account_priv != null - ? { currentAccountPriv: str(row.current_account_priv) } + ? { currentAccountPriv: dbToCrock(row.current_account_priv) } : undefined), ...(row.current_account_pub != null - ? { currentAccountPub: str(row.current_account_pub) } + ? { currentAccountPub: dbToCrock(row.current_account_pub) } : undefined), ...(row.peer_payments_disabled != null ? { peerPaymentsDisabled: dbToBool(row.peer_payments_disabled) } @@ -1375,7 +1442,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { : jsonToDb(rec.presetCurrencySpec), pt: rec.presetType ?? null, lw: rec.lastWithdrawal ?? null, - dpmp: rec.detailsPointer?.masterPublicKey ?? null, + dpmp: optCrockToDb(rec.detailsPointer?.masterPublicKey), dpc: rec.detailsPointer?.currency ?? null, dpuc: rec.detailsPointer?.updateClock ?? null, es: rec.entryStatus, @@ -1393,8 +1460,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { lke: rec.lastKeysEtag ?? null, nrcs: rec.nextRefreshCheckStamp, cmrri: rec.currentMergeReserveRowId ?? null, - cap: rec.currentAccountPriv ?? null, - capub: rec.currentAccountPub ?? null, + cap: optCrockToDb(rec.currentAccountPriv), + capub: optCrockToDb(rec.currentAccountPub), ppd: boolToDb(rec.peerPaymentsDisabled), ddd: boolToDb(rec.directDepositDisabled), nf: boolToDb(rec.noFees), @@ -1414,7 +1481,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return { rowId: num(row.row_id), exchangeBaseUrl: str(row.exchange_base_url), - masterPublicKey: str(row.master_public_key), + masterPublicKey: dbToCrock(row.master_public_key), currency: str(row.currency), auditors: dbToJson(row.auditors), protocolVersionRange: str(row.protocol_version_range), @@ -1443,7 +1510,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private exchangeDetailsCols(rec: WalletExchangeDetails) { return { url: rec.exchangeBaseUrl, - mpk: rec.masterPublicKey, + mpk: crockToDb(rec.masterPublicKey), cur: rec.currency, aud: jsonToDb(rec.auditors), pvr: rec.protocolVersionRange, @@ -1520,7 +1587,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "SELECT * FROM exchange_details" + " WHERE exchange_base_url = $url AND currency = $cur" + " AND master_public_key = $mpk", - { url: exchangeBaseUrl, cur: currency, mpk: masterPublicKey }, + { url: exchangeBaseUrl, cur: currency, mpk: crockToDb(masterPublicKey) }, ); return row ? this.rowToExchangeDetails(row) : undefined; } @@ -1581,11 +1648,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ); return rows.map((row) => ({ exchangeDetailsRowId: num(row.exchange_details_row_id), - signkeyPub: str(row.signkey_pub), + signkeyPub: dbToCrock(row.signkey_pub), stampStart: dbTimestamp(row.stamp_start), stampExpire: dbTimestamp(row.stamp_expire), stampEnd: dbTimestamp(row.stamp_end), - masterSig: str(row.master_sig), + masterSig: dbToCrock(row.master_sig), })); } @@ -1602,11 +1669,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { master_sig = excluded.master_sig`, { id: rec.exchangeDetailsRowId, - pub: rec.signkeyPub, + pub: crockToDb(rec.signkeyPub), start: rec.stampStart, expire: rec.stampExpire, end: rec.stampEnd, - sig: rec.masterSig, + sig: crockToDb(rec.masterSig), }, ); } @@ -1618,7 +1685,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { await this.run( "DELETE FROM exchange_sign_keys" + " WHERE exchange_details_row_id = $id AND signkey_pub = $pub", - { id: exchangeDetailsRowId, pub: signkeyPub }, + { id: exchangeDetailsRowId, pub: crockToDb(signkeyPub) }, ); } @@ -1629,7 +1696,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { denominationFamilySerial: num(row.denomination_family_serial), familyParams: { exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: str(row.exchange_master_pub), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), value: dbAmount(row.value), feeWithdraw: dbAmount(row.fee_withdraw), feeDeposit: dbAmount(row.fee_deposit), @@ -1645,7 +1712,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const p = rec.familyParams; const cols = { url: p.exchangeBaseUrl, - mpub: p.exchangeMasterPub, + mpub: crockToDb(p.exchangeMasterPub), val: p.value, fw: p.feeWithdraw, fd: p.feeDeposit, @@ -1690,7 +1757,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { " AND fee_refresh = $frs AND fee_refund = $frf", { url: params.exchangeBaseUrl, - mpub: params.exchangeMasterPub, + mpub: crockToDb(params.exchangeMasterPub), val: params.value, fw: params.feeWithdraw, fd: params.feeDeposit, @@ -1803,7 +1870,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const cols = { wtype: wgInfo.withdrawalType, uri: null as string | null, - cpriv: null as string | null, + cpriv: null as Uint8Array | null, binfo: null as string | null, eca: null as string | null, }; @@ -1825,7 +1892,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { : jsonToDb(wgInfo.exchangeCreditAccounts); break; case WithdrawalRecordType.PeerPullCredit: - cols.cpriv = wgInfo.contractPriv; + cols.cpriv = crockToDb(wgInfo.contractPriv); break; case WithdrawalRecordType.PeerPushCredit: case WithdrawalRecordType.Recoup: @@ -1870,7 +1937,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { case WithdrawalRecordType.PeerPullCredit: { const wg: WgInfoBankPeerPull = { withdrawalType: WithdrawalRecordType.PeerPullCredit, - contractPriv: str(row.contract_priv), + contractPriv: dbToCrock(row.contract_priv), }; return wg; } @@ -1893,16 +1960,16 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return { withdrawalGroupId: str(row.withdrawal_group_id), wgInfo: this.rowToWgInfo(row), - secretSeed: str(row.secret_seed), - reservePub: str(row.reserve_pub), - reservePriv: str(row.reserve_priv), + secretSeed: dbToCrock(row.secret_seed), + reservePub: dbToCrock(row.reserve_pub), + reservePriv: dbToCrock(row.reserve_priv), timestampStart: dbTimestamp(row.timestamp_start), status: num(row.status), ...(row.is_foreign_account != null ? { isForeignAccount: dbToBool(row.is_foreign_account) } : undefined), ...(row.kyc_payto_hash != null - ? { kycPaytoHash: str(row.kyc_payto_hash) } + ? { kycPaytoHash: dbToCrock(row.kyc_payto_hash) } : undefined), ...(row.kyc_access_token != null ? { kycAccessToken: str(row.kyc_access_token) } @@ -2025,7 +2092,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: rec.withdrawalGroupId, ...wg, ifa: boolToDb(rec.isForeignAccount), - kph: rec.kycPaytoHash ?? null, + kph: optCrockToDb(rec.kycPaytoHash), kat: rec.kycAccessToken ?? null, klcs: rec.kycLastCheckStatus ?? null, klcc: rec.kycLastCheckCode ?? null, @@ -2036,9 +2103,9 @@ export class SqliteWalletTransaction implements WalletDbTransaction { rec.kycWithdrawalDelay === undefined ? null : jsonToDb(rec.kycWithdrawalDelay), - seed: rec.secretSeed, - rpub: rec.reservePub, - rpriv: rec.reservePriv, + seed: crockToDb(rec.secretSeed), + rpub: crockToDb(rec.reservePub), + rpriv: crockToDb(rec.reservePriv), url: rec.exchangeBaseUrl ?? null, tstart: rec.timestampStart, tfinish: rec.timestampFinish ?? null, @@ -2119,17 +2186,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToPlanchet(row: ResultRow): WalletPlanchet { return { - coinPub: str(row.coin_pub), - coinPriv: str(row.coin_priv), + coinPub: dbToCrock(row.coin_pub), + coinPriv: dbToCrock(row.coin_priv), withdrawalGroupId: str(row.withdrawal_group_id), coinIdx: num(row.coin_idx), planchetStatus: num(row.planchet_status), lastError: dbToOptJson(row.last_error), - denomPubHash: str(row.denom_pub_hash), - blindingKey: str(row.blinding_key), - withdrawSig: str(row.withdraw_sig), + denomPubHash: dbToCrock(row.denom_pub_hash), + blindingKey: dbToCrock(row.blinding_key), + withdrawSig: dbToCrock(row.withdraw_sig), coinEv: dbToJson(row.coin_ev), - coinEvHash: str(row.coin_ev_hash), + coinEvHash: dbToCrock(row.coin_ev_hash), ...(row.age_commitment_proof != null ? { ageCommitmentProof: dbToJson(row.age_commitment_proof) } : undefined), @@ -2139,7 +2206,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined> { const row = await this.first( "SELECT * FROM planchets WHERE coin_pub = $pub", - { pub: coinPub }, + { pub: crockToDb(coinPub) }, ); return row ? this.rowToPlanchet(row) : undefined; } @@ -2167,17 +2234,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { coin_ev_hash = excluded.coin_ev_hash, age_commitment_proof = excluded.age_commitment_proof`, { - pub: rec.coinPub, - priv: rec.coinPriv, + pub: crockToDb(rec.coinPub), + priv: crockToDb(rec.coinPriv), wgid: rec.withdrawalGroupId, idx: rec.coinIdx, status: rec.planchetStatus, err: rec.lastError === undefined ? null : jsonToDb(rec.lastError), - dph: rec.denomPubHash, - bk: rec.blindingKey, - sig: rec.withdrawSig, + dph: crockToDb(rec.denomPubHash), + bk: crockToDb(rec.blindingKey), + sig: crockToDb(rec.withdrawSig), ev: jsonToDb(rec.coinEv), - evh: rec.coinEvHash, + evh: crockToDb(rec.coinEvHash), acp: rec.ageCommitmentProof === undefined ? null @@ -2218,7 +2285,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async deletePlanchet(coinPub: string): Promise<void> { await this.run("DELETE FROM planchets WHERE coin_pub = $pub", { - pub: coinPub, + pub: crockToDb(coinPub), }); } @@ -2358,17 +2425,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToPeerPushDebit(row: ResultRow): WalletPeerPushDebit { return { - pursePub: str(row.purse_pub), + pursePub: dbToCrock(row.purse_pub), exchangeBaseUrl: str(row.exchange_base_url), amount: dbAmount(row.amount), totalCost: dbAmount(row.total_cost), - contractTermsHash: str(row.contract_terms_hash), - pursePriv: str(row.purse_priv), - mergePub: str(row.merge_pub), - mergePriv: str(row.merge_priv), - contractPriv: str(row.contract_priv), - contractPub: str(row.contract_pub), - contractEncNonce: str(row.contract_enc_nonce), + contractTermsHash: dbToCrock(row.contract_terms_hash), + pursePriv: dbToCrock(row.purse_priv), + mergePub: dbToCrock(row.merge_pub), + mergePriv: dbToCrock(row.merge_priv), + contractPriv: dbToCrock(row.contract_priv), + contractPub: dbToCrock(row.contract_pub), + contractEncNonce: dbToCrock(row.contract_enc_nonce), purseExpiration: dbTimestamp(row.purse_expiration), timestampCreated: dbTimestamp(row.timestamp_created), status: num(row.status), @@ -2395,7 +2462,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletPeerPushDebit | undefined> { const row = await this.first( "SELECT * FROM peer_push_debit WHERE purse_pub = $pub", - { pub: pursePub }, + { pub: crockToDb(pursePub) }, ); return row ? this.rowToPeerPushDebit(row) : undefined; } @@ -2433,20 +2500,20 @@ export class SqliteWalletTransaction implements WalletDbTransaction { fail_reason = excluded.fail_reason, status = excluded.status`, { - pub: rec.pursePub, + pub: crockToDb(rec.pursePub), url: rec.exchangeBaseUrl, scope: rec.restrictScope === undefined ? null : jsonToDb(rec.restrictScope), amt: rec.amount, cost: rec.totalCost, csel: rec.coinSel === undefined ? null : jsonToDb(rec.coinSel), - cth: rec.contractTermsHash, - ppriv: rec.pursePriv, - mpub: rec.mergePub, - mpriv: rec.mergePriv, - cpriv: rec.contractPriv, - cpub: rec.contractPub, - nonce: rec.contractEncNonce, + cth: crockToDb(rec.contractTermsHash), + ppriv: crockToDb(rec.pursePriv), + mpub: crockToDb(rec.mergePub), + mpriv: crockToDb(rec.mergePriv), + cpriv: crockToDb(rec.contractPriv), + cpub: crockToDb(rec.contractPub), + nonce: crockToDb(rec.contractEncNonce), exp: rec.purseExpiration, created: rec.timestampCreated, argi: rec.abortRefreshGroupId ?? null, @@ -2459,7 +2526,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async deletePeerPushDebit(pursePub: string): Promise<void> { await this.run("DELETE FROM peer_push_debit WHERE purse_pub = $pub", { - pub: pursePub, + pub: crockToDb(pursePub), }); } @@ -2485,12 +2552,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { return { peerPushCreditId: str(row.peer_push_credit_id), exchangeBaseUrl: str(row.exchange_base_url), - pursePub: str(row.purse_pub), - mergePriv: str(row.merge_priv), - contractPriv: str(row.contract_priv), + pursePub: dbToCrock(row.purse_pub), + mergePriv: dbToCrock(row.merge_priv), + contractPriv: dbToCrock(row.contract_priv), timestamp: dbTimestamp(row.timestamp), estimatedAmountEffective: dbAmount(row.estimated_amount_effective), - contractTermsHash: str(row.contract_terms_hash), + contractTermsHash: dbToCrock(row.contract_terms_hash), status: num(row.status), withdrawalGroupId: optStr(row.withdrawal_group_id), currency: optStr(row.currency), @@ -2501,7 +2568,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? { failReason: dbToJson(row.fail_reason) } : undefined), ...(row.kyc_payto_hash != null - ? { kycPaytoHash: str(row.kyc_payto_hash) } + ? { kycPaytoHash: dbToCrock(row.kyc_payto_hash) } : undefined), ...(row.kyc_access_token != null ? { kycAccessToken: str(row.kyc_access_token) } @@ -2571,18 +2638,18 @@ export class SqliteWalletTransaction implements WalletDbTransaction { { id: rec.peerPushCreditId, url: rec.exchangeBaseUrl, - ppub: rec.pursePub, - mpriv: rec.mergePriv, - cpriv: rec.contractPriv, + ppub: crockToDb(rec.pursePub), + mpriv: crockToDb(rec.mergePriv), + cpriv: crockToDb(rec.contractPriv), ts: rec.timestamp, eae: rec.estimatedAmountEffective, - cth: rec.contractTermsHash, + cth: crockToDb(rec.contractTermsHash), status: rec.status, abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), wgid: rec.withdrawalGroupId ?? null, cur: rec.currency ?? null, - kph: rec.kycPaytoHash ?? null, + kph: optCrockToDb(rec.kycPaytoHash), kat: rec.kycAccessToken ?? null, klcs: rec.kycLastCheckStatus ?? null, klcc: rec.kycLastCheckCode ?? null, @@ -2623,7 +2690,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const row = await this.first( "SELECT * FROM peer_push_credit" + " WHERE exchange_base_url = $url AND contract_priv = $priv", - { url: exchangeBaseUrl, priv: contractPriv }, + { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, ); return row ? this.rowToPeerPushCredit(row) : undefined; } @@ -2633,12 +2700,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToPeerPullDebit(row: ResultRow): WalletPeerPullDebit { return { peerPullDebitId: str(row.peer_pull_debit_id), - pursePub: str(row.purse_pub), + pursePub: dbToCrock(row.purse_pub), exchangeBaseUrl: str(row.exchange_base_url), amount: dbAmount(row.amount), - contractTermsHash: str(row.contract_terms_hash), + contractTermsHash: dbToCrock(row.contract_terms_hash), timestampCreated: dbTimestamp(row.timestamp_created), - contractPriv: str(row.contract_priv), + contractPriv: dbToCrock(row.contract_priv), status: num(row.status), totalCostEstimated: dbAmount(row.total_cost_estimated), ...(row.abort_refresh_group_id != null @@ -2692,12 +2759,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { coin_sel = excluded.coin_sel`, { id: rec.peerPullDebitId, - ppub: rec.pursePub, + ppub: crockToDb(rec.pursePub), url: rec.exchangeBaseUrl, amt: rec.amount, - cth: rec.contractTermsHash, + cth: crockToDb(rec.contractTermsHash), created: rec.timestampCreated, - cpriv: rec.contractPriv, + cpriv: crockToDb(rec.contractPriv), status: rec.status, tce: rec.totalCostEstimated, argi: rec.abortRefreshGroupId ?? null, @@ -2727,7 +2794,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { const row = await this.first( "SELECT * FROM peer_pull_debit" + " WHERE exchange_base_url = $url AND contract_priv = $priv", - { url: exchangeBaseUrl, priv: contractPriv }, + { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, ); return row ? this.rowToPeerPullDebit(row) : undefined; } @@ -2736,23 +2803,23 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToPeerPullCredit(row: ResultRow): WalletPeerPullCredit { return { - pursePub: str(row.purse_pub), + pursePub: dbToCrock(row.purse_pub), exchangeBaseUrl: str(row.exchange_base_url), amount: dbAmount(row.amount), estimatedAmountEffective: dbAmount(row.estimated_amount_effective), - pursePriv: str(row.purse_priv), - contractTermsHash: str(row.contract_terms_hash), - mergePub: str(row.merge_pub), - mergePriv: str(row.merge_priv), - contractPub: str(row.contract_pub), - contractPriv: str(row.contract_priv), - contractEncNonce: str(row.contract_enc_nonce), + pursePriv: dbToCrock(row.purse_priv), + contractTermsHash: dbToCrock(row.contract_terms_hash), + mergePub: dbToCrock(row.merge_pub), + mergePriv: dbToCrock(row.merge_priv), + contractPub: dbToCrock(row.contract_pub), + contractPriv: dbToCrock(row.contract_priv), + contractEncNonce: dbToCrock(row.contract_enc_nonce), mergeTimestamp: dbTimestamp(row.merge_timestamp), mergeReserveRowId: num(row.merge_reserve_row_id), status: num(row.status), withdrawalGroupId: optStr(row.withdrawal_group_id), ...(row.kyc_payto_hash != null - ? { kycPaytoHash: str(row.kyc_payto_hash) } + ? { kycPaytoHash: dbToCrock(row.kyc_payto_hash) } : undefined), ...(row.kyc_access_token != null ? { kycAccessToken: str(row.kyc_access_token) } @@ -2786,7 +2853,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletPeerPullCredit | undefined> { const row = await this.first( "SELECT * FROM peer_pull_credit WHERE purse_pub = $pub", - { pub: pursePub }, + { pub: crockToDb(pursePub) }, ); return row ? this.rowToPeerPullCredit(row) : undefined; } @@ -2831,21 +2898,21 @@ export class SqliteWalletTransaction implements WalletDbTransaction { fail_reason = excluded.fail_reason, withdrawal_group_id = excluded.withdrawal_group_id`, { - pub: rec.pursePub, + pub: crockToDb(rec.pursePub), url: rec.exchangeBaseUrl, amt: rec.amount, eae: rec.estimatedAmountEffective, - ppriv: rec.pursePriv, - cth: rec.contractTermsHash, - mpub: rec.mergePub, - mpriv: rec.mergePriv, - cpub: rec.contractPub, - cpriv: rec.contractPriv, - nonce: rec.contractEncNonce, + ppriv: crockToDb(rec.pursePriv), + cth: crockToDb(rec.contractTermsHash), + mpub: crockToDb(rec.mergePub), + mpriv: crockToDb(rec.mergePriv), + cpub: crockToDb(rec.contractPub), + cpriv: crockToDb(rec.contractPriv), + nonce: crockToDb(rec.contractEncNonce), mts: rec.mergeTimestamp, mrri: rec.mergeReserveRowId, status: rec.status, - kph: rec.kycPaytoHash ?? null, + kph: optCrockToDb(rec.kycPaytoHash), kat: rec.kycAccessToken ?? null, klcs: rec.kycLastCheckStatus ?? null, klcc: rec.kycLastCheckCode ?? null, @@ -2861,7 +2928,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async deletePeerPullCredit(pursePub: string): Promise<void> { await this.run("DELETE FROM peer_pull_credit WHERE purse_pub = $pub", { - pub: pursePub, + pub: crockToDb(pursePub), }); } @@ -2878,12 +2945,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { currency: str(row.currency), amount: dbAmount(row.amount), wireTransferDeadline: dbTimestamp(row.wire_transfer_deadline), - merchantPub: str(row.merchant_pub), - merchantPriv: str(row.merchant_priv), - noncePriv: str(row.nonce_priv), - noncePub: str(row.nonce_pub), + merchantPub: dbToCrock(row.merchant_pub), + merchantPriv: dbToCrock(row.merchant_priv), + noncePriv: dbToCrock(row.nonce_priv), + noncePub: dbToCrock(row.nonce_pub), wire: dbToJson(row.wire), - contractTermsHash: str(row.contract_terms_hash), + contractTermsHash: dbToCrock(row.contract_terms_hash), totalPayCost: dbAmount(row.total_pay_cost), counterpartyEffectiveDepositAmount: dbAmount( row.counterparty_effective_deposit_amount, @@ -2996,12 +3063,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { cur: rec.currency, amt: rec.amount, wtd: rec.wireTransferDeadline, - mpub: rec.merchantPub, - mpriv: rec.merchantPriv, - npriv: rec.noncePriv, - npub: rec.noncePub, + mpub: crockToDb(rec.merchantPub), + mpriv: crockToDb(rec.merchantPriv), + npriv: crockToDb(rec.noncePriv), + npub: crockToDb(rec.noncePub), wire: jsonToDb(rec.wire), - cth: rec.contractTermsHash, + cth: crockToDb(rec.contractTermsHash), pcs: rec.payCoinSelection === undefined ? null @@ -3275,16 +3342,16 @@ export class SqliteWalletTransaction implements WalletDbTransaction { downloadSessionId: optStr(row.download_session_id), repurchaseProposalId: optStr(row.repurchase_proposal_id), purchaseStatus: num(row.purchase_status), - noncePriv: str(row.nonce_priv), - noncePub: str(row.nonce_pub), - secretSeed: optStr(row.secret_seed), + noncePriv: dbToCrock(row.nonce_priv), + noncePub: dbToCrock(row.nonce_pub), + secretSeed: dbToOptCrock(row.secret_seed), download, payInfo: dbToOptJson(row.pay_info), timestampFirstSuccessfulPay: row.timestamp_first_successful_pay == null ? undefined : dbTimestamp(row.timestamp_first_successful_pay), - merchantPaySig: optStr(row.merchant_pay_sig), + merchantPaySig: dbToOptCrock(row.merchant_pay_sig), posConfirmation: optStr(row.pos_confirmation), shared: dbToBool(row.shared), timestamp: dbTimestamp(row.timestamp), @@ -3331,7 +3398,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? { donauAmount: dbAmount(row.donau_amount) } : undefined), ...(row.donau_tax_id_hash != null - ? { donauTaxIdHash: str(row.donau_tax_id_hash) } + ? { donauTaxIdHash: dbToCrock(row.donau_tax_id_hash) } : undefined), ...(row.donau_tax_id_salt != null ? { donauTaxIdSalt: str(row.donau_tax_id_salt) } @@ -3468,10 +3535,10 @@ export class SqliteWalletTransaction implements WalletDbTransaction { argi: rec.abortRefreshGroupId ?? null, abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - npriv: rec.noncePriv, - npub: rec.noncePub, + npriv: crockToDb(rec.noncePriv), + npub: crockToDb(rec.noncePub), ci: rec.choiceIndex ?? null, - seed: rec.secretSeed ?? null, + seed: optCrockToDb(rec.secretSeed), dl: downloadJson, ffu: fulfillmentUrl, pi: rec.payInfo === undefined ? null : jsonToDb(rec.payInfo), @@ -3480,12 +3547,12 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? null : jsonToDb(rec.pendingRemovedCoinPubs), tfsp: rec.timestampFirstSuccessfulPay ?? null, - mps: rec.merchantPaySig ?? null, + mps: optCrockToDb(rec.merchantPaySig), posc: rec.posConfirmation ?? null, doi: rec.donauOutputIndex ?? null, dbu: rec.donauBaseUrl ?? null, damt: rec.donauAmount ?? null, - dtih: rec.donauTaxIdHash ?? null, + dtih: optCrockToDb(rec.donauTaxIdHash), dtis: rec.donauTaxIdSalt ?? null, dti: rec.donauTaxId ?? null, dy: rec.donauYear ?? null, @@ -3665,17 +3732,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToDonationPlanchet(row: ResultRow): WalletDonationPlanchet { return { - udiNonce: str(row.udi_nonce), + udiNonce: dbToCrock(row.udi_nonce), donauBaseUrl: str(row.donau_base_url), - donorTaxIdHash: str(row.donor_tax_id_hash), + donorTaxIdHash: dbToCrock(row.donor_tax_id_hash), donorHashSalt: str(row.donor_hash_salt), donorTaxId: str(row.donor_tax_id), donationYear: num(row.donation_year), proposalId: str(row.proposal_id), udiIndex: num(row.udi_index), blindedUdi: dbToJson(row.blinded_udi), - bks: str(row.bks), - donationUnitPubHash: str(row.donation_unit_pub_hash), + bks: dbToCrock(row.bks), + donationUnitPubHash: dbToCrock(row.donation_unit_pub_hash), value: dbAmount(row.value), }; } @@ -3703,17 +3770,17 @@ export class SqliteWalletTransaction implements WalletDbTransaction { donation_unit_pub_hash = excluded.donation_unit_pub_hash, value = excluded.value`, { - nonce: rec.udiNonce, + nonce: crockToDb(rec.udiNonce), url: rec.donauBaseUrl, - dtih: rec.donorTaxIdHash, + dtih: crockToDb(rec.donorTaxIdHash), dhs: rec.donorHashSalt, dti: rec.donorTaxId, year: rec.donationYear, pid: rec.proposalId, idx: rec.udiIndex, budi: jsonToDb(rec.blindedUdi), - bks: rec.bks, - duph: rec.donationUnitPubHash, + bks: crockToDb(rec.bks), + duph: crockToDb(rec.donationUnitPubHash), val: rec.value, }, ); @@ -3739,14 +3806,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToDonationReceipt(row: ResultRow): WalletDonationReceipt { return { - udiNonce: str(row.udi_nonce), + udiNonce: dbToCrock(row.udi_nonce), status: num(row.status), donauBaseUrl: str(row.donau_base_url), proposalId: str(row.proposal_id), donationYear: num(row.donation_year), - donationUnitPubHash: str(row.donation_unit_pub_hash), + donationUnitPubHash: dbToCrock(row.donation_unit_pub_hash), donationUnitSig: dbToJson(row.donation_unit_sig), - donorTaxIdHash: str(row.donor_tax_id_hash), + donorTaxIdHash: dbToCrock(row.donor_tax_id_hash), donorHashSalt: str(row.donor_hash_salt), donorTaxId: str(row.donor_tax_id), value: dbAmount(row.value), @@ -3759,7 +3826,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletDonationReceipt | undefined> { const row = await this.first( "SELECT * FROM donation_receipts WHERE udi_nonce = $nonce", - { nonce: udiNonce }, + { nonce: crockToDb(udiNonce) }, ); return row ? this.rowToDonationReceipt(row) : undefined; } @@ -3787,14 +3854,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { value = excluded.value, udi_index = excluded.udi_index`, { - nonce: rec.udiNonce, + nonce: crockToDb(rec.udiNonce), status: rec.status, url: rec.donauBaseUrl, pid: rec.proposalId, year: rec.donationYear, - duph: rec.donationUnitPubHash, + duph: crockToDb(rec.donationUnitPubHash), dus: jsonToDb(rec.donationUnitSig), - dtih: rec.donorTaxIdHash, + dtih: crockToDb(rec.donorTaxIdHash), dhs: rec.donorHashSalt, dti: rec.donorTaxId, val: rec.value, @@ -3988,7 +4055,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: num(row.id), currency: str(row.currency), exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: str(row.exchange_master_pub), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), })); } @@ -4002,7 +4069,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { { cur: rec.currency, url: rec.exchangeBaseUrl, - pub: rec.exchangeMasterPub, + pub: crockToDb(rec.exchangeMasterPub), }, ); } @@ -4022,7 +4089,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "SELECT * FROM global_currency_exchanges" + " WHERE currency = $cur AND exchange_base_url = $url" + " AND exchange_master_pub = $pub", - { cur: currency, url: exchangeBaseUrl, pub: exchangeMasterPub }, + { + cur: currency, + url: exchangeBaseUrl, + pub: crockToDb(exchangeMasterPub), + }, ); if (!row) { return undefined; @@ -4031,7 +4102,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: num(row.id), currency: str(row.currency), exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: str(row.exchange_master_pub), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), }; } @@ -4041,7 +4112,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: num(row.id), currency: str(row.currency), auditorBaseUrl: str(row.auditor_base_url), - auditorPub: str(row.auditor_pub), + auditorPub: dbToCrock(row.auditor_pub), })); } @@ -4052,7 +4123,11 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "INSERT OR IGNORE INTO global_currency_auditors" + " (currency, auditor_base_url, auditor_pub)" + " VALUES ($cur, $url, $pub)", - { cur: rec.currency, url: rec.auditorBaseUrl, pub: rec.auditorPub }, + { + cur: rec.currency, + url: rec.auditorBaseUrl, + pub: crockToDb(rec.auditorPub), + }, ); } @@ -4071,7 +4146,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { "SELECT * FROM global_currency_auditors" + " WHERE currency = $cur AND auditor_base_url = $url" + " AND auditor_pub = $pub", - { cur: currency, url: auditorBaseUrl, pub: auditorPub }, + { cur: currency, url: auditorBaseUrl, pub: crockToDb(auditorPub) }, ); if (!row) { return undefined; @@ -4080,7 +4155,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { id: num(row.id), currency: str(row.currency), auditorBaseUrl: str(row.auditor_base_url), - auditorPub: str(row.auditor_pub), + auditorPub: dbToCrock(row.auditor_pub), }; } @@ -4165,19 +4240,19 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToToken(row: ResultRow): WalletToken { return { - tokenUsePub: str(row.token_use_pub), - tokenUsePriv: str(row.token_use_priv), + tokenUsePub: dbToCrock(row.token_use_pub), + tokenUsePriv: dbToCrock(row.token_use_priv), purchaseId: str(row.purchase_id), merchantBaseUrl: str(row.merchant_base_url), kind: str(row.kind) as MerchantContractTokenKind, - tokenIssuePubHash: str(row.token_issue_pub_hash), + tokenIssuePubHash: dbToCrock(row.token_issue_pub_hash), validAfter: dbTimestamp(row.valid_after), validBefore: dbTimestamp(row.valid_before), tokenIssueSig: dbToJson(row.token_issue_sig), tokenUseSig: dbToOptJson(row.token_use_sig), tokenEv: dbToJson(row.token_ev), - tokenEvHash: str(row.token_ev_hash), - blindingKey: str(row.blinding_key), + tokenEvHash: dbToCrock(row.token_ev_hash), + blindingKey: dbToCrock(row.blinding_key), slug: str(row.slug), name: str(row.name), description: str(row.description), @@ -4197,7 +4272,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? { repeatIndex: num(row.repeat_index) } : undefined), ...(row.token_family_hash != null - ? { tokenFamilyHash: str(row.token_family_hash) } + ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } : undefined), }; } @@ -4210,7 +4285,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { const row = await this.first( "SELECT * FROM tokens WHERE token_use_pub = $pub", - { pub: tokenUsePub }, + { pub: crockToDb(tokenUsePub) }, ); return row ? this.rowToToken(row) : undefined; } @@ -4253,8 +4328,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { token_issue_pub = excluded.token_issue_pub, description_i18n = excluded.description_i18n`, { - pub: rec.tokenUsePub, - priv: rec.tokenUsePriv, + pub: crockToDb(rec.tokenUsePub), + priv: crockToDb(rec.tokenUsePriv), pid: rec.purchaseId, tid: rec.transactionId ?? null, ci: rec.choiceIndex ?? null, @@ -4262,15 +4337,15 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ri: rec.repeatIndex ?? null, url: rec.merchantBaseUrl, kind: rec.kind, - tiph: rec.tokenIssuePubHash, - tfh: rec.tokenFamilyHash ?? null, + tiph: crockToDb(rec.tokenIssuePubHash), + tfh: optCrockToDb(rec.tokenFamilyHash), va: rec.validAfter, vb: rec.validBefore, sig: jsonToDb(rec.tokenIssueSig), usig: rec.tokenUseSig === undefined ? null : jsonToDb(rec.tokenUseSig), ev: jsonToDb(rec.tokenEv), - evh: rec.tokenEvHash, - bk: rec.blindingKey, + evh: crockToDb(rec.tokenEvHash), + bk: crockToDb(rec.blindingKey), slug: rec.slug, name: rec.name, desc: rec.description, @@ -4286,7 +4361,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async deleteToken(tokenUsePub: string): Promise<void> { await this.run("DELETE FROM tokens WHERE token_use_pub = $pub", { - pub: tokenUsePub, + pub: crockToDb(tokenUsePub), }); } @@ -4295,7 +4370,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ): Promise<WalletToken[]> { const rows = await this.all( "SELECT * FROM tokens WHERE token_issue_pub_hash = $h", - { h: tokenIssuePubHash }, + { h: crockToDb(tokenIssuePubHash) }, ); return rows.map((r) => this.rowToToken(r)); } @@ -4304,18 +4379,18 @@ export class SqliteWalletTransaction implements WalletDbTransaction { private rowToSlate(row: ResultRow): WalletSlate { return { - tokenUsePub: str(row.token_use_pub), - tokenUsePriv: str(row.token_use_priv), + tokenUsePub: dbToCrock(row.token_use_pub), + tokenUsePriv: dbToCrock(row.token_use_priv), purchaseId: str(row.purchase_id), merchantBaseUrl: str(row.merchant_base_url), kind: str(row.kind) as MerchantContractTokenKind, - tokenIssuePubHash: str(row.token_issue_pub_hash), + tokenIssuePubHash: dbToCrock(row.token_issue_pub_hash), validAfter: dbTimestamp(row.valid_after), validBefore: dbTimestamp(row.valid_before), tokenUseSig: dbToOptJson(row.token_use_sig), tokenEv: dbToJson(row.token_ev), - tokenEvHash: str(row.token_ev_hash), - blindingKey: str(row.blinding_key), + tokenEvHash: dbToCrock(row.token_ev_hash), + blindingKey: dbToCrock(row.blinding_key), slug: str(row.slug), name: str(row.name), description: str(row.description), @@ -4335,7 +4410,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ? { repeatIndex: num(row.repeat_index) } : undefined), ...(row.token_family_hash != null - ? { tokenFamilyHash: str(row.token_family_hash) } + ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } : undefined), }; } @@ -4402,8 +4477,8 @@ export class SqliteWalletTransaction implements WalletDbTransaction { token_issue_pub = excluded.token_issue_pub, description_i18n = excluded.description_i18n`, { - pub: rec.tokenUsePub, - priv: rec.tokenUsePriv, + pub: crockToDb(rec.tokenUsePub), + priv: crockToDb(rec.tokenUsePriv), pid: rec.purchaseId, tid: rec.transactionId ?? null, ci: rec.choiceIndex ?? null, @@ -4411,14 +4486,14 @@ export class SqliteWalletTransaction implements WalletDbTransaction { ri: rec.repeatIndex ?? null, url: rec.merchantBaseUrl, kind: rec.kind, - tiph: rec.tokenIssuePubHash, - tfh: rec.tokenFamilyHash ?? null, + tiph: crockToDb(rec.tokenIssuePubHash), + tfh: optCrockToDb(rec.tokenFamilyHash), va: rec.validAfter, vb: rec.validBefore, usig: rec.tokenUseSig === undefined ? null : jsonToDb(rec.tokenUseSig), ev: jsonToDb(rec.tokenEv), - evh: rec.tokenEvHash, - bk: rec.blindingKey, + evh: crockToDb(rec.tokenEvHash), + bk: crockToDb(rec.blindingKey), slug: rec.slug, name: rec.name, desc: rec.description, @@ -4434,7 +4509,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async deleteSlate(tokenUsePub: string): Promise<void> { await this.run("DELETE FROM slates WHERE token_use_pub = $pub", { - pub: tokenUsePub, + pub: crockToDb(tokenUsePub), }); } @@ -4447,7 +4522,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { amountRefreshOutput: dbAmount(row.amount_refresh_output), newDenoms: dbToJson(row.new_denoms), ...(row.session_public_seed != null - ? { sessionPublicSeed: str(row.session_public_seed) } + ? { sessionPublicSeed: dbToCrock(row.session_public_seed) } : undefined), ...(row.noreveal_index != null ? { norevealIndex: num(row.noreveal_index) } @@ -4485,7 +4560,7 @@ export class SqliteWalletTransaction implements WalletDbTransaction { { id: rec.refreshGroupId, idx: rec.coinIndex, - seed: rec.sessionPublicSeed ?? null, + seed: optCrockToDb(rec.sessionPublicSeed), amt: rec.amountRefreshOutput, nd: jsonToDb(rec.newDenoms), nri: rec.norevealIndex ?? null, @@ -4872,7 +4947,29 @@ async function listDataTables(ndb: NativeSqliteWalletDb): Promise<string[]> { */ export interface NativeSqliteDbDump { schemaVersion: number; - tables: Record<string, Record<string, Sqlite3Value>[]>; + tables: Record<string, Record<string, DumpValue>[]>; +} + +/** + * A BLOB column in a dump. + * + * Tagged rather than raw bytes because the dump is stored in the IndexedDB + * backup database, and that layer cannot persist binary at all: + * structuredEncapsulate, which serialises values on their way to storage, + * handles arrays, dates, plain objects, bigint, boolean, number and string, + * and throws on anything else -- typed arrays included. (Its in-memory clone + * does support them, so this only bites on persistence.) + */ +export interface DumpBlob { + $blob: string; +} + +export type DumpValue = string | number | null | DumpBlob; + +function isDumpBlob(v: unknown): v is DumpBlob { + return ( + typeof v === "object" && v !== null && typeof (v as any).$blob === "string" + ); } export async function exportNativeSqliteDb( @@ -4888,13 +4985,19 @@ export async function exportNativeSqliteDb( const rows = await ( await ndb.db.prepare(`SELECT * FROM "${table}"`) ).getAll(); - // The helper can return INTEGER columns as bigint, which JSON cannot - // represent. Every integer in this schema (timestamps in - // microseconds, row ids, statuses) is inside the safe range. out.tables[table] = rows.map((row) => { - const clean: Record<string, Sqlite3Value> = {}; + const clean: Record<string, DumpValue> = {}; for (const [k, v] of Object.entries(row)) { - clean[k] = typeof v === "bigint" ? Number(v) : v; + if (v instanceof Uint8Array) { + clean[k] = { $blob: encodeCrock(v) }; + } else if (typeof v === "bigint") { + // The helper can return INTEGER columns as bigint, which JSON + // cannot represent. Every integer in this schema (timestamps in + // microseconds, row ids, statuses) is inside the safe range. + clean[k] = Number(v); + } else { + clean[k] = v; + } } return clean; }); @@ -4931,7 +5034,8 @@ export async function importNativeSqliteDb( const cols = Object.keys(row); const params: Record<string, Sqlite3Value> = {}; for (const c of cols) { - params[c] = row[c]; + const v = row[c]; + params[c] = isDumpBlob(v) ? decodeCrock(v.$blob) : v; } const colList = cols.map((c) => `"${c}"`).join(", "); const valList = cols.map((c) => `$${c}`).join(", ");