commit d902ca4bcc95a86123be89ab0cd2e3aaf2b691c1 parent a446ebe2a3a2418210a2c2f24ef2c01a70f489a7 Author: Florian Dold <dold@taler.net> Date: Thu, 20 Aug 2026 19:55:12 +0200 wallet-core: organize database modules Diffstat:
112 files changed, 27052 insertions(+), 26958 deletions(-)
diff --git a/packages/taler-wallet-core/src/auditorTrust.ts b/packages/taler-wallet-core/src/auditorTrust.ts @@ -7,7 +7,7 @@ Foundation; either version 3, or (at your option) any later version. */ -import type { WalletExchangeAuditor } from "./db-common.js"; +import type { WalletExchangeAuditor } from "./db/records.js"; export interface AuditorTrustRequirement { auditorBaseUrl?: string; diff --git a/packages/taler-wallet-core/src/balance.test.ts b/packages/taler-wallet-core/src/balance.test.ts @@ -29,8 +29,8 @@ import { WalletExchangeEntry, WalletRefreshGroup, WalletDepositGroup, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; function makeExchange( diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -99,11 +99,10 @@ import { WalletDonationSummary, WalletCoinAvailability, WalletExchangeDetails, -} from "./db-common.js"; +} from "./db/records.js"; import { getEffectiveExchangeType } from "./builtin-exchanges.js"; import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; -import {} from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { denomRefKey, getDenomInfos, diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -55,8 +55,8 @@ import { WalletDenomination, WalletExchangeDetails, WalletExchangeEntry, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { requireExchangeCoinUseConfirmedOrThrow } from "./exchanges.js"; import { WalletExecutionContext } from "./wallet.js"; diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -76,7 +76,7 @@ import { getAutoRefreshExecuteThreshold } from "./common.js"; import { DenominationVerificationStatus, WalletDenomination, -} from "./db-common.js"; +} from "./db/records.js"; import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; import { checkExchangeInScopeTx, @@ -88,7 +88,7 @@ import { getDenomInfos, WalletExecutionContext, } from "./wallet.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; const logger = new Logger("coinSelection.ts"); diff --git a/packages/taler-wallet-core/src/common.test.ts b/packages/taler-wallet-core/src/common.test.ts @@ -21,8 +21,8 @@ import { getRetryDuration, spendTokens, } from "./common.js"; -import { KycAuthTransferOptionRaw, WalletToken } from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { KycAuthTransferOptionRaw, WalletToken } from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; test("the retry delay grows but stays bounded", (t) => { const first = Duration.toMilliseconds(getRetryDuration(0)); @@ -90,5 +90,8 @@ test("spending tokens continues after an idempotently allocated token", async () tokenPubs: ["already-allocated", "newly-allocated"], }); - assert.strictEqual(tokens.get("newly-allocated")?.transactionId, transactionId); + assert.strictEqual( + tokens.get("newly-allocated")?.transactionId, + transactionId, + ); }); diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -75,14 +75,14 @@ import { WalletRetryInfo, WalletWithdrawalGroup, timestampPreciseToDb, -} from "./db-common.js"; +} from "./db/records.js"; import { PeerPullCreditRecord, PeerPullPaymentIncomingRecord, PeerPushCreditRecord, PeerPushDebitRecord, -} from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/indexeddb/schema.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { ReadyExchangeSummary, markExchangeUsed } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { BalanceEffect, applyNotifyTransition } from "./transactions.js"; @@ -573,7 +573,7 @@ export interface TaskRunErrorResult { /** * Retry state of a task. * - * The stored shape lives in db-common.ts as {@link WalletRetryInfo}; this + * The stored shape lives in db/records.ts as {@link WalletRetryInfo}; this * interface exists so the retry-policy helpers below can merge into a * namespace of the same name. */ diff --git a/packages/taler-wallet-core/src/contacts.ts b/packages/taler-wallet-core/src/contacts.ts @@ -30,7 +30,7 @@ import { Logger, NotificationType, } from "@gnu-taler/taler-util"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; const logger = new Logger("contacts.ts"); diff --git a/packages/taler-wallet-core/src/db-common.test.ts b/packages/taler-wallet-core/src/db-common.test.ts @@ -1,44 +0,0 @@ -/* - 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/> - */ - -import { TalerProtocolTimestamp } from "@gnu-taler/taler-util"; -import assert from "node:assert"; -import { test } from "node:test"; -import { - DbPreciseTimestamp, - timestampOptionalPreciseFromDb, - timestampPreciseFromDb, - timestampPreciseToDb, - timestampProtocolFromDb, - timestampProtocolToDb, -} from "./db-common.js"; - -test("database timestamps preserve the never sentinel", () => { - const precise = timestampPreciseFromDb( - timestampPreciseToDb({ t_s: "never" }), - ); - const protocol = timestampProtocolFromDb( - timestampProtocolToDb(TalerProtocolTimestamp.never()), - ); - assert.strictEqual(precise.t_s, "never"); - assert.strictEqual(protocol.t_s, "never"); -}); - -test("an optional precise timestamp preserves the Unix epoch", () => { - const epoch = timestampOptionalPreciseFromDb(0 as DbPreciseTimestamp); - assert.ok(epoch); - assert.strictEqual(epoch.t_s, 0); -}); diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -1,3059 +0,0 @@ -/* - 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/> - */ - -import { - AbsoluteTime, - AmountString, - TalerPreciseTimestamp, - TalerProtocolTimestamp, - MerchantContractTokenKind, - TokenIssuePublicKey, - UnblindedDenominationSignature, - TokenUseSig, - MerchantContractTokenDetails, - ScopeInfo, - TalerErrorDetail, - DenominationPubKey, - Amounts, - DenominationInfo, - TransferOptionRaw, - CoinRefreshRequest, - ExchangeRefundRequest, - ExchangeEntrySource, - RefreshReason, - WithdrawalExchangeAccountDetails, - CoinEnvelope, - TalerProtocolDuration, - DenomSelectionState, - DonationReceiptSignature, - HashCodeString, - BlindedUniqueDonationIdentifier, - SignedTokenEnvelope, - CurrencySpecification, - ExchangeAuditor, - ExchangeWithdrawValue, - ExchangeGlobalFees, - WireInfo, - AccountLimit, - ZeroLimitedOperation, - DenomLossEventType, - EddsaPublicKeyString, - EddsaSignatureString, - CoinStatus, - AgeCommitmentProof, - TransactionIdStr, - TokenEnvelope, - encodeCrock, - hash, - stringToBytes, - canonicalJson, -} from "@gnu-taler/taler-util"; - -declare const symDbProtocolTimestamp: unique symbol; - -declare const symDbPreciseTimestamp: unique symbol; - -/** - * Timestamp, stored as microseconds. - * - * Always rounded to a full second. - */ -export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true }; - -/** - * Timestamp, stored as microseconds. - */ -export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true }; - -const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER; - -export function timestampPreciseFromDb( - dbTs: DbPreciseTimestamp, -): TalerPreciseTimestamp { - if (dbTs >= DB_TIMESTAMP_FOREVER) { - return { t_s: "never" }; - } - return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); -} - -export function timestampOptionalPreciseFromDb( - dbTs: DbPreciseTimestamp | undefined, -): TalerPreciseTimestamp | undefined { - if (dbTs == null) { - return undefined; - } - return timestampPreciseFromDb(dbTs); -} - -export function timestampPreciseToDb( - stamp: TalerPreciseTimestamp, -): DbPreciseTimestamp { - if (stamp.t_s === "never") { - return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp; - } else { - let tUs = stamp.t_s * 1000000; - if (stamp.off_us) { - tUs += stamp.off_us; - } - return tUs as DbPreciseTimestamp; - } -} - -export function timestampProtocolToDb( - stamp: TalerProtocolTimestamp, -): DbProtocolTimestamp { - if (stamp.t_s === "never") { - return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp; - } else { - let tUs = stamp.t_s * 1000000; - return tUs as DbProtocolTimestamp; - } -} - -export function timestampProtocolFromDb( - stamp: DbProtocolTimestamp, -): TalerProtocolTimestamp { - if (stamp >= DB_TIMESTAMP_FOREVER) { - return TalerProtocolTimestamp.never(); - } - return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000)); -} - -export function timestampAbsoluteFromDb( - stamp: DbProtocolTimestamp | DbPreciseTimestamp, -): AbsoluteTime { - if (stamp >= DB_TIMESTAMP_FOREVER) { - return AbsoluteTime.never(); - } - return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000)); -} - -export function timestampOptionalAbsoluteFromDb( - stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined, -): AbsoluteTime | undefined { - if (stamp == null) { - return undefined; - } - if (stamp >= DB_TIMESTAMP_FOREVER) { - return AbsoluteTime.never(); - } - return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000)); -} - -/** - * Metadata for a transaction. - * This object store is effectively a materialzed view of transactions gathered - * from various other object stores. - * - * Primary key: transactionId - */ -export interface WalletTransactionMeta { - /** - * Transaction identifier. - * Also determines the type of the transaction. - */ - transactionId: string; - - timestamp: DbPreciseTimestamp; - - /** - * Status of the transaction, matches the status enum of the - * transaction of the type determined by the transaction ID. - */ - status: number; - - /** - * Exchanges involved in the transaction. - */ - exchanges: string[]; - - currency: string; -} - -/** Stable database cursor for transaction metadata pagination. */ -export interface WalletTransactionMetaCursor { - timestamp: DbPreciseTimestamp; - transactionId: string; -} - -/** - * Retry state of a task. - * - * The policy that computes these timestamps lives in common.ts; this is only - * the stored shape. - */ -export interface WalletRetryInfo { - firstTry: DbPreciseTimestamp; - nextRetry: DbPreciseTimestamp; - retryCounter: number; -} - -export interface WalletOperationRetry { - /** - * Unique identifier for the operation. Typically of - * the format `${opType}-${opUniqueKey}` - * - * @see {@link TaskIdentifiers} - */ - id: string; - - lastError?: TalerErrorDetail; - - retryInfo: WalletRetryInfo; -} - -export interface WalletContractTerms { - /** - * Contract terms hash. - */ - h: string; - - /** - * Contract terms JSON. - * - * Deliberately untyped: this is arbitrary JSON as received from the - * merchant, and there is nothing to validate it against at this layer. - */ - contractTermsRaw: any; -} - -export interface WalletCoinSelection { - coinPubs: string[]; - coinContributions: AmountString[]; -} - -export interface WalletDepositKycInfo { - accessToken?: string; - paytoHash: string; - exchangeBaseUrl: string; - lastCheckStatus?: number | undefined; - lastCheckCode?: number | undefined; - lastRuleGen?: number | undefined; - lastAmlReview?: boolean | undefined; - lastDeny?: DbPreciseTimestamp | undefined; - lastBadKycAuth?: boolean; -} - -export interface WalletDepositTrackingInfo { - // Raw wire transfer identifier of the deposit. - wireTransferId: string; - // When was the wire transfer given to the bank. - timestampExecuted: DbProtocolTimestamp; - // Total amount transfer for this wtid (including fees) - amountRaw: AmountString; - // Wire fee amount for this exchange - wireFee: AmountString; - - exchangePub: string; -} - -/** - * Group of deposits made by the wallet. - */ -export interface WalletDepositInfoPerExchange { - /** - * Expected effective amount that will be deposited - * from coins of this exchange. - */ - amountEffective: AmountString; -} - -export interface WalletDepositGroup { - depositGroupId: string; - - currency: string; - - /** - * Instructed amount. - */ - amount: AmountString; - - wireTransferDeadline: DbProtocolTimestamp; - - merchantPub: string; - merchantPriv: string; - - noncePriv: string; - noncePub: string; - - /** - * Wire information used by all deposits in this - * deposit group. - */ - wire: { - payto_uri: string; - salt: string; - }; - - contractTermsHash: string; - - payCoinSelection?: WalletCoinSelection; - - payCoinSelectionUid?: string; - - totalPayCost: AmountString; - - /** - * The counterparty effective deposit amount. - */ - counterpartyEffectiveDepositAmount: AmountString; - - timestampCreated: DbPreciseTimestamp; - - timestampFinished: DbPreciseTimestamp | undefined; - - /** - * When did the wallet last try a deposit request? - */ - timestampLastDepositAttempt: DbPreciseTimestamp | undefined; - - operationStatus: DepositOperationStatus; - - statusPerCoin?: DepositElementStatus[]; - - infoPerExchange?: Record<string, WalletDepositInfoPerExchange>; - - /** - * When the deposit transaction was aborted and - * refreshes were tried, we create a refresh - * group and store the ID here. - */ - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - kycInfo?: WalletDepositKycInfo; - kycAuthTransferOptions?: KycAuthTransferOptionRaw[]; - kycAuthTransferExpiry?: TalerProtocolTimestamp; - - // FIXME: Do we need this and should it be in this object store? - trackingState?: { - [signature: string]: WalletDepositTrackingInfo; - }; -} - -/** - * KYC auth transfer option persisted in the legacy flat options field. - * - * The optional metadata lets us recover the per-account withdrawal-style - * representation without duplicating the exchange account details in the - * deposit record. Older records contain plain TransferOptionRaw values and - * remain valid. - */ -export type KycAuthTransferOptionRaw = TransferOptionRaw & { - kycAuthAccountPaytoUri?: string; - kycAuthTransferExpiry?: TalerProtocolTimestamp; -}; - -/** - * Status of recoup operations that were grouped together. - * - * The remaining amount of involved coins should be set to zero - * in the same transaction that inserts the WalletRecoupGroup. - */ -export interface WalletRecoupGroup { - /** - * Unique identifier for the recoup group record. - */ - recoupGroupId: string; - - exchangeBaseUrl: string; - - operationStatus: RecoupOperationStatus; - - timestampStarted: DbPreciseTimestamp; - - timestampFinished: DbPreciseTimestamp | undefined; - - /** - * Public keys that identify the coins being recouped - * as part of this session. - * - * (Structured like this to enable multiEntry indexing in IndexedDB.) - */ - coinPubs: string[]; - - /** - * Array of flags to indicate whether the recoup finished on each individual coin. - */ - recoupFinishedPerCoin: boolean[]; - - /** - * Public keys of coins that should be scheduled for refreshing - * after all individual recoups are done. - */ - scheduleRefreshCoins: CoinRefreshRequest[]; -} - -/** - * Store for extra information about a reserve. - * - * Mostly used to store the private key for a reserve and to allow - * other records to reference the reserve key pair via a small row ID. - * - * In the future, we might also store KYC info about a reserve here. - * - * FIXME: Should reference exchange. - */ -export interface WalletReserve { - rowId?: number; - - reservePub: string; - - reservePriv: string; - - status?: ReserveRecordStatus; - - requirementRow?: number; - - /** - * Balance threshold that we're currently requesting KYC for. - */ - thresholdRequested?: AmountString; - - /** - * Balance threshold that we already have passed KYC for. - */ - thresholdGranted?: AmountString; - - /** - * Threshold that will trigger the next KYC. - */ - thresholdNext?: AmountString; - - kycAccessToken?: string; - - amlReview?: boolean; -} - -export interface WalletRefreshGroupPerExchangeInfo { - /** - * (Expected) output once the refresh group succeeded. - */ - outputEffective: AmountString; -} - -/** - * Group of refresh operations. The refreshed coins do not - * have to belong to the same exchange, but must have the same - * currency. - */ -export interface WalletRefreshGroup { - operationStatus: RefreshOperationStatus; - - /** - * Unique, randomly generated identifier for this group of - * refresh operations. - */ - refreshGroupId: string; - - /** - * Currency of this refresh group. - */ - currency: string; - - /** - * Reason why this refresh group has been created. - */ - reason: RefreshReason; - - originatingTransactionId?: string; - - oldCoinPubs: string[]; - - inputPerCoin: AmountString[]; - - expectedOutputPerCoin: AmountString[]; - - infoPerExchange?: Record<string, WalletRefreshGroupPerExchangeInfo>; - - /** - * Flag for each coin whether refreshing finished. - * If a coin can't be refreshed (remaining value too small), - * it will be marked as finished, but no refresh session will - * be created. - */ - statusPerCoin: RefreshCoinStatus[]; - - /** - * Refund requests that might still be necessary - * before the refresh can work. - */ - refundRequests: { [n: number]: ExchangeRefundRequest }; - - timestampCreated: DbPreciseTimestamp; - - failReason?: TalerErrorDetail; - - /** - * Timestamp when the refresh session finished. - */ - timestampFinished: DbPreciseTimestamp | undefined; -} - -/** - * Ongoing refresh - */ -export interface WalletRefreshSession { - refreshGroupId: string; - - /** - * Index of the coin in the refresh group. - */ - coinIndex: number; - - /** - * If this field is set, it's a V2 refresh session. - */ - sessionPublicSeed?: string; - - /** - * Exchange protocol version whose refresh protocol this session speaks, - * fixed when the melt request is prepared. - * - * The melt and the reveal step must agree on it, so it cannot be re-derived - * from the exchange's advertised version later: the exchange may have been - * upgraded in between. Absent means 27, which is what sessions written - * before this field existed use. - */ - refreshProtocolVersion?: number; - - /** - * Sum of the value of denominations we want - * to withdraw in this session, without fees. - */ - amountRefreshOutput: AmountString; - - /** - * Hashed denominations of the newly requested coins. - */ - newDenoms: { - denomPubHash: string; - count: number; - }[]; - - /** - * The no-reveal-index after we've done the melting. - */ - norevealIndex?: number; - - /** - * Last error response from the exchange. - * - * FIXME: We don't store the last HTTP status yet. - */ - lastError?: TalerErrorDetail; - - // Reserved legacy fields: - // * sessionSecretSeed: string - // (legacy v1 refresh) -} - -export const enum WithdrawalRecordType { - BankManual = "bank-manual", - BankIntegrated = "bank-integrated", - PeerPullCredit = "peer-pull-credit", - PeerPushCredit = "peer-push-credit", - Recoup = "recoup", -} - -/** - * Extra info about a withdrawal that is used - * with a bank-integrated withdrawal. - */ -export interface ReserveBankInfo { - talerWithdrawUri: string; - - /** - * URL that the user can be redirected to, and allows - * them to confirm (or abort) the bank-integrated withdrawal. - */ - confirmUrl: string | undefined; - - /** - * Exchange payto URI that the bank will use to fund the reserve. - */ - exchangePaytoUri?: string; - - /** - * Time when the information about this reserve was posted to the bank. - * - * Only applies if bankWithdrawStatusUrl is defined. - * - * Set to undefined if that hasn't happened yet. - */ - timestampReserveInfoPosted: DbPreciseTimestamp | undefined; - - /** - * Time when the reserve was confirmed by the bank. - * - * Set to undefined if not confirmed yet. - */ - timestampBankConfirmed: DbPreciseTimestamp | undefined; - - wireTypes: string[] | undefined; - - currency: string | undefined; - - externalConfirmation?: boolean; - - senderWire?: string; -} - -export interface WgInfoBankIntegrated { - withdrawalType: WithdrawalRecordType.BankIntegrated; - - /** - * Extra state for when this is a withdrawal involving - * a Taler-integrated bank. - */ - bankInfo: ReserveBankInfo; - - /** - * Info about withdrawal accounts, possibly including currency conversion. - */ - exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[]; -} - -export interface WgInfoBankManual { - withdrawalType: WithdrawalRecordType.BankManual; - - /** - * Info about withdrawal accounts, possibly including currency conversion. - */ - exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[]; -} - -export interface WgInfoBankPeerPull { - withdrawalType: WithdrawalRecordType.PeerPullCredit; - - // FIXME: include a transaction ID here? - - /** - * Needed to quickly construct the taler:// URI for the counterparty - * without a join. - */ - contractPriv: string; -} - -export interface WgInfoBankPeerPush { - withdrawalType: WithdrawalRecordType.PeerPushCredit; - - // FIXME: include a transaction ID here? -} - -export interface WgInfoBankRecoup { - withdrawalType: WithdrawalRecordType.Recoup; -} - -export type WgInfo = - | WgInfoBankIntegrated - | WgInfoBankManual - | WgInfoBankPeerPull - | WgInfoBankPeerPush - | WgInfoBankRecoup; - -/** - * Group of withdrawal operations that need to be executed. - * (Either for a normal withdrawal or from a reward.) - * - * The withdrawal group record is only created after we know - * the coin selection we want to withdraw. - */ -export interface WalletWithdrawalGroup { - /** - * Unique identifier for the withdrawal group. - */ - withdrawalGroupId: string; - - wgInfo: WgInfo; - - /** - * If set to true, the account used during withdrawal is treated as an - * account that does not belong to the user. It won't be shown in - * the list of know bank accounts. - * - * Defaults to false. - */ - isForeignAccount?: boolean; - - kycPaytoHash?: string; - - kycAccessToken?: string; - - kycLastCheckStatus?: number | undefined; - kycLastCheckCode?: number | undefined; - kycLastRuleGen?: number | undefined; - kycLastAmlReview?: boolean | undefined; - kycLastDeny?: DbPreciseTimestamp | undefined; - - /** - * Delay to wait until the next withdrawal attempt. - * - * @deprecated by https://bugs.gnunet.org/view.php?id=9694 - */ - kycWithdrawalDelay?: TalerProtocolDuration; - - /** - * Secret seed used to derive planchets. - * Stored since planchets are created lazily. - */ - secretSeed: string; - - /** - * Public key of the reserve that we're withdrawing from. - */ - reservePub: string; - - /** - * The reserve private key. - * - * FIXME: Already in the reserves object store, redundant! - */ - reservePriv: string; - - /** - * The exchange base URL that we're withdrawing from. - * (Redundantly stored, as the reserve record also has this info.) - */ - exchangeBaseUrl?: string; - - /** - * When was the withdrawal operation started started? - * Timestamp in milliseconds. - */ - timestampStart: DbPreciseTimestamp; - - /** - * When was the withdrawal operation completed? - */ - timestampFinish?: DbPreciseTimestamp; - - /** - * Current status of the reserve. - */ - status: WithdrawalGroupStatus; - - /** - * Restrict withdrawals from this reserve to this age. - */ - restrictAge?: number; - - /** - * Amount that was sent by the user to fund the reserve. - */ - instructedAmount?: AmountString; - - /** - * Amount that was observed when querying the reserve that - * we are withdrawing from. - * - * Useful for diagnostics. - */ - reserveBalanceAmount?: AmountString; - - /** - * Amount including fees (i.e. the amount subtracted from the - * reserve to withdraw all coins in this withdrawal session). - * - * (Initial amount confirmed by the user, might differ with denomSel - * on reselection.) - */ - rawWithdrawalAmount?: AmountString; - - /** - * Amount that will be added to the balance when the withdrawal succeeds. - * - * (Initial amount confirmed by the user, might differ with denomSel - * on reselection.) - */ - effectiveWithdrawalAmount?: AmountString; - - /** - * Denominations selected for withdrawal. - */ - denomsSel?: DenomSelectionState; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; -} - -/** - * A coin that isn't yet signed by an exchange. - */ -export interface WalletPlanchet { - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Private key of the coin. - */ - coinPriv: string; - - /** - * Withdrawal group that this planchet belongs to - * (or the empty string). - */ - withdrawalGroupId: string; - - /** - * Index within the withdrawal group (or -1). - */ - coinIdx: number; - - planchetStatus: PlanchetStatus; - - lastError: TalerErrorDetail | undefined; - - denomPubHash: string; - - blindingKey: string; - - withdrawSig: string; - - coinEv: CoinEnvelope; - - coinEvHash: string; - - ageCommitmentProof?: AgeCommitmentProof; - - exchangeWithdrawValues: ExchangeWithdrawValue; -} - -export interface WalletDonationSummary { - donauBaseUrl: string; - legalDomain?: string; - year: number; - currency: string; - amountReceiptsAvailable: AmountString; - amountReceiptsSubmitted: AmountString; -} - -/** - * Record for donation receipts. - */ -export interface WalletDonationReceipt { - status: DonationReceiptStatus; - donauBaseUrl: string; - udiNonce: HashCodeString; - proposalId: string; - donationYear: number; - donationUnitPubHash: HashCodeString; - donationUnitSig: DonationReceiptSignature; - donorTaxIdHash: HashCodeString; - donorHashSalt: string; - donorTaxId: string; - value: AmountString; - /** Index of this udi within the selected donation units for the purchase. */ - udiIndex: number; -} - -/** - * Record for donation planchets. - */ -export interface WalletDonationPlanchet { - donauBaseUrl: string; - udiNonce: HashCodeString; - donorTaxIdHash: HashCodeString; - donorHashSalt: string; - donorTaxId: string; - donationYear: number; - proposalId: string; - /** Index of this udi within the selected donation units for the purchase. */ - udiIndex: number; - blindedUdi: BlindedUniqueDonationIdentifier; - /** blinding key secret */ - bks: string; - donationUnitPubHash: HashCodeString; - value: AmountString; -} - -/** - * Partial information about the downloaded proposal. - * Only contains data that is relevant for indexing on the - * "purchases" object stores. - */ -export interface WalletProposalDownloadInfo { - contractTermsHash: string; - fulfillmentUrl?: string; - currency: string; - contractTermsMerchantSig: string; -} - -export interface WalletTokenSelection { - tokenPubs: string[]; -} - -export interface WalletPurchasePayInfo { - /** - * Undefined if payment is blocked by a pending refund. - */ - payCoinSelection?: WalletCoinSelection; - /** - * Undefined if payment is blocked by a pending refund. - */ - payCoinSelectionUid?: string; - - payTokenSelection?: WalletTokenSelection; - - /** - * Token signatures from merchant. - */ - slateTokenSigs?: SignedTokenEnvelope[]; - - /** - * Whether token selection should be forced - * e.g. when merchant URL is not in `expected_domains' - */ - payTokenForcedSel?: boolean; - - totalPayCost: AmountString; -} - -/** - * Record that stores status information about one purchase, starting from when - * the customer accepts a proposal. Includes refund status if applicable. - * - * Key: {@link proposalId} - * Operation status: {@link purchaseStatus} - */ -export interface WalletPurchase { - /** - * Proposal ID for this purchase. Uniquely identifies the - * purchase and the proposal. - * Assigned by the wallet. - */ - proposalId: string; - - /** - * Order ID, assigned by the merchant. - */ - orderId: string; - - merchantBaseUrl: string; - - /** - * Claim token used when downloading the contract terms. - */ - claimToken: string | undefined; - - /** - * Session ID we got when downloading the contract. - */ - downloadSessionId: string | undefined; - - /** - * If this purchase is a repurchase, this field identifies the original purchase. - */ - repurchaseProposalId: string | undefined; - - purchaseStatus: PurchaseStatus; - - /** - * Refresh group ID of the refresh transaction that - * has been created to abort the payment. - */ - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - /** - * Private key for the nonce. - */ - noncePriv: string; - - /** - * Public key for the nonce. - */ - noncePub: string; - - /** - * Index of selected choice in the choices array. - */ - choiceIndex?: number | undefined; - - /** - * Secret seed used to derive slates. - * Stored since slates are created lazily. - */ - secretSeed: string | undefined; - - /** - * Downloaded and parsed proposal data. - */ - download: WalletProposalDownloadInfo | undefined; - - payInfo: WalletPurchasePayInfo | undefined; - - /** - * Exchanges involved in this purchase. - * Used as a multiEntry index to find all purchases for - * an exchange. - */ - exchanges?: string[]; - - /** - * Pending removals from pay coin selection. - * - * Used when a the pay coin selection needs to be changed - * because a coin became known as double-spent or invalid, - * but a new coin selection can't immediately be done, as - * there is not enough balance (e.g. when waiting for a refresh). - */ - pendingRemovedCoinPubs?: string[]; - - /** - * Timestamp of the first time that sending a payment to the merchant - * for this purchase was successful. - */ - timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined; - - merchantPaySig: string | undefined; - - posConfirmation: string | undefined; - - donauOutputIndex?: number; - donauBaseUrl?: string; - donauAmount?: AmountString; - donauTaxIdHash?: string; - donauTaxIdSalt?: string; - donauTaxId?: string; - donauYear?: number; - - /** - * This purchase was shared with another wallet - * that is now supposed to finish the payment. - */ - shared: boolean; - - /** - * This purchase was created by reading - * a payment share or the wallet - * the nonce public by a payment share - * - * Defaults to false. - */ - createdFromShared?: boolean; - - /** - * When was the purchase record created? - */ - timestamp: DbPreciseTimestamp; - - /** - * When was the purchase made? - * Refers to the time that the user accepted. - */ - timestampAccept: DbPreciseTimestamp | undefined; - - /** - * When was the last refund made? - * Set to 0 if no refund was made on the purchase. - */ - timestampLastRefundStatus: DbPreciseTimestamp | undefined; - - /** - * Timestamp when the wallet noticed that the transaction expired. - * May be later than the pay deadline. - */ - timestampExpired?: DbPreciseTimestamp; - - /** - * Last session signature that we submitted to /pay (if any). - */ - lastSessionId: string | undefined; - - /** - * Continue querying the refund status until this deadline has expired. - */ - autoRefundDeadline: DbProtocolTimestamp | undefined; - - /** - * How much merchant has refund to be taken but the wallet - * did not picked up yet - */ - refundAmountAwaiting: AmountString | undefined; - - /** - * Taler URI that started this purchase, if available. - */ - talerUri?: string; -} - -/** - * Metadata about a group of refunds with the merchant. - */ -export interface WalletRefundGroup { - status: RefundGroupStatus; - - /** - * Timestamp when the refund group was created. - */ - timestampCreated: DbPreciseTimestamp; - - proposalId: string; - - refundGroupId: string; - - refreshGroupId?: string; - - amountRaw: AmountString; - - /** - * Estimated effective amount, based on - * refund fees and refresh costs. - */ - amountEffective: AmountString; -} - -/** - * Refund for a single coin in a payment with a merchant. - */ -export interface WalletRefundItem { - /** - * Auto-increment DB record ID. - */ - id?: number; - - status: RefundItemStatus; - - /** - * Mandatory since DB minor version 15. - */ - proposalId?: string; - - refundGroupId: string; - - /** - * Execution time as claimed by the merchant - */ - executionTime: DbProtocolTimestamp; - - /** - * Time when the wallet became aware of the refund. - */ - obtainedTime: DbPreciseTimestamp; - - refundAmount: AmountString; - - coinPub: string; - - rtxid: number; -} - -export interface WalletTombstone { - /** - * Tombstone ID, with the syntax "tmb:<type>:<key>". - */ - id: string; -} - -export interface WalletExchangeDetailsPointer { - masterPublicKey: string; - - currency: string; - - /** - * Timestamp when the (masterPublicKey, currency) pointer - * has been updated. - */ - updateClock: DbPreciseTimestamp; -} - -/** - * Exchange record as stored in the wallet's database. - */ -/** - * A key set an exchange replaced, pending the user's confirmation. - */ -export interface WalletSupersededKeySet { - masterPublicKey: string; - currency: string; - /** When the change was first observed. */ - firstSeen: DbPreciseTimestamp; - /** - * Whether the new key set re-advertises denominations the wallet holds - * coins of. - * - * A claim, not proof: denomination public keys are public, so anyone can - * re-publish them under a new master key. It says whether the exchange - * offers to settle the older coins at all, which is what decides if they - * are worth selecting. - */ - sharesDenominations: boolean; -} - -export interface WalletExchangeEntry { - /** - * Base url of the exchange. - */ - baseUrl: string; - - /** - * Currency hint for a preset exchange, relevant - * when we didn't contact a preset exchange yet. - */ - presetCurrencyHint?: string; - - /** - * Currency spec for a preset exchange, relevant - * when we didn't contact a preset exchange yet. - */ - presetCurrencySpec?: CurrencySpecification; - - /** - * Type of the exchange, if it was a preset entry. - */ - presetType?: string; - - /** How this exchange entry became known to the wallet. */ - source?: ExchangeEntrySource; - - /** - * When did we confirm the last withdrawal from this exchange? - * - * Used mostly in the UI to suggest exchanges. - */ - lastWithdrawal?: DbPreciseTimestamp; - - /** - * Pointer to the current exchange details. - * - * Should usually not change. Only changes when the - * exchange advertises a different master public key and/or - * currency. - * - * We could use a rowID here, but having the currency in the - * details pointer lets us do fewer DB queries - */ - detailsPointer: WalletExchangeDetailsPointer | undefined; - - /** - * The key set this exchange used before it changed keys, kept until the - * user confirms the change. - * - * The new key set is adopted immediately -- {@link detailsPointer} moves -- - * so the entry keeps working and the coins already held stay spendable. - * What is withheld is only the part that sends money to the exchange: the - * wire details a withdrawal pays into are signed by the master key, so a - * URL taken over by someone else would otherwise redirect the next - * withdrawal. Absent once the change has been confirmed, and never set on - * first contact, which is not a change. - */ - supersededKeySet?: WalletSupersededKeySet; - - entryStatus: ExchangeEntryDbRecordStatus; - - updateStatus: ExchangeEntryDbUpdateStatus; - - unavailableReason?: TalerErrorDetail; - - /** - * If set to true, the next update to the exchange - * status will request /keys with no-cache headers set. - */ - cachebreakNextUpdate?: boolean; - - /** - * Etag of the current ToS of the exchange. - */ - tosCurrentEtag: string | undefined; - - tosAcceptedEtag: string | undefined; - - tosAcceptedTimestamp: DbPreciseTimestamp | undefined; - - /** - * Last time when the exchange /keys info was updated - * successfully. - */ - lastUpdate: DbPreciseTimestamp | undefined; - - /** - * Next scheduled update for the exchange. - */ - nextUpdateStamp: DbPreciseTimestamp; - - lastKeysEtag: string | undefined; - - /** - * Next time that we should check if coins need to be refreshed. - * - * Updated whenever the exchange's denominations are updated or when - * the refresh check has been done. - */ - nextRefreshCheckStamp: DbPreciseTimestamp; - - /** - * Public key of the reserve that we're currently using for - * receiving P2P payments. - */ - currentMergeReserveRowId?: number; - - /** - * Current account private key. The corresponding public - * key is used as the merchant public key in deposits. - * - * When unset or reset, we use a heuristic to find an - * account priv/pub that likely already has KYC auth. - */ - currentAccountPriv?: string; - - /** - * @see currentAccountPriv - */ - currentAccountPub?: string; - - /** - * Defaults to false. - */ - peerPaymentsDisabled?: boolean; - - /** - * Are direct deposits using this exchange disabled? - * Defaults to false. - */ - directDepositDisabled?: boolean; - - /** - * Defaults to false. - */ - noFees?: boolean; -} - -/** - * Exchange details for a particular - * (exchangeBaseUrl, masterPublicKey, currency) tuple. - */ -export interface WalletExchangeDetails { - rowId?: number; - - /** - * Master public key of the exchange. - */ - masterPublicKey: string; - - exchangeBaseUrl: string; - - /** - * Currency that the exchange offers. - */ - currency: string; - - /** - * Auditors (partially) auditing the exchange. - */ - auditors: WalletExchangeAuditor[]; - - /** - * Last observed protocol version. - */ - protocolVersionRange: string; - - tinyAmount: AmountString; - - reserveClosingDelay: TalerProtocolDuration; - - shoppingUrl?: string; - - /** - * Fees for exchange services - */ - globalFees: ExchangeGlobalFees[]; - - wireInfo: WireInfo; - - /** - * Age restrictions supported by the exchange (bitmask). - */ - ageMask?: number; - - walletBalanceLimits?: AmountString[]; - - hardLimits?: AccountLimit[]; - - zeroLimits?: ZeroLimitedOperation[]; - - /** - * Instructs wallets to use certain bank-specific - * language (for buttons) and/or other UI/UX customization - * for compliance with the rules of that bank. - */ - bankComplianceLanguage: string | undefined; - - defaultPeerPushExpiration: TalerProtocolDuration | undefined; -} - -/** - * Auditor metadata persisted by the wallet. - * - * Older wallet versions stored the exchange's unverified /keys payload here. - * Only entries carrying this marker have had every remaining denomination - * signature checked by wallet-core. Keeping the marker inside the existing - * JSON column makes old databases fail closed without a schema migration. - */ -export interface WalletExchangeAuditor extends ExchangeAuditor { - walletAuditorSignaturesVerified?: true; -} - -export interface WalletDenomLossEvent { - denomLossEventId: string; - currency: string; - denomPubHashes: string[]; - status: DenomLossStatus; - timestampCreated: DbPreciseTimestamp; - amount: string; - eventType: DenomLossEventType; - exchangeBaseUrl: string; -} - -/** - * Denomination record as stored in the wallet's database. - */ - -export interface WalletExchangeSignkeys { - stampStart: DbProtocolTimestamp; - stampExpire: DbProtocolTimestamp; - stampEnd: DbProtocolTimestamp; - signkeyPub: EddsaPublicKeyString; - masterSig: EddsaSignatureString; - - /** - * Exchange details that thiis signkeys record belongs to. - */ - exchangeDetailsRowId: number; -} - -export interface WalletDenomFamilyParams { - exchangeBaseUrl: string; - exchangeMasterPub: string; - value: AmountString; - feeWithdraw: AmountString; - feeDeposit: AmountString; - feeRefresh: AmountString; - feeRefund: AmountString; -} - -export interface WalletDenominationFamily { - denominationFamilySerial?: number; - familyParams: WalletDenomFamilyParams; - - // Reserved legacy fields: - // * familyParamsHash -} - -export interface WalletExchangeBaseUrlFixup { - exchangeBaseUrl: string; - replacement: string; -} - -export interface WalletExchangeMigrationLog { - oldExchangeBaseUrl: string; - newExchangeBaseUrl: string; - timestamp: DbPreciseTimestamp; - /** - * Reason that triggered the exchange base URL migration. - */ - reason: ExchangeMigrationReason; -} - -export interface WalletGlobalCurrencyAuditor { - id?: number; - currency: string; - auditorBaseUrl: string; - auditorPub: string; -} - -export interface WalletGlobalCurrencyExchange { - id?: number; - currency: string; - exchangeBaseUrl: string; - exchangeMasterPub: string; -} - -/** - * User accounts - */ -export interface WalletBankAccount { - /** - * Opaque identifier for the bank account. - */ - bankAccountId: string; - - /** - * Payto URI of the bank account. - */ - paytoUri: string; - - /** - * User-defined label for the account. - */ - label: string | undefined; - - currencies: string[] | undefined; - - /** - * FIXME: Provide more info here. - */ - kycCompleted: boolean; -} - -export interface WalletWithdrawCoinSource { - type: CoinSourceType.Withdraw; - - /** - * Can be the empty string for orphaned coins. - */ - withdrawalGroupId: string; - - /** - * Index of the coin in the withdrawal session. - */ - coinIndex: number; - - /** - * Reserve public key for the reserve we got this coin from. - */ - reservePub: string; -} - -export interface WalletRefreshCoinSource { - type: CoinSourceType.Refresh; - refreshGroupId: string; - oldCoinPub: string; -} - -export interface WalletRewardCoinSource { - type: CoinSourceType.Reward; - walletRewardId: string; - coinIndex: number; -} - -/** - * WalletCoin as stored in the "coins" data store - * of the wallet database. - */ -export interface WalletCoin { - /** - * Where did the coin come from? Used for recouping coins. - */ - coinSource: WalletCoinSource; - - /** - * Source transaction ID of the coin. - * - * Used to make the coin visible after the transaction - * has entered a final state. - */ - sourceTransactionId?: string; - - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Private key to authorize operations on the coin. - */ - coinPriv: string; - - /** - * Hash of the public key that signs the coin. - */ - denomPubHash: string; - - /** - * Unblinded signature by the exchange. - */ - denomSig: UnblindedDenominationSignature; - - /** - * Base URL that identifies the exchange from which we got the - * coin. - */ - exchangeBaseUrl: string; - - /** - * Master public key that signed the denomination this coin was issued - * under. - * - * This, not the base URL, is what ties a coin to the keys that can settle - * it: the URL is where the exchange currently answers, and it can change - * without the coin changing. - */ - exchangeMasterPub: string; - - /** - * Blinding key used when withdrawing the coin. - * Potentionally used again during payback. - */ - blindingKey: string; - - exchangeWithdrawValues: ExchangeWithdrawValue; - - /** - * Hash of the coin envelope. - * - * Stored here for indexing purposes, so that when looking at a - * reserve history, we can quickly find the coin for a withdrawal transaction. - */ - coinEvHash: string; - - /** - * Status of the coin. - */ - status: CoinStatus; - - /** - * Non-zero for visible. - * - * A coin is visible when it is fresh and the - * source transaction is in a final state. - */ - visible?: number; - - /** - * Maximum age of purchases that can be made with this coin. - * - * (Used for indexing, redundant with {@link ageCommitmentProof}). - */ - maxAge: number; - - ageCommitmentProof: AgeCommitmentProof | undefined; -} - -/** - * Availability of coins of a given denomination (and age restriction!). - * - * We can't store this information with the denomination record, as one denomination - * can be withdrawn with multiple age restrictions. - */ -export interface WalletCoinAvailability { - currency: string; - value: AmountString; - denomPubHash: string; - exchangeBaseUrl: string; - /** - * Master public key that signed the denomination. - * - * Required: together with the hash it names the denomination these coins - * belong to. Rows written before it was recorded are backfilled from that - * denomination; the empty string means it could no longer be found. - */ - exchangeMasterPub: string; - - /** - * Age restriction on the coin, or 0 for no age restriction (or - * denomination without age restriction support). - */ - maxAge: number; - - /** - * Number of fresh coins of this denomination that are available. - */ - freshCoinCount: number; - - /** - * Numeric boolean derived from freshCoinCount for compound database indexes. - * IndexedDB booleans are not valid keys, hence the 0/1 representation. - */ - hasFreshCoins: 0 | 1; - - /** - * Number of fresh coins that are available - * and visible, i.e. the source transaction is in - * a final state. - */ - visibleCoinCount: number; - - /** - * Number of coins that we expect to obtain via a pending refresh. - */ - pendingRefreshOutputCount?: number; -} - -/** - * History event for a coin from the wallet's perspective. - * - * The history might reference transactions that were already deleted from the wallet. - */ -export interface WalletCoinHistory { - coinPub: string; - /** - * History items for the coin. - * - * We store this as an array in the object store, as the coin history - * is pretty much always very small. - */ - history: WalletCoinHistoryItem[]; -} - -export type WalletCoinSource = - | WalletWithdrawCoinSource - | WalletRefreshCoinSource - | WalletRewardCoinSource; - -/** - * History item for a coin. - * - * DB-specific format, - */ -export type WalletCoinHistoryItem = - | { - type: "withdraw"; - transactionId: TransactionIdStr; - } - | { - type: "spend"; - transactionId: TransactionIdStr; - amount: AmountString; - } - | { - type: "refresh"; - transactionId: TransactionIdStr; - amount: AmountString; - } - | { - type: "recoup"; - transactionId: TransactionIdStr; - amount: AmountString; - } - | { - type: "refund"; - transactionId: TransactionIdStr; - amount: AmountString; - }; - -/** - * How a coin came into the wallet. - */ -export enum CoinSourceType { - Withdraw = "withdraw", - Refresh = "refresh", - Reward = "reward", -} - -export enum RefundReason { - /** - * Normal refund given by the merchant. - */ - NormalRefund = "normal-refund", - /** - * Refund from an aborted payment. - */ - AbortRefund = "abort-pay-refund", -} - -export enum ExchangeMigrationReason { - MismatchedBaseUrl = "mismatched-base-url", - UnavailableOldUrl = "unavailable-old-url", -} - -/** - * Status of a denomination. - */ -export enum DenominationVerificationStatus { - /** - * Verification was delayed (pending). - */ - Unverified = 0x0100_0000, - - /** - * Verified as valid. - */ - VerifiedGood = 0x0500_0000, - - /** - * Verified as invalid. - */ - VerifiedBad = 0x0501_0000, -} - -/** - * Format of the operation status code: 0x0abc_nnnn - - * a=1: active - * 0x0100_nnnn: pending - * 0x0101_nnnn: dialog - * 0x0102_nnnn: (reserved) - * 0x0103_nnnn: aborting - * 0x0110_nnnn: suspended - * 0x0113_nnnn: suspended-aborting - * a=2: finalizing - * 0x0200_nnnn: finalizing - * 0x0210_nnnn: suspended-finalizing - * a=5: final - * 0x0500_nnnn: done - * 0x0501_nnnn: failed - * 0x0502_nnnn: expired - * 0x0503_nnnn: aborted - * - * nnnn=0000 should always be the most generic minor state for the major state - */ - -/** - * First possible operation status in the active range (inclusive). - */ -export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; - -/** - * LAST possible operation status in the active range (inclusive). - */ -export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; - -export const OPERATION_STATUS_DIALOG_FIRST = 0x0101_0000; -export const OPERATION_STATUS_DIALOG_LAST = 0x0101_ffff; - -export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000; -export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff; - -/** - * Status of a withdrawal. - */ -export enum WithdrawalGroupStatus { - /** - * Reserve must be registered with the bank. - */ - PendingRegisteringBank = 0x0100_0001, - SuspendedRegisteringBank = 0x0110_0001, - - /** - * We've registered reserve's information with the bank - * and are now waiting for the user to confirm the withdraw - * with the bank (typically 2nd factor auth). - */ - PendingWaitConfirmBank = 0x0100_0002, - SuspendedWaitConfirmBank = 0x0110_0002, - - /** - * Querying reserve status with the exchange. - */ - PendingQueryingStatus = 0x0100_0003, - SuspendedQueryingStatus = 0x0110_0003, - - /** - * Ready for withdrawal. - */ - PendingReady = 0x0100_0004, - SuspendedReady = 0x0110_0004, - - /** - * Redenominate the withdrawal - * after the exchange entry is ready again. - */ - PendingRedenominate = 0x0100_0008, - SuspendedRedenominate = 0x0110_0008, - - /** - * Exchange wants KYC info from the user. - */ - PendingKyc = 0x0100_0005, - SuspendedKyc = 0x0110_0005, - - /** - * Exchange wants KYC info from the user. - * KYC link is ready. - */ - PendingBalanceKyc = 0x0100_0006, - SuspendedBalanceKyc = 0x0110_0006, - - /** - * Exchange wants KYC info from the user. - * - * KYC link is not ready yet, the KYC process is still initializing. - */ - PendingBalanceKycInit = 0x0100_0007, - SuspendedBalanceKycInit = 0x0110_0007, - - /** - * Proposed to the user, has can choose to accept/refuse. - */ - DialogProposed = 0x0101_0000, - - /** - * We are telling the bank that we don't want to complete - * the withdrawal! - */ - AbortingBank = 0x0103_0001, - SuspendedAbortingBank = 0x0113_0001, - - /** Closing a funded reserve after KYC reports an unraisable hard limit. */ - FinalizingKycHardLimit = 0x0200_0000, - - /** - * The corresponding withdraw record has been created. - * No further processing is done, unless explicitly requested - * by the user. - */ - Done = 0x0500_0000, - - /** - * The bank aborted the withdrawal. - */ - FailedBankAborted = 0x0501_0001, - - FailedAbortingBank = 0x0501_0002, - - FailedKycHardLimit = 0x0501_0003, - FailedKycHardLimitRecovery = 0x0501_0004, - - /** - * Aborted in a state where we were supposed to - * talk to the exchange. Money might have been - * wired or not. - */ - AbortedExchange = 0x0503_0001, - - AbortedBank = 0x0503_0002, - - /** - * User didn't refused the withdrawal. - */ - AbortedUserRefused = 0x0503_0003, - - /** - * Another wallet confirmed the withdrawal - * (by POSTing the reserve pub to the bank) - * before we had the chance. - * - * In this situation, we'll let the other wallet continue - * and give up ourselves. - */ - AbortedOtherWallet = 0x0503_0004, -} - -export enum ExchangeEntryDbRecordStatus { - Preset = 1, - Ephemeral = 2, - Used = 3, -} - -// FIXME: Use status ranges for this as well? -export enum ExchangeEntryDbUpdateStatus { - Initial = 1, - InitialUpdate = 2, - Suspended = 3, - UnavailableUpdate = 4, - // Reserved 5 for backwards compatibility. - Ready = 6, - ReadyUpdate = 7, - OutdatedUpdate = 8, -} - -export enum PlanchetStatus { - Pending = 0x0100_0000, - KycRequired = 0x0100_0001, - WithdrawalDone = 0x0500_0000, - AbortedReplaced = 0x0503_0001, -} - -export enum RefreshCoinStatus { - Pending = 0x0100_0000, - - /** - * Re-try the melt with a new target denomination. - */ - PendingRedenominate = 0x0100_0001, - - Finished = 0x0500_0000, - - /** - * The refresh for this coin has been frozen, because of a permanent error. - * More info in lastErrorPerCoin. - */ - Failed = 0x0501_0000, -} - -export enum RefreshOperationStatus { - Pending = 0x0100_0000, - /** - * Entire output coin selection was bad, re-select - * and potentially revive finished coins with zero output. - */ - PendingRedenominate = 0x0100_0001, - Suspended = 0x0110_0000, - SuspendedRedenominate = 0x0110_0001, - - Finished = 0x0500_0000, - Failed = 0x0501_0000, -} - -/** - * Status of a single element of a deposit group. - */ -export enum DepositElementStatus { - DepositPending = 0x0100_0000, - /** - * Accepted, but tracking. - */ - Tracking = 0x0100_0001, - KycRequired = 0x0100_0002, - Wired = 0x0500_0000, - /** The exchange has already wired the deposit to the target account. */ - RefundTooLate = 0x0500_0001, - RefundSuccess = 0x0503_0000, - RefundFailed = 0x0501_0000, - RefundNotFound = 0x0501_0001, -} - -export enum PurchaseStatus { - /** - * Not downloaded yet. - */ - PendingDownloadingProposal = 0x0100_0000, - SuspendedDownloadingProposal = 0x0110_0000, - - /** - * The user has accepted the proposal. - */ - PendingPaying = 0x0100_0001, - SuspendedPaying = 0x0110_0001, - - /** - * Currently in the process of aborting with a refund. - */ - AbortingWithRefund = 0x0103_0000, - SuspendedAbortingWithRefund = 0x0113_0000, - - /** - * Paying a second time, likely with different session ID - */ - PendingPayingReplay = 0x0100_0002, - SuspendedPayingReplay = 0x0110_0002, - - /** - * Query for refunds (until query succeeds). - */ - PendingQueryingRefund = 0x0100_0003, - SuspendedQueryingRefund = 0x0110_0003, - - /** - * Query for refund (until auto-refund deadline is reached). - * - * Legacy state for compatibility. - */ - PendingQueryingAutoRefund = 0x0100_0004, - SuspendedQueryingAutoRefund = 0x0110_0004, - - FinalizingQueryingAutoRefund = 0x0200_0001, - SuspendedFinalizingQueryingAutoRefund = 0x0210_0001, - - PendingAcceptRefund = 0x0100_0005, - SuspendedPendingAcceptRefund = 0x0110_0005, - - /** - * Proposal downloaded, but the user needs to accept/reject it. - */ - DialogProposed = 0x0101_0000, - - /** - * Proposal shared to other wallet or read from other wallet - * the user needs to accept/reject it. - */ - DialogShared = 0x0101_0001, - - /** - * Generic failure, check error code. - */ - Failed = 0x0501_0000, - - /** - * Tried to abort, but aborting failed or was cancelled. - */ - FailedAbort = 0x0501_0001, - - FailedPaidByOther = 0x0501_0002, - - /** - * Downloading or processing the proposal has failed permanently. - */ - FailedClaim = 0x0501_0003, - - /** - * Payment was successful. - */ - Done = 0x0500_0000, - - /** - * Downloaded proposal was detected as a re-purchase. - */ - DoneRepurchaseDetected = 0x0500_0001, - - Expired = 0x0502_0000, - - /** - * The user has rejected the proposal. - */ - AbortedProposalRefused = 0x0503_0000, - - AbortedRefunded = 0x0503_0001, - - AbortedOrderDeleted = 0x0503_0002, - - /** - * The payment has been aborted. - */ - AbortedIncompletePayment = 0x0503_0003, -} - -export enum ConfigRecordKey { - WalletBackupState = "walletBackupState", - CurrencyDefaultsApplied = "currencyDefaultsApplied", - // Only for testing, do not use! - TestLoopTx = "testTxLoop", - LastInitInfo = "lastInitInfo", - LastResumed = "lastResumed", - MaterializedTransactionsVersion = "materializedTransactionsVersion", - DonauConfig = "donauConfig", -} - -export interface DonauConfig { - donauBaseUrl: string; - donauTaxId: string; - /** Tax ID hash, salted with donauSalt */ - donauTaxIdHash: string; - /** 32 byte salt, base32crockford encoded */ - donauSalt: string; -} - -export interface WalletBackupConfState { - deviceId: string; - walletRootPub: string; - walletRootPriv: string; - - /** - * Last hash of the canonicalized plain-text backup. - */ - lastBackupPlainHash?: string; - - /** - * Timestamp stored in the last backup. - */ - lastBackupTimestamp?: DbPreciseTimestamp; - - /** - * Last time we tried to do a backup. - */ - lastBackupCheckTimestamp?: DbPreciseTimestamp; - lastBackupNonce?: string; -} - -/** - * Configuration key/value entries to configure - * the wallet. - */ -export type ConfigRecord = - | { - key: ConfigRecordKey.WalletBackupState; - value: WalletBackupConfState; - } - | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean | number } - | { key: ConfigRecordKey.TestLoopTx; value: number } - | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp } - | { key: ConfigRecordKey.LastResumed; value: DbProtocolTimestamp } - | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number } - | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }; - -export enum RecoupOperationStatus { - Pending = 0x0100_0000, - Suspended = 0x0110_0000, - - Finished = 0x0500_0000, - Failed = 0x0501_0000, -} -export enum DepositOperationStatus { - PendingDeposit = 0x0100_0000, - SuspendedDeposit = 0x0110_0000, - - // Legacy states, we we now show - // the tracking state as a finalizing state. - LegacyPendingTrack = 0x0100_0001, - LegacySuspendedTrack = 0x0110_0001, - - PendingAggregateKyc = 0x0100_0002, - SuspendedAggregateKyc = 0x0110_0002, - - PendingDepositKyc = 0x0100_0003, - SuspendedDepositKyc = 0x0110_0003, - - PendingDepositKycAuth = 0x0100_0005, - SuspendedDepositKycAuth = 0x0110_0005, - - Aborting = 0x0103_0000, - SuspendedAborting = 0x0113_0000, - - FinalizingTrack = 0x0200_0001, - SuspendedFinalizingTrack = 0x0210_0001, - - Finished = 0x0500_0000, - - /** The abort lost the race: every selected coin was already wired. */ - FinishedAbortTooLate = 0x0500_0001, - - FailedDeposit = 0x0501_0000, - - FailedTrack = 0x0501_0001, - - /** Some selected coins were recovered and others were already wired. */ - FailedAbortPartial = 0x0501_0002, - - /** The abort refund succeeded, but the recovery refresh did not. */ - FailedAbortRecovery = 0x0501_0003, - - /** A permanent refund response did not prove recovery or delivery. */ - FailedAbortRefund = 0x0501_0004, - - AbortedDeposit = 0x0503_0000, -} - -export enum PeerPushDebitStatus { - /** - * Initiated, but no purse created yet. - */ - PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */, - PendingReady = 0x0100_0001, - AbortingDeletePurse = 0x0103_0000, - - /** - * The purse is gone because it expired, and the coins that went into it - * have to be reclaimed. Same clean-up as AbortingDeletePurse, but since - * nobody called the payment off it ends up expired instead of aborted. - */ - ExpiredDeletePurse = 0x0103_0003, - - SuspendedCreatePurse = 0x0110_0000, - SuspendedReady = 0x0110_0001, - SuspendedAbortingDeletePurse = 0x0113_0000, - SuspendedExpiredDeletePurse = 0x0113_0003, - - Done = 0x0500_0000, - Aborted = 0x0503_0000, - Failed = 0x0501_0000, - Expired = 0x0502_0000, - - // Legacy / reserved: - // SuspendedAbortingRefreshDeleted = 0x0113_0001, - // SuspendedAbortingRefreshExpired = 0x0113_0002, - // AbortingRefreshDeleted = 0x0103_0001, - // AbortingRefreshExpired = 0x0103_0002, -} - -export enum PeerPullPaymentCreditStatus { - /** - * Typically the initial state of the peer-pull-credit transaction, - * purse will be created. - */ - PendingCreatePurse = 0x0100_0000, - SuspendedCreatePurse = 0x0110_0000, - - /** - * Purse created, waiting for the other party to accept the - * invoice and deposit money into it. - */ - PendingReady = 0x0100_0001, - SuspendedReady = 0x0110_0001, - - PendingMergeKycRequired = 0x0100_0002, - SuspendedMergeKycRequired = 0x0110_0002, - - PendingWithdrawing = 0x0100_0003, - SuspendedWithdrawing = 0x0110_0003, - - PendingBalanceKycRequired = 0x0100_0004, - SuspendedBalanceKycRequired = 0x0110_0004, - - PendingBalanceKycInit = 0x0100_0005, - SuspendedBalanceKycInit = 0x0110_0005, - - AbortingDeletePurse = 0x0103_0000, - SuspendedAbortingDeletePurse = 0x0113_0000, - - /** Deleting the purse after an unraisable merge hard limit. */ - FinalizingKycHardLimit = 0x0200_0000, - - Done = 0x0500_0000, - Failed = 0x0501_0000, - FailedKycHardLimit = 0x0501_0001, - Expired = 0x0502_0000, - Aborted = 0x0503_0000, -} - -export enum PeerPushCreditStatus { - PendingMerge = 0x0100_0000, - SuspendedMerge = 0x0110_0000, - - PendingMergeKycRequired = 0x0100_0001, - SuspendedMergeKycRequired = 0x0110_0001, - - /** - * Merge was successful and withdrawal group has been created, now - * everything is in the hand of the withdrawal group. - */ - PendingWithdrawing = 0x0100_0002, - SuspendedWithdrawing = 0x0110_0002, - - PendingBalanceKycRequired = 0x0100_0003, - SuspendedBalanceKycRequired = 0x0110_0003, - - PendingBalanceKycInit = 0x0100_0004, - SuspendedBalanceKycInit = 0x0110_0004, - - DialogProposed = 0x0101_0000, - - /** Waiting for the rejected purse to expire and refund its payer. */ - FinalizingKycHardLimit = 0x0200_0000, - - Done = 0x0500_0000, - Aborted = 0x0503_0000, - Failed = 0x0501_0000, - FailedKycHardLimit = 0x0501_0001, - Expired = 0x0502_0000, -} - -export enum PeerPullDebitRecordStatus { - PendingDeposit = 0x0100_0001, - AbortingRefresh = 0x0103_0001, - - SuspendedDeposit = 0x0110_0001, - SuspendedAbortingRefresh = 0x0113_0001, - - DialogProposed = 0x0101_0001, - - Done = 0x0500_0000, - Expired = 0x0502_0000, - Aborted = 0x0503_0000, - Failed = 0x0501_0000, -} - -export enum ReserveRecordStatus { - // Need to call the "/kyc-wallet" endpoint - PendingLegiInit = 0x0100_0001, - SuspendedLegiInit = 0x0110_0001, - // Need to wait for user to pass legitimization - PendingLegi = 0x0100_0002, - SuspendedLegi = 0x0110_0002, - - /** - * Done with KYC. - */ - Done = 0x0500_0000, -} - -export enum RefundGroupStatus { - Pending = 0x0100_0000, - Done = 0x0500_0000, - Failed = 0x0501_0000, - Aborted = 0x0503_0000, - Expired = 0x0502_0000, -} - -export enum RefundItemStatus { - /** - * Intermittent error that the merchant is - * reporting from the exchange. - * - * We'll try again! - */ - Pending = 0x0100_0000, - /** - * Refund was obtained successfully. - */ - Done = 0x0500_0000, - /** - * Permanent error reported by the exchange - * for the refund. - */ - Failed = 0x0501_0000, -} - -export enum DenomLossStatus { - /** - * Done indicates that the loss happened. - */ - Done = 0x0500_0000, - - /** - * Aborted in the sense that the loss was reversed. - */ - Aborted = 0x0503_0001, -} - -export enum DonationReceiptStatus { - /** - * Done indicates that the receipt - * has been successfully submitted. - */ - DoneSubmitted = 0x0500_0000, - - /** - * Pending indicates that the - * receipt still needs to be submitted. - */ - Pending = 0x0100_0000, -} - -export interface DbPeerPushPaymentCoinSelection { - contributions: AmountString[]; - coinPubs: string[]; -} - -export interface PeerPullPaymentCoinSelection { - contributions: AmountString[]; - coinPubs: string[]; - totalCost: AmountString | undefined; - - /** Number of leading entries confirmed by a signed exchange response. */ - depositedCoinCount?: number; - - /** Latest purse balance covered by a verified deposit confirmation. */ - confirmedPurseBalance?: AmountString; -} - -/** - * Record for a push P2P payment that this wallet initiated. - */ -export interface WalletPeerPushDebit { - /** - * What exchange are funds coming from? - */ - exchangeBaseUrl: string; - - /** - * Restricted scope for this transaction. - * - * Relevant for coin reselection. - */ - restrictScope?: ScopeInfo; - - /** - * Instructed amount. - */ - amount: AmountString; - - /** - * Effective amount. - * - * (Called totalCost for historical reasons.) - */ - totalCost: AmountString; - - coinSel?: DbPeerPushPaymentCoinSelection; - - contractTermsHash: string; - - /** - * Purse public key. Used as the primary key to look - * up this record. - */ - pursePub: string; - - /** - * Purse private key. - */ - pursePriv: string; - - /** - * Public key of the merge capability of the purse. - */ - mergePub: string; - - /** - * Private key of the merge capability of the purse. - */ - mergePriv: string; - - contractPriv: string; - contractPub: string; - - /** - * 24 byte nonce. - */ - contractEncNonce: string; - - purseExpiration: DbProtocolTimestamp; - - timestampCreated: DbPreciseTimestamp; - - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - /** - * Status of the peer push payment initiation. - */ - status: PeerPushDebitStatus; -} - -/** - * Record for a pull P2P payment that this wallet initiated. - */ -export interface WalletPeerPullCredit { - /** - * What exchange are we using for the payment request? - */ - exchangeBaseUrl: string; - - /** - * Amount requested. - * FIXME: What type of instructed amount is i? - */ - amount: AmountString; - - estimatedAmountEffective: AmountString; - - /** - * Purse public key. Used as the primary key to look - * up this record. - */ - pursePub: string; - - /** - * Purse private key. - */ - pursePriv: string; - - /** - * Hash of the contract terms. Also - * used to look up the contract terms in the DB. - */ - contractTermsHash: string; - - mergePub: string; - mergePriv: string; - - contractPub: string; - contractPriv: string; - - contractEncNonce: string; - - mergeTimestamp: DbPreciseTimestamp; - - mergeReserveRowId: number; - - /** - * Status of the peer pull payment initiation. - */ - status: PeerPullPaymentCreditStatus; - - kycPaytoHash?: string; - - kycAccessToken?: string; - - kycLastCheckStatus?: number; - kycLastCheckCode?: number; - kycLastRuleGen?: number; - kycLastAmlReview?: boolean; - kycLastDeny?: DbPreciseTimestamp; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - withdrawalGroupId: string | undefined; -} - -/** - * Record for a push P2P payment that this wallet was offered. - */ -export interface WalletPeerPushCredit { - peerPushCreditId: string; - - exchangeBaseUrl: string; - - pursePub: string; - - mergePriv: string; - - contractPriv: string; - - timestamp: DbPreciseTimestamp; - - estimatedAmountEffective: AmountString; - - /** - * Hash of the contract terms. Also - * used to look up the contract terms in the DB. - */ - contractTermsHash: string; - - /** - * Status of the peer push payment incoming initiation. - */ - status: PeerPushCreditStatus; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - /** - * Associated withdrawal group. - */ - withdrawalGroupId: string | undefined; - - /** - * Currency of the peer push payment credit transaction. - * - * Mandatory in current schema version, optional for compatibility - * with older (ver_minor<4) DB versions. - */ - currency: string | undefined; - - kycPaytoHash?: string; - - kycAccessToken?: string; - - kycLastCheckStatus?: number; - kycLastCheckCode?: number; - kycLastRuleGen?: number; - kycLastAmlReview?: boolean; - kycLastDeny?: DbPreciseTimestamp; -} - -/** - * AKA PeerPullDebit. Record for a pull P2P payment that this wallet was offered. - */ -export interface WalletPeerPullDebit { - peerPullDebitId: string; - - pursePub: string; - - exchangeBaseUrl: string; - - amount: AmountString; - - contractTermsHash: string; - - timestampCreated: DbPreciseTimestamp; - - /** - * Contract priv that we got from the other party. - */ - contractPriv: string; - - /** - * Status of the peer push payment incoming initiation. - */ - status: PeerPullDebitRecordStatus; - - /** - * Estimated total cost when the record was created. - */ - totalCostEstimated: AmountString; - - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - coinSel?: PeerPullPaymentCoinSelection; -} - -/** - * Object to be hashed for use as a grouping key for token listings, such that - * any change in token family details results in a separate list item. - */ -export interface TokenFamilyInfo { - /** - * Identifier for the token family consisting of - * unreserved characters according to RFC 3986. - */ - slug: string; - - /** - * Human-readable name for the token family. - */ - name: string; - - /** - * Human-readable description for the token family. - */ - description: string; - - /** - * Optional map from IETF BCP 47 language tags to localized descriptions. - */ - descriptionI18n: any | undefined; - - /** - * Additional meta data, such as the trusted_domains - * or expected_domains. Depends on the kind. - */ - extraData: MerchantContractTokenDetails; - - /** - * Token issue public key used by merchant to verify tokens. - */ - tokenIssuePub: TokenIssuePublicKey; -} - -/** - * A token as stored in the "tokens" object store. - * - * This is the full stored shape: it carries the blinding material - * (tokenEv, tokenEvHash, blindingKey) alongside the issue signature, so a - * read-modify-write round trip through it is lossless. - */ -export interface WalletToken extends TokenFamilyInfo { - /** - * Source purchase of the token. - */ - purchaseId: string; - - /** - * Transaction where token is being used. - */ - transactionId?: string; - - /** - * Index of token in choices array. - */ - choiceIndex?: number; - - /** - * Index of token in outputs array. - */ - outputIndex?: number; - - /** - * For token outputs with a count>1, this stores - * the index of this token within the same output - * index. - * - * If missing, assumed to be 0. - */ - repeatIndex?: number; - - /** - * URL of the merchant issuing the token. - */ - merchantBaseUrl: string; - - /** - * Kind of the token. - */ - kind: MerchantContractTokenKind; - - /** - * Hash of token issue public key. - */ - tokenIssuePubHash: string; - - /** - * Hash of {@link TokenFamilyInfo} object. - */ - tokenFamilyHash?: string; - - /** - * Start time of the token family's validity period. - */ - validAfter: DbProtocolTimestamp; - - /** - * End time of the token family's validity period. - */ - validBefore: DbProtocolTimestamp; - - /** - * Unblinded token issue signature made by the merchant. - */ - tokenIssueSig: UnblindedDenominationSignature; - - /** - * Token use public key used to confirm usage of tokens. - */ - tokenUsePub: string; - - /** - * Token use private key used to verify usage of tokens. - */ - tokenUsePriv: string; - - /** - * Signature on token use request. - */ - tokenUseSig?: TokenUseSig; - - /** - * Envelope of the token. - */ - tokenEv: TokenEnvelope; - - /** - * Hash of the envelope. - */ - tokenEvHash: string; - - /** - * Blinding secret for token. - */ - blindingKey: string; -} - -/** - * A slate as stored in the "slates" object store. - * - * A slate is a token that has not been issued yet, so it has every token - * field except tokenIssueSig. It is spelled out rather than derived from - * WalletToken so that each store has its own record type. - */ -export interface WalletSlate extends TokenFamilyInfo { - /** - * Source purchase of the token. - */ - purchaseId: string; - - /** - * Transaction where token is being used. - */ - transactionId?: string; - - /** - * Index of token in choices array. - */ - choiceIndex?: number; - - /** - * Index of token in outputs array. - */ - outputIndex?: number; - - /** - * For token outputs with a count>1, this stores - * the index of this token within the same output - * index. - * - * If missing, assumed to be 0. - */ - repeatIndex?: number; - - /** - * URL of the merchant issuing the token. - */ - merchantBaseUrl: string; - - /** - * Kind of the token. - */ - kind: MerchantContractTokenKind; - - /** - * Hash of token issue public key. - */ - tokenIssuePubHash: string; - - /** - * Hash of {@link TokenFamilyInfo} object. - */ - tokenFamilyHash?: string; - - /** - * Start time of the token family's validity period. - */ - validAfter: DbProtocolTimestamp; - - /** - * End time of the token family's validity period. - */ - validBefore: DbProtocolTimestamp; - - /** - * Token use public key used to confirm usage of tokens. - */ - tokenUsePub: string; - - /** - * Token use private key used to verify usage of tokens. - */ - tokenUsePriv: string; - - /** - * Signature on token use request. - */ - tokenUseSig?: TokenUseSig; - - /** - * Envelope of the token. - */ - tokenEv: TokenEnvelope; - - /** - * Hash of the envelope. - */ - tokenEvHash: string; - - /** - * Blinding secret for token. - */ - blindingKey: string; -} - -export namespace WalletToken { - export function hashInfo(r: WalletToken | WalletSlate): string { - const info: TokenFamilyInfo = { - slug: r.slug, - name: r.name, - description: r.description, - descriptionI18n: r.descriptionI18n, - extraData: r.extraData, - tokenIssuePub: r.tokenIssuePub, - }; - return encodeCrock(hash(stringToBytes(canonicalJson(info) + "\0"))); - } -} -/** - * Denomination record as stored in the wallet's database. - */ -export interface WalletDenomination { - /** - * Currency of the denomination. - * - * Stored separately as we have an index on it. - */ - currency: string; - - value: AmountString; - - /** - * The denomination public key. - */ - denomPub: DenominationPubKey; - - /** - * Hash of the denomination public key. - * Stored in the database for faster lookups. - */ - denomPubHash: string; - - fees: DenomFees; - - /** - * Family the denomination belongs to. - * - * Absent for denominations stored before the family was known. - */ - denominationFamilySerial?: number; - - /** - * Validity start date of the denomination. - */ - stampStart: DbProtocolTimestamp; - - /** - * Date after which the currency can't be withdrawn anymore. - */ - stampExpireWithdraw: DbProtocolTimestamp; - - /** - * Date after the denomination officially doesn't exist anymore. - */ - stampExpireLegal: DbProtocolTimestamp; - - /** - * Data after which coins of this denomination can't be deposited anymore. - */ - stampExpireDeposit: DbProtocolTimestamp; - - /** - * Signature by the exchange's master key over the denomination - * information. - */ - masterSig: string; - - /** - * Did we verify the signature on the denomination? - */ - verificationStatus: DenominationVerificationStatus; - - /** - * Was this denomination still offered by the exchange the last time - * we checked? - * Only false when the exchange redacts a previously published denomination. - */ - isOffered: boolean; - - /** - * Did the exchange revoke the denomination? - * When this field is set to true in the database, the same transaction - * should also mark all affected coins as revoked. - */ - isRevoked: boolean; - - /** - * If set to true, the exchange announced that the private key for this - * denomination is lost. Thus it can't be used to sign new coins - * during withdrawal/refresh/..., but the coins can still be spent. - */ - isLost?: boolean; - - /** - * Base URL of the exchange. - */ - exchangeBaseUrl: string; - - /** - * Master public key of the exchange that made the signature - * on the denomination. - */ - exchangeMasterPub: string; -} - -export interface DenomFees { - /** - * Fee for withdrawing. - */ - feeWithdraw: AmountString; - - /** - * Fee for depositing. - */ - feeDeposit: AmountString; - - /** - * Fee for refreshing. - */ - feeRefresh: AmountString; - - /** - * Fee for refunding. - */ - feeRefund: AmountString; -} - -export namespace WalletDenomination { - export function toDenomInfo(d: WalletDenomination): DenominationInfo { - return { - denomPub: d.denomPub, - exchangeMasterPub: d.exchangeMasterPub, - denomPubHash: d.denomPubHash, - feeDeposit: Amounts.stringify(d.fees.feeDeposit), - feeRefresh: Amounts.stringify(d.fees.feeRefresh), - feeRefund: Amounts.stringify(d.fees.feeRefund), - feeWithdraw: Amounts.stringify(d.fees.feeWithdraw), - stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), - stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal), - stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), - stampStart: timestampProtocolFromDb(d.stampStart), - value: Amounts.stringify(d.value), - exchangeBaseUrl: d.exchangeBaseUrl, - isLost: d.isLost ?? false, - masterSig: d.masterSig, - isOffered: d.isOffered, - }; - } -} diff --git a/packages/taler-wallet-core/src/db-converter.test.ts b/packages/taler-wallet-core/src/db-converter.test.ts @@ -1,683 +0,0 @@ -/* - 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 for the backend-to-backend database converter. - * - * The database is populated by running the whole conformance corpus against - * the source, so the converter faces every record type the suite can - * produce rather than a hand-picked sample. The converter verifies its own - * copy record-by-record; the assertions here are that it succeeds, that it - * moved a plausible amount of data, and that it converts in both directions. - */ - -import assert from "node:assert"; -import { test } from "node:test"; - -import { - CoinStatus, - DatabaseMaintenanceProgressNotification, - DenomKeyType, - encodeCrock, - getRandomBytes, - NotificationType, - TalerPreciseTimestamp, - WalletNotification, -} from "@gnu-taler/taler-util"; - -import { - CoinSourceType, - ExchangeEntryDbRecordStatus, - ExchangeEntryDbUpdateStatus, - PeerPushCreditStatus, - PurchaseStatus, - ReserveRecordStatus, - timestampPreciseToDb, - WalletCoin, - WalletPeerPushCredit, - WalletPurchase, -} from "./db-common.js"; -import { SQLITE_BASELINE_SCHEMA } from "./db-sqlite-schema.js"; -import { - convertWalletDb, - DB_CONVERSION_BATCH_SIZE, - DB_CONVERSION_PROGRESS_RECORDS, -} from "./db-converter.js"; -import { applyFixups, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; -import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; -import { conformanceCases } from "./dbtx-conformance-cases.js"; -import { ConformanceAsserts } from "./dbtx-conformance.js"; -import { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js"; - -/** Assertions that ignore case-internal failures: only the data matters. */ -const quietAsserts: ConformanceAsserts = { - equal: () => {}, - deepEqual: () => {}, - ok: () => {}, - fail: () => { - throw Error("unreachable"); - }, -}; - -test("converter: preserves a legacy orphan coin without a master key", async () => { - const src = await makeIdbRunner(); - const dst = await makeSqliteRunner(); - const key = (): string => encodeCrock(getRandomBytes(32)); - const hash = (): string => encodeCrock(getRandomBytes(64)); - const coin: WalletCoin = { - coinPub: key(), - coinPriv: key(), - exchangeBaseUrl: "https://orphan.example/", - exchangeMasterPub: key(), - denomPubHash: hash(), - denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: "signature" }, - blindingKey: key(), - exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, - coinEvHash: hash(), - status: CoinStatus.Dormant, - maxAge: 0, - ageCommitmentProof: undefined, - coinSource: { - type: CoinSourceType.Withdraw, - withdrawalGroupId: "missing-withdrawal", - coinIndex: 0, - reservePub: key(), - }, - }; - delete (coin as any).exchangeMasterPub; - await src.runReadWriteTx((tx) => tx.upsertCoin(coin)); - - try { - await convertWalletDb(src, dst); - const migrated = await dst.runReadWriteTx((tx) => tx.getCoin(coin.coinPub)); - assert.ok(migrated); - assert.strictEqual(migrated.exchangeMasterPub, ""); - } finally { - await src.close(); - await dst.close(); - } -}); - -test("converter: IndexedDB to sqlite, populated by the conformance corpus", async () => { - const src = await makeIdbRunner(); - const progress: WalletNotification[] = []; - src.setNotificationSink((n) => progress.push(n)); - for (const c of conformanceCases) { - try { - await c.run(quietAsserts, src); - } catch (e) { - // A case failing its own assertions is the suite's concern; what - // matters here is whatever data it managed to write. - } - } - - // Simulate pre-Clause-Schnorr records. First prove the IndexedDB fixup is - // idempotent and fills both stores, then remove the fields again to prove - // converter-side normalization protects unusual/imported legacy records. - let legacyCoinPub = ""; - let legacyPlanchetPub = ""; - await src.runReadWriteTx(async (tx) => { - const coin = (await tx.listAllCoins())[0]; - const planchet = (await tx.listAllPlanchets())[0]; - assert.ok(coin && planchet, "corpus did not create legacy test records"); - legacyCoinPub = coin.coinPub; - legacyPlanchetPub = planchet.coinPub; - delete (coin as any).exchangeWithdrawValues; - delete (planchet as any).exchangeWithdrawValues; - await tx.upsertCoin(coin); - await tx.upsertPlanchet(planchet); - }); - // Force a store across multiple conversion pages. Tombstones are - // independent records, so this tests batching without manufacturing a - // large graph of otherwise unrelated wallet operations. - await src.runReadWriteTx(async (tx) => { - for (let i = 0; i < DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; i++) { - await tx.upsertTombstone({ id: `bounded-conversion-${i}` }); - } - }); - const idb = src as IdbWalletDbHandle; - const raw = await idb.rawAccess(); - await raw.runAllStoresReadWriteTx({}, async (tx) => { - await tx.fixups.delete("fixup20260812ExchangeWithdrawValues"); - }); - await applyFixups(raw); - await src.runReadWriteTx(async (tx) => { - assert.deepStrictEqual( - (await tx.getCoin(legacyCoinPub))?.exchangeWithdrawValues, - { cipher: "RSA" }, - ); - assert.deepStrictEqual( - (await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues, - { cipher: "RSA" }, - ); - const coin = (await tx.getCoin(legacyCoinPub))!; - const planchet = (await tx.getPlanchet(legacyPlanchetPub))!; - delete (coin as any).exchangeWithdrawValues; - delete (planchet as any).exchangeWithdrawValues; - await tx.upsertCoin(coin); - await tx.upsertPlanchet(planchet); - }); - - const dst = await makeSqliteRunner(); - const retainedTransactionsBefore = (src as any).idbHandle._transactions - .length; - const pageSizes: number[] = []; - for (const handle of [src, dst]) { - const originalRun = handle.runReadWriteTx.bind(handle); - handle.runReadWriteTx = (f) => - originalRun(async (tx) => { - const originalScan = tx.scanMigrationRecords.bind(tx); - tx.scanMigrationRecords = async (...args) => { - const page = await originalScan(...args); - pageSizes.push(page.records.length); - return page; - }; - return await f(tx); - }); - } - // convertWalletDb re-enumerates both sides and compares every record; - // a thrown error here is the actual test. - const report = await convertWalletDb(src, dst); - - await dst.runReadWriteTx(async (tx) => { - assert.deepStrictEqual( - (await tx.getCoin(legacyCoinPub))?.exchangeWithdrawValues, - { cipher: "RSA" }, - ); - assert.deepStrictEqual( - (await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues, - { cipher: "RSA" }, - ); - }); - - assert.ok( - report.totalRecords >= 100, - `only ${report.totalRecords} records converted -- the corpus did not` + - ` populate the source, so the conversion proved nothing`, - ); - assert.ok(pageSizes.length > 4, "conversion did not use multiple pages"); - assert.ok( - Math.max(...pageSizes) <= DB_CONVERSION_BATCH_SIZE, - `conversion retained a page of ${Math.max(...pageSizes)} records`, - ); - const maintenanceProgress = progress.filter( - (n): n is DatabaseMaintenanceProgressNotification => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-to-native-migration", - ); - assert.ok( - maintenanceProgress.every((n) => n.totalRecords === report.totalRecords), - "progress did not carry the global record total", - ); - for (const phase of ["copy", "verify"] as const) { - const records = maintenanceProgress - .filter((n) => n.phase === phase && n.processedRecords !== undefined) - .map((n) => n.processedRecords!); - assert.strictEqual( - records[0], - 0, - `${phase} progress did not start at zero`, - ); - assert.strictEqual( - records.at(-1), - report.totalRecords, - `${phase} progress did not reach the global total`, - ); - assert.ok( - records.length > 2, - `${phase} progress had no intermediate event`, - ); - for (let i = 1; i < records.length - 1; i++) { - assert.ok( - Math.floor(records[i] / DB_CONVERSION_PROGRESS_RECORDS) > - Math.floor(records[i - 1] / DB_CONVERSION_PROGRESS_RECORDS), - `${phase} record progress was reported too frequently`, - ); - } - } - assert.strictEqual( - (src as any).idbHandle._transactions.length, - retainedTransactionsBefore, - "completed scan transactions were retained by the IndexedDB bridge", - ); - // Every store in the plan must have been visited (0 records is fine for a - // store the corpus leaves empty; a missing key means the plan lost a step). - assert.ok( - Object.keys(report.copied).length >= 35, - `only ${Object.keys(report.copied).length} stores visited`, - ); - - await src.close(); - await dst.close(); -}); - -test("converter: sqlite to IndexedDB (reverse direction)", async () => { - const src = await makeSqliteRunner(); - for (const c of conformanceCases) { - try { - await c.run(quietAsserts, src); - } catch (e) { - // See above. - } - } - - const dst = await makeIdbRunner(); - const report = await convertWalletDb(src, dst); - assert.ok(report.totalRecords >= 100); - - await src.close(); - await dst.close(); -}); - -test("converter: discards the legacy exchange update retry counter", async () => { - // Wallets written before exchange update retries were moved to - // operation_retries still retain this field in the IndexedDB record. The - // native schema deliberately has no corresponding column. - const src = await makeIdbRunner(); - await src.runReadWriteTx(async (tx) => { - await tx.upsertExchange({ - baseUrl: "https://exchange.example.com/", - detailsPointer: undefined, - entryStatus: ExchangeEntryDbRecordStatus.Used, - updateStatus: ExchangeEntryDbUpdateStatus.Ready, - tosCurrentEtag: undefined, - tosAcceptedEtag: undefined, - tosAcceptedTimestamp: undefined, - lastUpdate: undefined, - nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - lastKeysEtag: undefined, - nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - updateRetryCounter: 8, - } as any); - }); - - const dst = await makeSqliteRunner(); - await convertWalletDb(src, dst); - const exchange = await dst.runReadWriteTx((tx) => - tx.getExchange("https://exchange.example.com/"), - ); - assert.ok(exchange); - assert.ok(!("updateRetryCounter" in exchange)); - - await src.close(); - await dst.close(); -}); - -test("converter: canonicalises a legacy purchase with empty exchanges", async () => { - // Old IndexedDB wallets could persist an explicit empty array here. The - // native representation uses a junction table, where no rows means the - // optional field is absent, so migration must accept this canonicalisation. - const purchase: WalletPurchase = { - proposalId: "empty-exchanges", - orderId: "order-empty-exchanges", - merchantBaseUrl: "https://merchant.example/", - claimToken: undefined, - downloadSessionId: undefined, - repurchaseProposalId: undefined, - purchaseStatus: PurchaseStatus.PendingDownloadingProposal, - noncePriv: encodeCrock(getRandomBytes(32)), - noncePub: encodeCrock(getRandomBytes(32)), - secretSeed: undefined, - download: undefined, - payInfo: undefined, - exchanges: [], - timestampFirstSuccessfulPay: undefined, - merchantPaySig: undefined, - posConfirmation: undefined, - shared: false, - timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - timestampAccept: undefined, - timestampLastRefundStatus: undefined, - lastSessionId: undefined, - autoRefundDeadline: undefined, - refundAmountAwaiting: undefined, - }; - const src = await makeIdbRunner(); - await src.runReadWriteTx((tx) => tx.upsertPurchase(purchase)); - - const dst = await makeSqliteRunner(); - const report = await convertWalletDb(src, dst); - assert.strictEqual(report.copied.purchases, 1); - const migrated = await dst.runReadWriteTx((tx) => - tx.getPurchase(purchase.proposalId), - ); - assert.ok(migrated); - assert.ok(!("exchanges" in migrated)); - - await src.close(); - await dst.close(); -}); - -test("converter: canonicalises a lowercase peer-push contract private key", async () => { - // Crockford encoding is case-insensitive. A legacy IndexedDB record with - // lowercase data must compare equal to the native BLOB's uppercase form. - const contractPriv = encodeCrock(getRandomBytes(32)).toLowerCase(); - const credit: WalletPeerPushCredit = { - peerPushCreditId: "lowercase-contract-private-key", - exchangeBaseUrl: "https://exchange.example/", - currency: "TESTKUDOS", - pursePub: encodeCrock(getRandomBytes(32)), - mergePriv: encodeCrock(getRandomBytes(32)), - contractPriv, - timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - estimatedAmountEffective: "TESTKUDOS:1", - contractTermsHash: encodeCrock(getRandomBytes(64)), - status: PeerPushCreditStatus.PendingMerge, - withdrawalGroupId: undefined, - }; - const src = await makeIdbRunner(); - await src.runReadWriteTx((tx) => tx.upsertPeerPushCredit(credit)); - - const dst = await makeSqliteRunner(); - await convertWalletDb(src, dst); - const migrated = await dst.runReadWriteTx((tx) => - tx.getPeerPushCredit(credit.peerPushCreditId), - ); - assert.strictEqual(migrated?.contractPriv, contractPriv.toUpperCase()); - - await src.close(); - await dst.close(); -}); - -test("IndexedDB fixup collapses only identical reserves and remaps references", async () => { - const src = await makeIdbRunner(); - const reservePub = encodeCrock(getRandomBytes(32)); - const reservePriv = encodeCrock(getRandomBytes(32)); - const ids = await src.runReadWriteTx(async (tx) => { - const out = []; - for (let i = 0; i < 3; i++) { - out.push(await tx.upsertReserve({ reservePub, reservePriv })); - } - await tx.upsertExchange({ - baseUrl: "https://exchange.example.com/", - detailsPointer: undefined, - entryStatus: ExchangeEntryDbRecordStatus.Preset, - updateStatus: ExchangeEntryDbUpdateStatus.Initial, - tosCurrentEtag: undefined, - tosAcceptedEtag: undefined, - tosAcceptedTimestamp: undefined, - lastUpdate: undefined, - nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - lastKeysEtag: undefined, - nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - currentMergeReserveRowId: out[2], - }); - return out; - }); - const raw = await (src as IdbWalletDbHandle).rawAccess(); - await raw.runAllStoresReadWriteTx({}, (tx) => - tx.fixups.delete("fixup20260720DuplicateReserves"), - ); - const fixupProgress: WalletNotification[] = []; - await applyFixups(raw, (n) => fixupProgress.push(n)); - assert.ok( - fixupProgress.some( - (n) => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-fixup" && - n.phase === "fixup" && - n.step === "fixup20260720DuplicateReserves", - ), - "fixup progress was not reported", - ); - assert.ok( - fixupProgress.some( - (n) => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-fixup" && - n.phase === "complete", - ), - "fixup completion was not reported", - ); - await src.runReadWriteTx(async (tx) => { - const matching = (await tx.listAllReserves()).filter( - (r) => r.reservePub === reservePub, - ); - assert.strictEqual(matching.length, 1); - assert.strictEqual(matching[0].rowId, ids[0]); - assert.strictEqual( - (await tx.getExchange("https://exchange.example.com/")) - ?.currentMergeReserveRowId, - ids[0], - ); - }); - const dst = await makeSqliteRunner(); - await convertWalletDb(src, dst); - await src.close(); - await dst.close(); -}); - -test("IndexedDB fixup retains the richer duplicate reserve", async () => { - const src = await makeIdbRunner(); - const reservePub = encodeCrock(getRandomBytes(32)); - const reservePriv = encodeCrock(getRandomBytes(32)); - const ids = await src.runReadWriteTx(async (tx) => { - const plain = await tx.upsertReserve({ reservePub, reservePriv }); - const rich = await tx.upsertReserve({ - reservePub, - reservePriv, - status: ReserveRecordStatus.Done, - thresholdGranted: "TESTKUDOS:10", - amlReview: false, - }); - await tx.upsertReserve({ reservePub, reservePriv }); - await tx.upsertExchange({ - baseUrl: "https://exchange.example.com/", - detailsPointer: undefined, - entryStatus: ExchangeEntryDbRecordStatus.Preset, - updateStatus: ExchangeEntryDbUpdateStatus.Initial, - tosCurrentEtag: undefined, - tosAcceptedEtag: undefined, - tosAcceptedTimestamp: undefined, - lastUpdate: undefined, - nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - lastKeysEtag: undefined, - nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), - currentMergeReserveRowId: plain, - }); - return { plain, rich }; - }); - const raw = await (src as IdbWalletDbHandle).rawAccess(); - await raw.runAllStoresReadWriteTx({}, (tx) => - tx.fixups.delete("fixup20260820DuplicateReserveMetadata"), - ); - await applyFixups(raw); - - await src.runReadWriteTx(async (tx) => { - const matching = (await tx.listAllReserves()).filter( - (r) => r.reservePub === reservePub, - ); - assert.strictEqual(matching.length, 1); - assert.strictEqual(matching[0].rowId, ids.rich); - assert.strictEqual(matching[0].status, ReserveRecordStatus.Done); - assert.strictEqual(matching[0].thresholdGranted, "TESTKUDOS:10"); - assert.strictEqual(matching[0].amlReview, false); - assert.strictEqual( - (await tx.getExchange("https://exchange.example.com/")) - ?.currentMergeReserveRowId, - ids.rich, - ); - }); - const dst = await makeSqliteRunner(); - await convertWalletDb(src, dst); - await src.close(); - await dst.close(); -}); - -test("converter: conflicting duplicate reserve metadata is rejected", async () => { - // The fixup must leave differing defined values untouched. Without a - // revision on either row, selecting a winner would hide corruption. - const src = await makeIdbRunner(); - const reservePub = encodeCrock(getRandomBytes(32)); - const reservePriv = encodeCrock(getRandomBytes(32)); - await src.runReadWriteTx(async (tx) => { - await tx.upsertReserve({ - reservePub, - reservePriv, - status: ReserveRecordStatus.PendingLegi, - }); - await tx.upsertReserve({ - reservePub, - reservePriv, - status: ReserveRecordStatus.Done, - }); - // A second, genuinely different reserve, which must survive untouched. - await tx.upsertReserve({ - reservePub: encodeCrock(getRandomBytes(32)), - reservePriv: encodeCrock(getRandomBytes(32)), - }); - }); - const raw = await (src as IdbWalletDbHandle).rawAccess(); - await raw.runAllStoresReadWriteTx({}, (tx) => - tx.fixups.delete("fixup20260820DuplicateReserveMetadata"), - ); - await applyFixups(raw); - - const dst = await makeSqliteRunner(); - await assert.rejects( - () => convertWalletDb(src, dst), - /multiple reserve rows have public key/, - ); - assert.strictEqual( - (await src.runReadWriteTx((tx) => tx.listAllReserves())).length, - 3, - "refusal modified the source", - ); - - await src.close(); - await dst.close(); -}); - -test("converter: the copy plan covers every table in the schema", async () => { - // An empty conversion still visits every step, so the report's keys are - // the plan's coverage. Comparing them against the schema's table list - // means a table added later cannot silently miss conversion: this test - // fails until the plan (and the mapping here) says what happens to it. - const src = await makeIdbRunner(); - const dst = await makeSqliteRunner(); - const report = await convertWalletDb(src, dst); - await src.close(); - await dst.close(); - - // table -> step that carries it, or the reason no step is needed. - const coverage: Record<string, string> = { - schema_migrations: "EXCLUDED: describes the schema, not wallet data", - idb_migration: - "EXCLUDED: describes where this file's data came from, not the data", - purchase_exchanges: "purchases", // stored inside the purchase record - config: "config", - currency_info: "currencyInfo", - contacts: "contacts", - mailbox_messages: "mailboxMessages", - mailbox_configurations: "mailboxConfigurations", - contract_terms: "contractTerms", - tombstones: "tombstones", - operation_retries: "operationRetries", - reserves: "reserves", - denominations: "denominations", - global_currency_exchanges: "globalCurrencyExchanges", - global_currency_auditors: "globalCurrencyAuditors", - bank_accounts: "bankAccounts", - tokens: "tokens", - slates: "slates", - refresh_sessions: "refreshSessions", - recoup_groups: "recoupGroups", - donation_summaries: "donationSummaries", - donation_planchets: "donationPlanchets", - donation_receipts: "donationReceipts", - purchases: "purchases", - deposit_groups: "depositGroups", - refresh_groups: "refreshGroups", - denom_loss_events: "denomLossEvents", - peer_push_debit: "peerPushDebit", - peer_push_credit: "peerPushCredit", - peer_pull_debit: "peerPullDebit", - peer_pull_credit: "peerPullCredit", - transactions_meta: "transactionsMeta", - transaction_local_id_counters: - "EXCLUDED: local transaction identifiers are re-assigned on conversion", - transaction_local_ids: - "EXCLUDED: local transaction identifiers are re-assigned on conversion", - exchanges: "exchanges", - exchange_details: "exchangeDetails", - exchange_sign_keys: "exchangeSignKeys", - denomination_families: "denominationFamilies", - exchange_base_url_fixups: "exchangeBaseUrlFixups", - exchange_base_url_migration_log: "exchangeBaseUrlMigrationLog", - withdrawal_groups: "withdrawalGroups", - planchets: "planchets", - coins: "coins", - coin_availability: "coinAvailability", - coin_history: "coinHistory", - refund_groups: "refundGroups", - refund_items: "refundItems", - }; - - const tables = [ - ...SQLITE_BASELINE_SCHEMA.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g), - ].map((m) => m[1]); - assert.ok(tables.length >= 44, "schema parse failed"); - - const visited = new Set(Object.keys(report.copied)); - for (const table of tables) { - const mapped = coverage[table]; - assert.ok( - mapped !== undefined, - `table ${table} is not accounted for in the conversion plan --` + - ` add a copy step for it, or record here why none is needed`, - ); - if (!mapped.startsWith("EXCLUDED")) { - assert.ok( - visited.has(mapped), - `table ${table} maps to step ${mapped}, which the plan did not visit`, - ); - } - } -}); - -test("converter: every IndexedDB store is copied or explicitly obsolete", async () => { - const src = await makeIdbRunner(); - const dst = await makeSqliteRunner(); - const visited = new Set( - Object.keys((await convertWalletDb(src, dst)).copied), - ); - await src.close(); - await dst.close(); - - const renamed: Record<string, string> = { - coinAvailabilityV2: "coinAvailability", - denominationsV2: "denominations", - bankAccountsV2: "bankAccounts", - }; - const excluded: Record<string, string> = { - coinAvailability: "obsolete pre-master-key store retained for fixups", - denominations: "obsolete pre-master-key store retained for fixups", - bankWithdrawUris: "obsolete unused legacy URI cache", - fixups: "schema repair log, not wallet data", - obsolete_backupProviders: "obsolete", - _obsolete_transactions: "obsolete materialized view", - _obsolete_bankAccounts: "obsolete pre-V2 store", - _obsolete_rewards: "obsolete", - obsolete_userAttention: "obsolete", - }; - for (const store of Object.keys(WalletIndexedDbStoresV1)) { - const step = renamed[store] ?? store; - assert.ok( - visited.has(step) || excluded[store] !== undefined, - `IndexedDB store ${store} is neither copied nor explicitly classified`, - ); - } -}); diff --git a/packages/taler-wallet-core/src/db-converter.ts b/packages/taler-wallet-core/src/db-converter.ts @@ -1,774 +0,0 @@ -/* - 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/> - */ - -/** - * Conversion between wallet database backends. - * - * The converter copies records through the DAL: it reads every entity from - * one {@link WalletDbHandle} and writes it into another, knowing nothing - * about either storage layout. Opening the IndexedDB source runs the fixup - * log, so the converted database contains repaired records by construction -- - * the native schema has no fixup log to replay. - * - * Auto-generated serials (reserves, exchange details, denomination families, - * refund items) are preserved: other records reference them, and every upsert - * honours a supplied key. - * - * The caller supplies a fresh, empty destination and swaps files afterwards; - * nothing here mutates the source. - */ - -import { - DatabaseMaintenanceProgressNotification, - Logger, - NotificationType, - sha256, - stringToBytes, -} from "@gnu-taler/taler-util"; - -import { - WalletCoin, - WalletCoinAvailability, - WalletPlanchet, - WalletPurchase, - WalletReserve, -} from "./db-common.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; -import { WalletDbMigrationStore, WalletDbTransaction } from "./dbtx.js"; - -const logger = new Logger("db-converter.ts"); - -/** - * One entity to copy. - * - * `read` identifies the existing DAL enumeration used by the backend's - * bounded migration scanner; `write` stores one record. - * `normalize` is applied before the verification comparison, for the few - * stores where the destination deliberately canonicalises a representation - * (for example global-currency ids, which nothing else references). - */ -interface CopyStep { - name: WalletDbMigrationStore; - read: (tx: WalletDbTransaction) => Promise<unknown[]>; - write: (tx: WalletDbTransaction, rec: unknown) => Promise<unknown>; - normalize?: (rec: unknown) => unknown; - validate?: (tx: WalletDbTransaction, rec: unknown) => Promise<void>; -} - -function step<T>( - name: WalletDbMigrationStore, - read: (tx: WalletDbTransaction) => Promise<T[]>, - write: (tx: WalletDbTransaction, rec: T) => Promise<unknown>, - normalize?: (rec: T) => unknown, - validate?: (tx: WalletDbTransaction, rec: T) => Promise<void>, -): CopyStep { - return { - name, - read: read as CopyStep["read"], - write: write as CopyStep["write"], - normalize: normalize as CopyStep["normalize"], - validate: validate as CopyStep["validate"], - }; -} - -const stripId = (rec: unknown): unknown => { - const { id, ...rest } = rec as Record<string, unknown>; - return rest; -}; - -/** - * Fields old databases still carry that no current record type has. - * - * The IndexedDB backend returns stored records as-is, unknown fields - * included; the modern code ignores them and the sqlite mapper cannot store - * them. Conversion drops them -- that is the "as if every fixup had run" - * shape of the data -- but only fields on this list: an unknown field NOT - * listed here fails verification, so a genuinely lost field cannot be - * mistaken for legacy junk without a human putting its name here. - */ -const LEGACY_FIELDS: Record<string, string[]> = { - // Removed when exchange update retry state moved to operation_retries. - exchanges: ["updateRetryCounter"], - // Removed 2024-06-13 ("remove coinAllocationId, simplify coin history"). - coins: ["spendAllocation"], - // Dropped when denomination records were restructured; nothing reads it. - denominations: ["listIssueDate"], - // Superseded by per-selection UIDs inside denomsSel; removed 2024-06-10. - withdrawalGroups: ["denomSelUid"], - // Documented in db-common.ts as a reserved legacy field (v1 refresh); the - // current protocol derives a public seed on demand instead. - refreshSessions: ["sessionSecretSeed"], -}; - -function stripLegacy( - stepName: string, -): ((rec: unknown) => unknown) | undefined { - const fields = LEGACY_FIELDS[stepName]; - if (!fields) return undefined; - return (rec: unknown) => { - const out = { ...(rec as Record<string, unknown>) }; - for (const f of fields) { - delete out[f]; - } - return out; - }; -} - -/** - * The IndexedDB schema allowed old wallet versions to persist an empty - * `exchanges` array. The native schema represents that field with rows in - * `purchase_exchanges`, so both an absent field and an empty array have zero - * rows and consequently read back as absent. The field is optional and the - * wallet itself does not create empty arrays, making absent the native - * canonical form. - */ -function normalizePurchase(rec: WalletPurchase): unknown { - if (rec.exchanges?.length !== 0) { - return rec; - } - const { exchanges: _exchanges, ...rest } = rec; - return rest; -} - -/** - * Crockford encoding is case-insensitive. IndexedDB records from an older - * wallet may contain this capability key in lowercase, whereas the native - * BLOB representation always decodes it in its canonical uppercase form. - */ -function normalizeContractPriv<T extends { contractPriv: string }>(rec: T): T { - return { ...rec, contractPriv: rec.contractPriv.toUpperCase() }; -} - -/** - * Require the one-reserve-per-public-key invariant of the native schema. - * - * The IndexedDB fixup may remove byte-identical duplicates after remapping all - * references. Anything left here is ambiguous; conversion never picks a - * winner merely because one row happens to be referenced or older. - */ -async function validateReservePub( - tx: WalletDbTransaction, - reserve: WalletReserve, -): Promise<void> { - const selected = await tx.getReserveByReservePub(reserve.reservePub); - if (!selected || selected.rowId !== reserve.rowId) { - throw Error( - `conversion refused: multiple reserve rows have public key ${reserve.reservePub}`, - ); - } -} - -const rsaWithdrawValues = { cipher: "RSA" } as const; - -function normalizePlanchet(rec: WalletPlanchet): WalletPlanchet { - return rec.exchangeWithdrawValues === undefined - ? ({ ...rec, exchangeWithdrawValues: rsaWithdrawValues } as WalletPlanchet) - : rec; -} - -function normalizeCoin(rec: WalletCoin): WalletCoin { - const stripped = stripLegacy("coins")!(rec) as WalletCoin; - return { - ...stripped, - exchangeMasterPub: stripped.exchangeMasterPub ?? "", - exchangeWithdrawValues: - stripped.exchangeWithdrawValues ?? rsaWithdrawValues, - }; -} - -function normalizeCoinAvailability( - rec: WalletCoinAvailability, -): WalletCoinAvailability { - return { ...rec, hasFreshCoins: rec.freshCoinCount > 0 ? 1 : 0 }; -} - -/** - * The copy plan, in groups. - * - * Each group runs in one destination transaction, and groups run in - * ownership order (parents before their children), so every commit leaves - * the destination's deferred foreign keys satisfied. - */ -const COPY_PLAN: CopyStep[][] = [ - [ - step( - "config", - (tx) => tx.listAllConfig(), - (tx, r) => tx.upsertConfig(r), - ), - step( - "currencyInfo", - (tx) => tx.listAllCurrencyInfo(), - (tx, r) => tx.upsertCurrencyInfoEntry(r), - ), - step( - "contacts", - (tx) => tx.listContacts(), - (tx, r) => tx.addContact(r), - ), - step( - "mailboxMessages", - (tx) => tx.listMailboxMessages(), - (tx, r) => tx.upsertMailboxMessage(r), - ), - step( - "mailboxConfigurations", - (tx) => tx.listAllMailboxConfigurations(), - (tx, r) => tx.upsertMailboxConfiguration(r), - ), - step( - "contractTerms", - (tx) => tx.listAllContractTerms(), - (tx, r) => tx.upsertContractTerms(r), - ), - step( - "tombstones", - (tx) => tx.listAllTombstones(), - (tx, r) => tx.upsertTombstone(r), - ), - step( - "operationRetries", - (tx) => tx.listAllOperationRetries(), - (tx, r) => tx.upsertOperationRetry(r), - ), - step( - "bankAccounts", - (tx) => tx.listBankAccounts(), - (tx, r) => tx.upsertBankAccount(r), - ), - step( - "globalCurrencyExchanges", - (tx) => tx.listGlobalCurrencyExchanges(), - (tx, r) => tx.upsertGlobalCurrencyExchange(r), - stripId, - ), - step( - "globalCurrencyAuditors", - (tx) => tx.listGlobalCurrencyAuditors(), - (tx, r) => tx.upsertGlobalCurrencyAuditor(r), - stripId, - ), - step( - "exchangeBaseUrlFixups", - (tx) => tx.listAllExchangeBaseUrlFixups(), - (tx, r) => tx.upsertExchangeBaseUrlFixup(r), - ), - step( - "exchangeBaseUrlMigrationLog", - (tx) => tx.listAllExchangeMigrationLogEntries(), - (tx, r) => tx.upsertExchangeMigrationLog(r), - ), - ], - [ - // Reserves before exchanges: an exchange entry may reference its merge - // reserve by row id. - step( - "reserves", - (tx) => tx.listAllReserves(), - (tx, r) => tx.upsertReserve(r), - undefined, - validateReservePub, - ), - ], - [ - step( - "exchanges", - (tx) => tx.getExchanges(), - (tx, r) => tx.upsertExchange(stripLegacy("exchanges")!(r) as any), - stripLegacy("exchanges"), - async (tx, r) => { - if ( - r.currentMergeReserveRowId != null && - !(await tx.getReserve(r.currentMergeReserveRowId)) - ) { - throw Error( - `conversion refused: exchange ${r.baseUrl} references missing reserve ${r.currentMergeReserveRowId}`, - ); - } - }, - ), - ], - [ - step( - "exchangeDetails", - (tx) => tx.listAllExchangeDetails(), - (tx, r) => tx.upsertExchangeDetails(r), - ), - ], - [ - step( - "exchangeSignKeys", - (tx) => tx.listAllExchangeSignKeys(), - (tx, r) => tx.upsertExchangeSignKey(r), - undefined, - async (tx, r) => { - if (!(await tx.getExchangeDetailsByRowId(r.exchangeDetailsRowId))) { - throw Error( - `conversion refused: signing key references missing exchange details ${r.exchangeDetailsRowId}`, - ); - } - }, - ), - step( - "denominationFamilies", - (tx) => tx.listAllDenominationFamilies(), - (tx, r) => tx.upsertDenominationFamily(r), - ), - ], - [ - step( - "denominations", - (tx) => tx.listAllDenominations(), - (tx, r) => tx.upsertDenomination(stripLegacy("denominations")!(r) as any), - stripLegacy("denominations"), - ), - ], - [ - step( - "withdrawalGroups", - (tx) => tx.listAllWithdrawalGroups(), - (tx, r) => - tx.upsertWithdrawalGroup(stripLegacy("withdrawalGroups")!(r) as any), - stripLegacy("withdrawalGroups"), - ), - step( - "purchases", - (tx) => tx.listAllPurchases(), - (tx, r) => tx.upsertPurchase(r), - normalizePurchase, - ), - step( - "refreshGroups", - (tx) => tx.listAllRefreshGroups(), - (tx, r) => tx.upsertRefreshGroup(r), - ), - step( - "coins", - (tx) => tx.listAllCoins(), - // Written through the strip too, so an IndexedDB destination does not - // re-preserve the junk the conversion exists to shed. - (tx, r) => tx.upsertCoin(normalizeCoin(r)), - normalizeCoin, - ), - ], - [ - step( - "planchets", - (tx) => tx.listAllPlanchets(), - (tx, r) => tx.upsertPlanchet(normalizePlanchet(r)), - normalizePlanchet, - async (tx, r) => { - if (!(await tx.getWithdrawalGroup(r.withdrawalGroupId))) { - throw Error( - `conversion refused: planchet references missing withdrawal group ${r.withdrawalGroupId}`, - ); - } - }, - ), - step( - "refreshSessions", - (tx) => tx.listAllRefreshSessions(), - (tx, r) => - tx.upsertRefreshSession(stripLegacy("refreshSessions")!(r) as any), - stripLegacy("refreshSessions"), - async (tx, r) => { - if (!(await tx.getRefreshGroup(r.refreshGroupId))) { - throw Error( - `conversion refused: refresh session references missing refresh group ${r.refreshGroupId}`, - ); - } - }, - ), - step( - "coinHistory", - (tx) => tx.listAllCoinHistories(), - (tx, r) => tx.upsertCoinHistory(r), - undefined, - async (tx, r) => { - if (!(await tx.getCoin(r.coinPub))) { - throw Error( - `conversion refused: coin history references missing coin ${r.coinPub}`, - ); - } - }, - ), - step( - "coinAvailability", - (tx) => tx.getCoinAvailabilities(), - (tx, r) => tx.upsertCoinAvailability(normalizeCoinAvailability(r)), - normalizeCoinAvailability, - ), - step( - "refundGroups", - (tx) => tx.listAllRefundGroups(), - (tx, r) => tx.upsertRefundGroup(r), - ), - step( - "tokens", - (tx) => tx.listTokens(), - (tx, r) => tx.upsertToken(r), - ), - step( - "slates", - (tx) => tx.listAllSlates(), - (tx, r) => tx.upsertSlate(r), - ), - step( - "depositGroups", - (tx) => tx.listAllDepositGroups(), - (tx, r) => tx.upsertDepositGroup(r), - ), - step( - "recoupGroups", - (tx) => tx.listAllRecoupGroups(), - (tx, r) => tx.upsertRecoupGroup(r), - ), - step( - "denomLossEvents", - (tx) => tx.listAllDenomLossEvents(), - (tx, r) => tx.upsertDenomLossEvent(r), - ), - step( - "peerPushDebit", - (tx) => tx.listAllPeerPushDebits(), - (tx, r) => tx.upsertPeerPushDebit(r), - normalizeContractPriv, - ), - step( - "peerPushCredit", - (tx) => tx.listAllPeerPushCredits(), - (tx, r) => tx.upsertPeerPushCredit(r), - normalizeContractPriv, - ), - step( - "peerPullDebit", - (tx) => tx.listAllPeerPullDebits(), - (tx, r) => tx.upsertPeerPullDebit(r), - normalizeContractPriv, - ), - step( - "peerPullCredit", - (tx) => tx.listAllPeerPullCredits(), - (tx, r) => tx.upsertPeerPullCredit(r), - normalizeContractPriv, - async (tx, r) => { - if (!(await tx.getReserve(r.mergeReserveRowId))) { - throw Error( - `conversion refused: peer pull credit references missing reserve ${r.mergeReserveRowId}`, - ); - } - }, - ), - step( - "donationSummaries", - (tx) => tx.getDonationSummaries(), - (tx, r) => tx.upsertDonationSummary(r), - ), - step( - "donationPlanchets", - (tx) => tx.listAllDonationPlanchets(), - (tx, r) => tx.upsertDonationPlanchet(r), - ), - step( - "donationReceipts", - (tx) => tx.listAllDonationReceipts(), - (tx, r) => tx.upsertDonationReceipt(r), - ), - step( - "transactionsMeta", - (tx) => tx.listTransactionMetaByTimestamp({}), - (tx, r) => tx.upsertTransactionMeta(r), - ), - ], - [ - step( - "refundItems", - (tx) => tx.listAllRefundItems(), - (tx, r) => tx.upsertRefundItem(r), - undefined, - async (tx, r) => { - if (!(await tx.getRefundGroup(r.refundGroupId))) { - throw Error( - `conversion refused: refund item references missing refund group ${r.refundGroupId}`, - ); - } - }, - ), - ], -]; - -export const DB_CONVERSION_STEP_COUNT = COPY_PLAN.flat().length; - -export interface DbConversionReport { - /** Records copied, per store. */ - copied: Record<string, number>; - /** Total number of records copied. */ - totalRecords: number; -} - -/** Optional hooks for observing or deliberately interrupting a conversion. */ -export interface DbConversionOptions { - /** - * Called after a progress notification has been delivered to the source - * handle. Throwing aborts the conversion, which lets callers inject a - * controlled interruption without relying on host notification callbacks. - */ - onProgress?: (notification: DatabaseMaintenanceProgressNotification) => void; -} - -/** Small enough to bound retained records while amortising transaction setup. */ -export const DB_CONVERSION_BATCH_SIZE = 128; - -/** Record interval at which migration progress is reported. */ -export const DB_CONVERSION_PROGRESS_RECORDS = 100; - -/** - * JSON stringification with sorted object keys, so structurally equal - * records compare equal regardless of property insertion order -- the two - * backends do not construct records in the same order. - */ -function stableStringify(v: unknown): string { - return JSON.stringify(v, (_k, val) => { - if (val !== null && typeof val === "object" && !Array.isArray(val)) { - const sorted: Record<string, unknown> = {}; - for (const key of Object.keys(val).sort()) { - // JSON.stringify drops undefined-valued properties on its own, but - // only at the top of each value; do it explicitly so a record with - // an explicitly-undefined key compares equal to one without it. - if (val[key] !== undefined) { - sorted[key] = val[key]; - } - } - return sorted; - } - return val; - }); -} - -/** - * Order-independent SHA-256 multiset digest. - * - * Each canonical record is hashed separately and the 256-bit hashes are - * added modulo 2^256. Addition preserves multiplicity but does not require - * records from the two different physical schemas to arrive in the same - * order. Only this fixed 32-byte accumulator is retained between batches. - */ -class RecordMultisetDigest { - private sum = new Uint8Array(32); - count = 0; - - add(record: unknown): void { - const encoded = stableStringify(record); - const h = sha256(stringToBytes(encoded)); - let carry = 0; - for (let i = this.sum.length - 1; i >= 0; i--) { - const n = this.sum[i] + h[i] + carry; - this.sum[i] = n & 0xff; - carry = n >>> 8; - } - this.count++; - } - - equals(other: RecordMultisetDigest): boolean { - if (this.count !== other.count) return false; - let difference = 0; - for (let i = 0; i < this.sum.length; i++) { - difference |= this.sum[i] ^ other.sum[i]; - } - return difference === 0; - } - - describe(): string { - return `${this.count}:${Array.from(this.sum, (x) => - x.toString(16).padStart(2, "0"), - ).join("")}`; - } -} - -async function readPage( - handle: WalletDbHandle, - step: CopyStep, - cursor: unknown | undefined, - validate: boolean, -): Promise<{ records: unknown[]; nextCursor?: unknown }> { - return await handle.runReadWriteTx(async (tx) => { - const page = await tx.scanMigrationRecords( - step.name, - step.read, - cursor, - DB_CONVERSION_BATCH_SIZE, - ); - if (validate && step.validate) { - for (const record of page.records) { - await step.validate(tx, record); - } - } - return page; - }); -} - -async function digestStore( - handle: WalletDbHandle, - step: CopyStep, - progress?: (processed: number) => void, -): Promise<RecordMultisetDigest> { - const digest = new RecordMultisetDigest(); - let cursor: unknown | undefined; - while (true) { - const page = await readPage(handle, step, cursor, false); - if (page.records.length === 0) break; - const normalize = step.normalize ?? ((r: unknown) => r); - for (const record of page.records) { - digest.add(normalize(record)); - } - progress?.(digest.count); - cursor = page.nextCursor; - if (cursor === undefined) break; - } - return digest; -} - -/** - * Copy every record from src into dst, then verify the copy. - * - * Verification re-enumerates both databases through the same bounded - * accessors and compares count plus an order-independent digest of every - * normalised record. A mapper that drops a field produces equal counts and - * unequal digests without retaining either store in memory. - * - * Throws on any difference; the destination should then be discarded. - */ -export async function convertWalletDb( - src: WalletDbHandle, - dst: WalletDbHandle, - options: DbConversionOptions = {}, -): Promise<DbConversionReport> { - const copied: Record<string, number> = {}; - - // Inventorying the source up front makes the progress denominator known - // before the first write. Keep the digests: verification can compare the - // destination against these instead of scanning the source a second time, - // so global progress does not add another full database pass. - const sourceDigests = new Map<WalletDbMigrationStore, RecordMultisetDigest>(); - let totalRecords = 0; - for (const group of COPY_PLAN) { - for (const st of group) { - const digest = await digestStore(src, st); - sourceDigests.set(st.name, digest); - totalRecords += digest.count; - } - } - - const progressInterval = DB_CONVERSION_PROGRESS_RECORDS; - - const notify = ( - phase: "copy" | "verify", - completedSteps: number, - step?: CopyStep, - processedRecords?: number, - ): void => { - const notification: DatabaseMaintenanceProgressNotification = { - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-to-native-migration", - phase, - completedSteps, - totalSteps: DB_CONVERSION_STEP_COUNT, - ...(step ? { step: step.name } : {}), - ...(processedRecords !== undefined ? { processedRecords } : {}), - totalRecords, - }; - src.emitNotification(notification); - options.onProgress?.(notification); - }; - - const makeRecordProgress = (phase: "copy" | "verify") => { - let next = progressInterval; - let last = -1; - return ( - completedSteps: number, - processedRecords: number, - step?: CopyStep, - force = false, - ): void => { - if (!force && processedRecords < next) return; - if (processedRecords === last) return; - notify(phase, completedSteps, step, processedRecords); - last = processedRecords; - next = - (Math.floor(processedRecords / progressInterval) + 1) * - progressInterval; - }; - }; - - const copyProgress = makeRecordProgress("copy"); - let copiedRecords = 0; - let stepIndex = 0; - copyProgress(stepIndex, 0, undefined, true); - for (const group of COPY_PLAN) { - for (const st of group) { - let cursor: unknown | undefined; - let storeCount = 0; - while (true) { - const page = await readPage(src, st, cursor, true); - if (page.records.length === 0) break; - await dst.runReadWriteTx(async (tx) => { - for (const rec of page.records) { - await st.write(tx, rec); - } - }); - storeCount += page.records.length; - copiedRecords += page.records.length; - copyProgress(stepIndex, copiedRecords, st); - cursor = page.nextCursor; - if (cursor === undefined) break; - } - copied[st.name] = storeCount; - stepIndex++; - notify("copy", stepIndex, st); - logger.trace(`copied ${storeCount} ${st.name}`); - } - } - copyProgress(stepIndex, copiedRecords, undefined, true); - - // Verify using fixed-size multiset digests. Source and destination have - // different primary keys/orderings for some entities, so comparing page - // boundaries would be incorrect even though both scans are bounded. - const verifyProgress = makeRecordProgress("verify"); - let verifiedRecords = 0; - stepIndex = 0; - verifyProgress(stepIndex, 0, undefined, true); - for (const group of COPY_PLAN) { - for (const st of group) { - const sourceDigest = sourceDigests.get(st.name)!; - const beforeStore = verifiedRecords; - const destinationDigest = await digestStore(dst, st, (processed) => - verifyProgress(stepIndex, beforeStore + processed, st), - ); - if (!sourceDigest.equals(destinationDigest)) { - throw Error( - `conversion verification failed: ${st.name} differs between` + - ` source and destination (${sourceDigest.describe()} versus` + - ` ${destinationDigest.describe()})`, - ); - } - verifiedRecords += destinationDigest.count; - stepIndex++; - notify("verify", stepIndex, st); - } - } - verifyProgress(stepIndex, verifiedRecords, undefined, true); - return { copied, totalRecords: copiedRecords }; -} diff --git a/packages/taler-wallet-core/src/db-indexeddb.test.ts b/packages/taler-wallet-core/src/db-indexeddb.test.ts @@ -1,254 +0,0 @@ -/* - 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/> - */ - -import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; -import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; -import assert from "node:assert"; -import { test } from "node:test"; -import { ConfigRecordKey } from "./db-common.js"; -import { - abortTalerDatabaseReplacement, - applyFixups, - beginTalerDatabaseReplacement, - exportSingleDb, - openTalerDatabase, - publishTalerDatabaseReplacement, - TALER_WALLET_MAIN_DB_NAME, -} from "./db-indexeddb.js"; -import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; -import { makeIdbRunner } from "./dbtx-runners.js"; - -test("indexeddb import clears stores absent from an older dump", async () => { - const handle = await makeIdbRunner(); - try { - const dump = await handle.exportDatabase(); - delete dump.databases[TALER_WALLET_MAIN_DB_NAME].stores.contacts; - - await handle.runReadWriteTx((tx) => - tx.addContact({ - alias: "alice", - aliasType: "email", - mailboxBaseUri: "https://mailbox.example/", - mailboxAddress: "mailbox-address" as any, - source: "test", - petname: "Alice", - }), - ); - await handle.importDatabase(dump, async () => {}); - - const contacts = await handle.runReadWriteTx((tx) => tx.listContacts()); - assert.deepStrictEqual(contacts, []); - } finally { - await handle.close(); - } -}); - -test("notification sink exceptions do not prevent the first database open", async () => { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - BridgeIDBFactory.enableTracing = false; - const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); - handle.setNotificationSink(() => { - throw Error("host notification failure"); - }); - try { - const result = await handle.ensureOpen(); - assert.ok(result.fixupsApplied > 0); - await handle.runReadWriteTx((tx) => - tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 1 }), - ); - } finally { - await handle.close(); - } -}); - -test("a failed fixup is retried on the next database open", async () => { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - BridgeIDBFactory.enableTracing = false; - let fixupAttempts = 0; - const handle = new IdbWalletDbHandle( - new BridgeIDBFactory(backend), - undefined, - async (access, notify) => { - fixupAttempts++; - if (fixupAttempts === 1) { - throw Error("injected fixup failure"); - } - return await applyFixups(access, notify); - }, - ); - try { - await assert.rejects(handle.ensureOpen(), /injected fixup failure/); - - const result = await handle.ensureOpen(); - assert.strictEqual(fixupAttempts, 2); - assert.ok(result.fixupsApplied > 0); - await handle.runReadWriteTx((tx) => - tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 2 }), - ); - } finally { - await handle.close(); - } -}); - -test("indexeddb import commit survives reopen and failure preserves old generation", async () => { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - BridgeIDBFactory.enableTracing = false; - const factory = new BridgeIDBFactory(backend); - let handle = new IdbWalletDbHandle(factory); - const source = await makeIdbRunner(); - const restored = await makeIdbRunner(); - try { - await handle.ensureOpen(); - await handle.runReadWriteTx((tx) => - tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 10 }), - ); - await source.runReadWriteTx((tx) => - tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 20 }), - ); - const dump = await source.exportDatabase(); - - await assert.rejects( - handle.importDatabase(dump, async () => { - throw Error("injected rematerialization failure"); - }), - /injected rematerialization failure/, - ); - await handle.close(); - handle = new IdbWalletDbHandle(factory); - await handle.ensureOpen(); - assert.strictEqual( - ( - await handle.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ) - )?.value, - 10, - ); - - await handle.importDatabase(dump, async (tx) => { - await tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 30 }); - }); - await handle.close(); - handle = new IdbWalletDbHandle(factory); - await handle.ensureOpen(); - assert.strictEqual( - ( - await handle.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ) - )?.value, - 30, - ); - const portableDump = await handle.exportDatabase(); - assert.ok(portableDump.databases[TALER_WALLET_MAIN_DB_NAME]); - await restored.importDatabase(portableDump, async () => {}); - assert.strictEqual( - ( - await restored.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ) - )?.value, - 30, - ); - } finally { - await source.close(); - await restored.close(); - await handle.close(); - } -}); - -test("indexeddb startup selects only a published generation", async () => { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - BridgeIDBFactory.enableTracing = false; - const factory = new BridgeIDBFactory(backend); - - const original = await openTalerDatabase(factory, async () => {}); - const abandoned = await beginTalerDatabaseReplacement( - factory, - original.name, - async () => {}, - ); - abandoned.handle.close(); - original.close(); - - const afterAbandon = await openTalerDatabase(factory, async () => {}); - assert.strictEqual(afterAbandon.name, TALER_WALLET_MAIN_DB_NAME); - const published = await beginTalerDatabaseReplacement( - factory, - afterAbandon.name, - async () => {}, - ); - await publishTalerDatabaseReplacement( - factory, - afterAbandon.name, - published.name, - ); - published.handle.close(); - afterAbandon.close(); - - const afterPublish = await openTalerDatabase(factory, async () => {}); - assert.strictEqual(afterPublish.name, published.name); - afterPublish.close(); - // The abandoned generation is deliberately not deleted during startup, - // but its stale pending claim was cleared, so its owner can clean it safely. - await abortTalerDatabaseReplacement(factory, abandoned.name); -}); - -test("export closes its database connection", async () => { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename: ":memory:", - }); - BridgeIDBFactory.enableTracing = false; - const factory = new BridgeIDBFactory(backend); - const db = await openTalerDatabase(factory, async () => {}); - db.close(); - - await exportSingleDb(factory, TALER_WALLET_MAIN_DB_NAME); - - await new Promise<void>((resolve, reject) => { - const req = factory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME); - req.addEventListener("success", () => resolve()); - req.addEventListener("error", () => - reject(req.error ?? Error("database deletion failed")), - ); - req.addEventListener("blocked", () => - reject(Error("export leaked an open database connection")), - ); - }); -}); diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts @@ -1,2954 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2025 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/> - */ - -/** - * Imports. - */ -import { - Event, - GlobalIDB, - IDBDatabase, - IDBFactory, - IDBKeyRange, - IDBObjectStore, - IDBRequest, - IDBTransaction, - structuredEncapsulate, - structuredRevive, -} from "@gnu-taler/idb-bridge"; -import { - AccountLimit, - AgeCommitmentProof, - AmountString, - Amounts, - BlindedUniqueDonationIdentifier, - CancellationToken, - Codec, - CoinEnvelope, - CoinPublicKeyString, - CoinRefreshRequest, - CoinStatus, - CurrencySpecification, - DenomLossEventType, - DenomSelectionState, - DenominationInfo, - DenominationPubKey, - DonationReceiptSignature, - EddsaPublicKeyString, - EddsaSignatureString, - ExchangeAuditor, - ExchangeGlobalFees, - ExchangeRefundRequest, - HashCodeString, - Logger, - NotificationType, - MailboxConfiguration, - MailboxMessageRecord, - MerchantContractTokenDetails, - MerchantContractTokenKind, - RefreshReason, - ScopeInfo, - SignedTokenEnvelope, - TalerErrorCode, - TalerErrorDetail, - TalerProtocolDuration, - TalerProtocolTimestamp, - TokenEnvelope, - TokenIssuePublicKey, - TokenUseSig, - TransactionIdStr, - TransferOptionRaw, - UnblindedDenominationSignature, - WireInfo, - WalletNotification, - WithdrawalExchangeAccountDetails, - ZeroLimitedOperation, - canonicalJson, - checkDbInvariant, - codecForAny, - encodeCrock, - getErrorDetailFromException, - getRandomBytes, - hash, - j2s, - stringToBytes, -} from "@gnu-taler/taler-util"; -import { DbRetryInfo, TaskIdentifiers } from "./common.js"; -import { - ConfigRecord, - ConfigRecordKey, - DbPreciseTimestamp, - DbProtocolTimestamp, - DenomLossStatus, - DenominationVerificationStatus, - DepositElementStatus, - DepositOperationStatus, - DonationReceiptStatus, - DonauConfig, - ExchangeEntryDbRecordStatus, - ExchangeEntryDbUpdateStatus, - PeerPullDebitRecordStatus, - PeerPullPaymentCreditStatus, - PeerPushCreditStatus, - PeerPushDebitStatus, - PlanchetStatus, - PurchaseStatus, - RecoupOperationStatus, - RefreshCoinStatus, - RefreshOperationStatus, - RefundGroupStatus, - RefundItemStatus, - ReserveRecordStatus, - WalletBackupConfState, - WithdrawalGroupStatus, - timestampProtocolFromDb, - WalletDenomination, - DenomFees, - CoinSourceType, - WalletTransactionMeta, - WalletOperationRetry, - WalletContractTerms, - WalletDepositGroup, - WalletCoinSelection, - WalletDepositKycInfo, - WalletDepositTrackingInfo, - WalletDepositInfoPerExchange, - WalletRecoupGroup, - WalletRefreshGroup, - WithdrawalRecordType, - ReserveBankInfo, - WgInfo, - WgInfoBankIntegrated, - WgInfoBankManual, - WgInfoBankPeerPull, - WgInfoBankPeerPush, - WgInfoBankRecoup, - WalletWithdrawalGroup, - WalletPlanchet, - WalletDonationSummary, - WalletProposalDownloadInfo, - WalletTokenSelection, - WalletPurchasePayInfo, - WalletPurchase, - WalletExchangeDetailsPointer, - WalletExchangeEntry, - WalletExchangeDetails, - WalletSlate, - WalletDenomLossEvent, - WalletExchangeSignkeys, - WalletDenomFamilyParams, - WalletDenominationFamily, - WalletExchangeBaseUrlFixup, - WalletExchangeMigrationLog, - WalletGlobalCurrencyAuditor, - WalletBankAccount, - WalletGlobalCurrencyExchange, - WalletToken, - TokenFamilyInfo, - WalletRefundGroup, - WalletRefundItem, - WalletTombstone, - WalletDonationReceipt, - WalletDonationPlanchet, - WalletRefreshSession, - WalletRefreshGroupPerExchangeInfo, - WalletReserve, - WalletCoin, - WalletCoinAvailability, - WalletCoinHistory, - WalletCoinSource, - WalletCoinHistoryItem, - WalletWithdrawCoinSource, - WalletRefreshCoinSource, - WalletRewardCoinSource, - RefundReason, - ExchangeMigrationReason, - OPERATION_STATUS_NONFINAL_FIRST, - OPERATION_STATUS_NONFINAL_LAST, -} from "./db-common.js"; -export { DenomFees, CoinSourceType, RefundReason, ExchangeMigrationReason }; -import { - DbAccess, - DbAccessImpl, - DbReadWriteTransaction, - IndexDescriptor, - StoreDescriptor, - StoreNames, - StoreWithIndexes, - describeContents, - describeIndex, - describeStore, - describeStoreV2, - openDatabase, -} from "./query.js"; -export { ConfigRecord, DonauConfig, WalletBackupConfState }; - -/** - * This file contains the database schema of the Taler wallet together - * with some helper functions. - * - * Some design considerations: - * - By convention, each object store must have a corresponding "<Name>Record" - * interface defined for it. - * - For records that represent operations, there should be exactly - * one top-level enum field that indicates the status of the operation. - * This field should be present even if redundant, because the field - * will have an index. - * - Amounts are stored as strings, except when they are needed for - * indexing. - * - Every record that has a corresponding transaction item must have - * an index for a mandatory timestamp field. - * - Optional fields should be avoided, use "T | undefined" instead. - * - Do all records have some obvious, indexed field that can - * be used for range queries? - * - * @author Florian Dold <dold@taler.net> - */ - -/** - FIXMEs: - - Contract terms can be quite large. We currently tend to read the - full contract terms from the DB quite often. - Instead, we should probably extract what we need into a separate object - store. - - More object stores should have an "id" primary key, - as this makes referencing less expensive. - - Coin selections should probably go into a separate object store. - - Some records should be split up into an extra "details" record - that we don't always need to iterate over. - */ - -/** - * Name of the Taler database. This is effectively the major - * version of the DB schema. Whenever it changes, custom import logic - * for all previous versions must be written, which should be - * avoided. - */ -export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10"; - -/** - * Name of the metadata database. This database is used - * to track major migrations of the main Taler database. - * - * (Minor migrations are handled via upgrade transactions.) - */ -export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta"; - -/** - * Name of the "meta config" database. - */ -export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; - -/** Database generation being prepared by an import but not authoritative yet. */ -const PENDING_DB_CONFIG_KEY = "pendingMainDbName"; - -/** Previous authoritative generation waiting for best-effort deletion. */ -const RETIRED_DB_CONFIG_KEY = "retiredMainDbName"; - -/** Names below this prefix are current-schema generations, not major versions. */ -const TALER_WALLET_DB_GENERATION_PREFIX = `${TALER_WALLET_MAIN_DB_NAME}-generation-`; - -/** - * Current database minor version, should be incremented - * each time we do minor schema changes on the database. - * A change is considered minor when fields are added in a - * backwards-compatible way or object stores and indices - * are added. - */ -export const WALLET_DB_MINOR_VERSION = 32; - -// FIXME: Should these be numeric codes? -export type KycUserType = "individual" | "business"; - -export interface BankWithdrawUriRecord { - /** - * The withdraw URI we got from the bank. - */ - talerWithdrawUri: string; - - /** - * Reserve that was created for the withdraw URI. - */ - reservePub: string; -} - -export interface DbPeerPushPaymentCoinSelection { - contributions: AmountString[]; - coinPubs: CoinPublicKeyString[]; -} - -/** - * Record for a push P2P payment that this wallet initiated. - */ -export interface PeerPushDebitRecord { - /** - * What exchange are funds coming from? - */ - exchangeBaseUrl: string; - - /** - * Restricted scope for this transaction. - * - * Relevant for coin reselection. - */ - restrictScope?: ScopeInfo; - - /** - * Instructed amount. - */ - amount: AmountString; - - /** - * Effective amount. - * - * (Called totalCost for historical reasons.) - */ - totalCost: AmountString; - - coinSel?: DbPeerPushPaymentCoinSelection; - - contractTermsHash: HashCodeString; - - /** - * Purse public key. Used as the primary key to look - * up this record. - */ - pursePub: string; - - /** - * Purse private key. - */ - pursePriv: string; - - /** - * Public key of the merge capability of the purse. - */ - mergePub: string; - - /** - * Private key of the merge capability of the purse. - */ - mergePriv: string; - - contractPriv: string; - contractPub: string; - - /** - * 24 byte nonce. - */ - contractEncNonce: string; - - purseExpiration: DbProtocolTimestamp; - - timestampCreated: DbPreciseTimestamp; - - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - /** - * Status of the peer push payment initiation. - */ - status: PeerPushDebitStatus; -} - -export interface PeerPullCreditRecord { - /** - * What exchange are we using for the payment request? - */ - exchangeBaseUrl: string; - - /** - * Amount requested. - * FIXME: What type of instructed amount is i? - */ - amount: AmountString; - - estimatedAmountEffective: AmountString; - - /** - * Purse public key. Used as the primary key to look - * up this record. - */ - pursePub: string; - - /** - * Purse private key. - */ - pursePriv: string; - - /** - * Hash of the contract terms. Also - * used to look up the contract terms in the DB. - */ - contractTermsHash: string; - - mergePub: string; - mergePriv: string; - - contractPub: string; - contractPriv: string; - - contractEncNonce: string; - - mergeTimestamp: DbPreciseTimestamp; - - mergeReserveRowId: number; - - /** - * Status of the peer pull payment initiation. - */ - status: PeerPullPaymentCreditStatus; - - kycPaytoHash?: string; - - kycAccessToken?: string; - - kycLastCheckStatus?: number | undefined; - kycLastCheckCode?: number | undefined; - kycLastRuleGen?: number | undefined; - kycLastAmlReview?: boolean | undefined; - kycLastDeny?: DbPreciseTimestamp | undefined; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - withdrawalGroupId: string | undefined; -} - -/** - * Record for a push P2P payment that this wallet was offered. - * - * Unique: (exchangeBaseUrl, pursePub) - */ -export interface PeerPushCreditRecord { - peerPushCreditId: string; - - exchangeBaseUrl: string; - - pursePub: string; - - mergePriv: string; - - contractPriv: string; - - timestamp: DbPreciseTimestamp; - - estimatedAmountEffective: AmountString; - - /** - * Hash of the contract terms. Also - * used to look up the contract terms in the DB. - */ - contractTermsHash: string; - - /** - * Status of the peer push payment incoming initiation. - */ - status: PeerPushCreditStatus; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - /** - * Associated withdrawal group. - */ - withdrawalGroupId: string | undefined; - - /** - * Currency of the peer push payment credit transaction. - * - * Mandatory in current schema version, optional for compatibility - * with older (ver_minor<4) DB versions. - */ - currency: string | undefined; - - kycPaytoHash?: string; - - kycAccessToken?: string; - - kycLastCheckStatus?: number | undefined; - kycLastCheckCode?: number | undefined; - kycLastRuleGen?: number | undefined; - kycLastAmlReview?: boolean | undefined; - kycLastDeny?: DbPreciseTimestamp | undefined; -} - -export interface PeerPullPaymentCoinSelection { - contributions: AmountString[]; - coinPubs: CoinPublicKeyString[]; - - /** - * Total cost based on the coin selection. - * Non undefined after status === "Accepted" - */ - totalCost: AmountString | undefined; -} - -/** - * AKA PeerPullDebit. - */ -export interface PeerPullPaymentIncomingRecord { - peerPullDebitId: string; - - pursePub: string; - - exchangeBaseUrl: string; - - amount: AmountString; - - contractTermsHash: string; - - timestampCreated: DbPreciseTimestamp; - - /** - * Contract priv that we got from the other party. - */ - contractPriv: string; - - /** - * Status of the peer push payment incoming initiation. - */ - status: PeerPullDebitRecordStatus; - - /** - * Estimated total cost when the record was created. - */ - totalCostEstimated: AmountString; - - abortRefreshGroupId?: string; - - abortReason?: TalerErrorDetail; - failReason?: TalerErrorDetail; - - coinSel?: PeerPullPaymentCoinSelection; -} - -export interface DbExchangeHandle { - url: string; - exchangeMasterPub: string; -} - -export interface DbAuditorHandle { - url: string; - auditorPub: string; -} - -export function passthroughCodec<T>(): Codec<T> { - return codecForAny(); -} - -export interface CurrencyInfoRecord { - /** - * Stringified scope info. - */ - scopeInfoStr: string; - - /** - * Currency specification. - */ - currencySpec: CurrencySpecification; - - /** - * How did the currency info get set? - */ - source: "exchange" | "user" | "preset"; -} - -export interface ContactRecord { - /** - * The mailbox URI of this contact - */ - mailboxBaseUri: string; - - /** - * The mailbox identity - */ - mailboxAddress: HashCodeString; - - /** - * The alias of this contact - */ - alias: string; - - /** - * The type of the alias - */ - aliasType: string; - - /** - * The source of this alias - */ - source: string; - - /** - * The local petname of this alias - */ - petname: string; -} - -/** - * Schema definition for the IndexedDB - * wallet database. - */ -export const WalletIndexedDbStoresV1 = { - exchangeBaseUrlMigrationLog: describeStoreV2({ - recordCodec: passthroughCodec<WalletExchangeMigrationLog>(), - storeName: "exchangeBaseUrlMigrationLog", - keyPath: ["oldExchangeBaseUrl", "newExchangeBaseUrl"], - versionAdded: 18, - indexes: {}, - }), - exchangeBaseUrlFixups: describeStoreV2({ - recordCodec: passthroughCodec<WalletExchangeBaseUrlFixup>(), - storeName: "exchangeBaseUrlFixups", - keyPath: "exchangeBaseUrl", - versionAdded: 19, - indexes: {}, - }), - denomLossEvents: describeStoreV2({ - recordCodec: passthroughCodec<WalletDenomLossEvent>(), - storeName: "denomLossEvents", - keyPath: "denomLossEventId", - versionAdded: 9, - indexes: { - byCurrency: describeIndex("byCurrency", "currency", { - versionAdded: 9, - }), - byStatus: describeIndex("byStatus", "status", { - versionAdded: 10, - }), - }, - }), - transactionsMeta: describeStoreV2({ - recordCodec: passthroughCodec<WalletTransactionMeta>(), - storeName: "transactionsMeta", - keyPath: "transactionId", - versionAdded: 13, - indexes: { - byCurrency: describeIndex("byCurrency", "currency", { - versionAdded: 13, - }), - byExchange: describeIndex("byExchange", "exchanges", { - versionAdded: 13, - multiEntry: true, - }), - byTimestamp: describeIndex("byTimestamp", "timestamp", { - versionAdded: 13, - }), - byTimestampAndId: describeIndex( - "byTimestampAndId", - ["timestamp", "transactionId"], - { versionAdded: 32 }, - ), - byStatus: describeIndex("byStatus", "status", { - versionAdded: 13, - }), - }, - }), - currencyInfo: describeStoreV2({ - recordCodec: passthroughCodec<CurrencyInfoRecord>(), - storeName: "currencyInfo", - keyPath: "scopeInfoStr", - versionAdded: 12, - }), - globalCurrencyAuditors: describeStoreV2({ - recordCodec: passthroughCodec<WalletGlobalCurrencyAuditor>(), - storeName: "globalCurrencyAuditors", - keyPath: "id", - autoIncrement: true, - versionAdded: 3, - indexes: { - byCurrencyAndUrlAndPub: describeIndex( - "byCurrencyAndUrlAndPub", - ["currency", "auditorBaseUrl", "auditorPub"], - { - unique: true, - versionAdded: 4, - }, - ), - }, - }), - globalCurrencyExchanges: describeStoreV2({ - recordCodec: passthroughCodec<WalletGlobalCurrencyExchange>(), - storeName: "globalCurrencyExchanges", - keyPath: "id", - autoIncrement: true, - versionAdded: 3, - indexes: { - byCurrencyAndUrlAndPub: describeIndex( - "byCurrencyAndUrlAndPub", - ["currency", "exchangeBaseUrl", "exchangeMasterPub"], - { - unique: true, - versionAdded: 4, - }, - ), - }, - }), - // Keyed by the master public key for the same reason as denominationsV2: - // the coins of one denomination hash under two different keys are not the - // same coins, and must not share a count. - coinAvailabilityV2: describeStore( - "coinAvailabilityV2", - describeContents<WalletCoinAvailability>({ - keyPath: ["exchangeMasterPub", "denomPubHash", "maxAge"], - versionAdded: 31, - }), - { - byExchangeAgeAvailability: describeIndex( - "byExchangeAgeAvailability", - ["exchangeBaseUrl", "maxAge", "freshCoinCount"], - { versionAdded: 31 }, - ), - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 31, - }), - byExchangeFreshAndAge: describeIndex( - "byExchangeFreshAndAge", - ["exchangeBaseUrl", "hasFreshCoins", "maxAge"], - { versionAdded: 32 }, - ), - }, - ), - // The pre-re-key store. Keeps its map key equal to its store name: the - // transaction client exposes accessors by store name, so an `_obsolete_` - // alias would typecheck and then be undefined at runtime. - coinAvailability: describeStore( - "coinAvailability", - describeContents<WalletCoinAvailability>({ - keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"], - }), - { - byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [ - "exchangeBaseUrl", - "maxAge", - "freshCoinCount", - ]), - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 8, - }), - }, - ), - coinHistory: describeStoreV2({ - storeName: "coinHistory", - recordCodec: passthroughCodec<WalletCoinHistory>(), - keyPath: "coinPub", - versionAdded: 11, - }), - coins: describeStore( - "coins", - describeContents<WalletCoin>({ - keyPath: "coinPub", - }), - { - byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"), - byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"), - byMasterPubDenomPubHashAndAgeAndStatus: describeIndex( - "byMasterPubDenomPubHashAndAgeAndStatus", - ["exchangeMasterPub", "denomPubHash", "maxAge", "status"], - { - versionAdded: 31, - }, - ), - byExchangeDenomPubHashAndAgeAndStatus: describeIndex( - "byExchangeDenomPubHashAndAgeAndStatus", - ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"], - ), - byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), - bySourceTransactionId: describeIndex( - "bySourceTransactionId", - "sourceTransactionId", - { - versionAdded: 9, - }, - ), - }, - ), - tokens: describeStore( - "tokens", - describeContents<WalletToken>({ - keyPath: "tokenUsePub", - versionAdded: 16, - }), - { - byTokenIssuePubHash: describeIndex( - "byTokenIssuePubHash", - "tokenIssuePubHash", - { - versionAdded: 17, - }, - ), - byPurchaseIdAndChoiceIndex: describeIndex( - "byPurchaseIdAndChoiceIndex", - ["purchaseId", "choiceIndex"], - { - versionAdded: 17, - }, - ), - byTokenFamilyHash: describeIndex("byTokenFamilyHash", "tokenFamilyHash", { - versionAdded: 21, - }), - }, - ), - slates: describeStore( - "slates", - describeContents<WalletSlate>({ - keyPath: "tokenUsePub", - versionAdded: 16, - }), - { - byPurchaseIdAndChoiceIndex: describeIndex( - "byPurchaseIdAndChoiceIndex", - ["purchaseId", "choiceIndex"], - { - versionAdded: 17, - }, - ), - byPurchaseIdAndChoiceIndexAndOutputIndex: describeIndex( - "byPurchaseIdAndChoiceIndexAndOutputIndex", - ["purchaseId", "choiceIndex", "outputIndex"], - { - versionAdded: 17, - }, - ), - byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex: describeIndex( - "byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex", - ["purchaseId", "choiceIndex", "outputIndex", "repeatIndex"], - { - versionAdded: 29, - }, - ), - }, - ), - reserves: describeStore( - "reserves", - describeContents<WalletReserve>({ - keyPath: "rowId", - autoIncrement: true, - }), - { - byReservePub: describeIndex("byReservePub", "reservePub", {}), - }, - ), - config: describeStore( - "config", - describeContents<ConfigRecord>({ keyPath: "key" }), - {}, - ), - // Keyed by the master public key that signed the denomination, not by the - // exchange's URL: the URL is where the exchange currently answers and can - // change, while the key is what decides whether a coin can be settled. A - // new store rather than a re-keyed one because the IndexedDB upgrade path - // can only add stores and indices, never change a keyPath. - denominationsV2: describeStore( - "denominationsV2", - describeContents<WalletDenomination>({ - keyPath: ["exchangeMasterPub", "denomPubHash"], - versionAdded: 31, - }), - { - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 31, - }), - byExchangeMasterPub: describeIndex( - "byExchangeMasterPub", - "exchangeMasterPub", - { - versionAdded: 31, - }, - ), - byVerificationStatus: describeIndex( - "byVerificationStatus", - "verificationStatus", - { - versionAdded: 31, - }, - ), - byDenominationFamilySerialAndStampExpireWithdraw: describeIndex( - "byDenominationFamilySerialAndStampExpireWithdraw", - ["denominationFamilySerial", "stampExpireWithdraw"], - { - versionAdded: 31, - }, - ), - }, - ), - denominations: describeStore( - "denominations", - describeContents<WalletDenomination>({ - keyPath: ["exchangeBaseUrl", "denomPubHash"], - }), - { - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"), - byVerificationStatus: describeIndex( - "byVerificationStatus", - "verificationStatus", - { - versionAdded: 26, - }, - ), - byDenominationFamilySerialAndStampExpireWithdraw: describeIndex( - "byDenominationFamilySerialAndStampExpireWithdraw", - ["denominationFamilySerial", "stampExpireWithdraw"], - { - versionAdded: 27, - }, - ), - }, - ), - denominationFamilies: describeStore( - "denominationFamilies", - describeContents<WalletDenominationFamily>({ - keyPath: "denominationFamilySerial", - versionAdded: 27, - autoIncrement: true, - }), - { - byExchangeBaseUrl: describeIndex( - "byExchangeBaseUrl", - "familyParams.exchangeBaseUrl", - { - versionAdded: 27, - }, - ), - byFamilyParms: describeIndex( - "byFamilyParams", - [ - "familyParams.exchangeBaseUrl", - "familyParams.exchangeMasterPub", - "familyParams.value", - "familyParams.feeWithdraw", - "familyParams.feeDeposit", - "familyParams.feeRefresh", - "familyParams.feeRefund", - ], - { - versionAdded: 28, - }, - ), - // Reserved legacy index names: - // * byFamilyParamsHash - }, - ), - exchanges: describeStore( - "exchanges", - describeContents<WalletExchangeEntry>({ - keyPath: "baseUrl", - }), - {}, - ), - exchangeDetails: describeStore( - "exchangeDetails", - describeContents<WalletExchangeDetails>({ - keyPath: "rowId", - autoIncrement: true, - }), - { - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 2, - }), - byPointer: describeIndex( - "byDetailsPointer", - ["exchangeBaseUrl", "currency", "masterPublicKey"], - { - unique: true, - }, - ), - // Not unique: the same exchange can be known under two base URLs - // while a migration between them is still in progress. - byMasterPublicKey: describeIndex("byMasterPublicKey", "masterPublicKey", { - versionAdded: 30, - }), - }, - ), - exchangeSignKeys: describeStore( - "exchangeSignKeys", - describeContents<WalletExchangeSignkeys>({ - keyPath: ["exchangeDetailsRowId", "signkeyPub"], - }), - { - // Caution: By historical accident, the index is over an array. - byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [ - "exchangeDetailsRowId", - ]), - }, - ), - contacts: describeStoreV2({ - recordCodec: passthroughCodec<ContactRecord>(), - storeName: "contacts", - keyPath: ["alias", "aliasType"], - indexes: {}, - versionAdded: 24, - }), - mailboxMessages: describeStoreV2({ - recordCodec: passthroughCodec<MailboxMessageRecord>(), - storeName: "mailboxMessages", - keyPath: ["originMailboxBaseUrl", "talerUri"], - indexes: {}, - versionAdded: 24, - }), - mailboxConfigurations: describeStoreV2({ - recordCodec: passthroughCodec<MailboxConfiguration>(), - storeName: "mailboxConfigurations", - keyPath: "mailboxBaseUrl", - indexes: {}, - versionAdded: 24, - }), - refreshGroups: describeStore( - "refreshGroups", - describeContents<WalletRefreshGroup>({ - keyPath: "refreshGroupId", - }), - { - byStatus: describeIndex("byStatus", "operationStatus"), - byOriginatingTransactionId: describeIndex( - "byOriginatingTransactionId", - "originatingTransactionId", - { - versionAdded: 5, - }, - ), - }, - ), - refreshSessions: describeStore( - "refreshSessions", - describeContents<WalletRefreshSession>({ - keyPath: ["refreshGroupId", "coinIndex"], - }), - { - byRefreshGroupId: describeIndex("byRefreshGroupId", "refreshGroupId", { - versionAdded: 15, - }), - }, - ), - recoupGroups: describeStore( - "recoupGroups", - describeContents<WalletRecoupGroup>({ - keyPath: "recoupGroupId", - }), - { - byStatus: describeIndex("byStatus", "operationStatus", { - versionAdded: 6, - }), - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 15, - }), - }, - ), - purchases: describeStore( - "purchases", - describeContents<WalletPurchase>({ keyPath: "proposalId" }), - { - byStatus: describeIndex("byStatus", "purchaseStatus"), - byFulfillmentUrl: describeIndex( - "byFulfillmentUrl", - "download.fulfillmentUrl", - ), - byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ - "merchantBaseUrl", - "orderId", - ]), - byExchange: describeIndex("byExchange", "exchanges", { - versionAdded: 15, - multiEntry: true, - }), - }, - ), - donationPlanchets: describeStoreV2({ - recordCodec: passthroughCodec<WalletDonationPlanchet>(), - storeName: "donationPlanchets", - keyPath: "udiNonce", - versionAdded: 20, - indexes: { - byProposalId: describeIndex("byProposalId", "proposalId", { - versionAdded: 20, - }), - }, - }), - donationReceipts: describeStoreV2({ - recordCodec: passthroughCodec<WalletDonationReceipt>(), - storeName: "donationReceipts", - keyPath: "udiNonce", - versionAdded: 20, - indexes: { - byStatus: describeIndex("byStatus", "status", { - versionAdded: 20, - }), - byDonauBaseUrl: describeIndex("byDonauBaseUrl", "donauBaseUrl", { - versionAdded: 23, - }), - byStatusAndDonauBaseUrl: describeIndex( - "byStatusAndDonauBaseUrl", - ["status", "donauBaseUrl"], - { - versionAdded: 23, - }, - ), - }, - }), - donationSummaries: describeStoreV2({ - recordCodec: passthroughCodec<WalletDonationSummary>(), - storeName: "donationSummaries", - keyPath: ["donauBaseUrl", "year", "currency"], - versionAdded: 22, - indexes: {}, - }), - withdrawalGroups: describeStore( - "withdrawalGroups", - describeContents<WalletWithdrawalGroup>({ - keyPath: "withdrawalGroupId", - }), - { - byStatus: describeIndex("byStatus", "status"), - byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { - versionAdded: 2, - }), - byTalerWithdrawUri: describeIndex( - "byTalerWithdrawUri", - "wgInfo.bankInfo.talerWithdrawUri", - ), - }, - ), - planchets: describeStore( - "planchets", - describeContents<WalletPlanchet>({ keyPath: "coinPub" }), - { - byGroupAndIndex: describeIndex( - "byGroupAndIndex", - ["withdrawalGroupId", "coinIdx"], - { - unique: true, - }, - ), - byGroup: describeIndex("byGroup", "withdrawalGroupId"), - byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"), - }, - ), - bankWithdrawUris: describeStore( - "bankWithdrawUris", - describeContents<BankWithdrawUriRecord>({ - keyPath: "talerWithdrawUri", - }), - { - byGroup: describeIndex("byGroup", "withdrawalGroupId"), - }, - ), - depositGroups: describeStore( - "depositGroups", - describeContents<WalletDepositGroup>({ - keyPath: "depositGroupId", - }), - { - byStatus: describeIndex("byStatus", "operationStatus"), - }, - ), - tombstones: describeStore( - "tombstones", - describeContents<WalletTombstone>({ keyPath: "id" }), - {}, - ), - operationRetries: describeStore( - "operationRetries", - describeContents<WalletOperationRetry>({ - keyPath: "id", - }), - {}, - ), - peerPushCredit: describeStore( - "peerPushCredit", - describeContents<PeerPushCreditRecord>({ - keyPath: "peerPushCreditId", - }), - { - byExchangeAndPurse: describeIndex("byExchangeAndPurse", [ - "exchangeBaseUrl", - "pursePub", - ]), - byExchangeAndContractPriv: describeIndex( - "byExchangeAndContractPriv", - ["exchangeBaseUrl", "contractPriv"], - { - unique: true, - }, - ), - byWithdrawalGroupId: describeIndex( - "byWithdrawalGroupId", - "withdrawalGroupId", - {}, - ), - byStatus: describeIndex("byStatus", "status"), - }, - ), - peerPullDebit: describeStore( - "peerPullDebit", - describeContents<PeerPullPaymentIncomingRecord>({ - keyPath: "peerPullDebitId", - }), - { - byExchangeAndPurse: describeIndex("byExchangeAndPurse", [ - "exchangeBaseUrl", - "pursePub", - ]), - byExchangeAndContractPriv: describeIndex( - "byExchangeAndContractPriv", - ["exchangeBaseUrl", "contractPriv"], - { - unique: true, - }, - ), - byStatus: describeIndex("byStatus", "status"), - }, - ), - peerPullCredit: describeStore( - "peerPullCredit", - describeContents<PeerPullCreditRecord>({ - keyPath: "pursePub", - }), - { - byStatus: describeIndex("byStatus", "status"), - byWithdrawalGroupId: describeIndex( - "byWithdrawalGroupId", - "withdrawalGroupId", - {}, - ), - }, - ), - peerPushDebit: describeStore( - "peerPushDebit", - describeContents<PeerPushDebitRecord>({ - keyPath: "pursePub", - }), - { - byStatus: describeIndex("byStatus", "status"), - }, - ), - bankAccountsV2: describeStore( - "bankAccountsV2", - describeContents<WalletBankAccount>({ - keyPath: "bankAccountId", - versionAdded: 14, - }), - { - byPaytoUri: describeIndex("byPaytoUri", "paytoUri", { - versionAdded: 14, - }), - }, - ), - contractTerms: describeStore( - "contractTerms", - describeContents<WalletContractTerms>({ - keyPath: "h", - }), - {}, - ), - refundGroups: describeStore( - "refundGroups", - describeContents<WalletRefundGroup>({ - keyPath: "refundGroupId", - }), - { - byProposalId: describeIndex("byProposalId", "proposalId"), - byStatus: describeIndex("byStatus", "status", {}), - }, - ), - refundItems: describeStore( - "refundItems", - describeContents<WalletRefundItem>({ - keyPath: "id", - autoIncrement: true, - }), - { - byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [ - "coinPub", - "rtxid", - ]), - // FIXME: Why is this a list of index keys? Confusing! - byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]), - }, - ), - fixups: describeStore( - "fixups", - describeContents<FixupRecord>({ - keyPath: "fixupName", - }), - {}, - ), - // - // Obsolete stores, not used anymore - // - obsolete_backupProviders: describeStore( - "backupProviders", - describeContents<unknown>({ - keyPath: "baseUrl", - }), - { - byPaymentProposalId: describeIndex( - "byPaymentProposalId", - "paymentProposalIds", - { - multiEntry: true, - }, - ), - }, - ), - _obsolete_transactions: describeStoreV2({ - recordCodec: passthroughCodec<unknown>(), - storeName: "transactions", - keyPath: "transactionItem.transactionId", - versionAdded: 7, - indexes: { - byCurrency: describeIndex("byCurrency", "currency", { - versionAdded: 7, - }), - byExchange: describeIndex("byExchange", "exchanges", { - versionAdded: 7, - multiEntry: true, - }), - }, - }), - _obsolete_bankAccounts: describeStore( - "bankAccounts", - describeContents<any>({ - keyPath: "uri", - }), - {}, - ), - _obsolete_rewards: describeStore( - "rewards", - describeContents<any>({ keyPath: "walletRewardId" }), - { - byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ - "merchantRewardId", - "merchantBaseUrl", - ]), - byStatus: describeIndex("byStatus", "status", { - versionAdded: 8, - }), - }, - ), - obsolete_userAttention: describeStore( - "userAttention", - describeContents<unknown>({ - keyPath: ["entityId", "info.type"], - }), - {}, - ), -}; - -export type WalletIndexedDbTransaction = DbReadWriteTransaction< - typeof WalletIndexedDbStoresV1, - Array<StoreNames<typeof WalletIndexedDbStoresV1>> ->; - -/** - * An applied migration. - */ -export interface FixupRecord { - fixupName: string; -} - -export interface MetaConfigRecord { - key: string; - value: any; -} - -export const walletMetadataStore = { - metaConfig: describeStore( - "metaConfig", - describeContents<MetaConfigRecord>({ keyPath: "key" }), - {}, - ), -}; - -export interface DbDumpRecord { - /** - * Key, serialized with structuredEncapsulated. - * - * Only present for out-of-line keys (i.e. no key path). - */ - key?: any; - /** - * Value, serialized with structuredEncapsulated. - */ - value: any; -} - -export interface DbIndexDump { - keyPath: string | string[]; - multiEntry: boolean; - unique: boolean; -} - -export interface DbStoreDump { - keyPath?: string | string[]; - autoIncrement: boolean; - indexes: { [indexName: string]: DbIndexDump }; - records: DbDumpRecord[]; -} - -export interface DbDumpDatabase { - version: number; - stores: { [storeName: string]: DbStoreDump }; -} - -export interface DbDump { - databases: { - [name: string]: DbDumpDatabase; - }; -} - -const logger = new Logger("db.ts"); - -export async function exportSingleDb( - idb: IDBFactory, - dbName: string, -): Promise<DbDumpDatabase> { - const myDb = await openDatabase( - idb, - dbName, - undefined, - () => { - logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`); - }, - () => { - logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`); - }, - ); - - const singleDbDump: DbDumpDatabase = { - version: myDb.version, - stores: {}, - }; - - return new Promise((resolve, reject) => { - let settled = false; - const fail = (error: unknown): void => { - if (settled) return; - settled = true; - myDb.close(); - reject(error); - }; - const tx = myDb.transaction(Array.from(myDb.objectStoreNames)); - tx.addEventListener("complete", () => { - if (settled) return; - settled = true; - myDb.close(); - resolve(singleDbDump); - }); - tx.addEventListener("abort", () => - fail(tx.error ?? Error(`export of ${dbName} was aborted`)), - ); - tx.addEventListener("error", () => - fail(tx.error ?? Error(`export of ${dbName} failed`)), - ); - try { - // tslint:disable-next-line:prefer-for-of - for (let i = 0; i < myDb.objectStoreNames.length; i++) { - const name = myDb.objectStoreNames[i]; - const store = tx.objectStore(name); - const storeDump: DbStoreDump = { - autoIncrement: store.autoIncrement, - keyPath: store.keyPath, - indexes: {}, - records: [], - }; - const indexNames = store.indexNames; - for (let j = 0; j < indexNames.length; j++) { - const idxName = indexNames[j]; - const index = store.index(idxName); - storeDump.indexes[idxName] = { - keyPath: index.keyPath, - multiEntry: index.multiEntry, - unique: index.unique, - }; - } - singleDbDump.stores[name] = storeDump; - store.openCursor().addEventListener("success", (e: Event) => { - const cursor = (e.target as any).result; - if (cursor) { - const rec: DbDumpRecord = { - value: structuredEncapsulate(cursor.value), - }; - // Only store key if necessary, i.e. when - // the key is not stored as part of the object via - // a key path. - if (store.keyPath == null) { - rec.key = structuredEncapsulate(cursor.key); - } - storeDump.records.push(rec); - cursor.continue(); - } - }); - } - } catch (e) { - fail(e); - } - }); -} - -export async function exportDb(idb: IDBFactory): Promise<DbDump> { - const dbDump: DbDump = { - databases: {}, - }; - - const currentMainDbName = await readCurrentMainDbName(idb); - - dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb( - idb, - TALER_WALLET_META_DB_NAME, - ); - // A dump is portable, so expose the active generation under the canonical - // logical name. The generation name is local crash-recovery bookkeeping - // and must not become part of the backup format. - dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb( - idb, - currentMainDbName, - ); - - return dbDump; -} - -async function recoverFromDump( - db: IDBDatabase, - dbDump: DbDumpDatabase, -): Promise<void> { - const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); - const txProm = promiseFromTransaction(tx); - const storeNames = db.objectStoreNames; - for (let i = 0; i < storeNames.length; i++) { - const name = db.objectStoreNames[i]; - const storeDump = dbDump.stores[name]; - await promiseFromRequest(tx.objectStore(name).clear()); - if (!storeDump) continue; - logger.info(`importing ${storeDump.records.length} records into ${name}`); - for (let rec of storeDump.records) { - await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key)); - logger.trace("importing record done"); - } - } - tx.commit(); - return await txProm; -} - -function checkDbDump(x: any): x is DbDump { - return "databases" in x; -} - -export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> { - const d = structuredRevive(dumpJson); - if (checkDbDump(d)) { - const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME]; - if (!walletDb) { - throw Error( - `unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`, - ); - } - await recoverFromDump(db, walletDb); - } else { - throw Error("unable to import, doesn't look like a valid DB dump"); - } -} - -export interface FixupDescription { - name: string; - fn(tx: WalletIndexedDbTransaction): Promise<void>; -} - -/** - * Manual migrations between minor versions of the DB schema. - * - * Fixups *must* be idempotent. - */ -export const walletDbFixups: FixupDescription[] = [ - // A later repair for duplicate merge-reserve rows whose key material is - // identical but where only one row carries KYC metadata. This needs its - // own fixup marker: affected databases have already recorded the older, - // byte-identical-only repair below as complete. - { - fn: fixup20260820DuplicateReserveMetadata, - name: "fixup20260820DuplicateReserveMetadata", - }, - // Clause-Schnorr support made this field explicit. Older RSA records imply - // the RSA defaults and remain valid after the field was introduced. - { - fn: fixup20260812ExchangeWithdrawValues, - name: "fixup20260812ExchangeWithdrawValues", - }, - // Deduplicate reserve rows left behind by a version that inserted its - // merge reserve repeatedly. Needed for as long as pre-2024 databases can - // still be imported. - { - fn: fixup20260720DuplicateReserves, - name: "fixup20260720DuplicateReserves", - }, - // Exchange details rows from before tinyAmount existed. - { - fn: fixup20260720ExchangeDetailsTinyAmount, - name: "fixup20260720ExchangeDetailsTinyAmount", - }, - // Refresh groups from before refundRequests existed. - { - fn: fixup20260720RefreshGroupRefundRequests, - name: "fixup20260720RefreshGroupRefundRequests", - }, - // Removing this would cause old transactions - // to show up under multiple exchanges - { - fn: fixup20260718TransactionsScope, - name: "fixup20260718TransactionsScope", - }, - // Removing this would cause merchant acceptable - // amount to be calculaed based on exchangeBaseUrl - // instead of masterPublicKey for old coins. - { - fn: fixupCoinAvailabilityExchangePub, - name: "fixupCoinAvailabilityExchangePub", - }, - // Can be removed once all affected refresh groups have - // been fixed. Conservative estimate: Jan 2028. - { - fn: fixup20260116BadRefreshCoinSelection, - name: "fixup20260116BadRefreshCoinSelection", - }, - // Denom families were introduced. - // This migration creates denom families - // for existing denomination records. - { - fn: fixup20260203DenomFamilyMigration, - name: "fixup20260203DenomFamilyMigration", - }, - // Fix a problem where refreshes went into a failed state - // instead of retrying. - { - fn: fixup20260213RefreshBlunder, - name: "fixup20260213RefreshBlunder", - }, - // Several status enum members were persisted with a dropped hex - // digit, putting them outside their status range. Rewrite the raw records - // to the corrected values (transactionsMeta is rebuilt separately via the - // MATERIALIZED_TRANSACTIONS_VERSION bump). - { - fn: fixup20260718StatusEnumDigits, - name: "fixup20260718StatusEnumDigits", - }, - // Denominations move to a store keyed by the master public key that signed - // them. Runs after the family migration, which assigns the family serial - // the copied rows carry. - { - fn: fixup20260807DenominationsByMasterPub, - name: "fixup20260807DenominationsByMasterPub", - }, - // Coin availability moves to the same key as the denominations it counts. - { - fn: fixup20260807CoinAvailabilityByMasterPub, - name: "fixup20260807CoinAvailabilityByMasterPub", - }, - // Coins record the key that signed their denomination, so that they are - // tied to the keys that can settle them rather than to the URL the - // exchange happens to answer on. - { - fn: fixup20260807CoinExchangeMasterPub, - name: "fixup20260807CoinExchangeMasterPub", - }, -]; - -/** - * Copy coin availability into the store keyed by master public key. - * - * The key comes from the row itself where it was recorded, and otherwise from - * the denomination it counts. A row that resolves to neither is left behind - * rather than filed under a guess: it would misreport what is spendable. - */ -async function fixup20260807CoinAvailabilityByMasterPub( - tx: WalletIndexedDbTransaction, -): Promise<void> { - const batchSize = 500; - let range: IDBKeyRange | undefined = undefined; - while (1) { - const batch = await tx.coinAvailability.getAll(range, batchSize); - if (batch.length === 0) { - break; - } - const last = batch[batch.length - 1]; - range = GlobalIDB.KeyRange.lowerBound( - [last.exchangeBaseUrl, last.denomPubHash, last.maxAge], - true, - ); - for (const av of batch) { - let masterPub: string | undefined = av.exchangeMasterPub; - if (!masterPub) { - const denom = await tx.denominations.get([ - av.exchangeBaseUrl, - av.denomPubHash, - ]); - masterPub = denom?.exchangeMasterPub; - } - if (!masterPub) { - logger.warn( - `coin availability for ${av.denomPubHash} has no master public key, not copying`, - ); - continue; - } - const existing = await tx.coinAvailabilityV2.get([ - masterPub, - av.denomPubHash, - av.maxAge, - ]); - if (existing) { - continue; - } - await tx.coinAvailabilityV2.put({ - ...av, - exchangeMasterPub: masterPub, - hasFreshCoins: av.freshCoinCount > 0 ? 1 : 0, - }); - } - } -} - -/** - * Copy denominations into the store keyed by master public key. - * - * The old store is left populated: it is the only source for this copy, so - * clearing it would make the fixup unrepeatable, and a fixup can abort and be - * retried on the next open. Two base URLs that served the same key set - * collapse onto one row here, which is the point -- they were never two - * denominations. - */ -async function fixup20260807DenominationsByMasterPub( - tx: WalletIndexedDbTransaction, -): Promise<void> { - const batchSize = 500; - let range: IDBKeyRange | undefined = undefined; - while (1) { - const batch = await tx.denominations.getAll(range, batchSize); - if (batch.length === 0) { - break; - } - const last = batch[batch.length - 1]; - range = GlobalIDB.KeyRange.lowerBound( - [last.exchangeBaseUrl, last.denomPubHash], - true, - ); - for (const denom of batch) { - if (!denom.exchangeMasterPub) { - logger.warn( - `denomination ${denom.denomPubHash} has no master public key, not copying`, - ); - continue; - } - const existing = await tx.denominationsV2.get([ - denom.exchangeMasterPub, - denom.denomPubHash, - ]); - if (existing) { - continue; - } - await tx.denominationsV2.put(denom); - } - } -} - -/** - * Backfill {@link WalletCoin.exchangeMasterPub} from the coin's denomination. - * - * The denomination has carried the master public key all along, so nothing - * has to be guessed. A coin whose denomination is gone is left alone rather - * than deleted: a fixup must never destroy coins, and an empty key reads as - * "not known" everywhere it is used. - */ -async function fixup20260807CoinExchangeMasterPub( - tx: WalletIndexedDbTransaction, -): Promise<void> { - const batchSize = 500; - let range: IDBKeyRange | undefined = undefined; - while (1) { - const batch = await tx.coins.getAll(range, batchSize); - if (batch.length === 0) { - break; - } - const last = batch[batch.length - 1]; - range = GlobalIDB.KeyRange.lowerBound(last.coinPub, true); - for (const coin of batch) { - if (coin.exchangeMasterPub) { - continue; - } - const denom = await tx.denominations.get([ - coin.exchangeBaseUrl, - coin.denomPubHash, - ]); - if (!denom) { - logger.warn( - `coin ${coin.coinPub} has no denomination, leaving its master public key unset`, - ); - continue; - } - coin.exchangeMasterPub = denom.exchangeMasterPub; - await tx.coins.put(coin); - } - } -} - -async function fixup20260718StatusEnumDigits( - tx: WalletIndexedDbTransaction, -): Promise<void> { - // These OLD (mis-typed, 7-hex-digit) values are what was actually persisted - // before, so we match on the raw numbers on purpose: the - // enum members now resolve to the CORRECTED values and would not match old - // records. - const WG_FIX = new Map<number, number>([ - [0x0110005, 0x0110_0005], // WithdrawalGroupStatus.SuspendedKyc - [0x0110006, 0x0110_0006], // WithdrawalGroupStatus.SuspendedBalanceKyc - [0x0110007, 0x0110_0007], // WithdrawalGroupStatus.SuspendedBalanceKycInit - ]); - await tx.withdrawalGroups.iter().forEachAsync(async (rec) => { - const nv = WG_FIX.get(rec.status); - if (nv !== undefined) { - rec.status = nv as WithdrawalGroupStatus; - await tx.withdrawalGroups.put(rec); - } - }); - - // PlanchetStatus.WithdrawalDone - await tx.planchets.iter().forEachAsync(async (rec) => { - if ((rec.planchetStatus as number) === 0x0500000) { - rec.planchetStatus = 0x0500_0000 as PlanchetStatus; - await tx.planchets.put(rec); - } - }); - - // RefreshOperationStatus.{Finished,Failed} and RefreshCoinStatus.Failed - const RO_FIX = new Map<number, number>([ - [0x0500000, 0x0500_0000], // Finished - [0x0501000, 0x0501_0000], // Failed - ]); - await tx.refreshGroups.iter().forEachAsync(async (rec) => { - let changed = false; - const nv = RO_FIX.get(rec.operationStatus); - if (nv !== undefined) { - rec.operationStatus = nv as RefreshOperationStatus; - changed = true; - } - for (let i = 0; i < rec.statusPerCoin.length; i++) { - if ((rec.statusPerCoin[i] as number) === 0x0501000) { - rec.statusPerCoin[i] = 0x0501_0000 as RefreshCoinStatus; - changed = true; - } - } - if (changed) { - await tx.refreshGroups.put(rec); - } - }); - - // RecoupOperationStatus.{Finished,Failed} - const RC_FIX = new Map<number, number>([ - [0x0500000, 0x0500_0000], // Finished - [0x0501000, 0x0501_0000], // Failed - ]); - await tx.recoupGroups.iter().forEachAsync(async (rec) => { - const nv = RC_FIX.get(rec.operationStatus); - if (nv !== undefined) { - rec.operationStatus = nv as RecoupOperationStatus; - await tx.recoupGroups.put(rec); - } - }); -} - -async function fixup20260213RefreshBlunder( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.refreshGroups.indexes.byStatus - .iter(RefreshOperationStatus.Failed) - .forEachAsync(async (refreshGroup) => { - for ( - let coinIndex = 0; - coinIndex < refreshGroup.statusPerCoin.length; - coinIndex++ - ) { - let changed = false; - if ( - refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Failed - ) { - const rs = await tx.refreshSessions.get([ - refreshGroup.refreshGroupId, - coinIndex, - ]); - if ( - rs?.lastError?.code === - TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED - ) { - refreshGroup.statusPerCoin[coinIndex] = - RefreshCoinStatus.PendingRedenominate; - refreshGroup.operationStatus = - RefreshOperationStatus.PendingRedenominate; - delete refreshGroup.timestampFinished; - changed = true; - } - } - if (changed) { - await tx.refreshGroups.put(refreshGroup); - } - } - }); -} - -async function fixup20260203DenomFamilyMigration( - tx: WalletIndexedDbTransaction, -): Promise<void> { - const batchSize = 500; - - let range: IDBKeyRange | undefined = undefined; - - while (1) { - const batch = await tx.denominations.getAll(range, batchSize); - - if (batch.length === 0) { - break; - } - - logger.info(`fixing up batch of ${batch.length} denominations`); - - const last = batch[batch.length - 1]; - range = GlobalIDB.KeyRange.lowerBound( - [last.exchangeBaseUrl, last.denomPubHash], - true, - ); - - for (const r of batch) { - const fp: WalletDenomFamilyParams = { - exchangeBaseUrl: r.exchangeBaseUrl, - exchangeMasterPub: r.exchangeMasterPub, - feeDeposit: r.fees.feeDeposit, - feeRefresh: r.fees.feeRefresh, - feeRefund: r.fees.feeRefund, - feeWithdraw: r.fees.feeWithdraw, - value: r.value, - }; - if (r.denominationFamilySerial != null) { - // Fast path: Check if family exists and is correct. - const oldFpRec = await tx.denominationFamilies.get( - r.denominationFamilySerial, - ); - if ( - oldFpRec && - canonicalJson(fp) == canonicalJson(oldFpRec.familyParams) - ) { - continue; - } - } - const familyParamsIndexKey = [ - fp.exchangeBaseUrl, - fp.exchangeMasterPub, - fp.value, - fp.feeWithdraw, - fp.feeDeposit, - fp.feeRefresh, - fp.feeRefund, - ]; - const dfRec = - await tx.denominationFamilies.indexes.byFamilyParms.get( - familyParamsIndexKey, - ); - let denominationFamilySerial; - if (dfRec) { - denominationFamilySerial = dfRec.denominationFamilySerial; - } else { - const insRes = await tx.denominationFamilies.put({ - familyParams: fp, - }); - denominationFamilySerial = insRes.key; - } - checkDbInvariant( - typeof denominationFamilySerial == "number", - "denominationFamilySerial", - ); - r.denominationFamilySerial = denominationFamilySerial; - await tx.denominations.put(r); - } - } -} - -async function fixup20260116BadRefreshCoinSelection( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.refreshGroups.iter().forEachAsync(async (rec) => { - // Only repair groups that are still in flight. "Input non-zero, output - // zero" also describes a refresh that legitimately finished with its - // whole input eaten by fees -- a dust refresh of TESTKUDOS:0.01 into - // nothing looks identical to the bad coin selection this repairs. - // Re-activating one of those undoes a completed operation, which showed - // up as a finished refresh transaction reverting to pending after an - // import. - if ( - rec.operationStatus < OPERATION_STATUS_NONFINAL_FIRST || - rec.operationStatus > OPERATION_STATUS_NONFINAL_LAST - ) { - return; - } - const inputAmount = Amounts.sumOrZero( - rec.currency, - rec.inputPerCoin, - ).amount; - const outputAmount = Amounts.sumOrZero( - rec.currency, - rec.expectedOutputPerCoin, - ).amount; - if (Amounts.isNonZero(inputAmount) && Amounts.isZero(outputAmount)) { - logger.info( - `fixing up refresh group ${rec.refreshGroupId}, setting status to PendingRedenominate`, - ); - rec.operationStatus = RefreshOperationStatus.PendingRedenominate; - delete rec.timestampFinished; - await tx.refreshGroups.put(rec); - } - }); -} - -/** - * Some old payment transactions didn't correctly - * set the involved exchanges. - * - * This fixup sets the exchanges of a payment transaction - * based on the coin selection. - */ -async function fixup20260718TransactionsScope( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.purchases.iter().forEachAsync(async (rec) => { - if ( - (rec.exchanges?.length ?? 0) == 0 && - rec.payInfo?.payCoinSelection != null - ) { - const pcs = rec.payInfo.payCoinSelection.coinPubs; - const exchSet: Set<string> = new Set(); - for (const pc of pcs) { - const coin = await tx.coins.get(pc); - if (!coin) { - continue; - } - exchSet.add(coin.exchangeBaseUrl); - } - rec.exchanges = [...exchSet]; - rec.exchanges.sort(); - if (rec.exchanges.length == 0) { - // For old SPURLOS transactions, set exchange manually - // when we can't infer it. - if ( - rec.timestamp <= 1736942400000_000 && - rec.download?.currency === "SPURLOS" - ) { - rec.exchanges = ["https://exchange.taler.datenspuren.de/"]; - } - logger.warn( - `unable to fix up pay transaction ${rec.proposalId}, could not reconstruct exchanges`, - ); - } - await tx.purchases.put(rec); - } - }); -} - -async function fixupCoinAvailabilityExchangePub( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.coinAvailability.iter().forEachAsync(async (car) => { - if (car.exchangeMasterPub === undefined) { - const exchange = await tx.exchangeDetails.indexes.byExchangeBaseUrl.get( - car.exchangeBaseUrl, - ); - if (exchange !== undefined) { - car.exchangeMasterPub = exchange.masterPublicKey; - await tx.coinAvailability.put(car); - } - } - }); -} - -/** - * Backfill tinyAmount on exchange details rows from before the field existed. - * - * Uses the same default the keys update applies when an exchange reports no - * tiny_amount, so a backfilled row equals what the next update would have - * written anyway. Without this, deposits read undefined where the type - * promises an AmountString. - */ -async function fixup20260720ExchangeDetailsTinyAmount( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.exchangeDetails.iter().forEachAsync(async (det) => { - if ((det as any).tinyAmount === undefined) { - det.tinyAmount = `${det.currency}:0.01` as AmountString; - await tx.exchangeDetails.put(det); - } - }); -} - -/** - * Backfill refundRequests on refresh groups from before the field existed. - * - * The refresh task reads refundRequests[coinIndex] unguarded, so a group - * written before the field existed throws when the task touches it. An - * empty map is the correct backfill: those groups have no pending refund - * requests, or they would have been recorded. - */ -async function fixup20260720RefreshGroupRefundRequests( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.refreshGroups.iter().forEachAsync(async (rg) => { - let changed = false; - if ((rg as any).refundRequests === undefined) { - rg.refundRequests = {}; - changed = true; - } - // originatingTransactionId used to be nested in a reasonDetails object; - // the modern record carries it top-level and reads it there. - const legacyDetails = (rg as any).reasonDetails; - if ( - rg.originatingTransactionId === undefined && - legacyDetails?.originatingTransactionId !== undefined - ) { - rg.originatingTransactionId = legacyDetails.originatingTransactionId; - changed = true; - } - if (legacyDetails !== undefined) { - delete (rg as any).reasonDetails; - changed = true; - } - if (changed) { - await tx.refreshGroups.put(rg); - } - }); -} - -function canonicalFixupValue(value: any): any { - if (Array.isArray(value)) return value.map(canonicalFixupValue); - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, canonicalFixupValue(value[key])]), - ); - } - return value; -} - -async function remapAndDeleteReserve( - tx: WalletIndexedDbTransaction, - droppedRowId: number, - retainedRowId: number, -): Promise<void> { - await tx.exchanges.iter().forEachAsync(async (e) => { - if (e.currentMergeReserveRowId === droppedRowId) { - e.currentMergeReserveRowId = retainedRowId; - await tx.exchanges.put(e); - } - }); - await tx.peerPullCredit.iter().forEachAsync(async (p) => { - if (p.mergeReserveRowId === droppedRowId) { - p.mergeReserveRowId = retainedRowId; - await tx.peerPullCredit.put(p); - } - }); - await tx.reserves.delete(droppedRowId); -} - -/** - * Remove byte-identical duplicate reserve rows. - * - * An older wallet version inserted its merge reserve again on each update - * instead of upserting, leaving several rows with identical key material - * under different row ids. Databases like that exist in the wild. The - * lowest row id is kept and the two references to reserve rows (the - * exchange's current merge reserve and peer-pull-credit merge reserves) are - * remapped onto it. - * - * Rows are removed only when every field except rowId matches the kept row. - * Any disagreement is corruption or ambiguity, which deleting would paper - * over, so those rows are left for conversion to reject. - */ -async function fixup20260720DuplicateReserves( - tx: WalletIndexedDbTransaction, -): Promise<void> { - let kept: WalletReserve | undefined; - const withoutRowId = (r: WalletReserve): string => { - const { rowId: _rowId, ...rest } = r; - return JSON.stringify(canonicalFixupValue(rest)); - }; - // The index groups equal public keys, so only the retained row for the - // current key is kept in memory. References are remapped immediately, - // avoiding a map proportional to the reserve store. - await tx.reserves.indexes.byReservePub.iter().forEachAsync(async (r) => { - if (r.rowId == null) return; - if (!kept || kept.reservePub !== r.reservePub) { - kept = r; - return; - } - if (kept.rowId == null || withoutRowId(kept) !== withoutRowId(r)) { - return; - } - const droppedRowId = r.rowId; - const keptRowId = kept.rowId; - await remapAndDeleteReserve(tx, droppedRowId, keptRowId); - }); -} - -/** - * Collapse the metadata-free reserve duplicate created by peer-credit - * withdrawals before 528a32fff. - * - * Equal public and private keys identify the same reserve. It is safe to - * discard one row when all of its defined metadata is also present and equal - * in the other row: the surviving row then loses no information. Different - * defined values remain untouched, so the converter still refuses an - * ambiguous database for which there is no revision information to select a - * winner. - */ -async function fixup20260820DuplicateReserveMetadata( - tx: WalletIndexedDbTransaction, -): Promise<void> { - const metadataIsSubset = ( - subset: WalletReserve, - superset: WalletReserve, - ): boolean => { - for (const [key, value] of Object.entries(subset)) { - if ( - key === "rowId" || - key === "reservePub" || - key === "reservePriv" || - value === undefined - ) { - continue; - } - if ( - JSON.stringify(canonicalFixupValue(value)) !== - JSON.stringify( - canonicalFixupValue( - (superset as unknown as Record<string, unknown>)[key], - ), - ) - ) { - return false; - } - } - return true; - }; - - let retained: WalletReserve | undefined; - await tx.reserves.indexes.byReservePub.iter().forEachAsync(async (row) => { - if (row.rowId == null) return; - if (!retained || retained.reservePub !== row.reservePub) { - retained = row; - return; - } - if (retained.rowId == null || retained.reservePriv !== row.reservePriv) { - return; - } - const retainedIsSubset = metadataIsSubset(retained, row); - const rowIsSubset = metadataIsSubset(row, retained); - if (!retainedIsSubset && !rowIsSubset) { - return; - } - if (retainedIsSubset && !rowIsSubset) { - await remapAndDeleteReserve(tx, retained.rowId, row.rowId); - retained = row; - } else { - await remapAndDeleteReserve(tx, row.rowId, retained.rowId); - } - }); -} - -async function fixup20260812ExchangeWithdrawValues( - tx: WalletIndexedDbTransaction, -): Promise<void> { - await tx.coins.iter().forEachAsync(async (coin) => { - if (coin.exchangeWithdrawValues === undefined) { - coin.exchangeWithdrawValues = { cipher: "RSA" } as any; - await tx.coins.put(coin); - } - }); - await tx.planchets.iter().forEachAsync(async (planchet) => { - if (planchet.exchangeWithdrawValues === undefined) { - planchet.exchangeWithdrawValues = { cipher: "RSA" } as any; - await tx.planchets.put(planchet); - } - }); -} - -export async function applyFixups( - db: DbAccess<typeof WalletIndexedDbStoresV1>, - onProgress: (notification: WalletNotification) => void = () => {}, -): Promise<number> { - logger.trace("applying fixups"); - let count = 0; - for (let index = 0; index < walletDbFixups.length; index++) { - const fixupInstruction = walletDbFixups[index]; - let applied = false; - try { - await db.runAllStoresReadWriteTx({}, async (tx) => { - logger.trace(`checking fixup ${fixupInstruction.name}`); - const fixupRecord = await tx.fixups.get(fixupInstruction.name); - if (fixupRecord) { - return; - } - applied = true; - logger.trace(`applying DB fixup ${fixupInstruction.name}`); - onProgress({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-fixup", - phase: "fixup", - step: fixupInstruction.name, - completedSteps: index, - totalSteps: walletDbFixups.length, - }); - await fixupInstruction.fn(tx); - // A fixup may change any operation record from which transactionsMeta - // is derived. Invalidate the durable view version in the same commit - // as the repair, so a crash or failed rematerialization is retried on - // the next initialization instead of leaving a stale "current" flag. - await tx.config.delete(ConfigRecordKey.MaterializedTransactionsVersion); - await tx.fixups.put({ - fixupName: fixupInstruction.name, - }); - }); - } catch (e) { - if (applied) { - onProgress({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-fixup", - phase: "failed", - step: fixupInstruction.name, - completedSteps: index, - totalSteps: walletDbFixups.length, - error: getErrorDetailFromException(e), - }); - } - throw e; - } - if (applied) { - // Announce completion only after the transaction has committed. A - // commit error above produces "failed", never a misleading completed - // step followed by a rollback. - onProgress({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-fixup", - phase: "fixup", - step: fixupInstruction.name, - completedSteps: index + 1, - totalSteps: walletDbFixups.length, - }); - count++; - } - } - if (count > 0) { - onProgress({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-fixup", - phase: "complete", - completedSteps: walletDbFixups.length, - totalSteps: walletDbFixups.length, - }); - } - return count; -} - -/** - * Upgrade an IndexedDB in an upgrade transaction. - * - * The upgrade is made based on a store map, i.e. the metadata - * structure that describes all the object stores and indexes. - */ -function upgradeFromStoreMap( - storeMap: any, // FIXME: nail down type - db: IDBDatabase, - oldVersion: number, - newVersion: number, - upgradeTransaction: IDBTransaction, -): void { - if (oldVersion === 0) { - for (const n in storeMap) { - const swi: StoreWithIndexes< - any, - StoreDescriptor<unknown>, - any - > = storeMap[n]; - const storeDesc: StoreDescriptor<unknown> = swi.store; - const s = db.createObjectStore(swi.storeName, { - autoIncrement: storeDesc.autoIncrement, - keyPath: storeDesc.keyPath, - }); - for (const indexName in swi.indexMap as any) { - const indexDesc: IndexDescriptor = swi.indexMap[indexName]; - s.createIndex(indexDesc.name, indexDesc.keyPath, { - multiEntry: indexDesc.multiEntry, - unique: indexDesc.unique, - }); - } - } - return; - } - if (oldVersion === newVersion) { - return; - } - logger.info(`upgrading database from ${oldVersion} to ${newVersion}`); - for (const n in storeMap) { - const swi: StoreWithIndexes<any, StoreDescriptor<unknown>, any> = storeMap[ - n - ]; - const storeDesc: StoreDescriptor<unknown> = swi.store; - const storeAddedVersion = storeDesc.versionAdded ?? 0; - let s: IDBObjectStore; - if (storeAddedVersion > oldVersion) { - // Be tolerant if object store already exists. - // Probably means somebody deployed without - // adding the "addedInVersion" attribute. - if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) { - try { - s = db.createObjectStore(swi.storeName, { - autoIncrement: storeDesc.autoIncrement, - keyPath: storeDesc.keyPath, - }); - } catch (e) { - const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; - throw new Error( - `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`, - { cause: e }, - ); - } - } - } - - s = upgradeTransaction.objectStore(swi.storeName); - - for (const indexName in swi.indexMap as any) { - const indexDesc: IndexDescriptor = swi.indexMap[indexName]; - const indexAddedVersion = indexDesc.versionAdded ?? 0; - if (indexAddedVersion <= oldVersion) { - continue; - } - // Be tolerant if index already exists. - // Probably means somebody deployed without - // adding the "addedInVersion" attribute. - if (!s.indexNames.contains(indexDesc.name)) { - try { - s.createIndex(indexDesc.name, indexDesc.keyPath, { - multiEntry: indexDesc.multiEntry, - unique: indexDesc.unique, - }); - } catch (e) { - const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; - throw Error( - `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`, - { cause: e }, - ); - } - } - } - } -} - -function promiseFromTransaction(transaction: IDBTransaction): Promise<void> { - return new Promise<void>((resolve, reject) => { - transaction.oncomplete = () => { - resolve(); - }; - transaction.onerror = () => { - reject(); - }; - }); -} - -export function promiseFromRequest(request: IDBRequest): Promise<any> { - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve(request.result); - }; - request.onerror = () => { - reject(request.error); - }; - }); -} - -/** - * Purge all data in the given database. - */ -export function clearDatabase(db: IDBDatabase): Promise<void> { - // db.objectStoreNames is a DOMStringList, so we need to convert - let stores: string[] = []; - for (let i = 0; i < db.objectStoreNames.length; i++) { - stores.push(db.objectStoreNames[i]); - } - logger.info(`clearing object stores: ${j2s(stores)}`); - const tx = db.transaction(stores, "readwrite"); - for (const store of stores) { - tx.objectStore(store).clear(); - } - return promiseFromTransaction(tx); -} - -function onTalerDbUpgradeNeeded( - db: IDBDatabase, - oldVersion: number, - newVersion: number, - upgradeTransaction: IDBTransaction, -) { - upgradeFromStoreMap( - WalletIndexedDbStoresV1, - db, - oldVersion, - newVersion, - upgradeTransaction, - ); - if (oldVersion < 32) { - const store = upgradeTransaction.objectStore("coinAvailabilityV2"); - const req = store.openCursor(); - req.onsuccess = () => { - const cursor = req.result; - if (!cursor) { - return; - } - const value = cursor.value as WalletCoinAvailability; - cursor.update({ - ...value, - hasFreshCoins: value.freshCoinCount > 0 ? 1 : 0, - }); - cursor.continue(); - }; - } -} - -function onMetaDbUpgradeNeeded( - db: IDBDatabase, - oldVersion: number, - newVersion: number, - upgradeTransaction: IDBTransaction, -) { - upgradeFromStoreMap( - walletMetadataStore, - db, - oldVersion, - newVersion, - upgradeTransaction, - ); -} - -/** - * Return a promise that resolves - * to the taler wallet db. - * - * @param onVersionChange Called when another client concurrenctly connects to the database - * with a higher version. - */ -export async function openTalerDatabase( - idbFactory: IDBFactory, - onVersionChange: () => void, -): Promise<IDBDatabase> { - const state = await readMainDbState(idbFactory, true); - await cleanInterruptedDatabaseReplacement(idbFactory, state); - return await openTalerDatabaseGeneration( - idbFactory, - state.current, - onVersionChange, - ); -} - -interface MainDbState { - current: string; - pending?: string; - retired?: string; -} - -function isCurrentGenerationName(name: string): boolean { - return ( - name === TALER_WALLET_MAIN_DB_NAME || - name.startsWith(TALER_WALLET_DB_GENERATION_PREFIX) - ); -} - -async function openMetaDatabase(idbFactory: IDBFactory): Promise<{ - handle: IDBDatabase; - access: DbAccess<typeof walletMetadataStore>; -}> { - const handle = await openDatabase( - idbFactory, - TALER_WALLET_META_DB_NAME, - 1, - () => {}, - onMetaDbUpgradeNeeded, - ); - return { - handle, - access: new DbAccessImpl( - handle, - walletMetadataStore, - CancellationToken.CONTINUE, - ), - }; -} - -async function readMainDbState( - idbFactory: IDBFactory, - initialize: boolean, -): Promise<MainDbState> { - const meta = await openMetaDatabase(idbFactory); - try { - let state!: MainDbState; - await meta.access.runAllStoresReadWriteTx({}, async (tx) => { - const currentRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); - let current = currentRecord?.value as string | undefined; - if (!current) { - current = TALER_WALLET_MAIN_DB_NAME; - if (initialize) { - await tx.metaConfig.put({ - key: CURRENT_DB_CONFIG_KEY, - value: current, - }); - } - } else if (!isCurrentGenerationName(current)) { - switch (current) { - case "taler-wallet-main-v2": - case "taler-wallet-main-v3": - case "taler-wallet-main-v4": - case "taler-wallet-main-v5": - case "taler-wallet-main-v6": - case "taler-wallet-main-v7": - case "taler-wallet-main-v8": - case "taler-wallet-main-v9": - // These were pre-release databases and have no supported major - // migration. Preserve the historical behaviour of starting the - // current major afresh. - current = TALER_WALLET_MAIN_DB_NAME; - if (initialize) { - await tx.metaConfig.put({ - key: CURRENT_DB_CONFIG_KEY, - value: current, - }); - } - break; - default: - throw Error( - `major migration from database major=${current} not supported`, - ); - } - } - state = { - current, - pending: (await tx.metaConfig.get(PENDING_DB_CONFIG_KEY))?.value, - retired: (await tx.metaConfig.get(RETIRED_DB_CONFIG_KEY))?.value, - }; - }); - return state; - } finally { - meta.handle.close(); - } -} - -async function readCurrentMainDbName(idbFactory: IDBFactory): Promise<string> { - return (await readMainDbState(idbFactory, true)).current; -} - -async function openTalerDatabaseGeneration( - idbFactory: IDBFactory, - name: string, - onVersionChange: () => void, -): Promise<IDBDatabase> { - if (!isCurrentGenerationName(name)) { - throw Error(`invalid wallet database generation name ${name}`); - } - return await openDatabase( - idbFactory, - name, - WALLET_DB_MINOR_VERSION, - onVersionChange, - onTalerDbUpgradeNeeded, - ); -} - -/** Resolve true on deletion, false when another client still blocks it. */ -async function tryDeleteDatabase( - idbFactory: IDBFactory, - name: string, -): Promise<boolean> { - return await new Promise<boolean>((resolve, reject) => { - const req = idbFactory.deleteDatabase(name); - let settled = false; - const finish = (result: boolean): void => { - if (settled) return; - settled = true; - resolve(result); - }; - req.onerror = () => { - if (settled) return; - settled = true; - reject(req.error); - }; - req.onblocked = () => finish(false); - req.onsuccess = () => finish(true); - }); -} - -async function clearMetaMarker( - idbFactory: IDBFactory, - key: string, - expectedValue: string, -): Promise<void> { - const meta = await openMetaDatabase(idbFactory); - try { - await meta.access.runAllStoresReadWriteTx({}, async (tx) => { - const record = await tx.metaConfig.get(key); - if (record?.value === expectedValue) { - await tx.metaConfig.delete(key); - } - }); - } finally { - meta.handle.close(); - } -} - -async function cleanupGeneration( - idbFactory: IDBFactory, - name: string | undefined, - marker: string, - current: string, -): Promise<void> { - if (!name || name === current) return; - try { - if (await tryDeleteDatabase(idbFactory, name)) { - await clearMetaMarker(idbFactory, marker, name); - } - } catch (e) { - // Cleanup is not authoritative-state recovery. Retaining an unreachable - // generation costs storage, but refusing to open the current wallet would - // turn that harmless residue into an outage. - logger.warn(`could not clean wallet database generation ${name}: ${e}`); - } -} - -async function cleanInterruptedDatabaseReplacement( - idbFactory: IDBFactory, - state: MainDbState, -): Promise<void> { - // A pending generation was never published, so current is authoritative. - // Clear its claim but do not issue deleteDatabase here: another wallet - // context could still be preparing it, and a blocked deletion request would - // remain armed and could delete the generation after that context publishes - // and eventually closes it. Clearing the claim instead makes that - // publisher fail its compare-and-swap safely. Hard-crash residue is an - // unreachable storage leak, never an authoritative-state ambiguity. - if (state.pending && state.pending !== state.current) { - await clearMetaMarker(idbFactory, PENDING_DB_CONFIG_KEY, state.pending); - } - // A retired generation has already been superseded and can never become - // authoritative again, so deletion is safe even when another context still - // has it open (in which case cleanup remains recorded for the next start). - await cleanupGeneration( - idbFactory, - state.retired, - RETIRED_DB_CONFIG_KEY, - state.current, - ); -} - -export interface StagedTalerDatabase { - name: string; - handle: IDBDatabase; -} - -/** Create and durably record an unpublished current-schema generation. */ -export async function beginTalerDatabaseReplacement( - idbFactory: IDBFactory, - currentName: string, - onVersionChange: () => void, -): Promise<StagedTalerDatabase> { - const name = `${TALER_WALLET_DB_GENERATION_PREFIX}${encodeCrock( - getRandomBytes(16), - )}`; - const meta = await openMetaDatabase(idbFactory); - try { - await meta.access.runAllStoresReadWriteTx({}, async (tx) => { - const current = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); - if (current?.value !== currentName) { - throw Error("wallet database generation changed during import"); - } - const pending = await tx.metaConfig.get(PENDING_DB_CONFIG_KEY); - if (pending) { - throw Error( - `another wallet database import is already pending (${pending.value})`, - ); - } - await tx.metaConfig.put({ key: PENDING_DB_CONFIG_KEY, value: name }); - }); - } finally { - meta.handle.close(); - } - try { - return { - name, - handle: await openTalerDatabaseGeneration( - idbFactory, - name, - onVersionChange, - ), - }; - } catch (e) { - await abortTalerDatabaseReplacement(idbFactory, name); - throw e; - } -} - -/** Atomically make a fully prepared generation authoritative. */ -export async function publishTalerDatabaseReplacement( - idbFactory: IDBFactory, - oldName: string, - newName: string, -): Promise<void> { - const meta = await openMetaDatabase(idbFactory); - try { - await meta.access.runAllStoresReadWriteTx({}, async (tx) => { - const current = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); - const pending = await tx.metaConfig.get(PENDING_DB_CONFIG_KEY); - if (current?.value !== oldName || pending?.value !== newName) { - throw Error("wallet database generation changed during import"); - } - await tx.metaConfig.put({ key: CURRENT_DB_CONFIG_KEY, value: newName }); - await tx.metaConfig.delete(PENDING_DB_CONFIG_KEY); - await tx.metaConfig.put({ key: RETIRED_DB_CONFIG_KEY, value: oldName }); - }); - } finally { - meta.handle.close(); - } -} - -/** Delete an unpublished generation after a failed import. */ -export async function abortTalerDatabaseReplacement( - idbFactory: IDBFactory, - name: string, -): Promise<void> { - await cleanupGeneration(idbFactory, name, PENDING_DB_CONFIG_KEY, ""); -} - -/** Best-effort deletion of the old generation after a successful publish. */ -export async function retireTalerDatabaseGeneration( - idbFactory: IDBFactory, - name: string, - currentName: string, -): Promise<void> { - await cleanupGeneration(idbFactory, name, RETIRED_DB_CONFIG_KEY, currentName); -} - -export async function deleteTalerDatabase( - idbFactory: IDBFactory, -): Promise<void> { - const state = await readMainDbState(idbFactory, false); - const names = new Set([ - TALER_WALLET_MAIN_DB_NAME, - state.current, - state.pending, - state.retired, - ]); - for (const name of names) { - if (name && !(await tryDeleteDatabase(idbFactory, name))) { - throw Error(`deletion of wallet database ${name} is blocked`); - } - } - if (!(await tryDeleteDatabase(idbFactory, TALER_WALLET_META_DB_NAME))) { - throw Error("deletion of wallet metadata database is blocked"); - } -} - -/** - * Compile-time proof that every IndexedDB record type can actually be stored. - * - * The store definitions below use the DAL's `Wallet<Name>` types directly as - * their record types, so a change made for the native sqlite backend changes - * what IndexedDB persists. That is fine while the types stay - * structured-clone friendly, and fatal the moment one does not: the - * serialiser these values pass through on their way to storage - * (structuredEncapsulate in idb-bridge) handles arrays, dates, plain objects, - * bigint, boolean, number and string, and throws on anything else. Typed - * arrays included -- IndexedDB here cannot store binary at all. - * - * Without this, giving a record field a Uint8Array type -- the direction the - * native backend wants to go, to stop encoding keys as Crockford base32 -- - * would compile cleanly and fail at runtime, on write, in production. - * - * The record types are derived from the store map rather than listed, so a - * new store is covered without anyone remembering to add it here. - * - * When this stops compiling: do not widen it. It means a stored type has - * gained a field IndexedDB cannot persist -- typically a Uint8Array, which - * structuredEncapsulate cannot represent. The fix is to give the IndexedDB - * store its own record type holding whatever it can persist, and convert at - * the DAL boundary, not to relax the check. - */ -type Persistable<T> = T extends ArrayBufferView | ArrayBuffer - ? never - : T extends (...args: any[]) => any - ? never - : // Primitives are checked before objects on purpose: the branded types - // used throughout the records (DbPreciseTimestamp is number & {...}, - // AmountString is string & {...}) are intersections that satisfy - // `extends object`, and mapping over one turns a number into an object - // type. That flagged perfectly persistable records. - T extends string | number | boolean | bigint | null | undefined - ? T - : T extends Date - ? T - : T extends Array<infer U> - ? Array<Persistable<U>> - : T extends object - ? { [K in keyof T]: Persistable<T[K]> } - : T; - -/** - * The tuple wrappers stop the outer conditional from distributing over a - * union of record types, which would let a single unpersistable member hide - * behind its persistable siblings. - */ -type IsPersistable<T> = [T] extends [Persistable<T>] ? true : false; - -type AssertTrue<T extends true> = T; - -type RecordTypeOf<S> = - S extends StoreWithIndexes<any, infer R, any> ? R : never; - -type StoreMapV1 = typeof WalletIndexedDbStoresV1; - -/** - * The names of stores whose record type cannot be persisted, or never. - * - * Checked per store rather than over the union of all record types: two - * obsolete stores are typed `any`, and `any` in a union makes every other - * member assignable to it, which made an earlier version of this check pass - * a record type containing a Uint8Array. A vacuous guard is worse than no - * guard, because it is trusted. - */ -type UnpersistableStores = { - [K in keyof StoreMapV1]: IsPersistable< - RecordTypeOf<StoreMapV1[K]> - > extends true - ? never - : K; -}[keyof StoreMapV1]; - -/** - * The assertion itself. Unused at runtime; its only job is to fail the build, - * naming the offending store in the error. - */ -export type _AllIndexedDbRecordsArePersistable = AssertTrue< - [UnpersistableStores] extends [never] ? true : UnpersistableStores ->; diff --git a/packages/taler-wallet-core/src/db-native-migration.test.ts b/packages/taler-wallet-core/src/db-native-migration.test.ts @@ -1,696 +0,0 @@ -/* - 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 for the in-place migration to the native schema. - * - * The source database is populated by running the whole conformance corpus - * against it, so the migration faces every record type the suite can produce. - * The copy itself is verified record by record by the converter underneath; - * what these cases are about is the part that only in-place migration has -- - * which schema a file is opened with afterwards, what happens to the tables - * that were migrated away from, and what an interrupted attempt leaves behind. - */ - -import assert from "node:assert"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { test } from "node:test"; - -import { - BridgeIDBFactory, - createSqliteBackendOverDb, - Sqlite3Database, -} from "@gnu-taler/idb-bridge"; -import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; -import { - DatabaseMaintenanceProgressNotification, - NotificationType, - WalletNotification, -} from "@gnu-taler/taler-util"; - -import { - dropExpiredMigrationBackup, - inspectWalletDbFile, - inspectWalletDbFileDetails, - migrateWalletDbToNative, - readNativeMigrationInfo, - resolveAmbiguousWalletDb, - restoreMigrationBackup, -} from "./db-native-migration.js"; -import { - IDB_BACKUP_PREFIX, - IDB_EMULATION_TABLES, - schemaMigrations, -} from "./db-sqlite-schema.js"; -import { DB_CONVERSION_PROGRESS_RECORDS } from "./db-converter.js"; -import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; -import { initSqliteWalletDb, openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; -import { - inspectWalletDbPath, - resolveWalletDbMigration, -} from "./host-impl.node.js"; -import { acquireSqliteWalletDbOwnership } from "./host-common.js"; -import { conformanceCases } from "./dbtx-conformance-cases.js"; -import { ConformanceAsserts } from "./dbtx-conformance.js"; - -/** Assertions that ignore case-internal failures: only the data matters. */ -const quietAsserts: ConformanceAsserts = { - equal: () => {}, - deepEqual: () => {}, - ok: () => {}, - fail: () => { - throw Error("unreachable"); - }, -}; - -async function listTables(db: Sqlite3Database): Promise<string[]> { - const rows = await ( - await db.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", - ) - ).getAll(); - return rows.map((r) => String(r.name)); -} - -async function countRows(db: Sqlite3Database, table: string): Promise<number> { - const row = await ( - await db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`) - ).getFirst({}); - return Number(row?.n); -} - -/** - * An emulation-backed wallet database with the conformance corpus in it, over - * a connection the caller keeps: the migration needs that same connection. - */ -async function makePopulatedIdbDb(filename = ":memory:"): Promise<{ - db: Sqlite3Database; - handle: IdbWalletDbHandle; -}> { - const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const db = await imp.open(filename); - const backend = await createSqliteBackendOverDb(imp, db); - BridgeIDBFactory.enableTracing = false; - const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); - await handle.ensureOpen(); - for (const c of conformanceCases) { - try { - await c.run(quietAsserts, handle as any); - } catch (e) { - // A case failing its own assertions is the conformance suite's concern; - // what matters here is whatever data it managed to write. - } - } - return { db, handle }; -} - -async function openIdbDb(filename: string): Promise<{ - db: Sqlite3Database; - handle: IdbWalletDbHandle; -}> { - const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const db = await imp.open(filename); - const backend = await createSqliteBackendOverDb(imp, db); - const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); - await handle.ensureOpen(); - return { db, handle }; -} - -async function makeMinimalIdbDb(filename = ":memory:"): Promise<{ - db: Sqlite3Database; - handle: IdbWalletDbHandle; -}> { - const { db, handle } = await openIdbDb(filename); - await handle.runReadWriteTx((tx) => - tx.upsertConfig({ key: "fault-test" as any, value: 1 }), - ); - return { db, handle }; -} - -test("wallet database ownership excludes another SQLite connection", async () => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-db-owner-")); - const filename = path.join(directory, "wallet.sqlite3"); - const firstImpl = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const secondImpl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const first = await firstImpl.open(filename); - const second = await secondImpl.open(filename); - try { - await acquireSqliteWalletDbOwnership(first); - // Native initialization switches to WAL; ownership must survive that - // transition because migrated wallets use WAL for their whole lifetime. - await openNativeSqliteWalletDb(first); - // Keep this conflict test fast; production uses the adapter's normal busy - // timeout so a wallet that is just closing can drain cleanly. - await second.exec("PRAGMA busy_timeout = 1"); - await assert.rejects( - acquireSqliteWalletDbOwnership(second), - /another wallet process may still be using it/, - ); - - await first.close(); - await acquireSqliteWalletDbOwnership(second); - } finally { - // first.close() is intentionally reached in the success path above. A - // second close is harmless for the node helper and ensures failure paths - // do not retain the test database lock. - await first.close().catch(() => {}); - await second.close().catch(() => {}); - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -test("native migration: happens in the same file and switches it over", async () => { - const { db, handle } = await makePopulatedIdbDb(); - const progress: WalletNotification[] = []; - handle.setNotificationSink((n) => progress.push(n)); - - assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); - - const { - handle: native, - report, - info, - } = await migrateWalletDbToNative(db, handle); - - assert.ok( - report.totalRecords >= 100, - `only ${report.totalRecords} records migrated -- the corpus did not` + - ` populate the source, so the migration proved nothing`, - ); - assert.strictEqual(info.status, "complete"); - assert.strictEqual(info.recordsCopied, report.totalRecords); - assert.strictEqual(info.backupStatus, "retained"); - assert.ok(info.backupExpiresAt! > info.finishedAt!); - assert.ok( - progress.some( - (n) => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-to-native-migration" && - n.phase === "complete", - ), - "successful migration did not report completion", - ); - - // The file now opens natively, without being told to. - assert.strictEqual(await inspectWalletDbFile(db), "native"); - - const tables = await listTables(db); - for (const t of IDB_EMULATION_TABLES) { - assert.ok( - !tables.includes(t), - `${t} is still there, so the emulation would keep being used`, - ); - assert.ok( - tables.includes(`${IDB_BACKUP_PREFIX}${t}`), - `${t} was not retained as a backup`, - ); - } - // The retained copy still holds the records it held before. - assert.ok((await countRows(db, `${IDB_BACKUP_PREFIX}object_data`)) > 0); - - // The migrated database is usable through the handle the wallet gets. - const coins = await native.runReadWriteTx((tx) => tx.listAllCoins()); - assert.ok(coins.length > 0, "no coins survived the migration"); - - await native.close(); -}); - -test("native migration recognizes a restored IndexedDB generation", async () => { - const { db, handle } = await makeMinimalIdbDb(); - const dump = await handle.exportDatabase(); - await handle.importDatabase(dump, async (tx) => { - await tx.upsertConfig({ key: "fault-test" as any, value: 2 }); - }); - - assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); - const { handle: native } = await migrateWalletDbToNative(db, handle); - assert.strictEqual( - (await native.runReadWriteTx((tx) => tx.getConfig("fault-test" as any))) - ?.value, - 2, - ); - await native.close(); -}); - -test("native migration: clearing the wallet leaves the retained backup", async () => { - const { db, handle } = await makePopulatedIdbDb(); - const { handle: native } = await migrateWalletDbToNative(db, handle); - - const before = await countRows(db, `${IDB_BACKUP_PREFIX}object_data`); - assert.ok(before > 0); - - // clearDatabase enumerates the tables in the file; the emulation's retained - // tables are in that same file and are not the wallet's data. - await native.clearDatabase(); - - assert.strictEqual( - await countRows(db, `${IDB_BACKUP_PREFIX}object_data`), - before, - ); - await native.close(); -}); - -test("native migration: the backup is dropped only once it expires", async () => { - const { db, handle } = await makePopulatedIdbDb(); - const { handle: native, info } = await migrateWalletDbToNative(db, handle); - - assert.strictEqual( - await dropExpiredMigrationBackup(db, info.backupExpiresAt! - 1), - false, - "the backup went away before its retention was over", - ); - assert.ok((await listTables(db)).includes(`${IDB_BACKUP_PREFIX}object_data`)); - - assert.strictEqual( - await dropExpiredMigrationBackup(db, info.backupExpiresAt!), - true, - ); - const tables = await listTables(db); - for (const t of IDB_EMULATION_TABLES) { - assert.ok(!tables.includes(`${IDB_BACKUP_PREFIX}${t}`)); - } - assert.strictEqual( - (await readNativeMigrationInfo(db))?.backupStatus, - "dropped", - ); - // Dropping the backup does not change which schema the file is read with. - assert.strictEqual(await inspectWalletDbFile(db), "native"); - - // And a second call has nothing left to do. - assert.strictEqual( - await dropExpiredMigrationBackup(db, info.backupExpiresAt!), - false, - ); - await native.close(); -}); - -test("native migration: the retained backup can be put back", async () => { - const { db, handle } = await makePopulatedIdbDb(); - const rowsBefore = await countRows(db, "object_data"); - await migrateWalletDbToNative(db, handle); - - // Not closing the handle first: closing it closes the connection this test - // still holds, and the file is what the restore works on. In production - // the restore runs against a wallet that is not running at all. - await restoreMigrationBackup(db); - - assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); - assert.strictEqual(await countRows(db, "object_data"), rowsBefore); - const info = await readNativeMigrationInfo(db); - assert.strictEqual(info?.status, "rolled-back"); - assert.strictEqual(info?.backupStatus, "restored"); - - // A rolled-back database is not migrated again behind the user's back. - await assert.rejects( - () => migrateWalletDbToNative(db, handle), - /rolled back/, - ); -}); - -test("native migration: an interrupted attempt restarts after reopening", async () => { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), "wallet-db-migration-restart-"), - ); - const filename = path.join(directory, "wallet.sqlite3"); - let firstDb: Sqlite3Database | undefined; - let reopenedDb: Sqlite3Database | undefined; - try { - const first = await makeMinimalIdbDb(filename); - firstDb = first.db; - const expectedTombstones = DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; - await first.handle.runReadWriteTx(async (tx) => { - for (let i = 0; i < expectedTombstones; i++) { - await tx.upsertTombstone({ id: `restart-${i}` }); - } - }); - - // Throw only after a committed destination batch has advanced global - // progress. This leaves the same durable state as process termination: - // untouched IndexedDB tables, a running marker and partial native rows. - let interruptionInjected = false; - const progress: WalletNotification[] = []; - first.handle.setNotificationSink((n) => { - progress.push(n); - }); - await assert.rejects( - () => - migrateWalletDbToNative(first.db, first.handle, { - onProgress(n) { - if ( - !interruptionInjected && - n.phase === "copy" && - (n.processedRecords ?? 0) >= DB_CONVERSION_PROGRESS_RECORDS - ) { - interruptionInjected = true; - throw Error("simulated migration interruption"); - } - }, - }), - /simulated migration interruption/, - ); - assert.ok( - interruptionInjected, - "migration was not interrupted after a copy", - ); - const failed = progress.find( - (n): n is DatabaseMaintenanceProgressNotification => - n.type === NotificationType.DatabaseMaintenanceProgress && - n.operation === "indexeddb-to-native-migration" && - n.phase === "failed", - ); - assert.ok(failed, "interrupted migration did not report failure"); - assert.match( - failed.error?.hint ?? "", - /simulated migration interruption/, - "failed migration notification did not include the exception", - ); - const interrupted = await inspectWalletDbFileDetails(first.db); - assert.strictEqual(interrupted.kind, "indexeddb"); - assert.ok(interrupted.nativeRecords > 0, "no partial native copy was left"); - assert.strictEqual( - (await readNativeMigrationInfo(first.db))?.status, - "running", - ); - - await first.handle.close(); - await first.db.close(); - firstDb = undefined; - - // A new factory and connection exercise the path that previously surfaced - // only as "database opening error", rather than reusing an already-open - // IndexedDB handle as the old regression did. - const reopened = await openIdbDb(filename); - reopenedDb = reopened.db; - assert.strictEqual(await inspectWalletDbFile(reopened.db), "indexeddb"); - const { handle: native, report } = await migrateWalletDbToNative( - reopened.db, - reopened.handle, - ); - const tombstones = await native.runReadWriteTx((tx) => - tx.listAllTombstones(), - ); - assert.strictEqual(tombstones.length, expectedTombstones); - assert.strictEqual( - new Set(tombstones.map((t) => t.id)).size, - expectedTombstones, - "the restarted copy duplicated records", - ); - assert.ok(report.totalRecords >= expectedTombstones); - assert.strictEqual(await inspectWalletDbFile(reopened.db), "native"); - await native.close(); - reopenedDb = undefined; - } finally { - await firstDb?.close().catch(() => {}); - await reopenedDb?.close().catch(() => {}); - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -test("native migration: a legacy interrupted attempt remains restartable", async () => { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), "wallet-db-migration-legacy-"), - ); - const filename = path.join(directory, "wallet.sqlite3"); - let firstDb: Sqlite3Database | undefined; - let reopenedDb: Sqlite3Database | undefined; - try { - const first = await makeMinimalIdbDb(filename); - firstDb = first.db; - await initSqliteWalletDb( - first.db, - schemaMigrations.filter((m) => m.version < 7), - ); - await ( - await first.db.prepare( - "INSERT INTO config (key, value) VALUES ('partial-only', '\"discard\"')", - ) - ).run({}); - await ( - await first.db.prepare( - "INSERT INTO idb_migration (id, status, started_at)" + - " VALUES (1, 'running', 1)", - ) - ).run({}); - assert.strictEqual( - (await readNativeMigrationInfo(first.db))?.cleanupSafe, - undefined, - ); - assert.strictEqual(await inspectWalletDbFile(first.db), "indexeddb"); - - await first.handle.close(); - await first.db.close(); - firstDb = undefined; - - const reopened = await openIdbDb(filename); - reopenedDb = reopened.db; - const { handle: native, info } = await migrateWalletDbToNative( - reopened.db, - reopened.handle, - ); - assert.strictEqual(info.status, "complete"); - assert.strictEqual(info.cleanupSafe, true); - const config = await native.runReadWriteTx((tx) => tx.listAllConfig()); - assert.ok(config.some((r) => r.key === ("fault-test" as any))); - assert.ok(!config.some((r) => r.key === ("partial-only" as any))); - await native.close(); - reopenedDb = undefined; - } finally { - await firstDb?.close().catch(() => {}); - await reopenedDb?.close().catch(() => {}); - fs.rmSync(directory, { recursive: true, force: true }); - } -}); - -test("native migration: mixed schemas without ownership fail closed", async () => { - const { db, handle } = await makePopulatedIdbDb(); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO config (key, value) VALUES ('native-only', '\"keep-me\"')", - ) - ).run({}); - - const inspection = await inspectWalletDbFileDetails(db); - assert.strictEqual(inspection.kind, "ambiguous"); - assert.ok(inspection.indexedDbRecords > 0); - assert.strictEqual(inspection.nativeRecords, 1); - await assert.rejects( - () => migrateWalletDbToNative(db, handle), - /native schema already contains wallet records/, - ); - assert.strictEqual(await countRows(db, "config"), 1); - assert.ok((await countRows(db, "object_data")) > 0); -}); - -test("native migration: an untrusted running marker never clears native rows", async () => { - const { db, handle } = await makePopulatedIdbDb(); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO config (key, value) VALUES ('native-only', '\"keep-me\"')", - ) - ).run({}); - await ( - await db.prepare( - "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + - " VALUES (1, 'running', 1, 0)", - ) - ).run({}); - - assert.strictEqual(await inspectWalletDbFile(db), "ambiguous"); - await assert.rejects( - () => migrateWalletDbToNative(db, handle), - /untrusted running marker/, - ); - assert.strictEqual(await countRows(db, "config"), 1); -}); - -test("native migration: an empty untrusted retry acquires cleanup ownership", async () => { - const { db, handle } = await makePopulatedIdbDb(); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + - " VALUES (1, 'running', 1, 0)", - ) - ).run({}); - const { handle: native, info } = await migrateWalletDbToNative(db, handle); - assert.strictEqual(info.cleanupSafe, true); - assert.strictEqual((await readNativeMigrationInfo(db))?.cleanupSafe, true); - await native.close(); -}); - -test("native migration: unrelated emulated databases are not wallet records", async () => { - const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const db = await imp.open(":memory:"); - const backend = await createSqliteBackendOverDb(imp, db); - const factory = new BridgeIDBFactory(backend); - const req = factory.open("not-the-wallet", 1); - req.addEventListener("upgradeneeded", () => { - req.result.createObjectStore("records").put({ unrelated: true }, "one"); - }); - await new Promise<void>((resolve, reject) => { - req.addEventListener("success", () => resolve()); - req.addEventListener("error", () => reject(req.error)); - }); - const inspection = await inspectWalletDbFileDetails(db); - assert.strictEqual(inspection.indexedDbRecords, 0); - assert.strictEqual(inspection.kind, "indexeddb"); - await db.close(); -}); - -test("native migration: explicit resolution can keep IndexedDB", async () => { - const { db } = await makePopulatedIdbDb(); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO config (key, value) VALUES ('native-only', '\"discard\"')", - ) - ).run({}); - await resolveAmbiguousWalletDb(db, "indexeddb"); - assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); - assert.strictEqual(await countRows(db, "config"), 0); - assert.strictEqual( - (await readNativeMigrationInfo(db))?.status, - "rolled-back", - ); - assert.ok((await countRows(db, "object_data")) > 0); - await db.close(); -}); - -test("native migration: explicit resolution can keep native", async () => { - const { db } = await makePopulatedIdbDb(); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO config (key, value) VALUES ('native-only', '\"keep\"')", - ) - ).run({}); - await resolveAmbiguousWalletDb(db, "native"); - assert.strictEqual(await inspectWalletDbFile(db), "native"); - assert.strictEqual(await countRows(db, "config"), 1); - assert.ok((await countRows(db, `${IDB_BACKUP_PREFIX}object_data`)) > 0); - await db.close(); -}); - -test("native migration: rollback preflight failure preserves native rows", async () => { - const { db, handle } = await makePopulatedIdbDb(); - const { handle: native } = await migrateWalletDbToNative(db, handle); - const coinsBefore = await countRows(db, "coins"); - await ( - await db.prepare(`DROP TABLE "${IDB_BACKUP_PREFIX}index_data"`) - ).run({}); - await assert.rejects( - () => restoreMigrationBackup(db), - /backup table .* is missing/, - ); - assert.strictEqual(await countRows(db, "coins"), coinsBefore); - assert.strictEqual((await readNativeMigrationInfo(db))?.status, "complete"); - await native.close(); -}); - -test("native migration: every rollback mutation failure is atomic", async () => { - // Six backup renames plus the final status update: failing any one must roll - // the earlier deletions/renames back with the native wallet and its complete - // marker intact. - for (let failAt = 0; failAt <= IDB_EMULATION_TABLES.length; failAt++) { - const { db, handle } = await makeMinimalIdbDb(); - await migrateWalletDbToNative(db, handle); - let mutation = 0; - const faultDb: Sqlite3Database = { - internalDbHandle: db.internalDbHandle, - exec: (sql) => db.exec(sql), - close: () => db.close(), - prepare: async (sql) => { - const stmt = await db.prepare(sql); - return { - ...stmt, - run: async (params) => { - if ( - sql.startsWith('ALTER TABLE "idb_backup_') || - sql.startsWith( - "UPDATE idb_migration SET backup_status = 'restored'", - ) - ) { - if (mutation++ === failAt) { - throw Error(`injected rollback failure ${failAt}`); - } - } - return await stmt.run(params); - }, - }; - }, - }; - await assert.rejects( - () => restoreMigrationBackup(faultDb), - new RegExp(`injected rollback failure ${failAt}`), - ); - assert.strictEqual(await countRows(db, "config"), 1); - assert.strictEqual((await readNativeMigrationInfo(db))?.status, "complete"); - const tables = await listTables(db); - for (const table of IDB_EMULATION_TABLES) { - assert.ok(tables.includes(`${IDB_BACKUP_PREFIX}${table}`)); - assert.ok(!tables.includes(table)); - } - await db.close(); - } -}); - -test("native migration: offline resolution creates the mandatory full backup", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-db-resolution-")); - const dbPath = path.join(dir, "wallet.sqlite3"); - const backupPath = path.join(dir, "before.sqlite3"); - try { - const { db, handle } = await makePopulatedIdbDb(dbPath); - await openNativeSqliteWalletDb(db); - await ( - await db.prepare( - "INSERT INTO config (key, value) VALUES ('native-only', '\"discard\"')", - ) - ).run({}); - await handle.close(); - await db.close(); - - await assert.rejects( - () => - resolveWalletDbMigration( - dbPath, - "indexeddb", - path.join(dir, "missing", "backup.sqlite3"), - ), - /unable to open database|cannot open|SQLITE_CANTOPEN/i, - ); - assert.strictEqual((await inspectWalletDbPath(dbPath)).kind, "ambiguous"); - - await resolveWalletDbMigration(dbPath, "indexeddb", backupPath); - assert.ok(fs.statSync(backupPath).size > 0); - assert.strictEqual((await inspectWalletDbPath(dbPath)).kind, "indexeddb"); - assert.strictEqual( - (await inspectWalletDbPath(backupPath)).kind, - "ambiguous", - ); - await assert.rejects( - () => resolveWalletDbMigration(dbPath, "native", backupPath), - /backup destination .* already exists/, - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/taler-wallet-core/src/db-native-migration.ts b/packages/taler-wallet-core/src/db-native-migration.ts @@ -1,686 +0,0 @@ -/* - 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/> - */ - -/** - * In-place migration of a wallet database from the IndexedDB emulation to the - * native schema. - * - * Both schemas are sqlite tables and their names do not overlap, so the - * migration happens inside the one file the wallet already has: nothing is - * written next to it and nothing is swapped afterwards. That matters because - * the platforms this migration exists for -- the mobile wallets -- hand - * wallet-core a database and no filesystem to put a second one in. - * - * The order of operations is what makes an interrupted migration safe. The - * emulation's tables are read-only throughout and are renamed out of the way - * only after the copy has been verified, in the same transaction that records - * the migration as complete. So at every instant exactly one of the two - * schemas is the authoritative copy, and which one it is can be read back - * from the file: - * - * - no idb_migration row: the emulation's tables are the wallet. - * - status 'running': an attempt was interrupted. The emulation's tables are - * still the wallet; the native tables hold a partial copy and are discarded - * when the migration is retried. - * - status 'complete': the native tables are the wallet. The emulation's - * tables are still in the file under their idb_backup_ names. - * - * The backup is kept for {@link MIGRATION_BACKUP_RETENTION} rather than - * dropped at the end: a migration that copies every record and verifies it can - * still turn out to have produced a wallet that misbehaves for a reason nobody - * anticipated, and until that window closes the original is one statement - * away. {@link restoreMigrationBackup} is that statement. - */ - -import { - Duration, - getErrorDetailFromException, - Logger, - NotificationType, -} from "@gnu-taler/taler-util"; -import type { Sqlite3Database } from "@gnu-taler/idb-bridge"; - -import { - convertWalletDb, - DB_CONVERSION_STEP_COUNT, - DbConversionOptions, - DbConversionReport, -} from "./db-converter.js"; -import { - IDB_BACKUP_PREFIX, - IDB_EMULATION_TABLES, - NATIVE_DATA_TABLES, -} from "./db-sqlite-schema.js"; -import { SqliteWalletDbHandle } from "./dbtx-handle-impl.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; -import { - clearNativeSqliteWalletDb, - clearNativeSqliteWalletDbInTransaction, - openNativeSqliteWalletDb, - SqliteTxControl, -} from "./dbtx-sqlite.js"; - -const logger = new Logger("db-native-migration.ts"); - -/** - * How long the renamed emulation tables are kept after a successful - * migration. - * - * Long enough that a wallet used every few days gets several chances to - * expose a problem before the original goes away, short enough that a - * wallet's storage does not carry two copies of itself indefinitely. - */ -export const MIGRATION_BACKUP_RETENTION = Duration.fromSpec({ months: 1 }); - -/** The retention as the microseconds the schema's timestamps are in. */ -function retentionMicros(): number { - const ms = MIGRATION_BACKUP_RETENTION.d_ms; - // fromSpec never yields "forever", but narrowing rather than casting means - // a retention that later becomes configurable cannot silently overflow into - // a negative expiry. MAX_SAFE_INTEGER is this schema's "never". - return ms === "forever" ? Number.MAX_SAFE_INTEGER : ms * 1000; -} - -/** Which schema the records in a wallet database file are stored in. */ -export type WalletDbFileKind = "empty" | "indexeddb" | "native" | "ambiguous"; - -/** - * 'rolled-back' is terminal: the emulation tables were put back by - * {@link restoreMigrationBackup}, and the wallet does not migrate again on its - * own, because whoever rolled back did so to stop using the native schema. - */ -export type MigrationStatus = "running" | "complete" | "rolled-back"; - -export type MigrationBackupStatus = "retained" | "dropped" | "restored"; - -export interface NativeMigrationInfo { - status: MigrationStatus; - /** Microseconds since the epoch, as everywhere in the native schema. */ - startedAt: number; - finishedAt?: number; - recordsCopied?: number; - backupStatus?: MigrationBackupStatus; - backupExpiresAt?: number; - /** - * Whether native rows are known to be only a disposable partial copy. - * Undefined identifies the released legacy schema from before this column - * was added; its running marker carried the same cleanup guarantee. - */ - cleanupSafe?: boolean; -} - -export interface WalletDbFileInspection { - kind: WalletDbFileKind; - indexedDbRecords: number; - nativeRecords: number; - ambiguityReason?: string; -} - -/** Current time in the microseconds the native schema's timestamps use. */ -function nowMicros(): number { - return Date.now() * 1000; -} - -function backupTableName(table: string): string { - return `${IDB_BACKUP_PREFIX}${table}`; -} - -async function tableExists( - db: Sqlite3Database, - name: string, -): Promise<boolean> { - const row = await ( - await db.prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name", - ) - ).getFirst({ name }); - return row != null; -} - -/** - * Read the migration bookkeeping, if this file has any. - * - * Tolerates a file that has never seen the native schema: the table itself is - * absent there, which is not an error but the most common case. - */ -export async function readNativeMigrationInfo( - db: Sqlite3Database, -): Promise<NativeMigrationInfo | undefined> { - if (!(await tableExists(db, "idb_migration"))) { - return undefined; - } - const row = await ( - await db.prepare("SELECT * FROM idb_migration WHERE id = 1") - ).getFirst({}); - if (!row) { - return undefined; - } - const optNum = (v: unknown): number | undefined => - v == null ? undefined : Number(v); - return { - status: String(row.status) as MigrationStatus, - startedAt: Number(row.started_at), - finishedAt: optNum(row.finished_at), - recordsCopied: optNum(row.records_copied), - backupStatus: (row.backup_status ?? undefined) as - | MigrationBackupStatus - | undefined, - backupExpiresAt: optNum(row.backup_expires_at), - cleanupSafe: - row.cleanup_safe == null ? undefined : Number(row.cleanup_safe) === 1, - }; -} - -async function countRows(db: Sqlite3Database, table: string): Promise<number> { - const row = await ( - await db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`) - ).getFirst({}); - return Number(row?.n ?? 0); -} - -async function countNativeRecords(db: Sqlite3Database): Promise<number> { - let total = 0; - for (const table of NATIVE_DATA_TABLES) { - if (await tableExists(db, table)) total += await countRows(db, table); - } - return total; -} - -async function countMainIndexedDbRecords(db: Sqlite3Database): Promise<number> { - if ( - !(await tableExists(db, "object_data")) || - !(await tableExists(db, "object_stores")) - ) { - return 0; - } - const row = await ( - await db.prepare( - "SELECT COUNT(*) AS n FROM object_data od" + - " JOIN object_stores os ON os.id = od.object_store_id" + - " WHERE os.database_name = 'taler-wallet-main-v10'" + - " OR os.database_name LIKE 'taler-wallet-main-v10-generation-%'", - ) - ).getFirst({}); - return Number(row?.n ?? 0); -} - -export async function inspectWalletDbFileDetails( - db: Sqlite3Database, -): Promise<WalletDbFileInspection> { - const info = await readNativeMigrationInfo(db); - const indexedDbRecords = await countMainIndexedDbRecords(db); - const nativeRecords = await countNativeRecords(db); - const result = (kind: WalletDbFileKind, ambiguityReason?: string) => ({ - kind, - indexedDbRecords, - nativeRecords, - ...(ambiguityReason ? { ambiguityReason } : undefined), - }); - - if (info?.status === "complete") return result("native"); - if (info?.status === "running" && info.cleanupSafe !== false) - return result("indexeddb"); - if (info?.status === "running" && nativeRecords > 0) { - return result( - "ambiguous", - "an untrusted running migration marker coexists with native wallet records", - ); - } - if (info?.status === "rolled-back" && nativeRecords > 0) { - return result( - "ambiguous", - "a rolled-back migration still has native wallet records", - ); - } - if (indexedDbRecords > 0 && nativeRecords > 0) { - return result( - "ambiguous", - "both IndexedDB and native schemas contain wallet records without a trustworthy authority marker", - ); - } - if (await tableExists(db, "object_data")) return result("indexeddb"); - if (await tableExists(db, "schema_migrations")) return result("native"); - return result("empty"); -} - -/** - * Decide which schema holds the wallet's records in an open database file. - * - * The host has to ask before it opens either backend over the file, because - * both create their tables with IF NOT EXISTS: opening the wrong one does not - * fail, it produces an empty wallet. - */ -export async function inspectWalletDbFile( - db: Sqlite3Database, -): Promise<WalletDbFileKind> { - return (await inspectWalletDbFileDetails(db)).kind; -} - -/** - * Run f inside one native sqlite transaction on db. - * - * The migration owns the connection while it runs, so it does not go through - * the wallet's transaction queue; it does need the same explicit - * BEGIN/COMMIT, since exec() would commit implicitly between statements. - */ -async function inTransaction( - txc: SqliteTxControl, - f: () => Promise<void>, -): Promise<void> { - await txc.begin(); - try { - await f(); - await txc.commit(); - } catch (e) { - try { - await txc.rollback(); - } catch (rollbackErr) { - logger.warn(`rollback failed: ${rollbackErr}`); - } - throw e; - } -} - -export interface NativeMigrationResult { - handle: SqliteWalletDbHandle; - report: DbConversionReport; - info: NativeMigrationInfo; -} - -/** - * Migrate the wallet records in db from the emulation to the native schema. - * - * `src` must be the open IndexedDB-emulation handle over the same connection: - * opening it is what replays the fixup log, so the records this copies are - * already repaired -- the native schema has no fixup log of its own. - * - * Returns the handle the wallet is to use from here on. The caller keeps - * using `src` if this throws: nothing destructive has happened, and the file - * still opens as an emulation database. - */ -export async function migrateWalletDbToNative( - db: Sqlite3Database, - src: WalletDbHandle, - conversionOptions: DbConversionOptions = {}, -): Promise<NativeMigrationResult> { - // Read this before native initialization upgrades the schema. A running - // marker written by versions before cleanup_safe existed is trustworthy: - // those versions also cleared the native tables before recording it. Once - // migration 7 adds the column its DEFAULT 0 deliberately cannot make that - // distinction for us anymore. - const previous = await readNativeMigrationInfo(db); - const previousPartialIsCleanupSafe = - previous?.status === "running" && previous.cleanupSafe !== false; - - const ndb = await openNativeSqliteWalletDb(db); - const dst = new SqliteWalletDbHandle(ndb); - const txc = ndb.txc; - - if (previous?.status === "complete") { - throw Error( - "this wallet database has already been migrated to the native schema", - ); - } - if (previous?.status === "rolled-back") { - throw Error( - "this wallet database was rolled back to the IndexedDB schema and is" + - " not migrated again automatically", - ); - } - if (previous?.status === "running") { - if (previousPartialIsCleanupSafe) { - logger.warn( - previous.cleanupSafe === undefined - ? "discarding the partial copy left by an interrupted legacy migration attempt" - : "discarding the cleanup-safe partial copy left by an interrupted migration attempt", - ); - await clearNativeSqliteWalletDb(ndb); - } else if ((await countNativeRecords(db)) !== 0) { - throw Error( - "migration refused: an untrusted running marker has native wallet records; use db-migration-resolve after making a backup", - ); - } - } else if ((await countNativeRecords(db)) !== 0) { - throw Error( - "migration refused: the native schema already contains wallet records; use db-migration-resolve after making a backup", - ); - } - - const startedAt = nowMicros(); - await ndb.lock.run(() => - inTransaction(txc, async () => { - if ((await countNativeRecords(db)) !== 0) { - throw Error( - "migration refused: native wallet records appeared before cleanup ownership could be recorded", - ); - } - await ( - await db.prepare( - "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + - " VALUES (1, 'running', $started_at, 1)" + - " ON CONFLICT (id) DO UPDATE SET status = 'running'," + - " started_at = $started_at, finished_at = NULL," + - " records_copied = NULL, backup_status = NULL," + - " backup_expires_at = NULL, cleanup_safe = 1", - ) - ).run({ started_at: startedAt }); - }), - ); - - try { - logger.info("migrating the wallet database to the native schema"); - // Verifies its own copy record by record and throws on any difference, so - // reaching the next statement means the native tables hold the wallet. - const report = await convertWalletDb(src, dst, conversionOptions); - - await ndb.lock.run(async () => { - const violations = await ( - await db.prepare("PRAGMA foreign_key_check") - ).getAll({}); - if (violations.length !== 0) { - throw Error( - `migration refused: native foreign-key validation found ${violations.length} violation(s)`, - ); - } - }); - - const finishedAt = nowMicros(); - const backupExpiresAt = finishedAt + retentionMicros(); - - // One transaction: the renames and the record of them being done cannot come - // apart. A crash between them would leave a file whose emulation tables are - // gone and whose bookkeeping still says the emulation is authoritative, and - // the retry would then wipe the only remaining copy. - await ndb.lock.run(() => - inTransaction(txc, async () => { - for (const table of IDB_EMULATION_TABLES) { - await ( - await db.prepare( - `ALTER TABLE "${table}" RENAME TO "${backupTableName(table)}"`, - ) - ).run({}); - } - await ( - await db.prepare( - "UPDATE idb_migration SET status = 'complete'," + - " finished_at = $finished_at, records_copied = $records_copied," + - " backup_status = 'retained'," + - " backup_expires_at = $backup_expires_at WHERE id = 1", - ) - ).run({ - finished_at: finishedAt, - records_copied: report.totalRecords, - backup_expires_at: backupExpiresAt, - }); - }), - ); - - logger.info( - `migrated ${report.totalRecords} records to the native schema;` + - ` the previous database is kept in this file until` + - ` ${new Date(backupExpiresAt / 1000).toISOString()}`, - ); - - // The bookkeeping is reported from what was just written rather than read - // back: past the transaction above the emulation's tables are gone, so a - // caller that treats a throw as "nothing happened, keep using the old - // handle" would be wrong from here on. Nothing after this can throw. - src.emitNotification({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-to-native-migration", - phase: "complete", - completedSteps: DB_CONVERSION_STEP_COUNT, - totalSteps: DB_CONVERSION_STEP_COUNT, - processedRecords: report.totalRecords, - totalRecords: report.totalRecords, - }); - return { - handle: dst, - report, - info: { - status: "complete", - startedAt, - finishedAt, - recordsCopied: report.totalRecords, - backupStatus: "retained", - backupExpiresAt, - cleanupSafe: true, - }, - }; - } catch (e) { - src.emitNotification({ - type: NotificationType.DatabaseMaintenanceProgress, - operation: "indexeddb-to-native-migration", - phase: "failed", - completedSteps: 0, - totalSteps: DB_CONVERSION_STEP_COUNT, - error: getErrorDetailFromException(e), - }); - throw e; - } -} - -/** - * Drop the retained emulation tables once their retention has passed. - * - * Called when a migrated database is opened, which is the only moment at - * which nothing is using it and a schema change is free. Returns whether it - * dropped anything. - */ -export async function dropExpiredMigrationBackup( - db: Sqlite3Database, - now: number = nowMicros(), -): Promise<boolean> { - const info = await readNativeMigrationInfo(db); - if (info?.status !== "complete" || info.backupStatus !== "retained") { - return false; - } - if (info.backupExpiresAt == null || now < info.backupExpiresAt) { - return false; - } - // Bring the native schema fully up to date and validate it before deleting - // the last pre-migration copy. - const ndb = await openNativeSqliteWalletDb(db); - logger.info("dropping the retained pre-migration database tables"); - await ndb.lock.run(async () => { - const violations = await ( - await db.prepare("PRAGMA foreign_key_check") - ).getAll({}); - if (violations.length !== 0) { - throw Error( - "native database failed foreign-key validation; retained backup was not dropped", - ); - } - await inTransaction(ndb.txc, async () => { - for (const table of IDB_EMULATION_TABLES) { - if (!(await tableExists(db, backupTableName(table)))) { - throw Error( - `retained backup table ${backupTableName(table)} is missing`, - ); - } - } - for (const table of IDB_EMULATION_TABLES) { - await ( - await db.prepare(`DROP TABLE "${backupTableName(table)}"`) - ).run({}); - } - await ( - await db.prepare( - "UPDATE idb_migration SET backup_status = 'dropped' WHERE id = 1", - ) - ).run({}); - }); - }); - return true; -} - -/** - * Undo a migration, putting the retained emulation tables back in place. - * - * The wallet database must not be open: this renames the tables both backends - * read. Afterwards the file is an emulation database again and the native - * tables are empty, so it opens the way it did before the migration. - * - * Deliberately not automatic. A wallet that migrated and then misbehaved has - * no way to tell whether the migration caused it, and rolling back on its own - * would discard whatever the wallet did since -- the emulation tables stopped - * being written the moment the migration completed. - */ -export async function restoreMigrationBackup( - db: Sqlite3Database, -): Promise<void> { - const info = await readNativeMigrationInfo(db); - if (info?.status !== "complete") { - throw Error("this database was not migrated to the native schema"); - } - if (info.backupStatus !== "retained") { - throw Error( - `the pre-migration tables are not available (backup is` + - ` ${info.backupStatus ?? "absent"})`, - ); - } - const ndb = await openNativeSqliteWalletDb(db); - await ndb.lock.run(async () => { - await inTransaction(ndb.txc, async () => { - // Preflight is inside the same transaction as deletion and renaming, so - // every failure leaves the complete native wallet authoritative. - for (const table of IDB_EMULATION_TABLES) { - if (await tableExists(db, table)) { - throw Error(`cannot restore: table ${table} already exists`); - } - if (!(await tableExists(db, backupTableName(table)))) { - throw Error( - `cannot restore: backup table ${backupTableName(table)} is missing`, - ); - } - } - await clearNativeSqliteWalletDbInTransaction(ndb); - for (const table of IDB_EMULATION_TABLES) { - await ( - await db.prepare( - `ALTER TABLE "${backupTableName(table)}" RENAME TO "${table}"`, - ) - ).run({}); - } - await ( - await db.prepare( - "UPDATE idb_migration SET backup_status = 'restored'," + - " status = 'rolled-back' WHERE id = 1 AND status = 'complete'", - ) - ).run({}); - const updated = await ( - await db.prepare("SELECT status FROM idb_migration WHERE id = 1") - ).getFirst({}); - if (updated?.status !== "rolled-back") { - throw Error( - "migration status changed while rollback was being prepared", - ); - } - }); - }); - logger.info("restored the pre-migration wallet database"); -} - -export type MigrationAuthority = "indexeddb" | "native"; - -async function requireResolutionTables(db: Sqlite3Database): Promise<void> { - for (const table of IDB_EMULATION_TABLES) { - if (!(await tableExists(db, table))) { - throw Error( - `cannot resolve migration: expected IndexedDB table ${table} is missing`, - ); - } - if (await tableExists(db, backupTableName(table))) { - throw Error( - `cannot resolve migration: backup table ${backupTableName(table)} already exists`, - ); - } - } - for (const table of NATIVE_DATA_TABLES) { - if (!(await tableExists(db, table))) { - throw Error( - `cannot resolve migration: expected native table ${table} is missing`, - ); - } - } -} - -/** Select authority in an ambiguous file. The caller must create a backup first. */ -export async function resolveAmbiguousWalletDb( - db: Sqlite3Database, - keep: MigrationAuthority, -): Promise<void> { - const inspection = await inspectWalletDbFileDetails(db); - if (inspection.kind !== "ambiguous") { - throw Error( - `migration resolution requires an ambiguous database, got ${inspection.kind}`, - ); - } - await requireResolutionTables(db); - const ndb = await openNativeSqliteWalletDb(db); - await ndb.lock.run(async () => { - if (keep === "native") { - const violations = await ( - await db.prepare("PRAGMA foreign_key_check") - ).getAll({}); - if (violations.length !== 0) { - throw Error( - `cannot keep native: foreign-key validation found ${violations.length} violation(s)`, - ); - } - } - await inTransaction(ndb.txc, async () => { - // Repeat preflight under the mutation transaction. - await requireResolutionTables(db); - const at = nowMicros(); - if (keep === "indexeddb") { - await clearNativeSqliteWalletDbInTransaction(ndb); - await ( - await db.prepare( - "INSERT INTO idb_migration" + - " (id, status, started_at, finished_at, backup_status, cleanup_safe)" + - " VALUES (1, 'rolled-back', $at, $at, 'restored', 1)" + - " ON CONFLICT(id) DO UPDATE SET status='rolled-back'," + - " finished_at=$at, backup_status='restored', cleanup_safe=1", - ) - ).run({ at }); - } else { - for (const table of IDB_EMULATION_TABLES) { - await ( - await db.prepare( - `ALTER TABLE "${table}" RENAME TO "${backupTableName(table)}"`, - ) - ).run({}); - } - await ( - await db.prepare( - "INSERT INTO idb_migration" + - " (id, status, started_at, finished_at, records_copied," + - " backup_status, backup_expires_at, cleanup_safe)" + - " VALUES (1, 'complete', $at, $at, $records, 'retained', $expires, 0)" + - " ON CONFLICT(id) DO UPDATE SET status='complete'," + - " started_at=$at, finished_at=$at, records_copied=$records," + - " backup_status='retained', backup_expires_at=$expires, cleanup_safe=0", - ) - ).run({ - at, - records: inspection.nativeRecords, - expires: at + retentionMicros(), - }); - } - }); - }); -} diff --git a/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts b/packages/taler-wallet-core/src/db-sqlite-migrations.test.ts @@ -1,521 +0,0 @@ -/* - 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 for the sqlite schema migration mechanism. - * - * These tests supplement the wallet's real migrations with synthetic ones in - * order to exercise DDL backfills, rollback and validation behavior. - */ - -import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; -import assert from "node:assert"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { test } from "node:test"; - -import { - SchemaMigration, - SQLITE_SCHEMA_VERSION, - schemaMigrations, -} from "./db-sqlite-schema.js"; -import { initSqliteWalletDb } from "./dbtx-sqlite.js"; - -/** - * A migration must survive the database being closed and reopened, so these - * run against a file rather than :memory:. - */ -function withTempDb(): { path: string; cleanup: () => void } { - const dir = mkdtempSync(join(tmpdir(), "wallet-migration-test-")); - return { - path: join(dir, "wallet.sqlite3"), - cleanup: () => rmSync(dir, { recursive: true, force: true }), - }; -} - -async function openRaw(path: string) { - const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); - return await impl.open(path); -} - -async function queryAll(db: any, sql: string): Promise<any[]> { - return await (await db.prepare(sql)).getAll(); -} - -const addColumn: SchemaMigration = { - version: 2, - name: "add-tombstone-note", - statements: [ - "ALTER TABLE tombstones ADD COLUMN note TEXT", - // A migration is DDL *and* the backfill that makes the new column true of - // rows written before it existed. - "UPDATE tombstones SET note = 'backfilled' WHERE note IS NULL", - ], -}; - -test("migration applies DDL and backfills existing rows", async () => { - const { path, cleanup } = withTempDb(); - try { - // A database at baseline, with a row written before the new column exists. - let db = await openRaw(path); - await initSqliteWalletDb(db); - await ( - await db.prepare("INSERT INTO tombstones (id) VALUES ($id)") - ).run({ id: "pre-existing" }); - await db.close(); - - // Reopened by a build that has the migration. - db = await openRaw(path); - await initSqliteWalletDb(db, [addColumn]); - - const rows = await queryAll(db, "SELECT id, note FROM tombstones"); - assert.strictEqual(rows.length, 1); - assert.strictEqual( - rows[0].note, - "backfilled", - "the row written before the migration must be backfilled", - ); - - const applied = await queryAll( - db, - "SELECT version, name, applied_at FROM schema_migrations ORDER BY version", - ); - assert.deepStrictEqual( - applied.map((r) => Number(r.version)), - [1, 2, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), - "the baseline, synthetic migration and wallet migration must be recorded", - ); - assert.strictEqual(applied[1].name, "add-tombstone-note"); - // Microseconds, per the schema's convention for INTEGER timestamps. A - // millisecond value would be ~1000x too small and still look plausible. - const appliedAt = Number(applied[1].applied_at); - const nowMicros = Date.now() * 1000; - assert.ok( - appliedAt > nowMicros - 60_000_000 && appliedAt <= nowMicros + 1_000_000, - `applied_at ${appliedAt} is not a plausible microsecond timestamp`, - ); - - await db.close(); - } finally { - cleanup(); - } -}); - -test("exchange source migration upgrades an existing native database", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb( - db, - schemaMigrations.filter((x) => x.version < 8), - ); - let columns = await queryAll(db, "PRAGMA table_info(exchanges)"); - assert.ok(!columns.some((x) => x.name === "source")); - await db.close(); - - db = await openRaw(path); - await initSqliteWalletDb(db); - columns = await queryAll(db, "PRAGMA table_info(exchanges)"); - assert.ok(columns.some((x) => x.name === "source")); - const applied = await queryAll( - db, - "SELECT name FROM schema_migrations WHERE version = 8", - ); - assert.deepStrictEqual( - applied.map((x) => x.name), - ["exchange-entry-source"], - ); - await db.close(); - } finally { - cleanup(); - } -}); - -test("wallet query migration backfills availability and creates indexes", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb( - db, - schemaMigrations.filter((x) => x.version < 9), - ); - await ( - await db.prepare( - `INSERT INTO coin_availability ( - exchange_base_url, denom_pub_hash, max_age, currency, value, - exchange_master_pub, fresh_coin_count, visible_coin_count - ) VALUES ($url, $dph, $age, 'TESTKUDOS', 'TESTKUDOS:1', $mpk, $fresh, 0)`, - ) - ).run({ - url: "https://migration.example/", - dph: new Uint8Array([1]), - age: 0, - mpk: new Uint8Array([2]), - fresh: 3, - }); - await db.close(); - - db = await openRaw(path); - await initSqliteWalletDb(db); - const rows = await queryAll( - db, - "SELECT has_fresh_coins FROM coin_availability", - ); - assert.strictEqual(Number(rows[0].has_fresh_coins), 1); - const indexes = await queryAll(db, "PRAGMA index_list(coin_availability)"); - assert.ok( - indexes.some((x) => x.name === "coin_availability_by_exchange_fresh_age"), - ); - const txIndexes = await queryAll( - db, - "PRAGMA index_list(transactions_meta)", - ); - assert.ok( - txIndexes.some((x) => x.name === "transactions_meta_by_timestamp_id"), - ); - await db.close(); - } finally { - cleanup(); - } -}); - -test("peer capability migration deterministically removes legacy duplicates", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb( - db, - schemaMigrations.filter((x) => x.version < 10), - ); - - const exchange = "https://migration.example/"; - const sharedPushContract = new Uint8Array([1]); - const insertPush = await db.prepare( - `INSERT INTO peer_push_credit ( - peer_push_credit_id, exchange_base_url, purse_pub, merge_priv, - contract_priv, timestamp, estimated_amount_effective, - contract_terms_hash, status - ) VALUES ($id, $exchange, $purse, $merge, $contract, $timestamp, - 'TESTKUDOS:1', $hash, 0)`, - ); - await insertPush.run({ - id: "push-older", - exchange, - purse: new Uint8Array([2]), - merge: new Uint8Array([3]), - contract: sharedPushContract, - timestamp: 100, - hash: new Uint8Array([4]), - }); - await insertPush.run({ - id: "push-newer", - exchange, - purse: new Uint8Array([5]), - merge: new Uint8Array([6]), - contract: sharedPushContract, - timestamp: 200, - hash: new Uint8Array([7]), - }); - - const sharedPullContract = new Uint8Array([8]); - const insertPull = await db.prepare( - `INSERT INTO peer_pull_debit ( - peer_pull_debit_id, purse_pub, exchange_base_url, amount, - contract_terms_hash, timestamp_created, contract_priv, status, - total_cost_estimated - ) VALUES ($id, $purse, $exchange, 'TESTKUDOS:1', $hash, $timestamp, - $contract, 0, 'TESTKUDOS:1')`, - ); - // Equal timestamps deliberately exercise the stable primary-key tie-break. - await insertPull.run({ - id: "pull-z", - purse: new Uint8Array([9]), - exchange, - hash: new Uint8Array([10]), - timestamp: 300, - contract: sharedPullContract, - }); - await insertPull.run({ - id: "pull-a", - purse: new Uint8Array([11]), - exchange, - hash: new Uint8Array([12]), - timestamp: 300, - contract: sharedPullContract, - }); - - const insertMeta = await db.prepare( - `INSERT INTO transactions_meta - (transaction_id, timestamp, status, currency, exchanges) - VALUES ($id, $timestamp, 0, 'TESTKUDOS', '[]')`, - ); - const insertLocalId = await db.prepare( - `INSERT INTO transaction_local_ids - (transaction_id, transaction_type, local_ident) - VALUES ($id, $type, $localId)`, - ); - const insertRetry = await db.prepare( - `INSERT INTO operation_retries (id, retry_info) - VALUES ($id, '{}')`, - ); - for (const [type, id, timestamp, localId] of [ - ["peer-push-credit", "push-older", 100, 1], - ["peer-push-credit", "push-newer", 200, 2], - ["peer-pull-debit", "pull-z", 300, 3], - ["peer-pull-debit", "pull-a", 300, 4], - ] as const) { - await insertMeta.run({ - id: `txn:${type}:${id}`, - timestamp, - }); - await insertLocalId.run({ - id: `txn:${type}:${id}`, - type, - localId, - }); - await insertRetry.run({ id: `${type}:${id}` }); - } - await db.close(); - - db = await openRaw(path); - await initSqliteWalletDb(db); - - assert.deepStrictEqual( - await queryAll( - db, - "SELECT peer_push_credit_id FROM peer_push_credit ORDER BY peer_push_credit_id", - ), - [{ peer_push_credit_id: "push-older" }], - ); - assert.deepStrictEqual( - await queryAll( - db, - "SELECT peer_pull_debit_id FROM peer_pull_debit ORDER BY peer_pull_debit_id", - ), - [{ peer_pull_debit_id: "pull-a" }], - ); - const applied = await queryAll( - db, - "SELECT name FROM schema_migrations WHERE version = 10", - ); - assert.deepStrictEqual(applied, [ - { name: "unique-peer-payment-capabilities" }, - ]); - assert.deepStrictEqual( - await queryAll( - db, - "SELECT transaction_id FROM transactions_meta ORDER BY transaction_id", - ), - [ - { transaction_id: "txn:peer-pull-debit:pull-a" }, - { transaction_id: "txn:peer-push-credit:push-older" }, - ], - "metadata for discarded duplicate transactions must be removed", - ); - assert.deepStrictEqual( - await queryAll( - db, - "SELECT transaction_id FROM transaction_local_ids ORDER BY transaction_id", - ), - [ - { transaction_id: "txn:peer-pull-debit:pull-a" }, - { transaction_id: "txn:peer-push-credit:push-older" }, - ], - "local identifiers for discarded duplicates must be removed", - ); - assert.deepStrictEqual( - await queryAll(db, "SELECT id FROM operation_retries ORDER BY id"), - [{ id: "peer-pull-debit:pull-a" }, { id: "peer-push-credit:push-older" }], - "retry records for discarded duplicates must be removed", - ); - await db.close(); - } finally { - cleanup(); - } -}); - -test("a migration already recorded is not applied twice", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb(db, [addColumn]); - const first = await queryAll( - db, - "SELECT applied_at FROM schema_migrations WHERE version = 2", - ); - await ( - await db.prepare("INSERT INTO tombstones (id, note) VALUES ($id, $n)") - ).run({ id: "later", n: "written-by-hand" }); - await db.close(); - - // Opening again must not re-run the migration: the second statement of - // this one would overwrite the note of any row where it is NULL, but more - // importantly re-running arbitrary DDL fails outright (the column already - // exists), so a backend that ignored the log could not open at all. - db = await openRaw(path); - await initSqliteWalletDb(db, [addColumn]); - - const second = await queryAll( - db, - "SELECT applied_at FROM schema_migrations WHERE version = 2", - ); - assert.strictEqual( - Number(second[0].applied_at), - Number(first[0].applied_at), - "applied_at must not change: the migration should not have re-run", - ); - const rows = await queryAll( - db, - "SELECT note FROM tombstones WHERE id = 'later'", - ); - assert.strictEqual(rows[0].note, "written-by-hand"); - await db.close(); - } finally { - cleanup(); - } -}); - -test("a failing migration rolls back and is not recorded", async () => { - const { path, cleanup } = withTempDb(); - try { - const broken: SchemaMigration = { - version: 2, - name: "half-broken", - statements: [ - "ALTER TABLE tombstones ADD COLUMN note TEXT", - "UPDATE tombstones SET note = 'x'", - "THIS IS NOT SQL", - ], - }; - - let db = await openRaw(path); - await initSqliteWalletDb(db); - await ( - await db.prepare("INSERT INTO tombstones (id) VALUES ($id)") - ).run({ id: "row" }); - await db.close(); - - db = await openRaw(path); - await assert.rejects( - async () => await initSqliteWalletDb(db, [broken]), - "opening must fail rather than continue with a half-applied migration", - ); - - // The DDL must have rolled back too, not just the bookkeeping. Checking - // only schema_migrations would pass even if the transaction were - // committed on failure, because the row is inserted after the statements - // and so is never written either way -- sqlite makes ALTER TABLE - // transactional, and this is the assertion that depends on it. - const cols = await queryAll(db, "PRAGMA table_info(tombstones)"); - assert.ok( - !cols.some((c) => c.name === "note"), - "the column added by the failed migration must not survive", - ); - - // Nothing recorded, so the next attempt starts from a known state rather - // than skipping the migration as done. - const applied = await queryAll( - db, - "SELECT version FROM schema_migrations ORDER BY version", - ); - assert.deepStrictEqual( - applied.map((r) => Number(r.version)), - [1, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), - "a failed migration must not be recorded as applied", - ); - await db.close(); - } finally { - cleanup(); - } -}); - -test("migration versions must strictly increase", async () => { - const { path, cleanup } = withTempDb(); - try { - const db = await openRaw(path); - for (const bad of [ - [ - { version: 3, name: "c", statements: [] }, - { version: 2, name: "b", statements: [] }, - ], - [ - { version: 2, name: "a", statements: [] }, - { version: 2, name: "b", statements: [] }, - ], - // 1 is the baseline; reusing it would make the migration a silent no-op. - [{ version: 1, name: "clashes-with-baseline", statements: [] }], - ] as SchemaMigration[][]) { - await assert.rejects( - async () => await initSqliteWalletDb(db, bad), - `must reject ${JSON.stringify(bad.map((m) => m.version))}`, - ); - } - await db.close(); - } finally { - cleanup(); - } -}); - -test("a newer sqlite schema is rejected without being relabeled", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb(db); - const futureVersion = SQLITE_SCHEMA_VERSION + 1; - await db.exec(`PRAGMA user_version = ${futureVersion}`); - await db.close(); - - db = await openRaw(path); - await assert.rejects( - initSqliteWalletDb(db), - /newer.*schema|schema.*newer|version/i, - ); - const version = await queryAll(db, "PRAGMA user_version"); - assert.strictEqual(Number(version[0].user_version), futureVersion); - await db.close(); - } finally { - cleanup(); - } -}); - -test("a mismatched schema migration name is rejected", async () => { - const { path, cleanup } = withTempDb(); - try { - let db = await openRaw(path); - await initSqliteWalletDb(db); - await ( - await db.prepare( - "UPDATE schema_migrations SET name = 'impostor' WHERE version = 9", - ) - ).run({}); - await db.close(); - - db = await openRaw(path); - await assert.rejects( - initSqliteWalletDb(db), - /expected wallet-query-indexes/, - ); - const rows = await queryAll( - db, - "SELECT name FROM schema_migrations WHERE version = 9", - ); - assert.strictEqual(rows[0].name, "impostor"); - await db.close(); - } finally { - cleanup(); - } -}); diff --git a/packages/taler-wallet-core/src/db-sqlite-schema.ts b/packages/taler-wallet-core/src/db-sqlite-schema.ts @@ -1,1406 +0,0 @@ -/* - 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/> - */ - -/** - * Relational schema for the native sqlite3 wallet database. - * - * This is a clean-slate schema: it is not a translation of the IndexedDB - * object stores at the storage level, and it carries none of the historical - * fixups. Converting an existing IndexedDB wallet is a separate concern, and - * that converter is responsible for producing data as if every fixup had - * already run. - * - * Naming: snake_case tables and columns. The mapping to camelCase record - * fields is written out explicitly in dbtx-sqlite.ts, never derived by string - * munging, so a rename on either side is a compile error rather than a - * column that silently stops being read. - * - * The declared type of a column is not just documentation here: sqlite's type - * affinity means a value of the "wrong" type is usually stored happily and - * only misbehaves at comparison time. The conventions below say what each - * declared type actually means, and every column in this file is one of them. - * - * - INTEGER timestamps are microseconds since the epoch. - * Number.MAX_SAFE_INTEGER is the sentinel for "never"; NULL means "not - * known / not set", which is a different thing and is used deliberately. - * Column names end in _time, _stamp, or say so where declared. - * - * - TEXT amounts are the canonical Taler amount string, "CURRENCY:X.Y", not - * a number. They are compared for equality and grouped, never summed in - * SQL: amount arithmetic is done in TypeScript, where the currency is - * checked. Column names end in _amount, or name the amount they hold. - * - * - BLOB is key material: public and private keys, hashes, signatures, - * blinding factors, nonces. The record types expose these as Crockford - * base32 strings and dbtx-sqlite.ts converts at the field mapping. - * BLOB_COLUMNS below is the single source of truth for which columns these - * are. A new column holding key material must be BLOB and listed there; a - * TEXT one silently fails to match a correctly-encoded parameter, because - * sqlite never compares a TEXT value equal to a BLOB one. - * - * - TEXT that is neither of the above is a plain string: a base URL, a - * currency name, a label, an order or transaction id supplied by a - * merchant or exchange. - * - * - INTEGER status enums come from the numeric enums in db-common.ts, whose - * values are laid out so that "still active" is a contiguous range and the - * query is a BETWEEN. Six enums are *string* enums upstream and their - * columns are TEXT accordingly: CoinStatus, ExchangeMigrationReason, - * RefreshReason, DenomLossEventType, MerchantContractTokenKind and - * WithdrawalRecordType. Each such column says so where it is declared. - * - * - INTEGER 0/1 is a boolean; sqlite has no boolean type. Every column the - * mappers convert with boolToDb carries a CHECK constraint restricting it - * to 0, 1 or NULL, so a stray value cannot be stored and later read back as - * a surprising truthy number. Not every INTEGER column is a boolean: - * status enums and counts are integers too, and have their own rules below. - * - * - "JSON" in a column comment means TEXT holding a JSON document, written - * and read whole by JSON.stringify/JSON.parse and never inspected by SQL. - * Anything that has to be filtered, sorted or joined on is a real column - * instead, even where it conceptually belongs to such a payload -- and in - * a few places it is deliberately both, with the column authoritative for - * queries and the payload authoritative for the record. Those say so. - * - * - INTEGER PRIMARY KEY columns named *_serial are sqlite rowids, handed out - * by the database and stable for the life of a row. They are the targets - * of every foreign key here. - */ - -/** - * Schema version of a freshly created database. - * - * Bump this when adding a migration to {@link schemaMigrations}. - */ -export const SQLITE_SCHEMA_VERSION = 10; - -/** - * Tables of the IndexedDB emulation, children before parents. - * - * A migrated wallet keeps them in the same file, renamed out of the way, so - * the native schema has to know their names: the emulation creates them with - * IF NOT EXISTS, and a file it opened after they were renamed would look like - * a brand-new, empty wallet rather than like a mistake. - * - * The order is the one in which they can be dropped with foreign keys - * enforced: index_data and unique_index_data reference indexes, indexes - * references object_stores, object_stores references databases. - */ -export const IDB_EMULATION_TABLES = [ - "index_data", - "unique_index_data", - "object_data", - "indexes", - "object_stores", - "databases", -]; - -/** Prefix the migration renames the emulation's tables to. */ -export const IDB_BACKUP_PREFIX = "idb_backup_"; - -/** - * Tables that live in the file but are not wallet data. - * - * Both describe the file rather than the wallet: schema_migrations says which - * schema changes ran, idb_migration says where the data came from. Restoring - * either from a backup would state something untrue about the file it was - * restored into. - */ -export const NON_DATA_TABLES = ["schema_migrations", "idb_migration"]; - -/** - * SQL condition selecting the tables that hold wallet data. - * - * Written once and used by export, import and clear alike: each of them - * enumerates tables from sqlite_master, and one of them forgetting the - * emulation's retained backup would silently destroy or export it. - */ -export const DATA_TABLES_CONDITION = `type = 'table' - AND name NOT LIKE 'sqlite_%' - AND name NOT LIKE '${IDB_BACKUP_PREFIX}%' - AND name NOT IN (${[...NON_DATA_TABLES, ...IDB_EMULATION_TABLES] - .map((n) => `'${n}'`) - .join(", ")})`; - -/** - * A single, ordered schema evolution step. - * - * Replaces both mechanisms the IndexedDB backend needs (versionAdded for - * structure, walletDbFixups for data): in a relational schema adding a column - * is DDL and backfilling it is a statement in the same migration. - */ -export interface SchemaMigration { - /** Strictly increasing. Gaps are allowed; reuse is not. */ - version: number; - /** Stable name, for the audit trail in schema_migrations. */ - name: string; - /** - * DDL and/or data statements, one per entry. - * - * A list rather than one string because the helper's `exec` commits each - * call implicitly: statements that must be atomic have to be issued as - * prepared statements inside an explicit transaction, and those take one - * statement at a time. - */ - statements: string[]; -} - -/** - * The baseline schema, version 1. - * - * Complete: every store the wallet uses has a table here, and every method of - * WalletDbTransaction is implemented against it. - */ - -/** - * 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. - * - * Every column holding key material is listed, not just the ones in the - * high-row-count tables: a value of the same kind stored as TEXT in one table - * and BLOB in another compares unequal across the two, so a partial - * conversion is a worse state to be in than none at all. - */ -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", - "exchange_master_pub", - ], - 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, - name TEXT NOT NULL, - applied_at INTEGER NOT NULL -); - --- State of the one-way migration from the IndexedDB emulation, which happens --- inside this same file. --- --- The table exists in every native database; a row exists only where this --- database was produced by migrating an emulation database in place. That --- row is what decides which backend a file is opened with: the emulation's --- tables are still present under their idb_backup_ names, and the emulation --- would recreate the originals empty rather than report that they are gone. --- --- Not wallet data: excluded from export, import and clear alike, because it --- describes this file's history and not the wallet's contents. -CREATE TABLE IF NOT EXISTS idb_migration ( - -- One row, ever. - id INTEGER PRIMARY KEY CHECK (id = 1), - -- 'running', 'complete' or 'rolled-back'. 'running' means an attempt was - -- interrupted before the emulation tables were renamed away, so those - -- tables are still the authoritative copy and the native tables are a - -- partial write. 'rolled-back' means they were deliberately put back. - status TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER, - -- Records copied, for the log. - records_copied INTEGER, - -- 'retained', 'dropped' or 'restored': what became of the renamed - -- emulation tables. - backup_status TEXT, - -- After this time the retained backup tables may be dropped. Keeping them - -- for a while is what makes a migration that succeeded but produced a - -- broken wallet recoverable. - backup_expires_at INTEGER -); - --- Config is a key to JSON mapping. The DAL narrows the value type by key, --- so typed columns would buy nothing. -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS currency_info ( - scope_info_str TEXT PRIMARY KEY, - -- JSON: CurrencySpecification - currency_spec TEXT NOT NULL, - source TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS contacts ( - alias TEXT NOT NULL, - alias_type TEXT NOT NULL, - -- NOT NULL: ContactEntry declares all of these as required, and the mapper - -- reads them unguarded, so a NULL would surface as null typed as string. - mailbox_base_uri TEXT NOT NULL, - mailbox_address TEXT NOT NULL, - source TEXT NOT NULL, - petname TEXT NOT NULL, - PRIMARY KEY (alias, alias_type) -); - -CREATE TABLE IF NOT EXISTS mailbox_messages ( - origin_mailbox_base_url TEXT NOT NULL, - taler_uri TEXT NOT NULL, - -- The record type carries a protocol Timestamp ({ t_s }); the mapper - -- converts, as it does for every other time in this schema. - downloaded_at INTEGER NOT NULL, - PRIMARY KEY (origin_mailbox_base_url, taler_uri) -); - -CREATE TABLE IF NOT EXISTS mailbox_configurations ( - mailbox_base_url TEXT PRIMARY KEY, - -- JSON: MailboxConfiguration - payload TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS contract_terms ( - h TEXT PRIMARY KEY, - -- JSON: the raw contract terms, as received - contract_terms_raw TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS tombstones ( - id TEXT PRIMARY KEY -); - -CREATE TABLE IF NOT EXISTS operation_retries ( - id TEXT PRIMARY KEY, - -- JSON: TalerErrorDetail - last_error TEXT, - -- JSON: WalletRetryInfo - retry_info TEXT NOT NULL -); - --- Reserves are auto-increment: upsertReserve returns the generated row id and --- 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 BLOB NOT NULL, - reserve_priv BLOB NOT NULL, - status INTEGER, - requirement_row INTEGER, - threshold_requested TEXT, - threshold_granted TEXT, - threshold_next TEXT, - kyc_access_token TEXT, - aml_review INTEGER CHECK (aml_review IN (0, 1)) -); --- UNIQUE: getReserveByPub is a single-row lookup, so a duplicate would make --- it return an arbitrary one of the matches. -CREATE UNIQUE INDEX IF NOT EXISTS reserves_by_reserve_pub - ON reserves (reserve_pub); - --- Fees are flattened rather than JSON: byFamilyParms indexes four of them, --- and five of that index's seven components are AmountString, so named --- columns turn a transposition into a compile error rather than a silent --- wrong lookup. -CREATE TABLE IF NOT EXISTS denominations ( - exchange_base_url TEXT NOT NULL, - denom_pub_hash BLOB NOT NULL, - -- JSON: DenominationPubKey - denom_pub TEXT NOT NULL, - exchange_master_pub BLOB NOT NULL, - currency TEXT NOT NULL, - value TEXT NOT NULL, - -- Nullable: a denomination may be stored before its family is known. - denomination_family_serial INTEGER - REFERENCES denomination_families(denomination_family_serial) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - stamp_start INTEGER NOT NULL, - stamp_expire_withdraw INTEGER NOT NULL, - stamp_expire_deposit INTEGER NOT NULL, - stamp_expire_legal INTEGER NOT NULL, - fee_deposit TEXT NOT NULL, - fee_refresh TEXT NOT NULL, - fee_refund TEXT NOT NULL, - fee_withdraw TEXT NOT NULL, - is_offered INTEGER NOT NULL CHECK (is_offered IN (0, 1)), - is_revoked INTEGER NOT NULL CHECK (is_revoked IN (0, 1)), - is_lost INTEGER CHECK (is_lost IN (0, 1)), - master_sig BLOB NOT NULL, - verification_status INTEGER NOT NULL, - -- Keyed by the master public key that signed the denomination, not by the - -- exchange's URL: the URL is where the exchange currently answers and can - -- change, while the key is what decides whether a coin can be settled. - PRIMARY KEY (exchange_master_pub, denom_pub_hash) -); --- Only for the queries that mean every key set a URL has served; the --- denomination's identity is the key. -CREATE INDEX IF NOT EXISTS denominations_by_exchange_base_url - ON denominations (exchange_base_url); -CREATE INDEX IF NOT EXISTS denominations_by_verification_status - ON denominations (verification_status); --- Serves findDenominationByFamilyFromExpiry. denom_pub_hash is part of the --- index so a keyset continuation is total: rows sharing an expiry would --- otherwise be skipped by a strictly-greater cursor. -CREATE INDEX IF NOT EXISTS denominations_by_family_and_expiry - ON denominations (denomination_family_serial, stamp_expire_withdraw, denom_pub_hash); - -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 BLOB NOT NULL -); -CREATE UNIQUE INDEX IF NOT EXISTS global_currency_exchanges_by_cur_url_pub - ON global_currency_exchanges (currency, exchange_base_url, - exchange_master_pub); - -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 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); - -CREATE TABLE IF NOT EXISTS bank_accounts ( - bank_account_id TEXT PRIMARY KEY, - payto_uri TEXT NOT NULL, - label TEXT, - -- JSON: string[] - currencies TEXT, - kyc_completed INTEGER NOT NULL CHECK (kyc_completed IN (0, 1)) -); -CREATE INDEX IF NOT EXISTS bank_accounts_by_payto_uri - ON bank_accounts (payto_uri); - --- An issued token. See slates below for the pre-issuance form, which --- carries the same columns except token_issue_sig. -CREATE TABLE IF NOT EXISTS tokens ( - token_use_pub BLOB PRIMARY KEY, - token_use_priv BLOB NOT NULL, - purchase_id TEXT NOT NULL, - transaction_id TEXT, - choice_index INTEGER, - output_index INTEGER, - repeat_index INTEGER, - merchant_base_url TEXT NOT NULL, - kind TEXT NOT NULL, - token_issue_pub_hash BLOB NOT NULL, - token_family_hash BLOB, - valid_after INTEGER NOT NULL, - valid_before INTEGER NOT NULL, - -- JSON: UnblindedDenominationSignature - token_issue_sig TEXT NOT NULL, - -- JSON: TokenUseSig - token_use_sig TEXT, - -- JSON: TokenEnvelope - token_ev 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, - description TEXT NOT NULL, - -- JSON: MerchantContractTokenDetails - extra_data TEXT NOT NULL, - -- JSON: TokenIssuePublicKey - token_issue_pub TEXT NOT NULL, - -- JSON: translations, keyed by IETF language tag - description_i18n TEXT -); -CREATE INDEX IF NOT EXISTS tokens_by_issue_pub_hash - ON tokens (token_issue_pub_hash); -CREATE INDEX IF NOT EXISTS tokens_by_purchase_and_choice - ON tokens (purchase_id, choice_index); -CREATE INDEX IF NOT EXISTS tokens_by_family_hash - ON tokens (token_family_hash); - --- A slate is a token that has not been issued yet: the same record minus --- token_issue_sig, which is what the merchant adds on issuance. The columns --- are repeated rather than shared with tokens because the two are separate --- stores with their own record types, and a slate becoming a token is a move --- between them rather than a column being filled in. -CREATE TABLE IF NOT EXISTS slates ( - token_use_pub BLOB PRIMARY KEY, - token_use_priv BLOB NOT NULL, - purchase_id TEXT NOT NULL, - transaction_id TEXT, - choice_index INTEGER, - output_index INTEGER, - repeat_index INTEGER, - merchant_base_url TEXT NOT NULL, - kind TEXT NOT NULL, - token_issue_pub_hash BLOB NOT NULL, - token_family_hash BLOB, - valid_after INTEGER NOT NULL, - valid_before INTEGER NOT NULL, - -- JSON: TokenUseSig - token_use_sig TEXT, - -- JSON: TokenEnvelope - token_ev 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, - description TEXT NOT NULL, - -- JSON: MerchantContractTokenDetails - extra_data TEXT NOT NULL, - -- JSON: TokenIssuePublicKey - token_issue_pub TEXT NOT NULL, - -- JSON: translations, keyed by IETF language tag - description_i18n TEXT -); -CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat - ON slates (purchase_id, choice_index, output_index, repeat_index); - -CREATE TABLE IF NOT EXISTS refresh_sessions ( - refresh_group_id TEXT NOT NULL - REFERENCES refresh_groups(refresh_group_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - coin_index INTEGER NOT NULL, - session_public_seed BLOB, - -- Exchange protocol version of the refresh protocol the session melted - -- with; NULL means the v27 one. - refresh_protocol_version INTEGER, - amount_refresh_output TEXT NOT NULL, - -- JSON: { denomPubHash, count }[] - new_denoms TEXT NOT NULL, - noreveal_index INTEGER, - -- JSON: TalerErrorDetail - last_error TEXT, - PRIMARY KEY (refresh_group_id, coin_index) -); - -CREATE TABLE IF NOT EXISTS recoup_groups ( - recoup_group_id TEXT PRIMARY KEY, - exchange_base_url TEXT NOT NULL, - operation_status INTEGER NOT NULL, - timestamp_started INTEGER NOT NULL, - timestamp_finished INTEGER, - -- JSON: string[] - coin_pubs TEXT NOT NULL, - -- JSON: boolean[] - recoup_finished_per_coin TEXT NOT NULL, - -- JSON: CoinRefreshRequest[] - schedule_refresh_coins TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS recoup_groups_by_status - ON recoup_groups (operation_status); -CREATE INDEX IF NOT EXISTS recoup_groups_by_exchange - ON recoup_groups (exchange_base_url); - -CREATE TABLE IF NOT EXISTS donation_summaries ( - donau_base_url TEXT NOT NULL, - year INTEGER NOT NULL, - currency TEXT NOT NULL, - legal_domain TEXT, - amount_receipts_available TEXT NOT NULL, - amount_receipts_submitted TEXT NOT NULL, - PRIMARY KEY (donau_base_url, year, currency) -); - -CREATE TABLE IF NOT EXISTS donation_planchets ( - udi_nonce BLOB PRIMARY KEY, - donau_base_url TEXT NOT NULL, - donor_tax_id_hash BLOB NOT NULL, - -- TEXT, not BLOB, unlike its neighbours: this salt comes from the donau - -- service rather than from an encodeCrock call here, so nothing guarantees - -- it is Crockford at all. Converting it would risk an EncodingError on - -- real data. Same for purchases.donau_tax_id_salt. - 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, - -- JSON: BlindedUniqueDonationIdentifier - blinded_udi 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 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 BLOB NOT NULL, - -- JSON: DonationReceiptSignature - donation_unit_sig TEXT NOT NULL, - donor_tax_id_hash BLOB NOT NULL, - -- TEXT, not BLOB: see the note in donation_planchets. - donor_hash_salt TEXT NOT NULL, - donor_tax_id TEXT NOT NULL, - value TEXT NOT NULL, - udi_index INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS donation_receipts_by_status_and_donau - ON donation_receipts (status, donau_base_url); - -CREATE TABLE IF NOT EXISTS purchases ( - proposal_id TEXT PRIMARY KEY, - order_id TEXT NOT NULL, - merchant_base_url TEXT NOT NULL, - claim_token TEXT, - download_session_id TEXT, - repurchase_proposal_id TEXT, - purchase_status INTEGER NOT NULL, - abort_refresh_group_id TEXT, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - nonce_priv BLOB NOT NULL, - nonce_pub BLOB NOT NULL, - choice_index INTEGER, - secret_seed BLOB, - -- JSON: WalletPurchaseDownloadInfo - 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 - -- the payload. Same rule as withdrawal_groups.taler_withdraw_uri. - download_fulfillment_url TEXT, - -- JSON: WalletPurchasePayInfo - pay_info TEXT, - -- JSON: string[] - pending_removed_coin_pubs TEXT, - timestamp_first_successful_pay INTEGER, - merchant_pay_sig BLOB, - pos_confirmation TEXT, - donau_output_index INTEGER, - donau_base_url TEXT, - donau_amount TEXT, - donau_tax_id_hash BLOB, - -- TEXT, not BLOB: supplied by the donau service, not encodeCrock'd here. - donau_tax_id_salt TEXT, - donau_tax_id TEXT, - donau_year INTEGER, - shared INTEGER NOT NULL CHECK (shared IN (0, 1)), - created_from_shared INTEGER CHECK (created_from_shared IN (0, 1)), - timestamp INTEGER NOT NULL, - timestamp_accept INTEGER, - timestamp_last_refund_status INTEGER, - timestamp_expired INTEGER, - last_session_id TEXT, - auto_refund_deadline INTEGER, - refund_amount_awaiting TEXT, - taler_uri TEXT -); -CREATE INDEX IF NOT EXISTS purchases_by_status - ON purchases (purchase_status); -CREATE INDEX IF NOT EXISTS purchases_by_fulfillment_url - ON purchases (download_fulfillment_url); -CREATE INDEX IF NOT EXISTS purchases_by_url_and_order_id - ON purchases (merchant_base_url, order_id); - --- Replaces the multiEntry byExchange index. This is the only copy of --- WalletPurchase.exchanges: idx preserves array order so the mapper can --- rebuild it exactly, and there is no parallel JSON column to drift from. -CREATE TABLE IF NOT EXISTS purchase_exchanges ( - proposal_id TEXT NOT NULL - REFERENCES purchases(proposal_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - idx INTEGER NOT NULL, - exchange_base_url TEXT NOT NULL, - PRIMARY KEY (proposal_id, idx) -); -CREATE INDEX IF NOT EXISTS purchase_exchanges_by_exchange - ON purchase_exchanges (exchange_base_url); - --- Deposit and refresh groups keep most of their structure as JSON: the --- nested pieces (wire details, per-coin status, per-exchange info) are read --- and written whole, and no query filters on them. -CREATE TABLE IF NOT EXISTS deposit_groups ( - deposit_group_id TEXT PRIMARY KEY, - currency TEXT NOT NULL, - amount TEXT NOT NULL, - wire_transfer_deadline INTEGER NOT NULL, - merchant_pub BLOB NOT NULL, - merchant_priv BLOB NOT NULL, - nonce_priv BLOB NOT NULL, - nonce_pub BLOB NOT NULL, - -- JSON: { payto_uri, salt } - wire TEXT NOT NULL, - contract_terms_hash BLOB NOT NULL, - -- JSON: WalletCoinSelection - pay_coin_selection TEXT, - pay_coin_selection_uid TEXT, - total_pay_cost TEXT NOT NULL, - counterparty_effective_deposit_amount TEXT NOT NULL, - timestamp_created INTEGER NOT NULL, - timestamp_finished INTEGER, - timestamp_last_deposit_attempt INTEGER, - operation_status INTEGER NOT NULL, - -- JSON: DepositElementStatus[] - status_per_coin TEXT, - -- JSON: Record<string, WalletDepositInfoPerExchange> - info_per_exchange TEXT, - abort_refresh_group_id TEXT, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - -- JSON: WalletDepositKycInfo - kyc_info TEXT, - -- JSON: KycAuthTransferOptionRaw[] (legacy TransferOptionRaw[] is valid) - kyc_auth_transfer_options TEXT, - kyc_auth_transfer_expiry INTEGER, - -- JSON: wire transfer tracking, keyed by signature - tracking_state TEXT -); -CREATE INDEX IF NOT EXISTS deposit_groups_by_status - ON deposit_groups (operation_status); - -CREATE TABLE IF NOT EXISTS refresh_groups ( - refresh_group_id TEXT PRIMARY KEY, - operation_status INTEGER NOT NULL, - currency TEXT NOT NULL, - reason TEXT NOT NULL, - originating_transaction_id TEXT, - -- JSON: string[] - old_coin_pubs TEXT NOT NULL, - -- JSON: AmountString[] - input_per_coin TEXT NOT NULL, - -- JSON: AmountString[] - expected_output_per_coin TEXT NOT NULL, - -- JSON: Record<string, WalletRefreshGroupPerExchangeInfo> - info_per_exchange TEXT, - -- JSON: RefreshCoinStatus[] - status_per_coin TEXT NOT NULL, - -- JSON: ExchangeRefundRequest, keyed by index - refund_requests TEXT NOT NULL, - timestamp_created INTEGER NOT NULL, - -- JSON: TalerErrorDetail - fail_reason TEXT, - timestamp_finished INTEGER -); -CREATE INDEX IF NOT EXISTS refresh_groups_by_status - ON refresh_groups (operation_status); -CREATE INDEX IF NOT EXISTS refresh_groups_by_originating_transaction - ON refresh_groups (originating_transaction_id); - -CREATE TABLE IF NOT EXISTS denom_loss_events ( - denom_loss_event_id TEXT PRIMARY KEY, - currency TEXT NOT NULL, - -- JSON: string[] - denom_pub_hashes TEXT NOT NULL, - status INTEGER NOT NULL, - timestamp_created INTEGER NOT NULL, - amount TEXT NOT NULL, - event_type TEXT NOT NULL, - exchange_base_url TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS denom_loss_events_by_currency - ON denom_loss_events (currency); -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 BLOB PRIMARY KEY, - exchange_base_url TEXT NOT NULL, - -- JSON: ScopeInfo - restrict_scope TEXT, - amount TEXT NOT NULL, - total_cost TEXT NOT NULL, - -- JSON: DbPeerPushPaymentCoinSelection - coin_sel TEXT, - 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, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - status INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS peer_push_debit_by_status - ON peer_push_debit (status); - -CREATE TABLE IF NOT EXISTS peer_push_credit ( - peer_push_credit_id TEXT PRIMARY KEY, - exchange_base_url 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 BLOB NOT NULL, - status INTEGER NOT NULL, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - withdrawal_group_id TEXT, - currency TEXT, - kyc_payto_hash BLOB, - kyc_access_token TEXT, - kyc_last_check_status INTEGER, - kyc_last_check_code INTEGER, - kyc_last_rule_gen INTEGER, - kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), - kyc_last_deny INTEGER -); -CREATE INDEX IF NOT EXISTS peer_push_credit_by_status - ON peer_push_credit (status); -CREATE INDEX IF NOT EXISTS peer_push_credit_by_exchange_and_purse - ON peer_push_credit (exchange_base_url, purse_pub); -CREATE INDEX IF NOT EXISTS peer_push_credit_by_exchange_and_contract_priv - ON peer_push_credit (exchange_base_url, contract_priv); -CREATE INDEX IF NOT EXISTS peer_push_credit_by_withdrawal_group - ON peer_push_credit (withdrawal_group_id); - -CREATE TABLE IF NOT EXISTS peer_pull_debit ( - peer_pull_debit_id TEXT PRIMARY KEY, - purse_pub BLOB NOT NULL, - exchange_base_url TEXT NOT NULL, - amount TEXT NOT NULL, - contract_terms_hash BLOB NOT NULL, - timestamp_created INTEGER NOT NULL, - contract_priv BLOB NOT NULL, - status INTEGER NOT NULL, - total_cost_estimated TEXT NOT NULL, - abort_refresh_group_id TEXT, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - -- JSON: PeerPullPaymentCoinSelection - coin_sel TEXT -); -CREATE INDEX IF NOT EXISTS peer_pull_debit_by_status - ON peer_pull_debit (status); -CREATE INDEX IF NOT EXISTS peer_pull_debit_by_exchange_and_purse - ON peer_pull_debit (exchange_base_url, purse_pub); -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 BLOB PRIMARY KEY, - exchange_base_url TEXT NOT NULL, - amount TEXT NOT NULL, - estimated_amount_effective 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 BLOB, - kyc_access_token TEXT, - kyc_last_check_status INTEGER, - kyc_last_check_code INTEGER, - kyc_last_rule_gen INTEGER, - kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), - kyc_last_deny INTEGER, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - withdrawal_group_id TEXT -); -CREATE INDEX IF NOT EXISTS peer_pull_credit_by_status - ON peer_pull_credit (status); -CREATE INDEX IF NOT EXISTS peer_pull_credit_by_withdrawal_group - ON peer_pull_credit (withdrawal_group_id); - -CREATE TABLE IF NOT EXISTS transactions_meta ( - transaction_id TEXT PRIMARY KEY, - timestamp INTEGER NOT NULL, - status INTEGER NOT NULL, - currency TEXT NOT NULL, - -- JSON array. The IndexedDB schema indexes this multiEntry, but no DAL - -- query uses that index (nor byCurrency), so no junction table is needed - -- until one appears. - exchanges TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS transactions_meta_by_timestamp - ON transactions_meta (timestamp); -CREATE INDEX IF NOT EXISTS transactions_meta_by_status - ON transactions_meta (status); - --- Local transaction identifiers deliberately live outside the materialized --- view. Re-materializing transactions must not renumber user-facing IDs. -CREATE TABLE IF NOT EXISTS transaction_local_id_counters ( - transaction_type TEXT PRIMARY KEY, - next_ident INTEGER NOT NULL -); -CREATE TABLE IF NOT EXISTS transaction_local_ids ( - transaction_id TEXT PRIMARY KEY, - transaction_type TEXT NOT NULL, - local_ident INTEGER NOT NULL, - UNIQUE (transaction_type, local_ident) -); - -CREATE TABLE IF NOT EXISTS exchanges ( - base_url TEXT PRIMARY KEY, - preset_currency_hint TEXT, - -- JSON: CurrencySpecification - preset_currency_spec TEXT, - preset_type TEXT, - last_withdrawal INTEGER, - -- detailsPointer is flattened. It is declared as - -- 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 BLOB, - details_pointer_currency TEXT, - details_pointer_update_clock INTEGER, - entry_status INTEGER NOT NULL, - update_status INTEGER NOT NULL, - -- JSON: TalerErrorDetail - unavailable_reason TEXT, - cachebreak_next_update INTEGER CHECK (cachebreak_next_update IN (0, 1)), - tos_current_etag TEXT, - tos_accepted_etag TEXT, - tos_accepted_timestamp INTEGER, - last_update INTEGER, - next_update_stamp INTEGER NOT NULL, - last_keys_etag TEXT, - next_refresh_check_stamp INTEGER NOT NULL, - current_merge_reserve_row_id INTEGER, - current_account_priv BLOB, - current_account_pub BLOB, - peer_payments_disabled INTEGER CHECK (peer_payments_disabled IN (0, 1)), - direct_deposit_disabled INTEGER CHECK (direct_deposit_disabled IN (0, 1)), - no_fees INTEGER CHECK (no_fees IN (0, 1)), - -- Key set this exchange used before it changed keys, kept until the user - -- confirms the change. Flattened like details_pointer. - superseded_master_pub BLOB, - superseded_currency TEXT, - superseded_first_seen INTEGER, - superseded_shares_denoms INTEGER - CHECK (superseded_shares_denoms IN (0, 1)), - -- The three details_pointer columns are one value. The mapper checks only - -- the master pub and then reads the other two unguarded, so a partially - -- set pointer would yield null typed as string. - CHECK ( - (details_pointer_master_pub IS NULL) = (details_pointer_currency IS NULL) - AND (details_pointer_master_pub IS NULL) - = (details_pointer_update_clock IS NULL) - ), - CHECK ( - (superseded_master_pub IS NULL) = (superseded_currency IS NULL) - AND (superseded_master_pub IS NULL) = (superseded_first_seen IS NULL) - ) -); - -CREATE TABLE IF NOT EXISTS exchange_details ( - row_id INTEGER PRIMARY KEY AUTOINCREMENT, - exchange_base_url TEXT NOT NULL, - master_public_key BLOB NOT NULL, - currency TEXT NOT NULL, - -- JSON: ExchangeAuditor[] - auditors TEXT NOT NULL, - protocol_version_range TEXT NOT NULL, - tiny_amount TEXT NOT NULL, - -- JSON: TalerProtocolDuration - reserve_closing_delay TEXT NOT NULL, - shopping_url TEXT, - -- JSON: ExchangeGlobalFees[] - global_fees TEXT NOT NULL, - -- JSON: WireInfo - wire_info TEXT NOT NULL, - age_mask INTEGER, - -- JSON: AmountString[] - wallet_balance_limits TEXT, - -- JSON: AccountLimit[] - hard_limits TEXT, - -- JSON: ZeroLimitedOperation[] - zero_limits TEXT, - bank_compliance_language TEXT, - -- JSON: TalerProtocolDuration - default_peer_push_expiration TEXT -); --- The pointer identifies at most one details row. -CREATE UNIQUE INDEX IF NOT EXISTS exchange_details_by_pointer - ON exchange_details (exchange_base_url, currency, master_public_key); --- Not unique: the same exchange can be known under two base URLs while a --- migration between them is still in progress. -CREATE INDEX IF NOT EXISTS exchange_details_by_master_pub - ON exchange_details (master_public_key); - -CREATE TABLE IF NOT EXISTS exchange_sign_keys ( - exchange_details_row_id INTEGER NOT NULL - REFERENCES exchange_details(row_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - signkey_pub BLOB NOT NULL, - stamp_start INTEGER NOT NULL, - stamp_expire INTEGER NOT NULL, - stamp_end INTEGER NOT NULL, - master_sig BLOB NOT NULL, - PRIMARY KEY (exchange_details_row_id, signkey_pub) -); - --- familyParams is flattened into its seven components because the lookup is --- on the whole tuple; keeping it as JSON would make that query a scan. -CREATE TABLE IF NOT EXISTS denomination_families ( - denomination_family_serial INTEGER PRIMARY KEY AUTOINCREMENT, - exchange_base_url TEXT NOT NULL, - exchange_master_pub BLOB NOT NULL, - value TEXT NOT NULL, - fee_withdraw TEXT NOT NULL, - fee_deposit TEXT NOT NULL, - fee_refresh TEXT NOT NULL, - fee_refund TEXT NOT NULL -); -CREATE UNIQUE INDEX IF NOT EXISTS denomination_families_by_params - ON denomination_families ( - exchange_base_url, exchange_master_pub, value, - fee_withdraw, fee_deposit, fee_refresh, fee_refund - ); - -CREATE TABLE IF NOT EXISTS exchange_base_url_fixups ( - exchange_base_url TEXT PRIMARY KEY, - replacement TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS exchange_base_url_migration_log ( - old_exchange_base_url TEXT NOT NULL, - new_exchange_base_url TEXT NOT NULL, - timestamp INTEGER NOT NULL, - -- TEXT: ExchangeMigrationReason is a string enum. - reason TEXT NOT NULL, - PRIMARY KEY (old_exchange_base_url, new_exchange_base_url) -); - --- The wgInfo union is stored as a discriminant, two promoted scalars and two --- JSON columns, rather than as a side table per variant or one opaque blob: --- the promoted columns are the ones queries filter on, and the variants differ --- too little to justify a table each. -CREATE TABLE IF NOT EXISTS withdrawal_groups ( - withdrawal_group_id TEXT PRIMARY KEY, - -- The wgInfo discriminant (WithdrawalRecordType, a string enum). - withdrawal_type TEXT NOT NULL, - -- The ONLY copy of this value: the mapper strips it from the JSON payload - -- on write and re-inserts it on read. Keeping a second copy in bank_info - -- 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 BLOB, - bank_info TEXT, - -- JSON: WithdrawalExchangeAccountDetails[] - exchange_credit_accounts TEXT, - is_foreign_account INTEGER CHECK (is_foreign_account IN (0, 1)), - kyc_payto_hash BLOB, - kyc_access_token TEXT, - kyc_last_check_status INTEGER, - kyc_last_check_code INTEGER, - kyc_last_rule_gen INTEGER, - kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), - kyc_last_deny INTEGER, - -- JSON: TalerProtocolDuration - kyc_withdrawal_delay TEXT, - 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, - status INTEGER NOT NULL, - restrict_age INTEGER, - instructed_amount TEXT, - reserve_balance_amount TEXT, - raw_withdrawal_amount TEXT, - effective_withdrawal_amount TEXT, - -- JSON: DenomSelectionState - denoms_sel TEXT, - -- JSON: TalerErrorDetail - abort_reason TEXT, - -- JSON: TalerErrorDetail - fail_reason TEXT, - -- Variant correctness lives here rather than in a side table: bank_info and - -- taler_withdraw_uri are present exactly for the bank-integrated variant. - -- The URI is included because the mapper reads it unguarded for that - -- variant, and it is the only copy of the value. - CHECK ((withdrawal_type = 'bank-integrated') = (bank_info IS NOT NULL)), - CHECK ( - (withdrawal_type = 'bank-integrated') = (taler_withdraw_uri IS NOT NULL) - ) -); -CREATE INDEX IF NOT EXISTS withdrawal_groups_by_status - ON withdrawal_groups (status); -CREATE INDEX IF NOT EXISTS withdrawal_groups_by_exchange - ON withdrawal_groups (exchange_base_url); -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 BLOB PRIMARY KEY, - coin_priv BLOB NOT NULL, - withdrawal_group_id TEXT NOT NULL - REFERENCES withdrawal_groups(withdrawal_group_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - coin_idx INTEGER NOT NULL, - planchet_status INTEGER NOT NULL, - -- JSON: TalerErrorDetail - last_error TEXT, - denom_pub_hash BLOB NOT NULL, - blinding_key BLOB NOT NULL, - withdraw_sig BLOB NOT NULL, - -- JSON: CoinEnvelope - coin_ev TEXT NOT NULL, - coin_ev_hash BLOB NOT NULL, - -- JSON: AgeCommitmentProof - age_commitment_proof TEXT -); -CREATE UNIQUE INDEX IF NOT EXISTS planchets_by_group_and_index - ON planchets (withdrawal_group_id, coin_idx); -CREATE INDEX IF NOT EXISTS planchets_by_coin_ev - ON planchets (coin_ev_hash); - -CREATE TABLE IF NOT EXISTS coins ( - coin_pub BLOB PRIMARY KEY, - coin_priv BLOB NOT NULL, - exchange_base_url TEXT NOT NULL, - -- Nullable: a coin whose denomination was already gone when the field was - -- introduced has no key recorded, and the mapper reads that as unknown. - exchange_master_pub BLOB, - denom_pub_hash BLOB NOT NULL, - -- JSON: UnblindedDenominationSignature - denom_sig 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, - visible INTEGER, - max_age INTEGER NOT NULL, - -- Absent for coins without age restriction; the record type spells this - -- as a required property that may hold undefined, so the mapper always - -- sets the key. - -- JSON: AgeCommitmentProof - age_commitment_proof TEXT, - -- JSON: WalletCoinSource - coin_source TEXT NOT NULL, - source_transaction_id TEXT -); -CREATE INDEX IF NOT EXISTS coins_by_denom_pub_hash - ON coins (denom_pub_hash); -CREATE INDEX IF NOT EXISTS coins_by_coin_ev_hash - ON coins (coin_ev_hash); -CREATE INDEX IF NOT EXISTS coins_by_source_transaction_id - ON coins (source_transaction_id); --- Serves getFreshCoinsByDenomAndAge, which looks up an exact four-tuple. -CREATE INDEX IF NOT EXISTS coins_by_master_pub_denom_age_status - ON coins (exchange_master_pub, denom_pub_hash, max_age, status); - -CREATE TABLE IF NOT EXISTS coin_availability ( - exchange_base_url 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 BLOB NOT NULL, - -- Counts, not flags. A negative value means a decrement ran without a - -- matching increment, which is a bug worth failing on rather than - -- storing: the coin selector reads these to decide what is spendable. - fresh_coin_count INTEGER NOT NULL CHECK (fresh_coin_count >= 0), - visible_coin_count INTEGER NOT NULL CHECK (visible_coin_count >= 0), - pending_refresh_output_count INTEGER - CHECK (pending_refresh_output_count >= 0), - PRIMARY KEY (exchange_master_pub, denom_pub_hash, max_age) -); --- Retained for compatibility with existing databases. Migration 9 adds the --- correctness-preserving exchange/has-fresh/age index used by current code. -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 BLOB PRIMARY KEY - REFERENCES coins(coin_pub) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - -- JSON: WalletCoinHistoryItem[] - history TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS refund_groups ( - refund_group_id TEXT PRIMARY KEY, - proposal_id TEXT NOT NULL - REFERENCES purchases(proposal_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - status INTEGER NOT NULL, - timestamp_created INTEGER NOT NULL, - amount_raw TEXT NOT NULL, - amount_effective TEXT NOT NULL, - refresh_group_id TEXT -); -CREATE INDEX IF NOT EXISTS refund_groups_by_proposal - ON refund_groups (proposal_id); -CREATE INDEX IF NOT EXISTS refund_groups_by_status - ON refund_groups (status); - -CREATE TABLE IF NOT EXISTS refund_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - -- Deferred: pay-merchant.ts writes refund items before the group they - -- belong to, within one transaction (upsertRefundItem then, ~30 lines - -- later, upsertRefundGroup). An immediate constraint would reject that - -- ordering; a deferred one still guarantees no orphans at commit. - refund_group_id TEXT NOT NULL - REFERENCES refund_groups(refund_group_id) - ON DELETE CASCADE - DEFERRABLE INITIALLY DEFERRED, - status INTEGER NOT NULL, - proposal_id TEXT, - execution_time INTEGER NOT NULL, - obtained_time INTEGER NOT NULL, - refund_amount TEXT NOT NULL, - coin_pub BLOB NOT NULL, - rtxid INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS refund_items_by_group - ON refund_items (refund_group_id); -CREATE UNIQUE INDEX IF NOT EXISTS refund_items_by_coin_and_rtxid - ON refund_items (coin_pub, rtxid); -`; - -const legacyPeerPushCreditDuplicate = - "EXISTS (SELECT 1 FROM peer_push_credit AS canonical" + - " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" + - " AND canonical.contract_priv = duplicate.contract_priv" + - " AND (canonical.timestamp < duplicate.timestamp" + - " OR (canonical.timestamp = duplicate.timestamp" + - " AND canonical.peer_push_credit_id < duplicate.peer_push_credit_id)))"; - -const legacyPeerPullDebitDuplicate = - "EXISTS (SELECT 1 FROM peer_pull_debit AS canonical" + - " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" + - " AND canonical.contract_priv = duplicate.contract_priv" + - " AND (canonical.timestamp_created < duplicate.timestamp_created" + - " OR (canonical.timestamp_created = duplicate.timestamp_created" + - " AND canonical.peer_pull_debit_id < duplicate.peer_pull_debit_id)))"; - -/** - * Migrations applied on top of the baseline. - * - * Empty: no native database exists yet that has to survive a schema change, - * so the baseline is still edited directly. - * - * That stops being true the moment one does. The baseline is all - * CREATE ... IF NOT EXISTS and is re-executed on every open, so an existing - * table keeps the definition it was created with, and a column added only to - * the baseline would be missing from every database created before the edit. - * From then on, every change appends an entry here and bumps - * {@link SQLITE_SCHEMA_VERSION} -- and goes in one place only, since a fresh - * database runs the baseline *and* the migrations. - */ -export const schemaMigrations: SchemaMigration[] = [ - { - version: 6, - name: "clause-schnorr-exchange-withdraw-values", - statements: [ - 'ALTER TABLE planchets ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', - 'ALTER TABLE coins ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', - ], - }, - { - version: 7, - name: "indexeddb-migration-cleanup-ownership", - statements: [ - "ALTER TABLE idb_migration ADD COLUMN cleanup_safe INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_safe IN (0, 1))", - ], - }, - { - version: 8, - name: "exchange-entry-source", - statements: ["ALTER TABLE exchanges ADD COLUMN source TEXT"], - }, - { - version: 9, - name: "wallet-query-indexes", - statements: [ - "ALTER TABLE coin_availability ADD COLUMN has_fresh_coins INTEGER NOT NULL DEFAULT 0 CHECK (has_fresh_coins IN (0, 1))", - "UPDATE coin_availability SET has_fresh_coins = CASE WHEN fresh_coin_count > 0 THEN 1 ELSE 0 END", - "CREATE INDEX coin_availability_by_exchange_fresh_age ON coin_availability (exchange_base_url, has_fresh_coins, max_age)", - "CREATE INDEX transactions_meta_by_timestamp_id ON transactions_meta (timestamp, transaction_id)", - "CREATE INDEX coins_by_exchange_base_url ON coins (exchange_base_url)", - "CREATE INDEX coins_by_master_pub_denom_age_status_pub ON coins (exchange_master_pub, denom_pub_hash, max_age, status, coin_pub)", - ], - }, - { - version: 10, - name: "unique-peer-payment-capabilities", - statements: [ - // Older SQLite wallets could commit the same URI twice because these - // indexes were not unique. Retain the first-created record (breaking an - // equal-timestamp tie by primary key) before strengthening the indexes. - `DELETE FROM transactions_meta WHERE transaction_id IN - (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id - FROM peer_push_credit AS duplicate - WHERE ${legacyPeerPushCreditDuplicate})`, - `DELETE FROM transaction_local_ids WHERE transaction_id IN - (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id - FROM peer_push_credit AS duplicate - WHERE ${legacyPeerPushCreditDuplicate})`, - `DELETE FROM operation_retries WHERE id IN - (SELECT 'peer-push-credit:' || duplicate.peer_push_credit_id - FROM peer_push_credit AS duplicate - WHERE ${legacyPeerPushCreditDuplicate})`, - `DELETE FROM peer_push_credit AS duplicate - WHERE ${legacyPeerPushCreditDuplicate}`, - "DROP INDEX peer_push_credit_by_exchange_and_contract_priv", - "CREATE UNIQUE INDEX peer_push_credit_by_exchange_and_contract_priv ON peer_push_credit (exchange_base_url, contract_priv)", - `DELETE FROM transactions_meta WHERE transaction_id IN - (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id - FROM peer_pull_debit AS duplicate - WHERE ${legacyPeerPullDebitDuplicate})`, - `DELETE FROM transaction_local_ids WHERE transaction_id IN - (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id - FROM peer_pull_debit AS duplicate - WHERE ${legacyPeerPullDebitDuplicate})`, - `DELETE FROM operation_retries WHERE id IN - (SELECT 'peer-pull-debit:' || duplicate.peer_pull_debit_id - FROM peer_pull_debit AS duplicate - WHERE ${legacyPeerPullDebitDuplicate})`, - `DELETE FROM peer_pull_debit AS duplicate - WHERE ${legacyPeerPullDebitDuplicate}`, - "DROP INDEX peer_pull_debit_by_exchange_and_contract_priv", - "CREATE UNIQUE INDEX peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv)", - ], - }, -]; - -/** Native tables that contain wallet records (not schema bookkeeping). */ -export const NATIVE_DATA_TABLES = [ - ...SQLITE_BASELINE_SCHEMA.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g), -] - .map((m) => m[1]) - .filter((name) => !NON_DATA_TABLES.includes(name)); diff --git a/packages/taler-wallet-core/src/db/handle.ts b/packages/taler-wallet-core/src/db/handle.ts @@ -0,0 +1,145 @@ +/* + 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/> + */ + +/** + * Backend-neutral handle for the wallet database as a whole. + * + * WalletDbTransaction abstracts a running transaction; this abstracts the + * database that hands them out, together with the operations that act on the + * store as a unit rather than on records: export, import, clear. + * + * The wallet holds exactly one of these. It used to hold an IndexedDB factory + * and an optional native sqlite handle side by side, and every operation that + * worked on the whole database had to pick one -- with the wrong choice + * producing a valid, empty database rather than an error, so a mistake looked + * like a freshly initialised wallet instead of a failure. With a single + * handle, that choice does not exist to get wrong. + */ + +import { WalletNotification } from "@gnu-taler/taler-util"; + +import { WalletDbTransaction } from "./transaction.js"; + +/** + * Number of records a backend has read, for tests that assert a query is + * bounded rather than scanning. + */ +export interface WalletDbAccessStats { + recordsRead: number; +} + +/** + * Wallet-level work that must become visible in the same atomic import as the + * restored records (currently rebuilding the materialized transaction view). + */ +export type WalletDbImportFinalizer = ( + tx: WalletDbTransaction, +) => Promise<void>; + +export interface WalletDbHandle { + /** + * Which backend this is, for logs and test names. + * + * Deliberately not something to branch on: code that needs to know whether a + * capability is present should test for the capability. + */ + readonly name: string; + + /** + * Run f in a read-write transaction over all stores and return its result. + */ + runReadWriteTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>; + + /** + * Serialise the whole database into a backend-specific dump. + * + * The dump is opaque to callers and is only meaningful to importDatabase on + * the same backend. + */ + exportDatabase(): Promise<any>; + + /** + * Replace the contents of the database with a dump. + * + * Returns with the database consistent: a backend whose stored records need + * repairing after an import that may predate its current schema does that + * repair here. The supplied finalizer rebuilds wallet-level derived state + * before the replacement becomes visible. A failure in import, repair, or + * finalization leaves the previous database authoritative. + * + * Throws if the dump did not come from this backend. + */ + importDatabase(dump: any, finalize: WalletDbImportFinalizer): Promise<void>; + + /** Remove all records, leaving an empty database of the current schema. */ + clearDatabase(): Promise<void>; + + /** Access statistics, if the backend tracks them. */ + getAccessStats(): WalletDbAccessStats | undefined; + + /** + * Where post-commit notifications go. + * + * The host opens the database before the wallet that will consume its + * notifications exists, so the sink starts as a no-op and the wallet + * installs its own during construction. + */ + setNotificationSink(sink: (n: WalletNotification) => void): void; + + /** Emit non-transactional maintenance progress to the installed sink. */ + emitNotification(notification: WalletNotification): void; + + /** + * Copy the database to a file, in whatever format the backend supports. + * + * Absent when the host cannot do this -- a browser extension has no + * filesystem -- so callers must say what they do without it rather than + * receive an error from a method that looked available. + */ + exportToFile?( + directory: string, + stem: string, + forceFormat?: string, + ): Promise<{ path: string }>; + + /** Read a dump previously written by exportToFile. Absent if unsupported. */ + readBackupJson?(path: string): Promise<any>; + + /** + * Migrate this database in place to the native schema and return the handle + * to use from here on. + * + * Present only where the host keeps the wallet in a sqlite file it can open + * with either schema, which is every host except a browser extension: there + * IndexedDB is the real thing rather than an emulation over sqlite, and + * there is nothing to migrate to. + * + * This handle is unusable afterwards. On failure it is untouched and still + * the authoritative database. + */ + migrateToNative?(): Promise<WalletDbHandle>; + + /** + * Backend-specific counters for the testing API. + * + * Deliberately untyped: this is diagnostic output whose shape follows + * whichever backend produced it, unlike getAccessStats which is the one + * figure both backends agree on. + */ + getDiagnosticStats?(): unknown; + + close(): Promise<void>; +} diff --git a/packages/taler-wallet-core/src/db/indexeddb/database.test.ts b/packages/taler-wallet-core/src/db/indexeddb/database.test.ts @@ -0,0 +1,254 @@ +/* + 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/> + */ + +import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import assert from "node:assert"; +import { test } from "node:test"; +import { ConfigRecordKey } from "../records.js"; +import { + abortTalerDatabaseReplacement, + beginTalerDatabaseReplacement, + openTalerDatabase, + publishTalerDatabaseReplacement, +} from "./database.js"; +import { exportSingleDb } from "./dump.js"; +import { applyFixups } from "./fixups.js"; +import { TALER_WALLET_MAIN_DB_NAME } from "./schema.js"; +import { IdbWalletDbHandle } from "./handle.js"; +import { makeIdbRunner } from "../testing/runners.js"; + +test("indexeddb import clears stores absent from an older dump", async () => { + const handle = await makeIdbRunner(); + try { + const dump = await handle.exportDatabase(); + delete dump.databases[TALER_WALLET_MAIN_DB_NAME].stores.contacts; + + await handle.runReadWriteTx((tx) => + tx.addContact({ + alias: "alice", + aliasType: "email", + mailboxBaseUri: "https://mailbox.example/", + mailboxAddress: "mailbox-address" as any, + source: "test", + petname: "Alice", + }), + ); + await handle.importDatabase(dump, async () => {}); + + const contacts = await handle.runReadWriteTx((tx) => tx.listContacts()); + assert.deepStrictEqual(contacts, []); + } finally { + await handle.close(); + } +}); + +test("notification sink exceptions do not prevent the first database open", async () => { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + BridgeIDBFactory.enableTracing = false; + const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); + handle.setNotificationSink(() => { + throw Error("host notification failure"); + }); + try { + const result = await handle.ensureOpen(); + assert.ok(result.fixupsApplied > 0); + await handle.runReadWriteTx((tx) => + tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 1 }), + ); + } finally { + await handle.close(); + } +}); + +test("a failed fixup is retried on the next database open", async () => { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + BridgeIDBFactory.enableTracing = false; + let fixupAttempts = 0; + const handle = new IdbWalletDbHandle( + new BridgeIDBFactory(backend), + undefined, + async (access, notify) => { + fixupAttempts++; + if (fixupAttempts === 1) { + throw Error("injected fixup failure"); + } + return await applyFixups(access, notify); + }, + ); + try { + await assert.rejects(handle.ensureOpen(), /injected fixup failure/); + + const result = await handle.ensureOpen(); + assert.strictEqual(fixupAttempts, 2); + assert.ok(result.fixupsApplied > 0); + await handle.runReadWriteTx((tx) => + tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 2 }), + ); + } finally { + await handle.close(); + } +}); + +test("indexeddb import commit survives reopen and failure preserves old generation", async () => { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + BridgeIDBFactory.enableTracing = false; + const factory = new BridgeIDBFactory(backend); + let handle = new IdbWalletDbHandle(factory); + const source = await makeIdbRunner(); + const restored = await makeIdbRunner(); + try { + await handle.ensureOpen(); + await handle.runReadWriteTx((tx) => + tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 10 }), + ); + await source.runReadWriteTx((tx) => + tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 20 }), + ); + const dump = await source.exportDatabase(); + + await assert.rejects( + handle.importDatabase(dump, async () => { + throw Error("injected rematerialization failure"); + }), + /injected rematerialization failure/, + ); + await handle.close(); + handle = new IdbWalletDbHandle(factory); + await handle.ensureOpen(); + assert.strictEqual( + ( + await handle.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ) + )?.value, + 10, + ); + + await handle.importDatabase(dump, async (tx) => { + await tx.upsertConfig({ key: ConfigRecordKey.TestLoopTx, value: 30 }); + }); + await handle.close(); + handle = new IdbWalletDbHandle(factory); + await handle.ensureOpen(); + assert.strictEqual( + ( + await handle.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ) + )?.value, + 30, + ); + const portableDump = await handle.exportDatabase(); + assert.ok(portableDump.databases[TALER_WALLET_MAIN_DB_NAME]); + await restored.importDatabase(portableDump, async () => {}); + assert.strictEqual( + ( + await restored.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ) + )?.value, + 30, + ); + } finally { + await source.close(); + await restored.close(); + await handle.close(); + } +}); + +test("indexeddb startup selects only a published generation", async () => { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + BridgeIDBFactory.enableTracing = false; + const factory = new BridgeIDBFactory(backend); + + const original = await openTalerDatabase(factory, async () => {}); + const abandoned = await beginTalerDatabaseReplacement( + factory, + original.name, + async () => {}, + ); + abandoned.handle.close(); + original.close(); + + const afterAbandon = await openTalerDatabase(factory, async () => {}); + assert.strictEqual(afterAbandon.name, TALER_WALLET_MAIN_DB_NAME); + const published = await beginTalerDatabaseReplacement( + factory, + afterAbandon.name, + async () => {}, + ); + await publishTalerDatabaseReplacement( + factory, + afterAbandon.name, + published.name, + ); + published.handle.close(); + afterAbandon.close(); + + const afterPublish = await openTalerDatabase(factory, async () => {}); + assert.strictEqual(afterPublish.name, published.name); + afterPublish.close(); + // The abandoned generation is deliberately not deleted during startup, + // but its stale pending claim was cleared, so its owner can clean it safely. + await abortTalerDatabaseReplacement(factory, abandoned.name); +}); + +test("export closes its database connection", async () => { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename: ":memory:", + }); + BridgeIDBFactory.enableTracing = false; + const factory = new BridgeIDBFactory(backend); + const db = await openTalerDatabase(factory, async () => {}); + db.close(); + + await exportSingleDb(factory, TALER_WALLET_MAIN_DB_NAME); + + await new Promise<void>((resolve, reject) => { + const req = factory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME); + req.addEventListener("success", () => resolve()); + req.addEventListener("error", () => + reject(req.error ?? Error("database deletion failed")), + ); + req.addEventListener("blocked", () => + reject(Error("export leaked an open database connection")), + ); + }); +}); diff --git a/packages/taler-wallet-core/src/db/indexeddb/database.ts b/packages/taler-wallet-core/src/db/indexeddb/database.ts @@ -0,0 +1,565 @@ +/* + This file is part of GNU Taler + (C) 2021-2025 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/> + */ + +/** + * IndexedDB database lifecycle. + */ +import { + IDBDatabase, + IDBFactory, + IDBObjectStore, + IDBRequest, + IDBTransaction, +} from "@gnu-taler/idb-bridge"; +import { + CancellationToken, + encodeCrock, + getRandomBytes, + j2s, + Logger, +} from "@gnu-taler/taler-util"; +import { WalletCoinAvailability } from "../records.js"; +import { + DbAccess, + DbAccessImpl, + IndexDescriptor, + openDatabase, + StoreDescriptor, + StoreWithIndexes, +} from "../query.js"; +import { + CURRENT_DB_CONFIG_KEY, + PENDING_DB_CONFIG_KEY, + RETIRED_DB_CONFIG_KEY, + TALER_WALLET_DB_GENERATION_PREFIX, + TALER_WALLET_MAIN_DB_NAME, + TALER_WALLET_META_DB_NAME, + WALLET_DB_MINOR_VERSION, + walletMetadataStore, + WalletIndexedDbStoresV1, +} from "./schema.js"; +export * from "./schema.js"; +export * from "./dump.js"; + +const logger = new Logger("db/indexeddb/database.ts"); +export * from "./fixups.js"; +/** + * Upgrade an IndexedDB in an upgrade transaction. + * + * The upgrade is made based on a store map, i.e. the metadata + * structure that describes all the object stores and indexes. + */ +function upgradeFromStoreMap( + storeMap: any, // FIXME: nail down type + db: IDBDatabase, + oldVersion: number, + newVersion: number, + upgradeTransaction: IDBTransaction, +): void { + if (oldVersion === 0) { + for (const n in storeMap) { + const swi: StoreWithIndexes< + any, + StoreDescriptor<unknown>, + any + > = storeMap[n]; + const storeDesc: StoreDescriptor<unknown> = swi.store; + const s = db.createObjectStore(swi.storeName, { + autoIncrement: storeDesc.autoIncrement, + keyPath: storeDesc.keyPath, + }); + for (const indexName in swi.indexMap as any) { + const indexDesc: IndexDescriptor = swi.indexMap[indexName]; + s.createIndex(indexDesc.name, indexDesc.keyPath, { + multiEntry: indexDesc.multiEntry, + unique: indexDesc.unique, + }); + } + } + return; + } + if (oldVersion === newVersion) { + return; + } + logger.info(`upgrading database from ${oldVersion} to ${newVersion}`); + for (const n in storeMap) { + const swi: StoreWithIndexes<any, StoreDescriptor<unknown>, any> = storeMap[ + n + ]; + const storeDesc: StoreDescriptor<unknown> = swi.store; + const storeAddedVersion = storeDesc.versionAdded ?? 0; + let s: IDBObjectStore; + if (storeAddedVersion > oldVersion) { + // Be tolerant if object store already exists. + // Probably means somebody deployed without + // adding the "addedInVersion" attribute. + if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) { + try { + s = db.createObjectStore(swi.storeName, { + autoIncrement: storeDesc.autoIncrement, + keyPath: storeDesc.keyPath, + }); + } catch (e) { + const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; + throw new Error( + `Migration failed. Could not create store ${swi.storeName}.${moreInfo}`, + { cause: e }, + ); + } + } + } + + s = upgradeTransaction.objectStore(swi.storeName); + + for (const indexName in swi.indexMap as any) { + const indexDesc: IndexDescriptor = swi.indexMap[indexName]; + const indexAddedVersion = indexDesc.versionAdded ?? 0; + if (indexAddedVersion <= oldVersion) { + continue; + } + // Be tolerant if index already exists. + // Probably means somebody deployed without + // adding the "addedInVersion" attribute. + if (!s.indexNames.contains(indexDesc.name)) { + try { + s.createIndex(indexDesc.name, indexDesc.keyPath, { + multiEntry: indexDesc.multiEntry, + unique: indexDesc.unique, + }); + } catch (e) { + const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : ""; + throw Error( + `Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`, + { cause: e }, + ); + } + } + } + } +} + +export function promiseFromTransaction( + transaction: IDBTransaction, +): Promise<void> { + return new Promise<void>((resolve, reject) => { + transaction.oncomplete = () => { + resolve(); + }; + transaction.onerror = () => { + reject(); + }; + }); +} + +export function promiseFromRequest(request: IDBRequest): Promise<any> { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = () => { + reject(request.error); + }; + }); +} + +/** + * Purge all data in the given database. + */ +export function clearDatabase(db: IDBDatabase): Promise<void> { + // db.objectStoreNames is a DOMStringList, so we need to convert + let stores: string[] = []; + for (let i = 0; i < db.objectStoreNames.length; i++) { + stores.push(db.objectStoreNames[i]); + } + logger.info(`clearing object stores: ${j2s(stores)}`); + const tx = db.transaction(stores, "readwrite"); + for (const store of stores) { + tx.objectStore(store).clear(); + } + return promiseFromTransaction(tx); +} + +function onTalerDbUpgradeNeeded( + db: IDBDatabase, + oldVersion: number, + newVersion: number, + upgradeTransaction: IDBTransaction, +) { + upgradeFromStoreMap( + WalletIndexedDbStoresV1, + db, + oldVersion, + newVersion, + upgradeTransaction, + ); + if (oldVersion < 32) { + const store = upgradeTransaction.objectStore("coinAvailabilityV2"); + const req = store.openCursor(); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) { + return; + } + const value = cursor.value as WalletCoinAvailability; + cursor.update({ + ...value, + hasFreshCoins: value.freshCoinCount > 0 ? 1 : 0, + }); + cursor.continue(); + }; + } +} + +function onMetaDbUpgradeNeeded( + db: IDBDatabase, + oldVersion: number, + newVersion: number, + upgradeTransaction: IDBTransaction, +) { + upgradeFromStoreMap( + walletMetadataStore, + db, + oldVersion, + newVersion, + upgradeTransaction, + ); +} + +/** + * Return a promise that resolves + * to the taler wallet db. + * + * @param onVersionChange Called when another client concurrenctly connects to the database + * with a higher version. + */ +export async function openTalerDatabase( + idbFactory: IDBFactory, + onVersionChange: () => void, +): Promise<IDBDatabase> { + const state = await readMainDbState(idbFactory, true); + await cleanInterruptedDatabaseReplacement(idbFactory, state); + return await openTalerDatabaseGeneration( + idbFactory, + state.current, + onVersionChange, + ); +} + +interface MainDbState { + current: string; + pending?: string; + retired?: string; +} + +function isCurrentGenerationName(name: string): boolean { + return ( + name === TALER_WALLET_MAIN_DB_NAME || + name.startsWith(TALER_WALLET_DB_GENERATION_PREFIX) + ); +} + +async function openMetaDatabase(idbFactory: IDBFactory): Promise<{ + handle: IDBDatabase; + access: DbAccess<typeof walletMetadataStore>; +}> { + const handle = await openDatabase( + idbFactory, + TALER_WALLET_META_DB_NAME, + 1, + () => {}, + onMetaDbUpgradeNeeded, + ); + return { + handle, + access: new DbAccessImpl( + handle, + walletMetadataStore, + CancellationToken.CONTINUE, + ), + }; +} + +async function readMainDbState( + idbFactory: IDBFactory, + initialize: boolean, +): Promise<MainDbState> { + const meta = await openMetaDatabase(idbFactory); + try { + let state!: MainDbState; + await meta.access.runAllStoresReadWriteTx({}, async (tx) => { + const currentRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); + let current = currentRecord?.value as string | undefined; + if (!current) { + current = TALER_WALLET_MAIN_DB_NAME; + if (initialize) { + await tx.metaConfig.put({ + key: CURRENT_DB_CONFIG_KEY, + value: current, + }); + } + } else if (!isCurrentGenerationName(current)) { + switch (current) { + case "taler-wallet-main-v2": + case "taler-wallet-main-v3": + case "taler-wallet-main-v4": + case "taler-wallet-main-v5": + case "taler-wallet-main-v6": + case "taler-wallet-main-v7": + case "taler-wallet-main-v8": + case "taler-wallet-main-v9": + // These were pre-release databases and have no supported major + // migration. Preserve the historical behaviour of starting the + // current major afresh. + current = TALER_WALLET_MAIN_DB_NAME; + if (initialize) { + await tx.metaConfig.put({ + key: CURRENT_DB_CONFIG_KEY, + value: current, + }); + } + break; + default: + throw Error( + `major migration from database major=${current} not supported`, + ); + } + } + state = { + current, + pending: (await tx.metaConfig.get(PENDING_DB_CONFIG_KEY))?.value, + retired: (await tx.metaConfig.get(RETIRED_DB_CONFIG_KEY))?.value, + }; + }); + return state; + } finally { + meta.handle.close(); + } +} + +export async function readCurrentMainDbName( + idbFactory: IDBFactory, +): Promise<string> { + return (await readMainDbState(idbFactory, true)).current; +} + +async function openTalerDatabaseGeneration( + idbFactory: IDBFactory, + name: string, + onVersionChange: () => void, +): Promise<IDBDatabase> { + if (!isCurrentGenerationName(name)) { + throw Error(`invalid wallet database generation name ${name}`); + } + return await openDatabase( + idbFactory, + name, + WALLET_DB_MINOR_VERSION, + onVersionChange, + onTalerDbUpgradeNeeded, + ); +} + +/** Resolve true on deletion, false when another client still blocks it. */ +async function tryDeleteDatabase( + idbFactory: IDBFactory, + name: string, +): Promise<boolean> { + return await new Promise<boolean>((resolve, reject) => { + const req = idbFactory.deleteDatabase(name); + let settled = false; + const finish = (result: boolean): void => { + if (settled) return; + settled = true; + resolve(result); + }; + req.onerror = () => { + if (settled) return; + settled = true; + reject(req.error); + }; + req.onblocked = () => finish(false); + req.onsuccess = () => finish(true); + }); +} + +async function clearMetaMarker( + idbFactory: IDBFactory, + key: string, + expectedValue: string, +): Promise<void> { + const meta = await openMetaDatabase(idbFactory); + try { + await meta.access.runAllStoresReadWriteTx({}, async (tx) => { + const record = await tx.metaConfig.get(key); + if (record?.value === expectedValue) { + await tx.metaConfig.delete(key); + } + }); + } finally { + meta.handle.close(); + } +} + +async function cleanupGeneration( + idbFactory: IDBFactory, + name: string | undefined, + marker: string, + current: string, +): Promise<void> { + if (!name || name === current) return; + try { + if (await tryDeleteDatabase(idbFactory, name)) { + await clearMetaMarker(idbFactory, marker, name); + } + } catch (e) { + // Cleanup is not authoritative-state recovery. Retaining an unreachable + // generation costs storage, but refusing to open the current wallet would + // turn that harmless residue into an outage. + logger.warn(`could not clean wallet database generation ${name}: ${e}`); + } +} + +async function cleanInterruptedDatabaseReplacement( + idbFactory: IDBFactory, + state: MainDbState, +): Promise<void> { + // A pending generation was never published, so current is authoritative. + // Clear its claim but do not issue deleteDatabase here: another wallet + // context could still be preparing it, and a blocked deletion request would + // remain armed and could delete the generation after that context publishes + // and eventually closes it. Clearing the claim instead makes that + // publisher fail its compare-and-swap safely. Hard-crash residue is an + // unreachable storage leak, never an authoritative-state ambiguity. + if (state.pending && state.pending !== state.current) { + await clearMetaMarker(idbFactory, PENDING_DB_CONFIG_KEY, state.pending); + } + // A retired generation has already been superseded and can never become + // authoritative again, so deletion is safe even when another context still + // has it open (in which case cleanup remains recorded for the next start). + await cleanupGeneration( + idbFactory, + state.retired, + RETIRED_DB_CONFIG_KEY, + state.current, + ); +} + +export interface StagedTalerDatabase { + name: string; + handle: IDBDatabase; +} + +/** Create and durably record an unpublished current-schema generation. */ +export async function beginTalerDatabaseReplacement( + idbFactory: IDBFactory, + currentName: string, + onVersionChange: () => void, +): Promise<StagedTalerDatabase> { + const name = `${TALER_WALLET_DB_GENERATION_PREFIX}${encodeCrock( + getRandomBytes(16), + )}`; + const meta = await openMetaDatabase(idbFactory); + try { + await meta.access.runAllStoresReadWriteTx({}, async (tx) => { + const current = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); + if (current?.value !== currentName) { + throw Error("wallet database generation changed during import"); + } + const pending = await tx.metaConfig.get(PENDING_DB_CONFIG_KEY); + if (pending) { + throw Error( + `another wallet database import is already pending (${pending.value})`, + ); + } + await tx.metaConfig.put({ key: PENDING_DB_CONFIG_KEY, value: name }); + }); + } finally { + meta.handle.close(); + } + try { + return { + name, + handle: await openTalerDatabaseGeneration( + idbFactory, + name, + onVersionChange, + ), + }; + } catch (e) { + await abortTalerDatabaseReplacement(idbFactory, name); + throw e; + } +} + +/** Atomically make a fully prepared generation authoritative. */ +export async function publishTalerDatabaseReplacement( + idbFactory: IDBFactory, + oldName: string, + newName: string, +): Promise<void> { + const meta = await openMetaDatabase(idbFactory); + try { + await meta.access.runAllStoresReadWriteTx({}, async (tx) => { + const current = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); + const pending = await tx.metaConfig.get(PENDING_DB_CONFIG_KEY); + if (current?.value !== oldName || pending?.value !== newName) { + throw Error("wallet database generation changed during import"); + } + await tx.metaConfig.put({ key: CURRENT_DB_CONFIG_KEY, value: newName }); + await tx.metaConfig.delete(PENDING_DB_CONFIG_KEY); + await tx.metaConfig.put({ key: RETIRED_DB_CONFIG_KEY, value: oldName }); + }); + } finally { + meta.handle.close(); + } +} + +/** Delete an unpublished generation after a failed import. */ +export async function abortTalerDatabaseReplacement( + idbFactory: IDBFactory, + name: string, +): Promise<void> { + await cleanupGeneration(idbFactory, name, PENDING_DB_CONFIG_KEY, ""); +} + +/** Best-effort deletion of the old generation after a successful publish. */ +export async function retireTalerDatabaseGeneration( + idbFactory: IDBFactory, + name: string, + currentName: string, +): Promise<void> { + await cleanupGeneration(idbFactory, name, RETIRED_DB_CONFIG_KEY, currentName); +} + +export async function deleteTalerDatabase( + idbFactory: IDBFactory, +): Promise<void> { + const state = await readMainDbState(idbFactory, false); + const names = new Set([ + TALER_WALLET_MAIN_DB_NAME, + state.current, + state.pending, + state.retired, + ]); + for (const name of names) { + if (name && !(await tryDeleteDatabase(idbFactory, name))) { + throw Error(`deletion of wallet database ${name} is blocked`); + } + } + if (!(await tryDeleteDatabase(idbFactory, TALER_WALLET_META_DB_NAME))) { + throw Error("deletion of wallet metadata database is blocked"); + } +} diff --git a/packages/taler-wallet-core/src/db/indexeddb/dump.ts b/packages/taler-wallet-core/src/db/indexeddb/dump.ts @@ -0,0 +1,216 @@ +/* + This file is part of GNU Taler + (C) 2021-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. + */ + +import { + Event, + IDBDatabase, + IDBFactory, + structuredEncapsulate, + structuredRevive, +} from "@gnu-taler/idb-bridge"; +import { Logger } from "@gnu-taler/taler-util"; +import { openDatabase } from "../query.js"; +import { + TALER_WALLET_MAIN_DB_NAME, + TALER_WALLET_META_DB_NAME, +} from "./schema.js"; +import { + promiseFromRequest, + promiseFromTransaction, + readCurrentMainDbName, +} from "./database.js"; + +export interface DbDumpRecord { + /** + * Key, serialized with structuredEncapsulated. + * + * Only present for out-of-line keys (i.e. no key path). + */ + key?: any; + /** + * Value, serialized with structuredEncapsulated. + */ + value: any; +} + +export interface DbIndexDump { + keyPath: string | string[]; + multiEntry: boolean; + unique: boolean; +} + +export interface DbStoreDump { + keyPath?: string | string[]; + autoIncrement: boolean; + indexes: { [indexName: string]: DbIndexDump }; + records: DbDumpRecord[]; +} + +export interface DbDumpDatabase { + version: number; + stores: { [storeName: string]: DbStoreDump }; +} + +export interface DbDump { + databases: { + [name: string]: DbDumpDatabase; + }; +} + +const logger = new Logger("db/indexeddb/dump.ts"); + +export async function exportSingleDb( + idb: IDBFactory, + dbName: string, +): Promise<DbDumpDatabase> { + const myDb = await openDatabase( + idb, + dbName, + undefined, + () => { + logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`); + }, + () => { + logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`); + }, + ); + + const singleDbDump: DbDumpDatabase = { + version: myDb.version, + stores: {}, + }; + + return new Promise((resolve, reject) => { + let settled = false; + const fail = (error: unknown): void => { + if (settled) return; + settled = true; + myDb.close(); + reject(error); + }; + const tx = myDb.transaction(Array.from(myDb.objectStoreNames)); + tx.addEventListener("complete", () => { + if (settled) return; + settled = true; + myDb.close(); + resolve(singleDbDump); + }); + tx.addEventListener("abort", () => + fail(tx.error ?? Error(`export of ${dbName} was aborted`)), + ); + tx.addEventListener("error", () => + fail(tx.error ?? Error(`export of ${dbName} failed`)), + ); + try { + // tslint:disable-next-line:prefer-for-of + for (let i = 0; i < myDb.objectStoreNames.length; i++) { + const name = myDb.objectStoreNames[i]; + const store = tx.objectStore(name); + const storeDump: DbStoreDump = { + autoIncrement: store.autoIncrement, + keyPath: store.keyPath, + indexes: {}, + records: [], + }; + const indexNames = store.indexNames; + for (let j = 0; j < indexNames.length; j++) { + const idxName = indexNames[j]; + const index = store.index(idxName); + storeDump.indexes[idxName] = { + keyPath: index.keyPath, + multiEntry: index.multiEntry, + unique: index.unique, + }; + } + singleDbDump.stores[name] = storeDump; + store.openCursor().addEventListener("success", (e: Event) => { + const cursor = (e.target as any).result; + if (cursor) { + const rec: DbDumpRecord = { + value: structuredEncapsulate(cursor.value), + }; + // Only store key if necessary, i.e. when + // the key is not stored as part of the object via + // a key path. + if (store.keyPath == null) { + rec.key = structuredEncapsulate(cursor.key); + } + storeDump.records.push(rec); + cursor.continue(); + } + }); + } + } catch (e) { + fail(e); + } + }); +} + +export async function exportDb(idb: IDBFactory): Promise<DbDump> { + const dbDump: DbDump = { + databases: {}, + }; + + const currentMainDbName = await readCurrentMainDbName(idb); + + dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb( + idb, + TALER_WALLET_META_DB_NAME, + ); + // A dump is portable, so expose the active generation under the canonical + // logical name. The generation name is local crash-recovery bookkeeping + // and must not become part of the backup format. + dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb( + idb, + currentMainDbName, + ); + + return dbDump; +} + +async function recoverFromDump( + db: IDBDatabase, + dbDump: DbDumpDatabase, +): Promise<void> { + const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); + const txProm = promiseFromTransaction(tx); + const storeNames = db.objectStoreNames; + for (let i = 0; i < storeNames.length; i++) { + const name = db.objectStoreNames[i]; + const storeDump = dbDump.stores[name]; + await promiseFromRequest(tx.objectStore(name).clear()); + if (!storeDump) continue; + logger.info(`importing ${storeDump.records.length} records into ${name}`); + for (let rec of storeDump.records) { + await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key)); + logger.trace("importing record done"); + } + } + tx.commit(); + return await txProm; +} + +function checkDbDump(x: any): x is DbDump { + return "databases" in x; +} + +export async function importDb(db: IDBDatabase, dumpJson: any): Promise<void> { + const d = structuredRevive(dumpJson); + if (checkDbDump(d)) { + const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME]; + if (!walletDb) { + throw Error( + `unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`, + ); + } + await recoverFromDump(db, walletDb); + } else { + throw Error("unable to import, doesn't look like a valid DB dump"); + } +} diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts @@ -0,0 +1,853 @@ +/* + This file is part of GNU Taler + (C) 2021-2025 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/> + */ + +/** + * IndexedDB record fixups. + */ +import { GlobalIDB, IDBKeyRange } from "@gnu-taler/idb-bridge"; +import { + AmountString, + Amounts, + canonicalJson, + checkDbInvariant, + getErrorDetailFromException, + Logger, + NotificationType, + TalerErrorCode, + WalletNotification, +} from "@gnu-taler/taler-util"; +import { + ConfigRecordKey, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, + PlanchetStatus, + RecoupOperationStatus, + RefreshCoinStatus, + RefreshOperationStatus, + WalletCoin, + WalletDenomFamilyParams, + WalletReserve, + WithdrawalGroupStatus, +} from "../records.js"; +import { DbAccess } from "../query.js"; +import { + WalletIndexedDbStoresV1, + WalletIndexedDbTransaction, +} from "./schema.js"; + +const logger = new Logger("db/indexeddb/fixups.ts"); + +export interface FixupDescription { + name: string; + fn(tx: WalletIndexedDbTransaction): Promise<void>; +} + +/** + * Manual migrations between minor versions of the DB schema. + * + * Fixups *must* be idempotent. + */ +export const walletDbFixups: FixupDescription[] = [ + // A later repair for duplicate merge-reserve rows whose key material is + // identical but where only one row carries KYC metadata. This needs its + // own fixup marker: affected databases have already recorded the older, + // byte-identical-only repair below as complete. + { + fn: fixup20260820DuplicateReserveMetadata, + name: "fixup20260820DuplicateReserveMetadata", + }, + // Clause-Schnorr support made this field explicit. Older RSA records imply + // the RSA defaults and remain valid after the field was introduced. + { + fn: fixup20260812ExchangeWithdrawValues, + name: "fixup20260812ExchangeWithdrawValues", + }, + // Deduplicate reserve rows left behind by a version that inserted its + // merge reserve repeatedly. Needed for as long as pre-2024 databases can + // still be imported. + { + fn: fixup20260720DuplicateReserves, + name: "fixup20260720DuplicateReserves", + }, + // Exchange details rows from before tinyAmount existed. + { + fn: fixup20260720ExchangeDetailsTinyAmount, + name: "fixup20260720ExchangeDetailsTinyAmount", + }, + // Refresh groups from before refundRequests existed. + { + fn: fixup20260720RefreshGroupRefundRequests, + name: "fixup20260720RefreshGroupRefundRequests", + }, + // Removing this would cause old transactions + // to show up under multiple exchanges + { + fn: fixup20260718TransactionsScope, + name: "fixup20260718TransactionsScope", + }, + // Removing this would cause merchant acceptable + // amount to be calculaed based on exchangeBaseUrl + // instead of masterPublicKey for old coins. + { + fn: fixupCoinAvailabilityExchangePub, + name: "fixupCoinAvailabilityExchangePub", + }, + // Can be removed once all affected refresh groups have + // been fixed. Conservative estimate: Jan 2028. + { + fn: fixup20260116BadRefreshCoinSelection, + name: "fixup20260116BadRefreshCoinSelection", + }, + // Denom families were introduced. + // This migration creates denom families + // for existing denomination records. + { + fn: fixup20260203DenomFamilyMigration, + name: "fixup20260203DenomFamilyMigration", + }, + // Fix a problem where refreshes went into a failed state + // instead of retrying. + { + fn: fixup20260213RefreshBlunder, + name: "fixup20260213RefreshBlunder", + }, + // Several status enum members were persisted with a dropped hex + // digit, putting them outside their status range. Rewrite the raw records + // to the corrected values (transactionsMeta is rebuilt separately via the + // MATERIALIZED_TRANSACTIONS_VERSION bump). + { + fn: fixup20260718StatusEnumDigits, + name: "fixup20260718StatusEnumDigits", + }, + // Denominations move to a store keyed by the master public key that signed + // them. Runs after the family migration, which assigns the family serial + // the copied rows carry. + { + fn: fixup20260807DenominationsByMasterPub, + name: "fixup20260807DenominationsByMasterPub", + }, + // Coin availability moves to the same key as the denominations it counts. + { + fn: fixup20260807CoinAvailabilityByMasterPub, + name: "fixup20260807CoinAvailabilityByMasterPub", + }, + // Coins record the key that signed their denomination, so that they are + // tied to the keys that can settle them rather than to the URL the + // exchange happens to answer on. + { + fn: fixup20260807CoinExchangeMasterPub, + name: "fixup20260807CoinExchangeMasterPub", + }, +]; + +/** + * Copy coin availability into the store keyed by master public key. + * + * The key comes from the row itself where it was recorded, and otherwise from + * the denomination it counts. A row that resolves to neither is left behind + * rather than filed under a guess: it would misreport what is spendable. + */ +async function fixup20260807CoinAvailabilityByMasterPub( + tx: WalletIndexedDbTransaction, +): Promise<void> { + const batchSize = 500; + let range: IDBKeyRange | undefined = undefined; + while (1) { + const batch = await tx.coinAvailability.getAll(range, batchSize); + if (batch.length === 0) { + break; + } + const last = batch[batch.length - 1]; + range = GlobalIDB.KeyRange.lowerBound( + [last.exchangeBaseUrl, last.denomPubHash, last.maxAge], + true, + ); + for (const av of batch) { + let masterPub: string | undefined = av.exchangeMasterPub; + if (!masterPub) { + const denom = await tx.denominations.get([ + av.exchangeBaseUrl, + av.denomPubHash, + ]); + masterPub = denom?.exchangeMasterPub; + } + if (!masterPub) { + logger.warn( + `coin availability for ${av.denomPubHash} has no master public key, not copying`, + ); + continue; + } + const existing = await tx.coinAvailabilityV2.get([ + masterPub, + av.denomPubHash, + av.maxAge, + ]); + if (existing) { + continue; + } + await tx.coinAvailabilityV2.put({ + ...av, + exchangeMasterPub: masterPub, + hasFreshCoins: av.freshCoinCount > 0 ? 1 : 0, + }); + } + } +} + +/** + * Copy denominations into the store keyed by master public key. + * + * The old store is left populated: it is the only source for this copy, so + * clearing it would make the fixup unrepeatable, and a fixup can abort and be + * retried on the next open. Two base URLs that served the same key set + * collapse onto one row here, which is the point -- they were never two + * denominations. + */ +async function fixup20260807DenominationsByMasterPub( + tx: WalletIndexedDbTransaction, +): Promise<void> { + const batchSize = 500; + let range: IDBKeyRange | undefined = undefined; + while (1) { + const batch = await tx.denominations.getAll(range, batchSize); + if (batch.length === 0) { + break; + } + const last = batch[batch.length - 1]; + range = GlobalIDB.KeyRange.lowerBound( + [last.exchangeBaseUrl, last.denomPubHash], + true, + ); + for (const denom of batch) { + if (!denom.exchangeMasterPub) { + logger.warn( + `denomination ${denom.denomPubHash} has no master public key, not copying`, + ); + continue; + } + const existing = await tx.denominationsV2.get([ + denom.exchangeMasterPub, + denom.denomPubHash, + ]); + if (existing) { + continue; + } + await tx.denominationsV2.put(denom); + } + } +} + +/** + * Backfill {@link WalletCoin.exchangeMasterPub} from the coin's denomination. + * + * The denomination has carried the master public key all along, so nothing + * has to be guessed. A coin whose denomination is gone is left alone rather + * than deleted: a fixup must never destroy coins, and an empty key reads as + * "not known" everywhere it is used. + */ +async function fixup20260807CoinExchangeMasterPub( + tx: WalletIndexedDbTransaction, +): Promise<void> { + const batchSize = 500; + let range: IDBKeyRange | undefined = undefined; + while (1) { + const batch = await tx.coins.getAll(range, batchSize); + if (batch.length === 0) { + break; + } + const last = batch[batch.length - 1]; + range = GlobalIDB.KeyRange.lowerBound(last.coinPub, true); + for (const coin of batch) { + if (coin.exchangeMasterPub) { + continue; + } + const denom = await tx.denominations.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + logger.warn( + `coin ${coin.coinPub} has no denomination, leaving its master public key unset`, + ); + continue; + } + coin.exchangeMasterPub = denom.exchangeMasterPub; + await tx.coins.put(coin); + } + } +} + +async function fixup20260718StatusEnumDigits( + tx: WalletIndexedDbTransaction, +): Promise<void> { + // These OLD (mis-typed, 7-hex-digit) values are what was actually persisted + // before, so we match on the raw numbers on purpose: the + // enum members now resolve to the CORRECTED values and would not match old + // records. + const WG_FIX = new Map<number, number>([ + [0x0110005, 0x0110_0005], // WithdrawalGroupStatus.SuspendedKyc + [0x0110006, 0x0110_0006], // WithdrawalGroupStatus.SuspendedBalanceKyc + [0x0110007, 0x0110_0007], // WithdrawalGroupStatus.SuspendedBalanceKycInit + ]); + await tx.withdrawalGroups.iter().forEachAsync(async (rec) => { + const nv = WG_FIX.get(rec.status); + if (nv !== undefined) { + rec.status = nv as WithdrawalGroupStatus; + await tx.withdrawalGroups.put(rec); + } + }); + + // PlanchetStatus.WithdrawalDone + await tx.planchets.iter().forEachAsync(async (rec) => { + if ((rec.planchetStatus as number) === 0x0500000) { + rec.planchetStatus = 0x0500_0000 as PlanchetStatus; + await tx.planchets.put(rec); + } + }); + + // RefreshOperationStatus.{Finished,Failed} and RefreshCoinStatus.Failed + const RO_FIX = new Map<number, number>([ + [0x0500000, 0x0500_0000], // Finished + [0x0501000, 0x0501_0000], // Failed + ]); + await tx.refreshGroups.iter().forEachAsync(async (rec) => { + let changed = false; + const nv = RO_FIX.get(rec.operationStatus); + if (nv !== undefined) { + rec.operationStatus = nv as RefreshOperationStatus; + changed = true; + } + for (let i = 0; i < rec.statusPerCoin.length; i++) { + if ((rec.statusPerCoin[i] as number) === 0x0501000) { + rec.statusPerCoin[i] = 0x0501_0000 as RefreshCoinStatus; + changed = true; + } + } + if (changed) { + await tx.refreshGroups.put(rec); + } + }); + + // RecoupOperationStatus.{Finished,Failed} + const RC_FIX = new Map<number, number>([ + [0x0500000, 0x0500_0000], // Finished + [0x0501000, 0x0501_0000], // Failed + ]); + await tx.recoupGroups.iter().forEachAsync(async (rec) => { + const nv = RC_FIX.get(rec.operationStatus); + if (nv !== undefined) { + rec.operationStatus = nv as RecoupOperationStatus; + await tx.recoupGroups.put(rec); + } + }); +} + +async function fixup20260213RefreshBlunder( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.refreshGroups.indexes.byStatus + .iter(RefreshOperationStatus.Failed) + .forEachAsync(async (refreshGroup) => { + for ( + let coinIndex = 0; + coinIndex < refreshGroup.statusPerCoin.length; + coinIndex++ + ) { + let changed = false; + if ( + refreshGroup.statusPerCoin[coinIndex] === RefreshCoinStatus.Failed + ) { + const rs = await tx.refreshSessions.get([ + refreshGroup.refreshGroupId, + coinIndex, + ]); + if ( + rs?.lastError?.code === + TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED + ) { + refreshGroup.statusPerCoin[coinIndex] = + RefreshCoinStatus.PendingRedenominate; + refreshGroup.operationStatus = + RefreshOperationStatus.PendingRedenominate; + delete refreshGroup.timestampFinished; + changed = true; + } + } + if (changed) { + await tx.refreshGroups.put(refreshGroup); + } + } + }); +} + +async function fixup20260203DenomFamilyMigration( + tx: WalletIndexedDbTransaction, +): Promise<void> { + const batchSize = 500; + + let range: IDBKeyRange | undefined = undefined; + + while (1) { + const batch = await tx.denominations.getAll(range, batchSize); + + if (batch.length === 0) { + break; + } + + logger.info(`fixing up batch of ${batch.length} denominations`); + + const last = batch[batch.length - 1]; + range = GlobalIDB.KeyRange.lowerBound( + [last.exchangeBaseUrl, last.denomPubHash], + true, + ); + + for (const r of batch) { + const fp: WalletDenomFamilyParams = { + exchangeBaseUrl: r.exchangeBaseUrl, + exchangeMasterPub: r.exchangeMasterPub, + feeDeposit: r.fees.feeDeposit, + feeRefresh: r.fees.feeRefresh, + feeRefund: r.fees.feeRefund, + feeWithdraw: r.fees.feeWithdraw, + value: r.value, + }; + if (r.denominationFamilySerial != null) { + // Fast path: Check if family exists and is correct. + const oldFpRec = await tx.denominationFamilies.get( + r.denominationFamilySerial, + ); + if ( + oldFpRec && + canonicalJson(fp) == canonicalJson(oldFpRec.familyParams) + ) { + continue; + } + } + const familyParamsIndexKey = [ + fp.exchangeBaseUrl, + fp.exchangeMasterPub, + fp.value, + fp.feeWithdraw, + fp.feeDeposit, + fp.feeRefresh, + fp.feeRefund, + ]; + const dfRec = + await tx.denominationFamilies.indexes.byFamilyParms.get( + familyParamsIndexKey, + ); + let denominationFamilySerial; + if (dfRec) { + denominationFamilySerial = dfRec.denominationFamilySerial; + } else { + const insRes = await tx.denominationFamilies.put({ + familyParams: fp, + }); + denominationFamilySerial = insRes.key; + } + checkDbInvariant( + typeof denominationFamilySerial == "number", + "denominationFamilySerial", + ); + r.denominationFamilySerial = denominationFamilySerial; + await tx.denominations.put(r); + } + } +} + +async function fixup20260116BadRefreshCoinSelection( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.refreshGroups.iter().forEachAsync(async (rec) => { + // Only repair groups that are still in flight. "Input non-zero, output + // zero" also describes a refresh that legitimately finished with its + // whole input eaten by fees -- a dust refresh of TESTKUDOS:0.01 into + // nothing looks identical to the bad coin selection this repairs. + // Re-activating one of those undoes a completed operation, which showed + // up as a finished refresh transaction reverting to pending after an + // import. + if ( + rec.operationStatus < OPERATION_STATUS_NONFINAL_FIRST || + rec.operationStatus > OPERATION_STATUS_NONFINAL_LAST + ) { + return; + } + const inputAmount = Amounts.sumOrZero( + rec.currency, + rec.inputPerCoin, + ).amount; + const outputAmount = Amounts.sumOrZero( + rec.currency, + rec.expectedOutputPerCoin, + ).amount; + if (Amounts.isNonZero(inputAmount) && Amounts.isZero(outputAmount)) { + logger.info( + `fixing up refresh group ${rec.refreshGroupId}, setting status to PendingRedenominate`, + ); + rec.operationStatus = RefreshOperationStatus.PendingRedenominate; + delete rec.timestampFinished; + await tx.refreshGroups.put(rec); + } + }); +} + +/** + * Some old payment transactions didn't correctly + * set the involved exchanges. + * + * This fixup sets the exchanges of a payment transaction + * based on the coin selection. + */ +async function fixup20260718TransactionsScope( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.purchases.iter().forEachAsync(async (rec) => { + if ( + (rec.exchanges?.length ?? 0) == 0 && + rec.payInfo?.payCoinSelection != null + ) { + const pcs = rec.payInfo.payCoinSelection.coinPubs; + const exchSet: Set<string> = new Set(); + for (const pc of pcs) { + const coin = await tx.coins.get(pc); + if (!coin) { + continue; + } + exchSet.add(coin.exchangeBaseUrl); + } + rec.exchanges = [...exchSet]; + rec.exchanges.sort(); + if (rec.exchanges.length == 0) { + // For old SPURLOS transactions, set exchange manually + // when we can't infer it. + if ( + rec.timestamp <= 1736942400000_000 && + rec.download?.currency === "SPURLOS" + ) { + rec.exchanges = ["https://exchange.taler.datenspuren.de/"]; + } + logger.warn( + `unable to fix up pay transaction ${rec.proposalId}, could not reconstruct exchanges`, + ); + } + await tx.purchases.put(rec); + } + }); +} + +async function fixupCoinAvailabilityExchangePub( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.coinAvailability.iter().forEachAsync(async (car) => { + if (car.exchangeMasterPub === undefined) { + const exchange = await tx.exchangeDetails.indexes.byExchangeBaseUrl.get( + car.exchangeBaseUrl, + ); + if (exchange !== undefined) { + car.exchangeMasterPub = exchange.masterPublicKey; + await tx.coinAvailability.put(car); + } + } + }); +} + +/** + * Backfill tinyAmount on exchange details rows from before the field existed. + * + * Uses the same default the keys update applies when an exchange reports no + * tiny_amount, so a backfilled row equals what the next update would have + * written anyway. Without this, deposits read undefined where the type + * promises an AmountString. + */ +async function fixup20260720ExchangeDetailsTinyAmount( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.exchangeDetails.iter().forEachAsync(async (det) => { + if ((det as any).tinyAmount === undefined) { + det.tinyAmount = `${det.currency}:0.01` as AmountString; + await tx.exchangeDetails.put(det); + } + }); +} + +/** + * Backfill refundRequests on refresh groups from before the field existed. + * + * The refresh task reads refundRequests[coinIndex] unguarded, so a group + * written before the field existed throws when the task touches it. An + * empty map is the correct backfill: those groups have no pending refund + * requests, or they would have been recorded. + */ +async function fixup20260720RefreshGroupRefundRequests( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.refreshGroups.iter().forEachAsync(async (rg) => { + let changed = false; + if ((rg as any).refundRequests === undefined) { + rg.refundRequests = {}; + changed = true; + } + // originatingTransactionId used to be nested in a reasonDetails object; + // the modern record carries it top-level and reads it there. + const legacyDetails = (rg as any).reasonDetails; + if ( + rg.originatingTransactionId === undefined && + legacyDetails?.originatingTransactionId !== undefined + ) { + rg.originatingTransactionId = legacyDetails.originatingTransactionId; + changed = true; + } + if (legacyDetails !== undefined) { + delete (rg as any).reasonDetails; + changed = true; + } + if (changed) { + await tx.refreshGroups.put(rg); + } + }); +} + +function canonicalFixupValue(value: any): any { + if (Array.isArray(value)) return value.map(canonicalFixupValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalFixupValue(value[key])]), + ); + } + return value; +} + +async function remapAndDeleteReserve( + tx: WalletIndexedDbTransaction, + droppedRowId: number, + retainedRowId: number, +): Promise<void> { + await tx.exchanges.iter().forEachAsync(async (e) => { + if (e.currentMergeReserveRowId === droppedRowId) { + e.currentMergeReserveRowId = retainedRowId; + await tx.exchanges.put(e); + } + }); + await tx.peerPullCredit.iter().forEachAsync(async (p) => { + if (p.mergeReserveRowId === droppedRowId) { + p.mergeReserveRowId = retainedRowId; + await tx.peerPullCredit.put(p); + } + }); + await tx.reserves.delete(droppedRowId); +} + +/** + * Remove byte-identical duplicate reserve rows. + * + * An older wallet version inserted its merge reserve again on each update + * instead of upserting, leaving several rows with identical key material + * under different row ids. Databases like that exist in the wild. The + * lowest row id is kept and the two references to reserve rows (the + * exchange's current merge reserve and peer-pull-credit merge reserves) are + * remapped onto it. + * + * Rows are removed only when every field except rowId matches the kept row. + * Any disagreement is corruption or ambiguity, which deleting would paper + * over, so those rows are left for conversion to reject. + */ +async function fixup20260720DuplicateReserves( + tx: WalletIndexedDbTransaction, +): Promise<void> { + let kept: WalletReserve | undefined; + const withoutRowId = (r: WalletReserve): string => { + const { rowId: _rowId, ...rest } = r; + return JSON.stringify(canonicalFixupValue(rest)); + }; + // The index groups equal public keys, so only the retained row for the + // current key is kept in memory. References are remapped immediately, + // avoiding a map proportional to the reserve store. + await tx.reserves.indexes.byReservePub.iter().forEachAsync(async (r) => { + if (r.rowId == null) return; + if (!kept || kept.reservePub !== r.reservePub) { + kept = r; + return; + } + if (kept.rowId == null || withoutRowId(kept) !== withoutRowId(r)) { + return; + } + const droppedRowId = r.rowId; + const keptRowId = kept.rowId; + await remapAndDeleteReserve(tx, droppedRowId, keptRowId); + }); +} + +/** + * Collapse the metadata-free reserve duplicate created by peer-credit + * withdrawals before 528a32fff. + * + * Equal public and private keys identify the same reserve. It is safe to + * discard one row when all of its defined metadata is also present and equal + * in the other row: the surviving row then loses no information. Different + * defined values remain untouched, so the converter still refuses an + * ambiguous database for which there is no revision information to select a + * winner. + */ +async function fixup20260820DuplicateReserveMetadata( + tx: WalletIndexedDbTransaction, +): Promise<void> { + const metadataIsSubset = ( + subset: WalletReserve, + superset: WalletReserve, + ): boolean => { + for (const [key, value] of Object.entries(subset)) { + if ( + key === "rowId" || + key === "reservePub" || + key === "reservePriv" || + value === undefined + ) { + continue; + } + if ( + JSON.stringify(canonicalFixupValue(value)) !== + JSON.stringify( + canonicalFixupValue( + (superset as unknown as Record<string, unknown>)[key], + ), + ) + ) { + return false; + } + } + return true; + }; + + let retained: WalletReserve | undefined; + await tx.reserves.indexes.byReservePub.iter().forEachAsync(async (row) => { + if (row.rowId == null) return; + if (!retained || retained.reservePub !== row.reservePub) { + retained = row; + return; + } + if (retained.rowId == null || retained.reservePriv !== row.reservePriv) { + return; + } + const retainedIsSubset = metadataIsSubset(retained, row); + const rowIsSubset = metadataIsSubset(row, retained); + if (!retainedIsSubset && !rowIsSubset) { + return; + } + if (retainedIsSubset && !rowIsSubset) { + await remapAndDeleteReserve(tx, retained.rowId, row.rowId); + retained = row; + } else { + await remapAndDeleteReserve(tx, row.rowId, retained.rowId); + } + }); +} + +async function fixup20260812ExchangeWithdrawValues( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.coins.iter().forEachAsync(async (coin) => { + if (coin.exchangeWithdrawValues === undefined) { + coin.exchangeWithdrawValues = { cipher: "RSA" } as any; + await tx.coins.put(coin); + } + }); + await tx.planchets.iter().forEachAsync(async (planchet) => { + if (planchet.exchangeWithdrawValues === undefined) { + planchet.exchangeWithdrawValues = { cipher: "RSA" } as any; + await tx.planchets.put(planchet); + } + }); +} + +export async function applyFixups( + db: DbAccess<typeof WalletIndexedDbStoresV1>, + onProgress: (notification: WalletNotification) => void = () => {}, +): Promise<number> { + logger.trace("applying fixups"); + let count = 0; + for (let index = 0; index < walletDbFixups.length; index++) { + const fixupInstruction = walletDbFixups[index]; + let applied = false; + try { + await db.runAllStoresReadWriteTx({}, async (tx) => { + logger.trace(`checking fixup ${fixupInstruction.name}`); + const fixupRecord = await tx.fixups.get(fixupInstruction.name); + if (fixupRecord) { + return; + } + applied = true; + logger.trace(`applying DB fixup ${fixupInstruction.name}`); + onProgress({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-fixup", + phase: "fixup", + step: fixupInstruction.name, + completedSteps: index, + totalSteps: walletDbFixups.length, + }); + await fixupInstruction.fn(tx); + // A fixup may change any operation record from which transactionsMeta + // is derived. Invalidate the durable view version in the same commit + // as the repair, so a crash or failed rematerialization is retried on + // the next initialization instead of leaving a stale "current" flag. + await tx.config.delete(ConfigRecordKey.MaterializedTransactionsVersion); + await tx.fixups.put({ + fixupName: fixupInstruction.name, + }); + }); + } catch (e) { + if (applied) { + onProgress({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-fixup", + phase: "failed", + step: fixupInstruction.name, + completedSteps: index, + totalSteps: walletDbFixups.length, + error: getErrorDetailFromException(e), + }); + } + throw e; + } + if (applied) { + // Announce completion only after the transaction has committed. A + // commit error above produces "failed", never a misleading completed + // step followed by a rollback. + onProgress({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-fixup", + phase: "fixup", + step: fixupInstruction.name, + completedSteps: index + 1, + totalSteps: walletDbFixups.length, + }); + count++; + } + } + if (count > 0) { + onProgress({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-fixup", + phase: "complete", + completedSteps: walletDbFixups.length, + totalSteps: walletDbFixups.length, + }); + } + return count; +} diff --git a/packages/taler-wallet-core/src/db/indexeddb/handle.ts b/packages/taler-wallet-core/src/db/indexeddb/handle.ts @@ -0,0 +1,321 @@ +/* + 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/> + */ + +/** + * The two WalletDbHandle implementations. + * + * Everything that differs between the IndexedDB emulation and the native + * sqlite database is confined to these two classes. + */ + +import { + CancellationToken, + Logger, + WalletNotification, +} from "@gnu-taler/taler-util"; +import { + AccessStats, + BridgeIDBFactory, + IDBDatabase, +} from "@gnu-taler/idb-bridge"; + +import { + abortTalerDatabaseReplacement, + beginTalerDatabaseReplacement, + clearDatabase, + openTalerDatabase, + publishTalerDatabaseReplacement, + retireTalerDatabaseGeneration, +} from "./database.js"; +import { exportDb, importDb } from "./dump.js"; +import { applyFixups } from "./fixups.js"; +import { WalletIndexedDbStoresV1 } from "./schema.js"; +import { + WalletDbAccessStats, + WalletDbHandle, + WalletDbImportFinalizer, +} from "../handle.js"; +import { IdbWalletTransaction } from "./transaction.js"; +import { WalletDbTransaction } from "../transaction.js"; +import { DbAccess, DbAccessImpl } from "../query.js"; + +const logger = new Logger("db/indexeddb/handle.ts"); + +function notifySafely( + sink: (notification: WalletNotification) => void, + notification: WalletNotification, +): void { + try { + sink(notification); + } catch (e) { + logger.warn( + `ignoring exception from wallet notification sink: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } +} + +/** + * WalletDbHandle over the IndexedDB emulation. + * + * Opens lazily: the wallet is constructed before it is initialised, and + * opening on construction would create a database file for a wallet that is + * never used. + */ +export class IdbWalletDbHandle implements WalletDbHandle { + readonly name = "indexeddb"; + + private idbHandle: IDBDatabase | undefined; + private dbAccess: DbAccess<typeof WalletIndexedDbStoresV1> | undefined; + private opening: Promise<{ fixupsApplied: number }> | undefined; + + private notify: (n: WalletNotification) => void = () => {}; + + /** + * Filesystem-backed capabilities, supplied by the host when it has them. + * A browser extension leaves them unset. + */ + exportToFile?: ( + directory: string, + stem: string, + forceFormat?: string, + ) => Promise<{ path: string }>; + readBackupJson?: (path: string) => Promise<any>; + getDiagnosticStats?: () => unknown; + + /** + * In-place migration to the native schema, set by the host when the + * emulation runs over a sqlite database the host can also open natively. + * See {@link WalletDbHandle.migrateToNative}. + */ + migrateToNative?: () => Promise<WalletDbHandle>; + + setNotificationSink(sink: (n: WalletNotification) => void): void { + this.notify = sink; + } + + emitNotification(notification: WalletNotification): void { + notifySafely(this.notify, notification); + } + + constructor( + private idbFactory: BridgeIDBFactory, + /** + * Raw backend counters, when the backend was asked to track them. + * Summed into a single figure by getAccessStats. + */ + private rawStats?: () => AccessStats | undefined, + private applyDbFixups: typeof applyFixups = applyFixups, + ) {} + + /** + * Open the database if it is not open yet. + * + * Returns whether fixups changed anything, which the caller needs in order + * to decide whether wallet-level views have to be rebuilt. + */ + async ensureOpen(): Promise<{ fixupsApplied: number }> { + if (this.dbAccess) { + return { fixupsApplied: 0 }; + } + if (this.opening) { + return await this.opening; + } + const opening = this.openDatabase(); + this.opening = opening; + try { + return await opening; + } finally { + if (this.opening === opening) { + this.opening = undefined; + } + } + } + + private async openDatabase(): Promise<{ fixupsApplied: number }> { + const idbHandle = await openTalerDatabase(this.idbFactory, async () => {}); + const dbAccess = this.makeAccess(idbHandle); + try { + const fixupsApplied = await this.applyDbFixups(dbAccess, (n) => + this.emitNotification(n), + ); + this.idbHandle = idbHandle; + this.dbAccess = dbAccess; + return { fixupsApplied }; + } catch (e) { + idbHandle.close(); + throw e; + } + } + + private makeAccess( + idbHandle: IDBDatabase, + notificationSink: (n: WalletNotification) => void = (n) => + this.emitNotification(n), + ): DbAccess<typeof WalletIndexedDbStoresV1> { + return new DbAccessImpl( + idbHandle, + WalletIndexedDbStoresV1, + CancellationToken.CONTINUE, + (notifs: WalletNotification[]) => { + for (const n of notifs) { + notificationSink(n); + } + }, + ); + } + + /** + * The raw DbAccess for the fixup log, which is an IndexedDB concern rather + * than a generic database concern. + * + * Reachable only through this class, so generic code cannot pick it up by + * accident the way it could when the wallet state exposed a factory. + */ + async rawAccess(): Promise<DbAccess<typeof WalletIndexedDbStoresV1>> { + await this.ensureOpen(); + if (!this.dbAccess) { + throw Error("wallet database is not open"); + } + return this.dbAccess; + } + + /** The IndexedDB factory used by hosts that install the bridge shim. */ + factory(): BridgeIDBFactory { + return this.idbFactory; + } + + async runReadWriteTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + const access = await this.rawAccess(); + return await access.runAllStoresReadWriteTx({}, async (mytx) => { + return await f(new IdbWalletTransaction(mytx)); + }); + } + + async exportDatabase(): Promise<any> { + await this.ensureOpen(); + return await exportDb(this.idbFactory); + } + + async importDatabase( + dump: any, + finalize: WalletDbImportFinalizer, + ): Promise<void> { + // A native-backend dump has {schemaVersion, tables}; this backend's dumps + // have {databases}. Importing across backends is a format conversion, + // not a copy, and silently accepting the wrong shape would import + // nothing while reporting success. + if (dump != null && typeof dump === "object" && "tables" in dump) { + throw Error( + "this dump is from the native sqlite backend and cannot be" + + " imported into the IndexedDB backend; convert the database" + + " instead", + ); + } + await this.ensureOpen(); + if (!this.idbHandle) { + throw Error("wallet database is not open"); + } + const oldHandle = this.idbHandle; + const oldName = oldHandle.name; + const stagedNotifications: WalletNotification[] = []; + const staged = await beginTalerDatabaseReplacement( + this.idbFactory, + oldName, + async () => {}, + ); + const stagedAccess = this.makeAccess(staged.handle, (n) => + stagedNotifications.push(n), + ); + let published = false; + try { + await importDb(staged.handle, dump); + // The imported records may predate any of the fixups, whatever the old + // generation had applied. Clear the imported log and repair the staged + // generation before it can become authoritative. + await stagedAccess.runAllStoresReadWriteTx({}, async (tx) => { + const fixups = await tx.fixups.getAll(); + for (const fx of fixups) { + await tx.fixups.delete(fx.fixupName); + } + }); + await this.applyDbFixups(stagedAccess, (n) => + stagedNotifications.push(n), + ); + await stagedAccess.runAllStoresReadWriteTx({}, async (tx) => { + await finalize(new IdbWalletTransaction(tx)); + }); + + // This metadata transaction is the commit point. A crash before it + // keeps oldName authoritative; a crash afterwards opens staged.name. + await publishTalerDatabaseReplacement( + this.idbFactory, + oldName, + staged.name, + ); + published = true; + this.idbHandle = staged.handle; + this.dbAccess = stagedAccess; + oldHandle.close(); + for (const n of stagedNotifications) this.emitNotification(n); + } catch (e) { + if (!published) { + staged.handle.close(); + await abortTalerDatabaseReplacement(this.idbFactory, staged.name); + } + throw e; + } + + // Deletion is deliberately outside the commit semantics: failure or a + // second client blocking it only retains an unreachable old generation. + await retireTalerDatabaseGeneration(this.idbFactory, oldName, staged.name); + } + + async clearDatabase(): Promise<void> { + await this.ensureOpen(); + if (!this.idbHandle) { + throw Error("wallet database is not open"); + } + await clearDatabase(this.idbHandle); + } + + getAccessStats(): WalletDbAccessStats | undefined { + const st = this.rawStats?.(); + if (!st) { + return undefined; + } + // Index reads and store reads both count: a query served entirely from an + // index still read those records, and leaving them out would make a + // full index scan look bounded. + let recordsRead = 0; + for (const k of Object.keys(st.readItemsPerIndex)) { + recordsRead += st.readItemsPerIndex[k]; + } + for (const k of Object.keys(st.readItemsPerStore)) { + recordsRead += st.readItemsPerStore[k]; + } + return { recordsRead }; + } + + async close(): Promise<void> { + this.idbHandle?.close(); + this.idbHandle = undefined; + this.dbAccess = undefined; + } +} diff --git a/packages/taler-wallet-core/src/db/indexeddb/schema.ts b/packages/taler-wallet-core/src/db/indexeddb/schema.ts @@ -0,0 +1,1360 @@ +/* + This file is part of GNU Taler + (C) 2021-2025 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/> + */ + +/** + * IndexedDB schema and store descriptors. + */ +import { + AmountString, + Amounts, + Codec, + codecForAny, + CoinPublicKeyString, + CurrencySpecification, + hash, + HashCodeString, + MailboxConfiguration, + MailboxMessageRecord, + ScopeInfo, + TalerErrorDetail, +} from "@gnu-taler/taler-util"; +import { + CoinSourceType, + ConfigRecord, + DbPreciseTimestamp, + DbProtocolTimestamp, + DenomFees, + DonauConfig, + ExchangeMigrationReason, + PeerPullDebitRecordStatus, + PeerPullPaymentCreditStatus, + PeerPushCreditStatus, + PeerPushDebitStatus, + RefundReason, + WalletBackupConfState, + WalletBankAccount, + WalletCoin, + WalletCoinAvailability, + WalletCoinHistory, + WalletContractTerms, + WalletDenomination, + WalletDenominationFamily, + WalletDenomLossEvent, + WalletDepositGroup, + WalletDonationPlanchet, + WalletDonationReceipt, + WalletDonationSummary, + WalletExchangeBaseUrlFixup, + WalletExchangeDetails, + WalletExchangeEntry, + WalletExchangeMigrationLog, + WalletExchangeSignkeys, + WalletGlobalCurrencyAuditor, + WalletGlobalCurrencyExchange, + WalletOperationRetry, + WalletPlanchet, + WalletPurchase, + WalletRecoupGroup, + WalletRefreshGroup, + WalletRefreshSession, + WalletRefundGroup, + WalletRefundItem, + WalletReserve, + WalletSlate, + WalletToken, + WalletTombstone, + WalletTransactionMeta, + WalletWithdrawalGroup, +} from "../records.js"; +export { + CoinSourceType, + ConfigRecord, + DenomFees, + DonauConfig, + ExchangeMigrationReason, + RefundReason, + WalletBackupConfState, +}; +import { + DbReadWriteTransaction, + describeContents, + describeIndex, + describeStore, + describeStoreV2, + StoreNames, + StoreWithIndexes, +} from "../query.js"; +/** + * This file contains the database schema of the Taler wallet together + * with some helper functions. + * + * Some design considerations: + * - By convention, each object store must have a corresponding "<Name>Record" + * interface defined for it. + * - For records that represent operations, there should be exactly + * one top-level enum field that indicates the status of the operation. + * This field should be present even if redundant, because the field + * will have an index. + * - Amounts are stored as strings, except when they are needed for + * indexing. + * - Every record that has a corresponding transaction item must have + * an index for a mandatory timestamp field. + * - Optional fields should be avoided, use "T | undefined" instead. + * - Do all records have some obvious, indexed field that can + * be used for range queries? + * + * @author Florian Dold <dold@taler.net> + */ + +/** + FIXMEs: + - Contract terms can be quite large. We currently tend to read the + full contract terms from the DB quite often. + Instead, we should probably extract what we need into a separate object + store. + - More object stores should have an "id" primary key, + as this makes referencing less expensive. + - Coin selections should probably go into a separate object store. + - Some records should be split up into an extra "details" record + that we don't always need to iterate over. + */ + +/** + * Name of the Taler database. This is effectively the major + * version of the DB schema. Whenever it changes, custom import logic + * for all previous versions must be written, which should be + * avoided. + */ +export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10"; + +/** + * Name of the metadata database. This database is used + * to track major migrations of the main Taler database. + * + * (Minor migrations are handled via upgrade transactions.) + */ +export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta"; + +/** + * Name of the "meta config" database. + */ +export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; + +/** Database generation being prepared by an import but not authoritative yet. */ +export const PENDING_DB_CONFIG_KEY = "pendingMainDbName"; + +/** Previous authoritative generation waiting for best-effort deletion. */ +export const RETIRED_DB_CONFIG_KEY = "retiredMainDbName"; + +/** Names below this prefix are current-schema generations, not major versions. */ +export const TALER_WALLET_DB_GENERATION_PREFIX = `${TALER_WALLET_MAIN_DB_NAME}-generation-`; + +/** + * Current database minor version, should be incremented + * each time we do minor schema changes on the database. + * A change is considered minor when fields are added in a + * backwards-compatible way or object stores and indices + * are added. + */ +export const WALLET_DB_MINOR_VERSION = 32; + +// FIXME: Should these be numeric codes? +export type KycUserType = "individual" | "business"; + +export interface BankWithdrawUriRecord { + /** + * The withdraw URI we got from the bank. + */ + talerWithdrawUri: string; + + /** + * Reserve that was created for the withdraw URI. + */ + reservePub: string; +} + +export interface DbPeerPushPaymentCoinSelection { + contributions: AmountString[]; + coinPubs: CoinPublicKeyString[]; +} + +/** + * Record for a push P2P payment that this wallet initiated. + */ +export interface PeerPushDebitRecord { + /** + * What exchange are funds coming from? + */ + exchangeBaseUrl: string; + + /** + * Restricted scope for this transaction. + * + * Relevant for coin reselection. + */ + restrictScope?: ScopeInfo; + + /** + * Instructed amount. + */ + amount: AmountString; + + /** + * Effective amount. + * + * (Called totalCost for historical reasons.) + */ + totalCost: AmountString; + + coinSel?: DbPeerPushPaymentCoinSelection; + + contractTermsHash: HashCodeString; + + /** + * Purse public key. Used as the primary key to look + * up this record. + */ + pursePub: string; + + /** + * Purse private key. + */ + pursePriv: string; + + /** + * Public key of the merge capability of the purse. + */ + mergePub: string; + + /** + * Private key of the merge capability of the purse. + */ + mergePriv: string; + + contractPriv: string; + contractPub: string; + + /** + * 24 byte nonce. + */ + contractEncNonce: string; + + purseExpiration: DbProtocolTimestamp; + + timestampCreated: DbPreciseTimestamp; + + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + /** + * Status of the peer push payment initiation. + */ + status: PeerPushDebitStatus; +} + +export interface PeerPullCreditRecord { + /** + * What exchange are we using for the payment request? + */ + exchangeBaseUrl: string; + + /** + * Amount requested. + * FIXME: What type of instructed amount is i? + */ + amount: AmountString; + + estimatedAmountEffective: AmountString; + + /** + * Purse public key. Used as the primary key to look + * up this record. + */ + pursePub: string; + + /** + * Purse private key. + */ + pursePriv: string; + + /** + * Hash of the contract terms. Also + * used to look up the contract terms in the DB. + */ + contractTermsHash: string; + + mergePub: string; + mergePriv: string; + + contractPub: string; + contractPriv: string; + + contractEncNonce: string; + + mergeTimestamp: DbPreciseTimestamp; + + mergeReserveRowId: number; + + /** + * Status of the peer pull payment initiation. + */ + status: PeerPullPaymentCreditStatus; + + kycPaytoHash?: string; + + kycAccessToken?: string; + + kycLastCheckStatus?: number | undefined; + kycLastCheckCode?: number | undefined; + kycLastRuleGen?: number | undefined; + kycLastAmlReview?: boolean | undefined; + kycLastDeny?: DbPreciseTimestamp | undefined; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + withdrawalGroupId: string | undefined; +} + +/** + * Record for a push P2P payment that this wallet was offered. + * + * Unique: (exchangeBaseUrl, pursePub) + */ +export interface PeerPushCreditRecord { + peerPushCreditId: string; + + exchangeBaseUrl: string; + + pursePub: string; + + mergePriv: string; + + contractPriv: string; + + timestamp: DbPreciseTimestamp; + + estimatedAmountEffective: AmountString; + + /** + * Hash of the contract terms. Also + * used to look up the contract terms in the DB. + */ + contractTermsHash: string; + + /** + * Status of the peer push payment incoming initiation. + */ + status: PeerPushCreditStatus; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + /** + * Associated withdrawal group. + */ + withdrawalGroupId: string | undefined; + + /** + * Currency of the peer push payment credit transaction. + * + * Mandatory in current schema version, optional for compatibility + * with older (ver_minor<4) DB versions. + */ + currency: string | undefined; + + kycPaytoHash?: string; + + kycAccessToken?: string; + + kycLastCheckStatus?: number | undefined; + kycLastCheckCode?: number | undefined; + kycLastRuleGen?: number | undefined; + kycLastAmlReview?: boolean | undefined; + kycLastDeny?: DbPreciseTimestamp | undefined; +} + +export interface PeerPullPaymentCoinSelection { + contributions: AmountString[]; + coinPubs: CoinPublicKeyString[]; + + /** + * Total cost based on the coin selection. + * Non undefined after status === "Accepted" + */ + totalCost: AmountString | undefined; +} + +/** + * AKA PeerPullDebit. + */ +export interface PeerPullPaymentIncomingRecord { + peerPullDebitId: string; + + pursePub: string; + + exchangeBaseUrl: string; + + amount: AmountString; + + contractTermsHash: string; + + timestampCreated: DbPreciseTimestamp; + + /** + * Contract priv that we got from the other party. + */ + contractPriv: string; + + /** + * Status of the peer push payment incoming initiation. + */ + status: PeerPullDebitRecordStatus; + + /** + * Estimated total cost when the record was created. + */ + totalCostEstimated: AmountString; + + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + coinSel?: PeerPullPaymentCoinSelection; +} + +export interface DbExchangeHandle { + url: string; + exchangeMasterPub: string; +} + +export interface DbAuditorHandle { + url: string; + auditorPub: string; +} + +export function passthroughCodec<T>(): Codec<T> { + return codecForAny(); +} + +export interface CurrencyInfoRecord { + /** + * Stringified scope info. + */ + scopeInfoStr: string; + + /** + * Currency specification. + */ + currencySpec: CurrencySpecification; + + /** + * How did the currency info get set? + */ + source: "exchange" | "user" | "preset"; +} + +export interface ContactRecord { + /** + * The mailbox URI of this contact + */ + mailboxBaseUri: string; + + /** + * The mailbox identity + */ + mailboxAddress: HashCodeString; + + /** + * The alias of this contact + */ + alias: string; + + /** + * The type of the alias + */ + aliasType: string; + + /** + * The source of this alias + */ + source: string; + + /** + * The local petname of this alias + */ + petname: string; +} + +/** + * Schema definition for the IndexedDB + * wallet database. + */ +export const WalletIndexedDbStoresV1 = { + exchangeBaseUrlMigrationLog: describeStoreV2({ + recordCodec: passthroughCodec<WalletExchangeMigrationLog>(), + storeName: "exchangeBaseUrlMigrationLog", + keyPath: ["oldExchangeBaseUrl", "newExchangeBaseUrl"], + versionAdded: 18, + indexes: {}, + }), + exchangeBaseUrlFixups: describeStoreV2({ + recordCodec: passthroughCodec<WalletExchangeBaseUrlFixup>(), + storeName: "exchangeBaseUrlFixups", + keyPath: "exchangeBaseUrl", + versionAdded: 19, + indexes: {}, + }), + denomLossEvents: describeStoreV2({ + recordCodec: passthroughCodec<WalletDenomLossEvent>(), + storeName: "denomLossEvents", + keyPath: "denomLossEventId", + versionAdded: 9, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 9, + }), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 10, + }), + }, + }), + transactionsMeta: describeStoreV2({ + recordCodec: passthroughCodec<WalletTransactionMeta>(), + storeName: "transactionsMeta", + keyPath: "transactionId", + versionAdded: 13, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 13, + }), + byExchange: describeIndex("byExchange", "exchanges", { + versionAdded: 13, + multiEntry: true, + }), + byTimestamp: describeIndex("byTimestamp", "timestamp", { + versionAdded: 13, + }), + byTimestampAndId: describeIndex( + "byTimestampAndId", + ["timestamp", "transactionId"], + { versionAdded: 32 }, + ), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 13, + }), + }, + }), + currencyInfo: describeStoreV2({ + recordCodec: passthroughCodec<CurrencyInfoRecord>(), + storeName: "currencyInfo", + keyPath: "scopeInfoStr", + versionAdded: 12, + }), + globalCurrencyAuditors: describeStoreV2({ + recordCodec: passthroughCodec<WalletGlobalCurrencyAuditor>(), + storeName: "globalCurrencyAuditors", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "auditorBaseUrl", "auditorPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), + globalCurrencyExchanges: describeStoreV2({ + recordCodec: passthroughCodec<WalletGlobalCurrencyExchange>(), + storeName: "globalCurrencyExchanges", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "exchangeBaseUrl", "exchangeMasterPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), + // Keyed by the master public key for the same reason as denominationsV2: + // the coins of one denomination hash under two different keys are not the + // same coins, and must not share a count. + coinAvailabilityV2: describeStore( + "coinAvailabilityV2", + describeContents<WalletCoinAvailability>({ + keyPath: ["exchangeMasterPub", "denomPubHash", "maxAge"], + versionAdded: 31, + }), + { + byExchangeAgeAvailability: describeIndex( + "byExchangeAgeAvailability", + ["exchangeBaseUrl", "maxAge", "freshCoinCount"], + { versionAdded: 31 }, + ), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 31, + }), + byExchangeFreshAndAge: describeIndex( + "byExchangeFreshAndAge", + ["exchangeBaseUrl", "hasFreshCoins", "maxAge"], + { versionAdded: 32 }, + ), + }, + ), + // The pre-re-key store. Keeps its map key equal to its store name: the + // transaction client exposes accessors by store name, so an `_obsolete_` + // alias would typecheck and then be undefined at runtime. + coinAvailability: describeStore( + "coinAvailability", + describeContents<WalletCoinAvailability>({ + keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"], + }), + { + byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [ + "exchangeBaseUrl", + "maxAge", + "freshCoinCount", + ]), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 8, + }), + }, + ), + coinHistory: describeStoreV2({ + storeName: "coinHistory", + recordCodec: passthroughCodec<WalletCoinHistory>(), + keyPath: "coinPub", + versionAdded: 11, + }), + coins: describeStore( + "coins", + describeContents<WalletCoin>({ + keyPath: "coinPub", + }), + { + byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"), + byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"), + byMasterPubDenomPubHashAndAgeAndStatus: describeIndex( + "byMasterPubDenomPubHashAndAgeAndStatus", + ["exchangeMasterPub", "denomPubHash", "maxAge", "status"], + { + versionAdded: 31, + }, + ), + byExchangeDenomPubHashAndAgeAndStatus: describeIndex( + "byExchangeDenomPubHashAndAgeAndStatus", + ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"], + ), + byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), + bySourceTransactionId: describeIndex( + "bySourceTransactionId", + "sourceTransactionId", + { + versionAdded: 9, + }, + ), + }, + ), + tokens: describeStore( + "tokens", + describeContents<WalletToken>({ + keyPath: "tokenUsePub", + versionAdded: 16, + }), + { + byTokenIssuePubHash: describeIndex( + "byTokenIssuePubHash", + "tokenIssuePubHash", + { + versionAdded: 17, + }, + ), + byPurchaseIdAndChoiceIndex: describeIndex( + "byPurchaseIdAndChoiceIndex", + ["purchaseId", "choiceIndex"], + { + versionAdded: 17, + }, + ), + byTokenFamilyHash: describeIndex("byTokenFamilyHash", "tokenFamilyHash", { + versionAdded: 21, + }), + }, + ), + slates: describeStore( + "slates", + describeContents<WalletSlate>({ + keyPath: "tokenUsePub", + versionAdded: 16, + }), + { + byPurchaseIdAndChoiceIndex: describeIndex( + "byPurchaseIdAndChoiceIndex", + ["purchaseId", "choiceIndex"], + { + versionAdded: 17, + }, + ), + byPurchaseIdAndChoiceIndexAndOutputIndex: describeIndex( + "byPurchaseIdAndChoiceIndexAndOutputIndex", + ["purchaseId", "choiceIndex", "outputIndex"], + { + versionAdded: 17, + }, + ), + byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex: describeIndex( + "byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex", + ["purchaseId", "choiceIndex", "outputIndex", "repeatIndex"], + { + versionAdded: 29, + }, + ), + }, + ), + reserves: describeStore( + "reserves", + describeContents<WalletReserve>({ + keyPath: "rowId", + autoIncrement: true, + }), + { + byReservePub: describeIndex("byReservePub", "reservePub", {}), + }, + ), + config: describeStore( + "config", + describeContents<ConfigRecord>({ keyPath: "key" }), + {}, + ), + // Keyed by the master public key that signed the denomination, not by the + // exchange's URL: the URL is where the exchange currently answers and can + // change, while the key is what decides whether a coin can be settled. A + // new store rather than a re-keyed one because the IndexedDB upgrade path + // can only add stores and indices, never change a keyPath. + denominationsV2: describeStore( + "denominationsV2", + describeContents<WalletDenomination>({ + keyPath: ["exchangeMasterPub", "denomPubHash"], + versionAdded: 31, + }), + { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 31, + }), + byExchangeMasterPub: describeIndex( + "byExchangeMasterPub", + "exchangeMasterPub", + { + versionAdded: 31, + }, + ), + byVerificationStatus: describeIndex( + "byVerificationStatus", + "verificationStatus", + { + versionAdded: 31, + }, + ), + byDenominationFamilySerialAndStampExpireWithdraw: describeIndex( + "byDenominationFamilySerialAndStampExpireWithdraw", + ["denominationFamilySerial", "stampExpireWithdraw"], + { + versionAdded: 31, + }, + ), + }, + ), + denominations: describeStore( + "denominations", + describeContents<WalletDenomination>({ + keyPath: ["exchangeBaseUrl", "denomPubHash"], + }), + { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"), + byVerificationStatus: describeIndex( + "byVerificationStatus", + "verificationStatus", + { + versionAdded: 26, + }, + ), + byDenominationFamilySerialAndStampExpireWithdraw: describeIndex( + "byDenominationFamilySerialAndStampExpireWithdraw", + ["denominationFamilySerial", "stampExpireWithdraw"], + { + versionAdded: 27, + }, + ), + }, + ), + denominationFamilies: describeStore( + "denominationFamilies", + describeContents<WalletDenominationFamily>({ + keyPath: "denominationFamilySerial", + versionAdded: 27, + autoIncrement: true, + }), + { + byExchangeBaseUrl: describeIndex( + "byExchangeBaseUrl", + "familyParams.exchangeBaseUrl", + { + versionAdded: 27, + }, + ), + byFamilyParms: describeIndex( + "byFamilyParams", + [ + "familyParams.exchangeBaseUrl", + "familyParams.exchangeMasterPub", + "familyParams.value", + "familyParams.feeWithdraw", + "familyParams.feeDeposit", + "familyParams.feeRefresh", + "familyParams.feeRefund", + ], + { + versionAdded: 28, + }, + ), + // Reserved legacy index names: + // * byFamilyParamsHash + }, + ), + exchanges: describeStore( + "exchanges", + describeContents<WalletExchangeEntry>({ + keyPath: "baseUrl", + }), + {}, + ), + exchangeDetails: describeStore( + "exchangeDetails", + describeContents<WalletExchangeDetails>({ + keyPath: "rowId", + autoIncrement: true, + }), + { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), + byPointer: describeIndex( + "byDetailsPointer", + ["exchangeBaseUrl", "currency", "masterPublicKey"], + { + unique: true, + }, + ), + // Not unique: the same exchange can be known under two base URLs + // while a migration between them is still in progress. + byMasterPublicKey: describeIndex("byMasterPublicKey", "masterPublicKey", { + versionAdded: 30, + }), + }, + ), + exchangeSignKeys: describeStore( + "exchangeSignKeys", + describeContents<WalletExchangeSignkeys>({ + keyPath: ["exchangeDetailsRowId", "signkeyPub"], + }), + { + // Caution: By historical accident, the index is over an array. + byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [ + "exchangeDetailsRowId", + ]), + }, + ), + contacts: describeStoreV2({ + recordCodec: passthroughCodec<ContactRecord>(), + storeName: "contacts", + keyPath: ["alias", "aliasType"], + indexes: {}, + versionAdded: 24, + }), + mailboxMessages: describeStoreV2({ + recordCodec: passthroughCodec<MailboxMessageRecord>(), + storeName: "mailboxMessages", + keyPath: ["originMailboxBaseUrl", "talerUri"], + indexes: {}, + versionAdded: 24, + }), + mailboxConfigurations: describeStoreV2({ + recordCodec: passthroughCodec<MailboxConfiguration>(), + storeName: "mailboxConfigurations", + keyPath: "mailboxBaseUrl", + indexes: {}, + versionAdded: 24, + }), + refreshGroups: describeStore( + "refreshGroups", + describeContents<WalletRefreshGroup>({ + keyPath: "refreshGroupId", + }), + { + byStatus: describeIndex("byStatus", "operationStatus"), + byOriginatingTransactionId: describeIndex( + "byOriginatingTransactionId", + "originatingTransactionId", + { + versionAdded: 5, + }, + ), + }, + ), + refreshSessions: describeStore( + "refreshSessions", + describeContents<WalletRefreshSession>({ + keyPath: ["refreshGroupId", "coinIndex"], + }), + { + byRefreshGroupId: describeIndex("byRefreshGroupId", "refreshGroupId", { + versionAdded: 15, + }), + }, + ), + recoupGroups: describeStore( + "recoupGroups", + describeContents<WalletRecoupGroup>({ + keyPath: "recoupGroupId", + }), + { + byStatus: describeIndex("byStatus", "operationStatus", { + versionAdded: 6, + }), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 15, + }), + }, + ), + purchases: describeStore( + "purchases", + describeContents<WalletPurchase>({ keyPath: "proposalId" }), + { + byStatus: describeIndex("byStatus", "purchaseStatus"), + byFulfillmentUrl: describeIndex( + "byFulfillmentUrl", + "download.fulfillmentUrl", + ), + byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ + "merchantBaseUrl", + "orderId", + ]), + byExchange: describeIndex("byExchange", "exchanges", { + versionAdded: 15, + multiEntry: true, + }), + }, + ), + donationPlanchets: describeStoreV2({ + recordCodec: passthroughCodec<WalletDonationPlanchet>(), + storeName: "donationPlanchets", + keyPath: "udiNonce", + versionAdded: 20, + indexes: { + byProposalId: describeIndex("byProposalId", "proposalId", { + versionAdded: 20, + }), + }, + }), + donationReceipts: describeStoreV2({ + recordCodec: passthroughCodec<WalletDonationReceipt>(), + storeName: "donationReceipts", + keyPath: "udiNonce", + versionAdded: 20, + indexes: { + byStatus: describeIndex("byStatus", "status", { + versionAdded: 20, + }), + byDonauBaseUrl: describeIndex("byDonauBaseUrl", "donauBaseUrl", { + versionAdded: 23, + }), + byStatusAndDonauBaseUrl: describeIndex( + "byStatusAndDonauBaseUrl", + ["status", "donauBaseUrl"], + { + versionAdded: 23, + }, + ), + }, + }), + donationSummaries: describeStoreV2({ + recordCodec: passthroughCodec<WalletDonationSummary>(), + storeName: "donationSummaries", + keyPath: ["donauBaseUrl", "year", "currency"], + versionAdded: 22, + indexes: {}, + }), + withdrawalGroups: describeStore( + "withdrawalGroups", + describeContents<WalletWithdrawalGroup>({ + keyPath: "withdrawalGroupId", + }), + { + byStatus: describeIndex("byStatus", "status"), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), + byTalerWithdrawUri: describeIndex( + "byTalerWithdrawUri", + "wgInfo.bankInfo.talerWithdrawUri", + ), + }, + ), + planchets: describeStore( + "planchets", + describeContents<WalletPlanchet>({ keyPath: "coinPub" }), + { + byGroupAndIndex: describeIndex( + "byGroupAndIndex", + ["withdrawalGroupId", "coinIdx"], + { + unique: true, + }, + ), + byGroup: describeIndex("byGroup", "withdrawalGroupId"), + byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"), + }, + ), + bankWithdrawUris: describeStore( + "bankWithdrawUris", + describeContents<BankWithdrawUriRecord>({ + keyPath: "talerWithdrawUri", + }), + { + byGroup: describeIndex("byGroup", "withdrawalGroupId"), + }, + ), + depositGroups: describeStore( + "depositGroups", + describeContents<WalletDepositGroup>({ + keyPath: "depositGroupId", + }), + { + byStatus: describeIndex("byStatus", "operationStatus"), + }, + ), + tombstones: describeStore( + "tombstones", + describeContents<WalletTombstone>({ keyPath: "id" }), + {}, + ), + operationRetries: describeStore( + "operationRetries", + describeContents<WalletOperationRetry>({ + keyPath: "id", + }), + {}, + ), + peerPushCredit: describeStore( + "peerPushCredit", + describeContents<PeerPushCreditRecord>({ + keyPath: "peerPushCreditId", + }), + { + byExchangeAndPurse: describeIndex("byExchangeAndPurse", [ + "exchangeBaseUrl", + "pursePub", + ]), + byExchangeAndContractPriv: describeIndex( + "byExchangeAndContractPriv", + ["exchangeBaseUrl", "contractPriv"], + { + unique: true, + }, + ), + byWithdrawalGroupId: describeIndex( + "byWithdrawalGroupId", + "withdrawalGroupId", + {}, + ), + byStatus: describeIndex("byStatus", "status"), + }, + ), + peerPullDebit: describeStore( + "peerPullDebit", + describeContents<PeerPullPaymentIncomingRecord>({ + keyPath: "peerPullDebitId", + }), + { + byExchangeAndPurse: describeIndex("byExchangeAndPurse", [ + "exchangeBaseUrl", + "pursePub", + ]), + byExchangeAndContractPriv: describeIndex( + "byExchangeAndContractPriv", + ["exchangeBaseUrl", "contractPriv"], + { + unique: true, + }, + ), + byStatus: describeIndex("byStatus", "status"), + }, + ), + peerPullCredit: describeStore( + "peerPullCredit", + describeContents<PeerPullCreditRecord>({ + keyPath: "pursePub", + }), + { + byStatus: describeIndex("byStatus", "status"), + byWithdrawalGroupId: describeIndex( + "byWithdrawalGroupId", + "withdrawalGroupId", + {}, + ), + }, + ), + peerPushDebit: describeStore( + "peerPushDebit", + describeContents<PeerPushDebitRecord>({ + keyPath: "pursePub", + }), + { + byStatus: describeIndex("byStatus", "status"), + }, + ), + bankAccountsV2: describeStore( + "bankAccountsV2", + describeContents<WalletBankAccount>({ + keyPath: "bankAccountId", + versionAdded: 14, + }), + { + byPaytoUri: describeIndex("byPaytoUri", "paytoUri", { + versionAdded: 14, + }), + }, + ), + contractTerms: describeStore( + "contractTerms", + describeContents<WalletContractTerms>({ + keyPath: "h", + }), + {}, + ), + refundGroups: describeStore( + "refundGroups", + describeContents<WalletRefundGroup>({ + keyPath: "refundGroupId", + }), + { + byProposalId: describeIndex("byProposalId", "proposalId"), + byStatus: describeIndex("byStatus", "status", {}), + }, + ), + refundItems: describeStore( + "refundItems", + describeContents<WalletRefundItem>({ + keyPath: "id", + autoIncrement: true, + }), + { + byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [ + "coinPub", + "rtxid", + ]), + // FIXME: Why is this a list of index keys? Confusing! + byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]), + }, + ), + fixups: describeStore( + "fixups", + describeContents<FixupRecord>({ + keyPath: "fixupName", + }), + {}, + ), + // + // Obsolete stores, not used anymore + // + obsolete_backupProviders: describeStore( + "backupProviders", + describeContents<unknown>({ + keyPath: "baseUrl", + }), + { + byPaymentProposalId: describeIndex( + "byPaymentProposalId", + "paymentProposalIds", + { + multiEntry: true, + }, + ), + }, + ), + _obsolete_transactions: describeStoreV2({ + recordCodec: passthroughCodec<unknown>(), + storeName: "transactions", + keyPath: "transactionItem.transactionId", + versionAdded: 7, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 7, + }), + byExchange: describeIndex("byExchange", "exchanges", { + versionAdded: 7, + multiEntry: true, + }), + }, + }), + _obsolete_bankAccounts: describeStore( + "bankAccounts", + describeContents<any>({ + keyPath: "uri", + }), + {}, + ), + _obsolete_rewards: describeStore( + "rewards", + describeContents<any>({ keyPath: "walletRewardId" }), + { + byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ + "merchantRewardId", + "merchantBaseUrl", + ]), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 8, + }), + }, + ), + obsolete_userAttention: describeStore( + "userAttention", + describeContents<unknown>({ + keyPath: ["entityId", "info.type"], + }), + {}, + ), +}; + +export type WalletIndexedDbTransaction = DbReadWriteTransaction< + typeof WalletIndexedDbStoresV1, + Array<StoreNames<typeof WalletIndexedDbStoresV1>> +>; + +/** + * An applied migration. + */ +export interface FixupRecord { + fixupName: string; +} + +export interface MetaConfigRecord { + key: string; + value: any; +} + +export const walletMetadataStore = { + metaConfig: describeStore( + "metaConfig", + describeContents<MetaConfigRecord>({ keyPath: "key" }), + {}, + ), +}; + +/** + * Compile-time proof that every IndexedDB record type can actually be stored. + * + * The store definitions below use the DAL's `Wallet<Name>` types directly as + * their record types, so a change made for the native sqlite backend changes + * what IndexedDB persists. That is fine while the types stay + * structured-clone friendly, and fatal the moment one does not: the + * serialiser these values pass through on their way to storage + * (structuredEncapsulate in idb-bridge) handles arrays, dates, plain objects, + * bigint, boolean, number and string, and throws on anything else. Typed + * arrays included -- IndexedDB here cannot store binary at all. + * + * Without this, giving a record field a Uint8Array type -- the direction the + * native backend wants to go, to stop encoding keys as Crockford base32 -- + * would compile cleanly and fail at runtime, on write, in production. + * + * The record types are derived from the store map rather than listed, so a + * new store is covered without anyone remembering to add it here. + * + * When this stops compiling: do not widen it. It means a stored type has + * gained a field IndexedDB cannot persist -- typically a Uint8Array, which + * structuredEncapsulate cannot represent. The fix is to give the IndexedDB + * store its own record type holding whatever it can persist, and convert at + * the DAL boundary, not to relax the check. + */ +type Persistable<T> = T extends ArrayBufferView | ArrayBuffer + ? never + : T extends (...args: any[]) => any + ? never + : // Primitives are checked before objects on purpose: the branded types + // used throughout the records (DbPreciseTimestamp is number & {...}, + // AmountString is string & {...}) are intersections that satisfy + // `extends object`, and mapping over one turns a number into an object + // type. That flagged perfectly persistable records. + T extends string | number | boolean | bigint | null | undefined + ? T + : T extends Date + ? T + : T extends Array<infer U> + ? Array<Persistable<U>> + : T extends object + ? { [K in keyof T]: Persistable<T[K]> } + : T; + +/** + * The tuple wrappers stop the outer conditional from distributing over a + * union of record types, which would let a single unpersistable member hide + * behind its persistable siblings. + */ +type IsPersistable<T> = [T] extends [Persistable<T>] ? true : false; + +type AssertTrue<T extends true> = T; + +type RecordTypeOf<S> = + S extends StoreWithIndexes<any, infer R, any> ? R : never; + +type StoreMapV1 = typeof WalletIndexedDbStoresV1; + +/** + * The names of stores whose record type cannot be persisted, or never. + * + * Checked per store rather than over the union of all record types: two + * obsolete stores are typed `any`, and `any` in a union makes every other + * member assignable to it, which made an earlier version of this check pass + * a record type containing a Uint8Array. A vacuous guard is worse than no + * guard, because it is trusted. + */ +type UnpersistableStores = { + [K in keyof StoreMapV1]: IsPersistable< + RecordTypeOf<StoreMapV1[K]> + > extends true + ? never + : K; +}[keyof StoreMapV1]; + +/** + * The assertion itself. Unused at runtime; its only job is to fail the build, + * naming the offending store in the error. + */ +export type _AllIndexedDbRecordsArePersistable = AssertTrue< + [UnpersistableStores] extends [never] ? true : UnpersistableStores +>; diff --git a/packages/taler-wallet-core/src/db/indexeddb/transaction.ts b/packages/taler-wallet-core/src/db/indexeddb/transaction.ts @@ -0,0 +1,2164 @@ +/* + 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/> + */ + +/** + * IndexedDB implementation of the backend-neutral {@link WalletDbTransaction} + * data access layer. + * + * All IndexedDB specifics (key ranges, store/index names, the + * <Name>Record storage types) belong here. The interface itself lives in + * transaction.ts and must stay free of them. + */ + +import { + ContactEntry, + CurrencySpecification, + MailboxConfiguration, + MailboxMessageRecord, + ScopeInfo, + TransactionIdStr, + stringifyScopeInfo, + assertUnreachable, + ScopeType, + WalletNotification, + checkDbInvariant, + CoinStatus, +} from "@gnu-taler/taler-util"; +import { GlobalIDB } from "@gnu-taler/idb-bridge"; +import { + ConfigRecord, + WalletPeerPullCredit, + WalletPeerPushDebit, + WalletPeerPushCredit, + WalletPeerPullDebit, + WalletToken, + WalletSlate, + WalletDenomination, + WalletTransactionMeta, + WalletTransactionMetaCursor, + DbPreciseTimestamp, + DbProtocolTimestamp, + WalletOperationRetry, + WalletContractTerms, + DenominationVerificationStatus, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, + WalletCoinAvailability, + WalletCoinHistory, + WalletCoin, + WalletDepositGroup, + WalletRecoupGroup, + PurchaseStatus, + WalletReserve, + WalletRefreshGroup, + WalletRefreshSession, + WalletWithdrawalGroup, + WalletPlanchet, + WalletDonationSummary, + WalletDonationReceipt, + WalletDonationPlanchet, + DonationReceiptStatus, + WalletPurchase, + WalletRefundGroup, + WalletRefundItem, + WalletTombstone, + WalletExchangeEntry, + WalletDenomLossEvent, + WalletExchangeSignkeys, + WalletDenomFamilyParams, + WalletDenominationFamily, + WalletExchangeBaseUrlFixup, + WalletExchangeMigrationLog, + WalletGlobalCurrencyExchange, + WalletGlobalCurrencyAuditor, + WalletBankAccount, + WalletExchangeDetails, +} from "../records.js"; +import { WalletIndexedDbTransaction } from "./schema.js"; +import type { + WalletCurrencyInfoEntry, + WalletDbRecordCounts, + GetCurrencyInfoDbResult, + StoreCurrencyInfoDbRequest, + WalletCoinAvailabilityRef, + WalletDbTransaction, + WalletDbMigrationStore, + WalletDbMigrationPage, + WalletDenomRef, +} from "../transaction.js"; +import { auditorProvidesVerifiedTrust } from "../../auditorTrust.js"; + +function getActiveKeyRange() { + return GlobalIDB.KeyRange.bound( + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, + ); +} + +export class IdbWalletTransaction implements WalletDbTransaction { + tx: WalletIndexedDbTransaction; + constructor(tx: WalletIndexedDbTransaction) { + this.tx = tx; + } + + async scanMigrationRecords<T>( + store: WalletDbMigrationStore, + _read: (tx: WalletDbTransaction) => Promise<T[]>, + cursor: unknown | undefined, + limit: number, + ): Promise<WalletDbMigrationPage<T>> { + const physicalStore: Record<WalletDbMigrationStore, string> = { + config: "config", + currencyInfo: "currencyInfo", + contacts: "contacts", + mailboxMessages: "mailboxMessages", + mailboxConfigurations: "mailboxConfigurations", + contractTerms: "contractTerms", + tombstones: "tombstones", + operationRetries: "operationRetries", + bankAccounts: "bankAccountsV2", + globalCurrencyExchanges: "globalCurrencyExchanges", + globalCurrencyAuditors: "globalCurrencyAuditors", + exchangeBaseUrlFixups: "exchangeBaseUrlFixups", + exchangeBaseUrlMigrationLog: "exchangeBaseUrlMigrationLog", + reserves: "reserves", + exchanges: "exchanges", + exchangeDetails: "exchangeDetails", + exchangeSignKeys: "exchangeSignKeys", + denominationFamilies: "denominationFamilies", + denominations: "denominationsV2", + withdrawalGroups: "withdrawalGroups", + purchases: "purchases", + refreshGroups: "refreshGroups", + coins: "coins", + planchets: "planchets", + refreshSessions: "refreshSessions", + coinHistory: "coinHistory", + coinAvailability: "coinAvailabilityV2", + refundGroups: "refundGroups", + tokens: "tokens", + slates: "slates", + depositGroups: "depositGroups", + recoupGroups: "recoupGroups", + denomLossEvents: "denomLossEvents", + peerPushDebit: "peerPushDebit", + peerPushCredit: "peerPushCredit", + peerPullDebit: "peerPullDebit", + peerPullCredit: "peerPullCredit", + donationSummaries: "donationSummaries", + donationPlanchets: "donationPlanchets", + donationReceipts: "donationReceipts", + transactionsMeta: "transactionsMeta", + refundItems: "refundItems", + }; + const accessor = (this.tx as any)[physicalStore[store]]; + if (!accessor) { + throw Error(`migration store ${store} is not available`); + } + const page = await accessor.scan(cursor, limit); + return { + records: page.records as T[], + ...(page.records.length > 0 ? { nextCursor: page.lastKey } : {}), + }; + } + + scheduleOnCommit(f: () => void): void { + this.tx._util.scheduleOnCommit(f); + } + + /** + * Bound as an instance property, not a prototype method: call sites pass + * this around unbound (e.g. applyNotifyTransition(tx.notify, ...)), which + * would otherwise lose "this" and fail on this.tx. + */ + notify = (notif: WalletNotification): void => { + this.tx.notify(notif); + }; + async getCurrencyInfo( + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined> { + const tx = this.tx; + const s = stringifyScopeInfo(scopeInfo); + const res = await tx.currencyInfo.get(s); + if (!res) { + return undefined; + } + return { + currencySpec: res.currencySpec, + source: res.source, + }; + } + + async getConfig<T extends ConfigRecord["key"]>( + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { + const tx = this.tx; + return (await tx.config.get(key)) as any; + } + + async upsertConfig(record: ConfigRecord): Promise<void> { + const tx = this.tx; + await tx.config.put(record); + } + + async listAllConfig(): Promise<ConfigRecord[]> { + return await this.tx.config.getAll(); + } + + async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { + return await this.tx.currencyInfo.getAll(); + } + + async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { + await this.tx.currencyInfo.put(entry); + } + + async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { + const tx = this.tx; + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } + + async insertCurrencyInfoUnlessExists( + req: StoreCurrencyInfoDbRequest, + ): Promise<void> { + const tx = this.tx; + const scopeInfoStr = stringifyScopeInfo(req.scopeInfo); + const oldRec = await tx.currencyInfo.get(scopeInfoStr); + if (oldRec) { + return; + } + await tx.currencyInfo.put({ + scopeInfoStr: stringifyScopeInfo(req.scopeInfo), + currencySpec: req.currencySpec, + source: req.source, + }); + } + + async addContact(contact: ContactEntry): Promise<void> { + const tx = this.tx; + await tx.contacts.put({ + alias: contact.alias, + aliasType: contact.aliasType, + mailboxBaseUri: contact.mailboxBaseUri, + mailboxAddress: contact.mailboxAddress, + source: contact.source, + petname: contact.petname, + }); + } + + async deleteContact(alias: string, aliasType: string): Promise<void> { + const tx = this.tx; + await tx.contacts.delete([alias, aliasType]); + } + + async listContacts(): Promise<ContactEntry[]> { + const tx = this.tx; + const records = await tx.contacts.getAll(); + return records.map((r) => ({ + alias: r.alias, + aliasType: r.aliasType, + mailboxBaseUri: r.mailboxBaseUri, + mailboxAddress: r.mailboxAddress, + source: r.source, + petname: r.petname, + })); + } + + async upsertMailboxMessage(message: MailboxMessageRecord): Promise<void> { + const tx = this.tx; + await tx.mailboxMessages.put(message); + } + + async deleteMailboxMessage( + originMailboxBaseUrl: string, + talerUri: string, + ): Promise<void> { + const tx = this.tx; + await tx.mailboxMessages.delete([originMailboxBaseUrl, talerUri]); + } + + async listMailboxMessages(): Promise<MailboxMessageRecord[]> { + const tx = this.tx; + return await tx.mailboxMessages.getAll(); + } + + async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { + return await this.tx.mailboxConfigurations.getAll(); + } + + async getMailboxConfiguration( + mailboxBaseUrl: string, + ): Promise<MailboxConfiguration | undefined> { + const tx = this.tx; + return await tx.mailboxConfigurations.get(mailboxBaseUrl); + } + + async upsertMailboxConfiguration( + mailboxConf: MailboxConfiguration, + ): Promise<void> { + const tx = this.tx; + await tx.mailboxConfigurations.put(mailboxConf); + } + + async getPurchase(proposalId: string): Promise<WalletPurchase | undefined> { + const tx = this.tx; + return await tx.purchases.get(proposalId); + } + + async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> { + const tx = this.tx; + await tx.transactionsMeta.put({ + transactionId: rec.transactionId, + timestamp: rec.timestamp, + status: rec.status, + exchanges: rec.exchanges, + currency: rec.currency, + }); + } + + async getLocalTransactionIdentifiers( + _transactionIds: string[], + ): Promise<Map<string, string>> { + // Do not add a store just for this feature: assigning a counter safely + // across IndexedDB transactions would require serialising every metadata + // update. Native SQLite can do this cheaply and atomically. + return new Map(); + } + + async getTransactionIdByLocalIdentifier( + _transactionType: string, + _localIdent: string, + ): Promise<string | undefined> { + return undefined; + } + + async deleteTransactionMeta(transactionId: string): Promise<void> { + const tx = this.tx; + await tx.transactionsMeta.delete(transactionId); + } + + async getTransactionMeta( + transactionId: string, + ): Promise<WalletTransactionMeta | undefined> { + const tx = this.tx; + return await tx.transactionsMeta.get(transactionId); + } + + async getTransactionMetaAtTimestamp( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + const tx = this.tx; + return await tx.transactionsMeta.indexes.byTimestamp.get(timestamp); + } + + async getTransactionMetaBefore( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + const tx = this.tx; + // Walk the index downwards from the bound and stop at the first hit. + // Reading the whole range and keeping the last entry gave the same answer + // but touched every record below the bound, which on a wallet with a long + // history is the entire table. + const cursor = tx.transactionsMeta.indexes.byTimestamp.iterPrev( + GlobalIDB.KeyRange.upperBound(timestamp, false), + ); + const first = await cursor.next(); + return first.hasValue ? first.value : undefined; + } + + async getTransactionMetaAfter( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + const tx = this.tx; + const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll( + GlobalIDB.KeyRange.lowerBound(timestamp, false), + 1, + ); + return recs[0]; + } + + async listTransactionMetaByTimestamp(req: { + afterTimestamp?: DbPreciseTimestamp; + limit?: number; + }): Promise<WalletTransactionMeta[]> { + const tx = this.tx; + const range = + req.afterTimestamp != null + ? GlobalIDB.KeyRange.lowerBound(req.afterTimestamp, true) + : undefined; + return await tx.transactionsMeta.indexes.byTimestamp.getAll( + range, + req.limit, + ); + } + + async listTransactionMetaPage(req: { + cursor?: WalletTransactionMetaCursor; + direction: "forward" | "backward"; + limit: number; + }): Promise<WalletTransactionMeta[]> { + const index = this.tx.transactionsMeta.indexes.byTimestampAndId; + const key = req.cursor + ? [req.cursor.timestamp, req.cursor.transactionId] + : undefined; + if (req.direction === "forward") { + const range = key ? GlobalIDB.KeyRange.lowerBound(key, true) : undefined; + return await index.getAll(range, req.limit); + } + const range = key ? GlobalIDB.KeyRange.upperBound(key, true) : undefined; + const cursor = index.iterPrev(range); + const records: WalletTransactionMeta[] = []; + while (records.length < req.limit) { + const next = await cursor.next(); + if (!next.hasValue) { + break; + } + records.push(next.value); + } + return records; + } + + async listTransactionMetaByStatus(req: { + onlyActive: boolean; + }): Promise<WalletTransactionMeta[]> { + const tx = this.tx; + const range = req.onlyActive ? getActiveKeyRange() : undefined; + return await tx.transactionsMeta.indexes.byStatus.getAll(range); + } + + async deleteAllTransactionMeta(): Promise<void> { + const tx = this.tx; + const all = await tx.transactionsMeta.getAll(); + for (const rec of all) { + await tx.transactionsMeta.delete(rec.transactionId); + } + } + + async getOperationRetry( + taskId: string, + ): Promise<WalletOperationRetry | undefined> { + const tx = this.tx; + return await tx.operationRetries.get(taskId); + } + + async upsertOperationRetry(rec: WalletOperationRetry): Promise<void> { + const tx = this.tx; + await tx.operationRetries.put({ + id: rec.id, + lastError: rec.lastError, + retryInfo: rec.retryInfo, + }); + } + async listAllOperationRetries(): Promise<WalletOperationRetry[]> { + return await this.tx.operationRetries.getAll(); + } + + async deleteOperationRetry(taskId: string): Promise<void> { + const tx = this.tx; + await tx.operationRetries.delete(taskId); + } + + async getContractTerms( + contractTermsHash: string, + ): Promise<WalletContractTerms | undefined> { + const tx = this.tx; + return await tx.contractTerms.get(contractTermsHash); + } + + async upsertContractTerms(rec: WalletContractTerms): Promise<void> { + const tx = this.tx; + await tx.contractTerms.put({ + h: rec.h, + contractTermsRaw: rec.contractTermsRaw, + }); + } + + async countWithdrawalGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<number> { + const tx = this.tx; + return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count( + exchangeBaseUrl, + ); + } + + async getWithdrawalGroupsByExchangeForRekey( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]> { + const tx = this.tx; + return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]> { + return await this.tx.globalCurrencyExchanges.getAll(); + } + + async upsertGlobalCurrencyExchange( + rec: WalletGlobalCurrencyExchange, + ): Promise<void> { + // The row id is generated, so putting a record that is already stored + // would insert a second row and violate the unique index over the three + // identifying fields, which aborts the whole transaction. + const existing = await this.getGlobalCurrencyExchange( + rec.currency, + rec.exchangeBaseUrl, + rec.exchangeMasterPub, + ); + if (existing) { + return; + } + await this.tx.globalCurrencyExchanges.put(rec); + } + + async deleteGlobalCurrencyExchange(id: number): Promise<void> { + await this.tx.globalCurrencyExchanges.delete(id); + } + + async listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]> { + return await this.tx.globalCurrencyAuditors.getAll(); + } + + async upsertGlobalCurrencyAuditor( + rec: WalletGlobalCurrencyAuditor, + ): Promise<void> { + // See upsertGlobalCurrencyExchange. + const existing = await this.getGlobalCurrencyAuditor( + rec.currency, + rec.auditorBaseUrl, + rec.auditorPub, + ); + if (existing) { + return; + } + await this.tx.globalCurrencyAuditors.put(rec); + } + + async deleteGlobalCurrencyAuditor(id: number): Promise<void> { + await this.tx.globalCurrencyAuditors.delete(id); + } + + async deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void> { + await this.tx.currencyInfo.delete(stringifyScopeInfo(scopeInfo)); + } + + async getGlobalCurrencyExchange( + currency: string, + exchangeBaseUrl: string, + exchangeMasterPub: string, + ): Promise<WalletGlobalCurrencyExchange | undefined> { + const tx = this.tx; + return await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ + currency, + exchangeBaseUrl, + exchangeMasterPub, + ]); + } + + async getGlobalCurrencyAuditor( + currency: string, + auditorBaseUrl: string, + auditorPub: string, + ): Promise<WalletGlobalCurrencyAuditor | undefined> { + const tx = this.tx; + return await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([ + currency, + auditorBaseUrl, + auditorPub, + ]); + } + + async listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]> { + const tx = this.tx; + return await tx.denomLossEvents.getAll(); + } + + async getFreshCoinsByDenomAndAge( + ref: WalletCoinAvailabilityRef, + limit: number, + ): Promise<WalletCoin[]> { + const tx = this.tx; + return await tx.coins.indexes.byMasterPubDenomPubHashAndAgeAndStatus.getAll( + [ref.exchangeMasterPub, ref.denomPubHash, ref.maxAge, CoinStatus.Fresh], + limit, + ); + } + + async getCoinAvailabilityByExchangeAndAgeRange( + exchangeBaseUrl: string, + ageLower: number, + ageUpper: number, + ): Promise<WalletCoinAvailability[]> { + const tx = this.tx; + // Lower bound of 1 on freshCoinCount: only denominations that actually + // have a fresh coin available. + return await tx.coinAvailabilityV2.indexes.byExchangeFreshAndAge.getAll( + GlobalIDB.KeyRange.bound( + [exchangeBaseUrl, 1, ageLower], + [exchangeBaseUrl, 1, ageUpper], + ), + ); + } + + async listBankAccounts(): Promise<WalletBankAccount[]> { + return await this.tx.bankAccountsV2.getAll(); + } + + async getBankAccount( + bankAccountId: string, + ): Promise<WalletBankAccount | undefined> { + return await this.tx.bankAccountsV2.get(bankAccountId); + } + + async deleteBankAccount(bankAccountId: string): Promise<void> { + await this.tx.bankAccountsV2.delete(bankAccountId); + } + + async getBankAccountByPaytoUri( + paytoUri: string, + ): Promise<WalletBankAccount | undefined> { + return await this.tx.bankAccountsV2.indexes.byPaytoUri.get(paytoUri); + } + + async upsertBankAccount(rec: WalletBankAccount): Promise<void> { + await this.tx.bankAccountsV2.put(rec); + } + + async getRecordCounts(): Promise<WalletDbRecordCounts> { + const tx = this.tx; + return { + coins: await tx.coins.count(), + coinAvailability: await tx.coinAvailabilityV2.count(), + denominations: await tx.denominationsV2.count(), + denominationFamilies: await tx.denominationFamilies.count(), + exchanges: await tx.exchanges.count(), + exchangeDetails: await tx.exchangeDetails.count(), + exchangeSignKeys: await tx.exchangeSignKeys.count(), + }; + } + + async listAllCoins(): Promise<WalletCoin[]> { + return await this.tx.coins.getAll(); + } + + async getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]> { + const tx = this.tx; + return await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl); + } + + async countCoinsByExchange(exchangeBaseUrl: string): Promise<number> { + const tx = this.tx; + return await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl); + } + + async getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]> { + const tx = this.tx; + return await tx.coins.indexes.byDenomPubHash.getAll(denomPubHash); + } + + async getCoinsByDenomPubHashes( + denomPubHashes: string[], + ): Promise<WalletCoin[]> { + const uniqueHashes = [...new Set(denomPubHashes)]; + const groups = await Promise.all( + uniqueHashes.map((hash) => + this.tx.coins.indexes.byDenomPubHash.getAll(hash), + ), + ); + return groups.flat(); + } + + async deleteCoin(coinPub: string): Promise<void> { + const tx = this.tx; + // Cascade to the history, which describes this coin and nothing else. + // Every reader looks it up for a coin it already holds, so a history row + // without its coin is unreachable. Matches the sqlite constraint. + await tx.coinHistory.delete(coinPub); + await tx.coins.delete(coinPub); + } + + async deleteCoinHistory(coinPub: string): Promise<void> { + const tx = this.tx; + await tx.coinHistory.delete(coinPub); + } + + async getCoinAvailabilityByExchange( + exchangeBaseUrl: string, + ): Promise<WalletCoinAvailability[]> { + const tx = this.tx; + return await tx.coinAvailabilityV2.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void> { + const tx = this.tx; + await tx.coinAvailabilityV2.delete([ + ref.exchangeMasterPub, + ref.denomPubHash, + ref.maxAge, + ]); + } + + async getRecoupGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletRecoupGroup[]> { + const tx = this.tx; + return await tx.recoupGroups.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async listAllRefreshGroups(): Promise<WalletRefreshGroup[]> { + const tx = this.tx; + return await tx.refreshGroups.getAll(); + } + + async listAllDepositGroups(): Promise<WalletDepositGroup[]> { + const tx = this.tx; + return await tx.depositGroups.getAll(); + } + + async listAllRefundGroups(): Promise<WalletRefundGroup[]> { + return await this.tx.refundGroups.getAll(); + } + + async listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { + return await this.tx.withdrawalGroups.getAll(); + } + + async listAllPurchases(): Promise<WalletPurchase[]> { + return await this.tx.purchases.getAll(); + } + + async listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]> { + const tx = this.tx; + return await tx.peerPullCredit.getAll(); + } + + async listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]> { + const tx = this.tx; + return await tx.peerPullDebit.getAll(); + } + + async listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]> { + const tx = this.tx; + return await tx.peerPushCredit.getAll(); + } + + async listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]> { + const tx = this.tx; + return await tx.peerPushDebit.getAll(); + } + + async getDenominationFamilyByParams( + params: WalletDenomFamilyParams, + ): Promise<WalletDenominationFamily | undefined> { + const tx = this.tx; + // The byFamilyParms index key is a 7-component array whose component + // order is part of the schema; it is built here so exactly one place + // knows it. + return await tx.denominationFamilies.indexes.byFamilyParms.get([ + params.exchangeBaseUrl, + params.exchangeMasterPub, + params.value, + params.feeWithdraw, + params.feeDeposit, + params.feeRefresh, + params.feeRefund, + ]); + } + + async upsertDenominationFamily( + rec: WalletDenominationFamily, + ): Promise<number> { + const tx = this.tx; + const res = await tx.denominationFamilies.put(rec); + checkDbInvariant( + typeof res.key === "number", + "denomination family serial must be a number", + ); + return res.key; + } + + async getDenominationFamiliesByExchange( + exchangeBaseUrl: string, + ): Promise<WalletDenominationFamily[]> { + const tx = this.tx; + return await tx.denominationFamilies.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async deleteDenominationFamily( + denominationFamilySerial: number, + ): Promise<void> { + const tx = this.tx; + // Cascade to the denominations of that family. There is no accessor for + // "denominations by family" on its own, so this walks the index whose + // first component is the family serial. + const doomed = + await tx.denominationsV2.indexes.byDenominationFamilySerialAndStampExpireWithdraw.getAll( + GlobalIDB.KeyRange.bound( + [denominationFamilySerial, Number.MIN_SAFE_INTEGER], + [denominationFamilySerial, Number.MAX_SAFE_INTEGER], + ), + ); + for (const d of doomed) { + await tx.denominationsV2.delete([d.exchangeMasterPub, d.denomPubHash]); + } + await tx.denominationFamilies.delete(denominationFamilySerial); + } + + async getExchangeBaseUrlFixup( + exchangeBaseUrl: string, + ): Promise<WalletExchangeBaseUrlFixup | undefined> { + const tx = this.tx; + return await tx.exchangeBaseUrlFixups.get(exchangeBaseUrl); + } + + async upsertExchangeBaseUrlFixup( + rec: WalletExchangeBaseUrlFixup, + ): Promise<void> { + const tx = this.tx; + await tx.exchangeBaseUrlFixups.put(rec); + } + + async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { + return await this.tx.exchangeBaseUrlFixups.getAll(); + } + + async listAllExchangeMigrationLogEntries(): Promise< + WalletExchangeMigrationLog[] + > { + return await this.tx.exchangeBaseUrlMigrationLog.getAll(); + } + + async getExchangeMigrationLog( + oldExchangeBaseUrl: string, + newExchangeBaseUrl: string, + ): Promise<WalletExchangeMigrationLog | undefined> { + const tx = this.tx; + return await tx.exchangeBaseUrlMigrationLog.get([ + oldExchangeBaseUrl, + newExchangeBaseUrl, + ]); + } + + async upsertExchangeMigrationLog( + rec: WalletExchangeMigrationLog, + ): Promise<void> { + const tx = this.tx; + await tx.exchangeBaseUrlMigrationLog.put(rec); + } + + async getExchangeDetailsByPointer( + exchangeBaseUrl: string, + currency: string, + masterPublicKey: string, + ): Promise<WalletExchangeDetails | undefined> { + const tx = this.tx; + return await tx.exchangeDetails.indexes.byPointer.get([ + exchangeBaseUrl, + currency, + masterPublicKey, + ]); + } + + async getExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined> { + const tx = this.tx; + return await tx.exchangeDetails.indexes.byExchangeBaseUrl.get( + exchangeBaseUrl, + ); + } + + async listExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails[]> { + const tx = this.tx; + return await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async listExchangeDetailsByMasterPub( + masterPublicKey: string, + ): Promise<WalletExchangeDetails[]> { + const tx = this.tx; + return await tx.exchangeDetails.indexes.byMasterPublicKey.getAll( + masterPublicKey, + ); + } + + async listAllExchangeDetails(): Promise<WalletExchangeDetails[]> { + const tx = this.tx; + return await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); + } + + async getExchangeDetailsByRowId( + rowId: number, + ): Promise<WalletExchangeDetails | undefined> { + return await this.tx.exchangeDetails.get(rowId); + } + + async upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number> { + const tx = this.tx; + const res = await tx.exchangeDetails.put(rec); + checkDbInvariant( + typeof res.key === "number", + "exchange details row id must be a number", + ); + return res.key; + } + + async deleteExchangeDetails(rowId: number): Promise<void> { + const tx = this.tx; + // Cascade to the sign keys, which describe this details row and nothing + // else. Matches ON DELETE CASCADE in the sqlite schema. + for (const sk of await this.getExchangeSignKeysByDetailsRowId(rowId)) { + await tx.exchangeSignKeys.delete([rowId, sk.signkeyPub]); + } + await tx.exchangeDetails.delete(rowId); + } + + async getExchangeSignKeysByDetailsRowId( + exchangeDetailsRowId: number, + ): Promise<WalletExchangeSignkeys[]> { + const tx = this.tx; + // byExchangeDetailsRowId has an array keyPath (["exchangeDetailsRowId"]), + // so its keys are single-element arrays and a bare number matches nothing. + return await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll([ + exchangeDetailsRowId, + ]); + } + + async listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]> { + return await this.tx.exchangeSignKeys.getAll(); + } + + async upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void> { + const tx = this.tx; + await tx.exchangeSignKeys.put(rec); + } + + async deleteExchangeSignKey( + exchangeDetailsRowId: number, + signkeyPub: string, + ): Promise<void> { + const tx = this.tx; + await tx.exchangeSignKeys.delete([exchangeDetailsRowId, signkeyPub]); + } + + async getDenomLossEvent( + denomLossEventId: string, + ): Promise<WalletDenomLossEvent | undefined> { + const tx = this.tx; + return await tx.denomLossEvents.get(denomLossEventId); + } + + async upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void> { + const tx = this.tx; + await tx.denomLossEvents.put(rec); + } + + async deleteDenomLossEvent(denomLossEventId: string): Promise<void> { + const tx = this.tx; + await tx.denomLossEvents.delete(denomLossEventId); + } + + async getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined> { + const tx = this.tx; + return await tx.exchanges.get(baseUrl); + } + + async upsertExchange(rec: WalletExchangeEntry): Promise<void> { + const tx = this.tx; + await tx.exchanges.put(rec); + } + + async deleteExchange(baseUrl: string): Promise<void> { + const tx = this.tx; + await tx.exchanges.delete(baseUrl); + } + + async upsertPurchase(rec: WalletPurchase): Promise<void> { + const tx = this.tx; + await tx.purchases.put(rec); + } + + async deletePurchase(proposalId: string): Promise<void> { + const tx = this.tx; + // Cascade to the refund groups, and through deleteRefundGroup to their + // items -- two levels, matching what the sqlite constraints do. + for (const rg of await this.getRefundGroupsByProposal(proposalId)) { + await this.deleteRefundGroup(rg.refundGroupId); + } + await tx.purchases.delete(proposalId); + } + + async getPurchaseByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase | undefined> { + const tx = this.tx; + return await tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + } + + async getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]> { + const purchases = await Promise.all( + proposalIds.map((proposalId) => this.tx.purchases.get(proposalId)), + ); + return purchases.filter((x): x is WalletPurchase => x !== undefined); + } + + async getPurchasesByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase[]> { + const tx = this.tx; + return await tx.purchases.indexes.byUrlAndOrderId.getAll([ + merchantBaseUrl, + orderId, + ]); + } + + async getPurchasesByFulfillmentUrl( + fulfillmentUrl: string, + ): Promise<WalletPurchase[]> { + const tx = this.tx; + return await tx.purchases.indexes.byFulfillmentUrl.getAll(fulfillmentUrl); + } + + async getPurchasesByExchange( + exchangeBaseUrl: string, + ): Promise<WalletPurchase[]> { + const tx = this.tx; + return await tx.purchases.indexes.byExchange.getAll(exchangeBaseUrl); + } + + async getRefundGroup( + refundGroupId: string, + ): Promise<WalletRefundGroup | undefined> { + const tx = this.tx; + return await tx.refundGroups.get(refundGroupId); + } + + async upsertRefundGroup(rec: WalletRefundGroup): Promise<void> { + const tx = this.tx; + await tx.refundGroups.put(rec); + } + + async deleteRefundGroup(refundGroupId: string): Promise<void> { + const tx = this.tx; + // Cascade to the items. A refund item exists only as part of its group, + // and the sqlite schema enforces that with ON DELETE CASCADE; deleting + // only the group here would leave rows behind on this backend that the + // other one removes. + for (const item of await this.getRefundItemsByGroup(refundGroupId)) { + checkDbInvariant( + typeof item.id === "number", + "stored refund item must have a row id", + ); + await tx.refundItems.delete(item.id); + } + await tx.refundGroups.delete(refundGroupId); + } + + async getRefundGroupsByProposal( + proposalId: string, + ): Promise<WalletRefundGroup[]> { + const tx = this.tx; + return await tx.refundGroups.indexes.byProposalId.getAll(proposalId); + } + + async getRefundItemsByGroup( + refundGroupId: string, + ): Promise<WalletRefundItem[]> { + const tx = this.tx; + // byRefundGroupId has an array keyPath (["refundGroupId"]), so its keys + // are single-element arrays and a bare string matches nothing. + return await tx.refundItems.indexes.byRefundGroupId.getAll([refundGroupId]); + } + + async listAllRefundItems(): Promise<WalletRefundItem[]> { + return await this.tx.refundItems.getAll(); + } + + async upsertRefundItem(rec: WalletRefundItem): Promise<number> { + const tx = this.tx; + const res = await tx.refundItems.put(rec); + checkDbInvariant( + typeof res.key === "number", + "refund item row id must be a number", + ); + return res.key; + } + + async deleteRefundItem(id: number): Promise<void> { + const tx = this.tx; + await tx.refundItems.delete(id); + } + + async getRefundItemByCoinAndRtxid( + coinPub: string, + rtxid: number, + ): Promise<WalletRefundItem | undefined> { + const tx = this.tx; + return await tx.refundItems.indexes.byCoinPubAndRtxid.get([coinPub, rtxid]); + } + + async getSlate( + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, + ): Promise<WalletSlate | undefined> { + const tx = this.tx; + return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get( + [purchaseId, choiceIndex, outputIndex, repeatIndex], + ); + } + + async getSlatesByPurchaseAndChoice( + purchaseId: string, + choiceIndex: number, + ): Promise<WalletSlate[]> { + const tx = this.tx; + return await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([ + purchaseId, + choiceIndex, + ]); + } + + async upsertSlate(rec: WalletSlate): Promise<void> { + const tx = this.tx; + await tx.slates.put(rec); + } + + async deleteSlate(tokenUsePub: string): Promise<void> { + const tx = this.tx; + await tx.slates.delete(tokenUsePub); + } + + async listAllSlates(): Promise<WalletSlate[]> { + return await this.tx.slates.getAll(); + } + + async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { + return await this.tx.recoupGroups.getAll(); + } + + async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { + return await this.tx.donationPlanchets.getAll(); + } + + async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { + return await this.tx.donationReceipts.getAll(); + } + + async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { + return await this.tx.denominationFamilies.getAll(); + } + + async listAllDenominations(): Promise<WalletDenomination[]> { + return await this.tx.denominationsV2.getAll(); + } + + async listAllContractTerms(): Promise<WalletContractTerms[]> { + return await this.tx.contractTerms.getAll(); + } + + async upsertTombstone(rec: WalletTombstone): Promise<void> { + const tx = this.tx; + await tx.tombstones.put(rec); + } + + async listAllTombstones(): Promise<WalletTombstone[]> { + return await this.tx.tombstones.getAll(); + } + + async getDonationSummary( + donauBaseUrl: string, + year: number, + currency: string, + ): Promise<WalletDonationSummary | undefined> { + const tx = this.tx; + return await tx.donationSummaries.get([donauBaseUrl, year, currency]); + } + + async upsertDonationSummary(rec: WalletDonationSummary): Promise<void> { + const tx = this.tx; + await tx.donationSummaries.put(rec); + } + + async getDonationReceipt( + udiNonce: string, + ): Promise<WalletDonationReceipt | undefined> { + const tx = this.tx; + return await tx.donationReceipts.get(udiNonce); + } + + async upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void> { + const tx = this.tx; + await tx.donationReceipts.put(rec); + } + + async getDonationReceiptsByStatus( + status: DonationReceiptStatus, + ): Promise<WalletDonationReceipt[]> { + const tx = this.tx; + return await tx.donationReceipts.indexes.byStatus.getAll(status); + } + + async getDonationReceiptsByStatusAndDonau( + status: DonationReceiptStatus, + donauBaseUrl: string, + ): Promise<WalletDonationReceipt[]> { + const tx = this.tx; + return await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ + status, + donauBaseUrl, + ]); + } + + async upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void> { + const tx = this.tx; + await tx.donationPlanchets.put(rec); + } + + async getDonationPlanchetsByProposal( + proposalId: string, + ): Promise<WalletDonationPlanchet[]> { + const tx = this.tx; + return await tx.donationPlanchets.indexes.byProposalId.getAll(proposalId); + } + + async countDonationPlanchetsByProposal(proposalId: string): Promise<number> { + return await this.tx.donationPlanchets.indexes.byProposalId.count( + proposalId, + ); + } + + async getWithdrawalGroup( + withdrawalGroupId: string, + ): Promise<WalletWithdrawalGroup | undefined> { + const tx = this.tx; + return await tx.withdrawalGroups.get(withdrawalGroupId); + } + + async upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void> { + const tx = this.tx; + await tx.withdrawalGroups.put(rec); + } + + async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> { + const tx = this.tx; + // Cascade to the planchets, which exist only as part of the group. + await this.deletePlanchetsByGroup(withdrawalGroupId); + await tx.withdrawalGroups.delete(withdrawalGroupId); + } + + async getWithdrawalGroupByTalerWithdrawUri( + talerWithdrawUri: string, + ): Promise<WalletWithdrawalGroup | undefined> { + const tx = this.tx; + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + talerWithdrawUri, + ); + } + + async getWithdrawalGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]> { + const tx = this.tx; + return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( + exchangeBaseUrl, + ); + } + + async getPlanchetByGroupAndIndex( + withdrawalGroupId: string, + coinIdx: number, + ): Promise<WalletPlanchet | undefined> { + const tx = this.tx; + return await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroupId, + coinIdx, + ]); + } + + async getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined> { + const tx = this.tx; + return await tx.planchets.get(coinPub); + } + + async upsertPlanchet(rec: WalletPlanchet): Promise<void> { + const tx = this.tx; + await tx.planchets.put(rec); + } + + async deletePlanchet(coinPub: string): Promise<void> { + const tx = this.tx; + await tx.planchets.delete(coinPub); + } + + async getPlanchetsByGroup( + withdrawalGroupId: string, + ): Promise<WalletPlanchet[]> { + const tx = this.tx; + return await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + } + + async listAllPlanchets(): Promise<WalletPlanchet[]> { + return await this.tx.planchets.getAll(); + } + + async countPlanchetsByGroup(withdrawalGroupId: string): Promise<number> { + return await this.tx.planchets.indexes.byGroup.count(withdrawalGroupId); + } + + async deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void> { + const tx = this.tx; + const planchets = + await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); + for (const p of planchets) { + await tx.planchets.delete(p.coinPub); + } + } + + async getRefreshGroup( + refreshGroupId: string, + ): Promise<WalletRefreshGroup | undefined> { + const tx = this.tx; + return await tx.refreshGroups.get(refreshGroupId); + } + + async upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void> { + const tx = this.tx; + await tx.refreshGroups.put(rec); + } + + async deleteRefreshGroup(refreshGroupId: string): Promise<void> { + const tx = this.tx; + // Cascade to the sessions, which exist only as part of the group. + for (const sess of await this.getRefreshSessionsByGroup(refreshGroupId)) { + await tx.refreshSessions.delete([refreshGroupId, sess.coinIndex]); + } + await tx.refreshGroups.delete(refreshGroupId); + } + + async getRefreshGroupsByOriginatingTransaction( + transactionId: string, + ): Promise<WalletRefreshGroup[]> { + const tx = this.tx; + return await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll( + transactionId, + ); + } + + async getRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<WalletRefreshSession | undefined> { + const tx = this.tx; + return await tx.refreshSessions.get([refreshGroupId, coinIndex]); + } + + async upsertRefreshSession(rec: WalletRefreshSession): Promise<void> { + const tx = this.tx; + await tx.refreshSessions.put(rec); + } + + async deleteRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<void> { + const tx = this.tx; + await tx.refreshSessions.delete([refreshGroupId, coinIndex]); + } + + async getRefreshSessionsByGroup( + refreshGroupId: string, + ): Promise<WalletRefreshSession[]> { + const tx = this.tx; + return await tx.refreshSessions.indexes.byRefreshGroupId.getAll( + refreshGroupId, + ); + } + + async listAllRefreshSessions(): Promise<WalletRefreshSession[]> { + return await this.tx.refreshSessions.getAll(); + } + + async getRecoupGroup( + recoupGroupId: string, + ): Promise<WalletRecoupGroup | undefined> { + const tx = this.tx; + return await tx.recoupGroups.get(recoupGroupId); + } + + async upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void> { + const tx = this.tx; + await tx.recoupGroups.put(rec); + } + + async deleteRecoupGroup(recoupGroupId: string): Promise<void> { + const tx = this.tx; + await tx.recoupGroups.delete(recoupGroupId); + } + + async getReserve(reserveRowId: number): Promise<WalletReserve | undefined> { + const tx = this.tx; + return await tx.reserves.get(reserveRowId); + } + + async getReserveByReservePub( + reservePub: string, + ): Promise<WalletReserve | undefined> { + const tx = this.tx; + return await tx.reserves.indexes.byReservePub.get(reservePub); + } + + async getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]> { + const reserves = await Promise.all( + reservePubs.map((reservePub) => + this.tx.reserves.indexes.byReservePub.get(reservePub), + ), + ); + return reserves.filter((x): x is WalletReserve => x !== undefined); + } + + async listAllReserves(): Promise<WalletReserve[]> { + return await this.tx.reserves.getAll(); + } + + async upsertReserve(rec: WalletReserve): Promise<number> { + const tx = this.tx; + const res = await tx.reserves.put(rec); + checkDbInvariant( + typeof res.key === "number", + "reserve row id must be a number", + ); + return res.key; + } + + async getDepositGroup( + depositGroupId: string, + ): Promise<WalletDepositGroup | undefined> { + const tx = this.tx; + return await tx.depositGroups.get(depositGroupId); + } + + async upsertDepositGroup(rec: WalletDepositGroup): Promise<void> { + const tx = this.tx; + await tx.depositGroups.put(rec); + } + + async deleteDepositGroup(depositGroupId: string): Promise<void> { + const tx = this.tx; + await tx.depositGroups.delete(depositGroupId); + } + + async getCoin(coinPub: string): Promise<WalletCoin | undefined> { + const tx = this.tx; + return await tx.coins.get(coinPub); + } + + async upsertCoin(coin: WalletCoin): Promise<void> { + const tx = this.tx; + await tx.coins.put(coin); + } + + async getCoinsBySourceTransaction( + transactionId: string, + ): Promise<WalletCoin[]> { + const tx = this.tx; + return await tx.coins.indexes.bySourceTransactionId.getAll(transactionId); + } + + async getCoinAvailability( + ref: WalletCoinAvailabilityRef, + ): Promise<WalletCoinAvailability | undefined> { + const tx = this.tx; + return await tx.coinAvailabilityV2.get([ + ref.exchangeMasterPub, + ref.denomPubHash, + ref.maxAge, + ]); + } + + async getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]> { + const records = await Promise.all( + refs.map((ref) => + this.tx.coinAvailabilityV2.get([ + ref.exchangeMasterPub, + ref.denomPubHash, + ref.maxAge, + ]), + ), + ); + return records.filter( + (record): record is WalletCoinAvailability => record !== undefined, + ); + } + + async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { + const tx = this.tx; + await tx.coinAvailabilityV2.put({ + ...rec, + hasFreshCoins: rec.freshCoinCount > 0 ? 1 : 0, + }); + } + + async getCoinHistory( + coinPub: string, + ): Promise<WalletCoinHistory | undefined> { + const tx = this.tx; + return await tx.coinHistory.get(coinPub); + } + + async getCoinHistoriesByPubs( + coinPubs: string[], + ): Promise<WalletCoinHistory[]> { + const records = await Promise.all( + coinPubs.map((coinPub) => this.tx.coinHistory.get(coinPub)), + ); + return records.filter( + (record): record is WalletCoinHistory => record !== undefined, + ); + } + + async listAllCoinHistories(): Promise<WalletCoinHistory[]> { + return await this.tx.coinHistory.getAll(); + } + + async upsertCoinHistory(rec: WalletCoinHistory): Promise<void> { + const tx = this.tx; + await tx.coinHistory.put(rec); + } + + async listTokens(): Promise<WalletToken[]> { + const tx = this.tx; + return await tx.tokens.getAll(); + } + + async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { + const tx = this.tx; + return await tx.tokens.get(tokenUsePub); + } + + async upsertToken(token: WalletToken): Promise<void> { + const tx = this.tx; + await tx.tokens.put(token); + } + + async deleteToken(tokenUsePub: string): Promise<void> { + const tx = this.tx; + await tx.tokens.delete(tokenUsePub); + } + + async getTokensByIssuePubHash( + tokenIssuePubHash: string, + ): Promise<WalletToken[]> { + const tx = this.tx; + return await tx.tokens.indexes.byTokenIssuePubHash.getAll( + tokenIssuePubHash, + ); + } + + async getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]> { + return await this.tx.tokens.indexes.byTokenFamilyHash.getAll( + tokenFamilyHash, + ); + } + + async getPeerPullCredit( + pursePub: string, + ): Promise<WalletPeerPullCredit | undefined> { + const tx = this.tx; + const r = await tx.peerPullCredit.get(pursePub); + if (!r) { + return undefined; + } + return { + exchangeBaseUrl: r.exchangeBaseUrl, + amount: r.amount, + estimatedAmountEffective: r.estimatedAmountEffective, + pursePub: r.pursePub, + pursePriv: r.pursePriv, + contractTermsHash: r.contractTermsHash, + mergePub: r.mergePub, + mergePriv: r.mergePriv, + contractPub: r.contractPub, + contractPriv: r.contractPriv, + contractEncNonce: r.contractEncNonce, + mergeTimestamp: r.mergeTimestamp, + mergeReserveRowId: r.mergeReserveRowId, + status: r.status, + kycPaytoHash: r.kycPaytoHash, + kycAccessToken: r.kycAccessToken, + kycLastCheckStatus: r.kycLastCheckStatus, + kycLastCheckCode: r.kycLastCheckCode, + kycLastRuleGen: r.kycLastRuleGen, + kycLastAmlReview: r.kycLastAmlReview, + kycLastDeny: r.kycLastDeny, + abortReason: r.abortReason, + failReason: r.failReason, + withdrawalGroupId: r.withdrawalGroupId, + }; + } + + async upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void> { + const tx = this.tx; + await tx.peerPullCredit.put({ + exchangeBaseUrl: rec.exchangeBaseUrl, + amount: rec.amount, + estimatedAmountEffective: rec.estimatedAmountEffective, + pursePub: rec.pursePub, + pursePriv: rec.pursePriv, + contractTermsHash: rec.contractTermsHash, + mergePub: rec.mergePub, + mergePriv: rec.mergePriv, + contractPub: rec.contractPub, + contractPriv: rec.contractPriv, + contractEncNonce: rec.contractEncNonce, + mergeTimestamp: rec.mergeTimestamp, + mergeReserveRowId: rec.mergeReserveRowId, + status: rec.status, + kycPaytoHash: rec.kycPaytoHash, + kycAccessToken: rec.kycAccessToken, + kycLastCheckStatus: rec.kycLastCheckStatus, + kycLastCheckCode: rec.kycLastCheckCode, + kycLastRuleGen: rec.kycLastRuleGen, + kycLastAmlReview: rec.kycLastAmlReview, + kycLastDeny: rec.kycLastDeny, + abortReason: rec.abortReason, + failReason: rec.failReason, + withdrawalGroupId: rec.withdrawalGroupId, + }); + } + + async deletePeerPullCredit(pursePub: string): Promise<void> { + const tx = this.tx; + await tx.peerPullCredit.delete(pursePub); + } + + async getPeerPushDebit( + pursePub: string, + ): Promise<WalletPeerPushDebit | undefined> { + const tx = this.tx; + const r = await tx.peerPushDebit.get(pursePub); + if (!r) { + return undefined; + } + return { + exchangeBaseUrl: r.exchangeBaseUrl, + restrictScope: r.restrictScope, + amount: r.amount, + totalCost: r.totalCost, + coinSel: r.coinSel, + contractTermsHash: r.contractTermsHash, + pursePub: r.pursePub, + pursePriv: r.pursePriv, + mergePub: r.mergePub, + mergePriv: r.mergePriv, + contractPriv: r.contractPriv, + contractPub: r.contractPub, + contractEncNonce: r.contractEncNonce, + purseExpiration: r.purseExpiration, + timestampCreated: r.timestampCreated, + abortRefreshGroupId: r.abortRefreshGroupId, + abortReason: r.abortReason, + failReason: r.failReason, + status: r.status, + }; + } + + async upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void> { + const tx = this.tx; + await tx.peerPushDebit.put({ + exchangeBaseUrl: rec.exchangeBaseUrl, + restrictScope: rec.restrictScope, + amount: rec.amount, + totalCost: rec.totalCost, + coinSel: rec.coinSel, + contractTermsHash: rec.contractTermsHash, + pursePub: rec.pursePub, + pursePriv: rec.pursePriv, + mergePub: rec.mergePub, + mergePriv: rec.mergePriv, + contractPriv: rec.contractPriv, + contractPub: rec.contractPub, + contractEncNonce: rec.contractEncNonce, + purseExpiration: rec.purseExpiration, + timestampCreated: rec.timestampCreated, + abortRefreshGroupId: rec.abortRefreshGroupId, + abortReason: rec.abortReason, + failReason: rec.failReason, + status: rec.status, + }); + } + + async deletePeerPushDebit(pursePub: string): Promise<void> { + const tx = this.tx; + await tx.peerPushDebit.delete(pursePub); + } + + async getPeerPushCredit( + peerPushCreditId: string, + ): Promise<WalletPeerPushCredit | undefined> { + const tx = this.tx; + const r = await tx.peerPushCredit.get(peerPushCreditId); + if (!r) { + return undefined; + } + return { + peerPushCreditId: r.peerPushCreditId, + exchangeBaseUrl: r.exchangeBaseUrl, + pursePub: r.pursePub, + mergePriv: r.mergePriv, + contractPriv: r.contractPriv, + timestamp: r.timestamp, + estimatedAmountEffective: r.estimatedAmountEffective, + contractTermsHash: r.contractTermsHash, + status: r.status, + abortReason: r.abortReason, + failReason: r.failReason, + withdrawalGroupId: r.withdrawalGroupId, + currency: r.currency, + kycPaytoHash: r.kycPaytoHash, + kycAccessToken: r.kycAccessToken, + kycLastCheckStatus: r.kycLastCheckStatus, + kycLastCheckCode: r.kycLastCheckCode, + kycLastRuleGen: r.kycLastRuleGen, + kycLastAmlReview: r.kycLastAmlReview, + kycLastDeny: r.kycLastDeny, + }; + } + + async upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void> { + const tx = this.tx; + await tx.peerPushCredit.put({ + peerPushCreditId: rec.peerPushCreditId, + exchangeBaseUrl: rec.exchangeBaseUrl, + pursePub: rec.pursePub, + mergePriv: rec.mergePriv, + contractPriv: rec.contractPriv, + timestamp: rec.timestamp, + estimatedAmountEffective: rec.estimatedAmountEffective, + contractTermsHash: rec.contractTermsHash, + status: rec.status, + abortReason: rec.abortReason, + failReason: rec.failReason, + withdrawalGroupId: rec.withdrawalGroupId, + currency: rec.currency, + kycPaytoHash: rec.kycPaytoHash, + kycAccessToken: rec.kycAccessToken, + kycLastCheckStatus: rec.kycLastCheckStatus, + kycLastCheckCode: rec.kycLastCheckCode, + kycLastRuleGen: rec.kycLastRuleGen, + kycLastAmlReview: rec.kycLastAmlReview, + kycLastDeny: rec.kycLastDeny, + }); + } + + async deletePeerPushCredit(peerPushCreditId: string): Promise<void> { + const tx = this.tx; + await tx.peerPushCredit.delete(peerPushCreditId); + } + + async getPeerPushCreditByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPushCredit | undefined> { + const tx = this.tx; + const r = await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ + exchangeBaseUrl, + contractPriv, + ]); + if (!r) { + return undefined; + } + return this.getPeerPushCredit(r.peerPushCreditId); + } + + async getPeerPullDebit( + peerPullDebitId: string, + ): Promise<WalletPeerPullDebit | undefined> { + const tx = this.tx; + const r = await tx.peerPullDebit.get(peerPullDebitId); + if (!r) { + return undefined; + } + return { + peerPullDebitId: r.peerPullDebitId, + pursePub: r.pursePub, + exchangeBaseUrl: r.exchangeBaseUrl, + amount: r.amount, + contractTermsHash: r.contractTermsHash, + timestampCreated: r.timestampCreated, + contractPriv: r.contractPriv, + status: r.status, + totalCostEstimated: r.totalCostEstimated, + abortRefreshGroupId: r.abortRefreshGroupId, + abortReason: r.abortReason, + failReason: r.failReason, + coinSel: r.coinSel, + }; + } + + async upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void> { + const tx = this.tx; + await tx.peerPullDebit.put({ + peerPullDebitId: rec.peerPullDebitId, + pursePub: rec.pursePub, + exchangeBaseUrl: rec.exchangeBaseUrl, + amount: rec.amount, + contractTermsHash: rec.contractTermsHash, + timestampCreated: rec.timestampCreated, + contractPriv: rec.contractPriv, + status: rec.status, + totalCostEstimated: rec.totalCostEstimated, + abortRefreshGroupId: rec.abortRefreshGroupId, + abortReason: rec.abortReason, + failReason: rec.failReason, + coinSel: rec.coinSel, + }); + } + + async deletePeerPullDebit(peerPullDebitId: string): Promise<void> { + const tx = this.tx; + await tx.peerPullDebit.delete(peerPullDebitId); + } + + async getPeerPullDebitByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPullDebit | undefined> { + const tx = this.tx; + const r = await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ + exchangeBaseUrl, + contractPriv, + ]); + if (!r) { + return undefined; + } + return this.getPeerPullDebit(r.peerPullDebitId); + } + + async upsertDenomination(rec: WalletDenomination): Promise<void> { + const tx = this.tx; + await tx.denominationsV2.put(rec); + } + + async getDenomination( + ref: WalletDenomRef, + ): Promise<WalletDenomination | undefined> { + const tx = this.tx; + return await tx.denominationsV2.get([ + ref.exchangeMasterPub, + ref.denomPubHash, + ]); + } + + async getDenominationsByRefs( + refs: WalletDenomRef[], + ): Promise<WalletDenomination[]> { + const records = await Promise.all( + refs.map((ref) => + this.tx.denominationsV2.get([ref.exchangeMasterPub, ref.denomPubHash]), + ), + ); + return records.filter( + (record): record is WalletDenomination => record !== undefined, + ); + } + + async findDenominationByFamilyFromExpiry( + denominationFamilySerial: number, + minStampExpireWithdraw: DbProtocolTimestamp, + match: (d: WalletDenomination) => boolean, + ): Promise<WalletDenomination | undefined> { + const tx = this.tx; + const cursor = + tx.denominationsV2.indexes.byDenominationFamilySerialAndStampExpireWithdraw.iter(); + // The cursor has to be positioned before it can be moved. + const first = await cursor.current(); + if (!first.hasValue) { + return undefined; + } + // Denominations without a family are not part of the index. + const firstSerial = first.value.denominationFamilySerial; + if ( + firstSerial == null || + firstSerial < denominationFamilySerial || + (firstSerial === denominationFamilySerial && + first.value.stampExpireWithdraw < minStampExpireWithdraw) + ) { + cursor.continue([denominationFamilySerial, minStampExpireWithdraw]); + } + while (true) { + const cur = await cursor.current(); + if (!cur.hasValue) { + return undefined; + } + if (cur.value.denominationFamilySerial != denominationFamilySerial) { + // Moved past this family. + return undefined; + } + if (match(cur.value)) { + return cur.value; + } + cursor.continue(); + } + } + + async getDenominationsByMasterPub( + exchangeMasterPub: string, + ): Promise<WalletDenomination[]> { + const tx = this.tx; + return await tx.denominationsV2.indexes.byExchangeMasterPub.getAll( + exchangeMasterPub, + ); + } + + async deleteDenomination(ref: WalletDenomRef): Promise<void> { + const tx = this.tx; + await tx.denominationsV2.delete([ref.exchangeMasterPub, ref.denomPubHash]); + } + + async getDenominationsByVerificationStatus( + verificationStatus: DenominationVerificationStatus, + ): Promise<WalletDenomination[]> { + const tx = this.tx; + return await tx.denominationsV2.indexes.byVerificationStatus.getAll( + verificationStatus, + ); + } + + async getDonationSummaries(): Promise<WalletDonationSummary[]> { + return await this.tx.donationSummaries.getAll(); + } + + async getExchanges(): Promise<WalletExchangeEntry[]> { + return await this.tx.exchanges.getAll(); + } + + async getCoinAvailabilities(): Promise<WalletCoinAvailability[]> { + return await this.tx.coinAvailabilityV2.getAll(); + } + + async getActiveRefreshGroups(): Promise<WalletRefreshGroup[]> { + return await this.tx.refreshGroups.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { + return await this.tx.withdrawalGroups.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]> { + return await this.tx.peerPushDebit.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]> { + return await this.tx.peerPushCredit.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]> { + return await this.tx.peerPullCredit.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]> { + return await this.tx.peerPullDebit.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getActiveRecoupGroups(): Promise<WalletRecoupGroup[]> { + return await this.tx.recoupGroups.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getPurchasesByStatus( + status: PurchaseStatus, + ): Promise<WalletPurchase[]> { + return await this.tx.purchases.indexes.byStatus.getAll(status); + } + + async getActivePurchases(): Promise<WalletPurchase[]> { + return await this.tx.purchases.indexes.byStatus.getAll(getActiveKeyRange()); + } + + async getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]> { + const coins = await Promise.all( + coinPubs.map((pub) => this.tx.coins.get(pub)), + ); + return coins.filter((coin): coin is WalletCoin => coin !== undefined); + } + + async getActiveDepositGroups(): Promise<WalletDepositGroup[]> { + return await this.tx.depositGroups.indexes.byStatus.getAll( + getActiveKeyRange(), + ); + } + + async getExchangeDetails( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined> { + const r = await this.tx.exchanges.get(exchangeBaseUrl); + if (!r || !r.detailsPointer) { + return undefined; + } + return await this.tx.exchangeDetails.indexes.byPointer.get([ + r.baseUrl, + r.detailsPointer.currency, + r.detailsPointer.masterPublicKey, + ]); + } + + async checkExchangeInScope( + exchangeBaseUrl: string, + scope: ScopeInfo, + denomPubHash?: string, + ): Promise<boolean> { + switch (scope.type) { + case ScopeType.Exchange: { + return scope.url === exchangeBaseUrl; + } + case ScopeType.Global: { + const exchangeDetails = await this.getExchangeDetails(exchangeBaseUrl); + if (!exchangeDetails) { + return false; + } + const gr = + await this.tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( + [ + exchangeDetails.currency, + exchangeBaseUrl, + exchangeDetails.masterPublicKey, + ], + ); + return gr != null; + } + case ScopeType.Auditor: { + const exchangeDetails = await this.getExchangeDetails(exchangeBaseUrl); + if (!exchangeDetails || exchangeDetails.currency !== scope.currency) { + return false; + } + for (const auditor of exchangeDetails.auditors) { + if ( + !auditorProvidesVerifiedTrust(auditor, { + auditorBaseUrl: scope.url, + denomPubHash, + }) + ) { + continue; + } + const configured = + await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( + [ + exchangeDetails.currency, + auditor.auditor_url, + auditor.auditor_pub, + ], + ); + if (configured) { + return true; + } + } + return false; + } + case ScopeType.ExchangeLegacyKeys: + // See checkExchangeInScopeGeneric: an entry stands for its current + // key set, which is never a superseded one. + return false; + default: + assertUnreachable(scope); + } + } + + async getExchangeScopeInfo( + exchangeBaseUrl: string, + currency: string, + denomPubHash?: string, + ): Promise<ScopeInfo> { + const det = await this.getExchangeDetails(exchangeBaseUrl); + if (!det) { + return { + type: ScopeType.Exchange, + currency: currency, + url: exchangeBaseUrl, + }; + } + const globalExchangeRec = + await this.tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ + det.currency, + det.exchangeBaseUrl, + det.masterPublicKey, + ]); + if (globalExchangeRec) { + return { + currency: det.currency, + type: ScopeType.Global, + }; + } else { + for (const aud of denomPubHash == null ? [] : det.auditors) { + if (!auditorProvidesVerifiedTrust(aud, { denomPubHash })) { + continue; + } + const globalAuditorRec = + await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( + [det.currency, aud.auditor_url, aud.auditor_pub], + ); + if (globalAuditorRec) { + return { + currency: det.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + } + return { + currency: det.currency, + type: ScopeType.Exchange, + url: det.exchangeBaseUrl, + }; + } +} diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts @@ -0,0 +1,684 @@ +/* + 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 for the backend-to-backend database converter. + * + * The database is populated by running the whole conformance corpus against + * the source, so the converter faces every record type the suite can + * produce rather than a hand-picked sample. The converter verifies its own + * copy record-by-record; the assertions here are that it succeeds, that it + * moved a plausible amount of data, and that it converts in both directions. + */ + +import assert from "node:assert"; +import { test } from "node:test"; + +import { + CoinStatus, + DatabaseMaintenanceProgressNotification, + DenomKeyType, + encodeCrock, + getRandomBytes, + NotificationType, + TalerPreciseTimestamp, + WalletNotification, +} from "@gnu-taler/taler-util"; + +import { + CoinSourceType, + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + PeerPushCreditStatus, + PurchaseStatus, + ReserveRecordStatus, + timestampPreciseToDb, + WalletCoin, + WalletPeerPushCredit, + WalletPurchase, +} from "../records.js"; +import { SQLITE_BASELINE_SCHEMA } from "../sqlite/schema.js"; +import { + convertWalletDb, + DB_CONVERSION_BATCH_SIZE, + DB_CONVERSION_PROGRESS_RECORDS, +} from "./converter.js"; +import { applyFixups } from "../indexeddb/fixups.js"; +import { WalletIndexedDbStoresV1 } from "../indexeddb/schema.js"; +import { IdbWalletDbHandle } from "../indexeddb/handle.js"; +import { conformanceCases } from "../testing/conformance-cases.js"; +import { ConformanceAsserts } from "../testing/conformance.js"; +import { makeIdbRunner, makeSqliteRunner } from "../testing/runners.js"; + +/** Assertions that ignore case-internal failures: only the data matters. */ +const quietAsserts: ConformanceAsserts = { + equal: () => {}, + deepEqual: () => {}, + ok: () => {}, + fail: () => { + throw Error("unreachable"); + }, +}; + +test("converter: preserves a legacy orphan coin without a master key", async () => { + const src = await makeIdbRunner(); + const dst = await makeSqliteRunner(); + const key = (): string => encodeCrock(getRandomBytes(32)); + const hash = (): string => encodeCrock(getRandomBytes(64)); + const coin: WalletCoin = { + coinPub: key(), + coinPriv: key(), + exchangeBaseUrl: "https://orphan.example/", + exchangeMasterPub: key(), + denomPubHash: hash(), + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: "signature" }, + blindingKey: key(), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + coinEvHash: hash(), + status: CoinStatus.Dormant, + maxAge: 0, + ageCommitmentProof: undefined, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: "missing-withdrawal", + coinIndex: 0, + reservePub: key(), + }, + }; + delete (coin as any).exchangeMasterPub; + await src.runReadWriteTx((tx) => tx.upsertCoin(coin)); + + try { + await convertWalletDb(src, dst); + const migrated = await dst.runReadWriteTx((tx) => tx.getCoin(coin.coinPub)); + assert.ok(migrated); + assert.strictEqual(migrated.exchangeMasterPub, ""); + } finally { + await src.close(); + await dst.close(); + } +}); + +test("converter: IndexedDB to sqlite, populated by the conformance corpus", async () => { + const src = await makeIdbRunner(); + const progress: WalletNotification[] = []; + src.setNotificationSink((n) => progress.push(n)); + for (const c of conformanceCases) { + try { + await c.run(quietAsserts, src); + } catch (e) { + // A case failing its own assertions is the suite's concern; what + // matters here is whatever data it managed to write. + } + } + + // Simulate pre-Clause-Schnorr records. First prove the IndexedDB fixup is + // idempotent and fills both stores, then remove the fields again to prove + // converter-side normalization protects unusual/imported legacy records. + let legacyCoinPub = ""; + let legacyPlanchetPub = ""; + await src.runReadWriteTx(async (tx) => { + const coin = (await tx.listAllCoins())[0]; + const planchet = (await tx.listAllPlanchets())[0]; + assert.ok(coin && planchet, "corpus did not create legacy test records"); + legacyCoinPub = coin.coinPub; + legacyPlanchetPub = planchet.coinPub; + delete (coin as any).exchangeWithdrawValues; + delete (planchet as any).exchangeWithdrawValues; + await tx.upsertCoin(coin); + await tx.upsertPlanchet(planchet); + }); + // Force a store across multiple conversion pages. Tombstones are + // independent records, so this tests batching without manufacturing a + // large graph of otherwise unrelated wallet operations. + await src.runReadWriteTx(async (tx) => { + for (let i = 0; i < DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; i++) { + await tx.upsertTombstone({ id: `bounded-conversion-${i}` }); + } + }); + const idb = src as IdbWalletDbHandle; + const raw = await idb.rawAccess(); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + await tx.fixups.delete("fixup20260812ExchangeWithdrawValues"); + }); + await applyFixups(raw); + await src.runReadWriteTx(async (tx) => { + assert.deepStrictEqual( + (await tx.getCoin(legacyCoinPub))?.exchangeWithdrawValues, + { cipher: "RSA" }, + ); + assert.deepStrictEqual( + (await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues, + { cipher: "RSA" }, + ); + const coin = (await tx.getCoin(legacyCoinPub))!; + const planchet = (await tx.getPlanchet(legacyPlanchetPub))!; + delete (coin as any).exchangeWithdrawValues; + delete (planchet as any).exchangeWithdrawValues; + await tx.upsertCoin(coin); + await tx.upsertPlanchet(planchet); + }); + + const dst = await makeSqliteRunner(); + const retainedTransactionsBefore = (src as any).idbHandle._transactions + .length; + const pageSizes: number[] = []; + for (const handle of [src, dst]) { + const originalRun = handle.runReadWriteTx.bind(handle); + handle.runReadWriteTx = (f) => + originalRun(async (tx) => { + const originalScan = tx.scanMigrationRecords.bind(tx); + tx.scanMigrationRecords = async (...args) => { + const page = await originalScan(...args); + pageSizes.push(page.records.length); + return page; + }; + return await f(tx); + }); + } + // convertWalletDb re-enumerates both sides and compares every record; + // a thrown error here is the actual test. + const report = await convertWalletDb(src, dst); + + await dst.runReadWriteTx(async (tx) => { + assert.deepStrictEqual( + (await tx.getCoin(legacyCoinPub))?.exchangeWithdrawValues, + { cipher: "RSA" }, + ); + assert.deepStrictEqual( + (await tx.getPlanchet(legacyPlanchetPub))?.exchangeWithdrawValues, + { cipher: "RSA" }, + ); + }); + + assert.ok( + report.totalRecords >= 100, + `only ${report.totalRecords} records converted -- the corpus did not` + + ` populate the source, so the conversion proved nothing`, + ); + assert.ok(pageSizes.length > 4, "conversion did not use multiple pages"); + assert.ok( + Math.max(...pageSizes) <= DB_CONVERSION_BATCH_SIZE, + `conversion retained a page of ${Math.max(...pageSizes)} records`, + ); + const maintenanceProgress = progress.filter( + (n): n is DatabaseMaintenanceProgressNotification => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-to-native-migration", + ); + assert.ok( + maintenanceProgress.every((n) => n.totalRecords === report.totalRecords), + "progress did not carry the global record total", + ); + for (const phase of ["copy", "verify"] as const) { + const records = maintenanceProgress + .filter((n) => n.phase === phase && n.processedRecords !== undefined) + .map((n) => n.processedRecords!); + assert.strictEqual( + records[0], + 0, + `${phase} progress did not start at zero`, + ); + assert.strictEqual( + records.at(-1), + report.totalRecords, + `${phase} progress did not reach the global total`, + ); + assert.ok( + records.length > 2, + `${phase} progress had no intermediate event`, + ); + for (let i = 1; i < records.length - 1; i++) { + assert.ok( + Math.floor(records[i] / DB_CONVERSION_PROGRESS_RECORDS) > + Math.floor(records[i - 1] / DB_CONVERSION_PROGRESS_RECORDS), + `${phase} record progress was reported too frequently`, + ); + } + } + assert.strictEqual( + (src as any).idbHandle._transactions.length, + retainedTransactionsBefore, + "completed scan transactions were retained by the IndexedDB bridge", + ); + // Every store in the plan must have been visited (0 records is fine for a + // store the corpus leaves empty; a missing key means the plan lost a step). + assert.ok( + Object.keys(report.copied).length >= 35, + `only ${Object.keys(report.copied).length} stores visited`, + ); + + await src.close(); + await dst.close(); +}); + +test("converter: sqlite to IndexedDB (reverse direction)", async () => { + const src = await makeSqliteRunner(); + for (const c of conformanceCases) { + try { + await c.run(quietAsserts, src); + } catch (e) { + // See above. + } + } + + const dst = await makeIdbRunner(); + const report = await convertWalletDb(src, dst); + assert.ok(report.totalRecords >= 100); + + await src.close(); + await dst.close(); +}); + +test("converter: discards the legacy exchange update retry counter", async () => { + // Wallets written before exchange update retries were moved to + // operation_retries still retain this field in the IndexedDB record. The + // native schema deliberately has no corresponding column. + const src = await makeIdbRunner(); + await src.runReadWriteTx(async (tx) => { + await tx.upsertExchange({ + baseUrl: "https://exchange.example.com/", + detailsPointer: undefined, + entryStatus: ExchangeEntryDbRecordStatus.Used, + updateStatus: ExchangeEntryDbUpdateStatus.Ready, + tosCurrentEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + lastUpdate: undefined, + nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + lastKeysEtag: undefined, + nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + updateRetryCounter: 8, + } as any); + }); + + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + const exchange = await dst.runReadWriteTx((tx) => + tx.getExchange("https://exchange.example.com/"), + ); + assert.ok(exchange); + assert.ok(!("updateRetryCounter" in exchange)); + + await src.close(); + await dst.close(); +}); + +test("converter: canonicalises a legacy purchase with empty exchanges", async () => { + // Old IndexedDB wallets could persist an explicit empty array here. The + // native representation uses a junction table, where no rows means the + // optional field is absent, so migration must accept this canonicalisation. + const purchase: WalletPurchase = { + proposalId: "empty-exchanges", + orderId: "order-empty-exchanges", + merchantBaseUrl: "https://merchant.example/", + claimToken: undefined, + downloadSessionId: undefined, + repurchaseProposalId: undefined, + purchaseStatus: PurchaseStatus.PendingDownloadingProposal, + noncePriv: encodeCrock(getRandomBytes(32)), + noncePub: encodeCrock(getRandomBytes(32)), + secretSeed: undefined, + download: undefined, + payInfo: undefined, + exchanges: [], + timestampFirstSuccessfulPay: undefined, + merchantPaySig: undefined, + posConfirmation: undefined, + shared: false, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + timestampAccept: undefined, + timestampLastRefundStatus: undefined, + lastSessionId: undefined, + autoRefundDeadline: undefined, + refundAmountAwaiting: undefined, + }; + const src = await makeIdbRunner(); + await src.runReadWriteTx((tx) => tx.upsertPurchase(purchase)); + + const dst = await makeSqliteRunner(); + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.purchases, 1); + const migrated = await dst.runReadWriteTx((tx) => + tx.getPurchase(purchase.proposalId), + ); + assert.ok(migrated); + assert.ok(!("exchanges" in migrated)); + + await src.close(); + await dst.close(); +}); + +test("converter: canonicalises a lowercase peer-push contract private key", async () => { + // Crockford encoding is case-insensitive. A legacy IndexedDB record with + // lowercase data must compare equal to the native BLOB's uppercase form. + const contractPriv = encodeCrock(getRandomBytes(32)).toLowerCase(); + const credit: WalletPeerPushCredit = { + peerPushCreditId: "lowercase-contract-private-key", + exchangeBaseUrl: "https://exchange.example/", + currency: "TESTKUDOS", + pursePub: encodeCrock(getRandomBytes(32)), + mergePriv: encodeCrock(getRandomBytes(32)), + contractPriv, + timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + estimatedAmountEffective: "TESTKUDOS:1", + contractTermsHash: encodeCrock(getRandomBytes(64)), + status: PeerPushCreditStatus.PendingMerge, + withdrawalGroupId: undefined, + }; + const src = await makeIdbRunner(); + await src.runReadWriteTx((tx) => tx.upsertPeerPushCredit(credit)); + + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + const migrated = await dst.runReadWriteTx((tx) => + tx.getPeerPushCredit(credit.peerPushCreditId), + ); + assert.strictEqual(migrated?.contractPriv, contractPriv.toUpperCase()); + + await src.close(); + await dst.close(); +}); + +test("IndexedDB fixup collapses only identical reserves and remaps references", async () => { + const src = await makeIdbRunner(); + const reservePub = encodeCrock(getRandomBytes(32)); + const reservePriv = encodeCrock(getRandomBytes(32)); + const ids = await src.runReadWriteTx(async (tx) => { + const out = []; + for (let i = 0; i < 3; i++) { + out.push(await tx.upsertReserve({ reservePub, reservePriv })); + } + await tx.upsertExchange({ + baseUrl: "https://exchange.example.com/", + detailsPointer: undefined, + entryStatus: ExchangeEntryDbRecordStatus.Preset, + updateStatus: ExchangeEntryDbUpdateStatus.Initial, + tosCurrentEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + lastUpdate: undefined, + nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + lastKeysEtag: undefined, + nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + currentMergeReserveRowId: out[2], + }); + return out; + }); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260720DuplicateReserves"), + ); + const fixupProgress: WalletNotification[] = []; + await applyFixups(raw, (n) => fixupProgress.push(n)); + assert.ok( + fixupProgress.some( + (n) => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-fixup" && + n.phase === "fixup" && + n.step === "fixup20260720DuplicateReserves", + ), + "fixup progress was not reported", + ); + assert.ok( + fixupProgress.some( + (n) => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-fixup" && + n.phase === "complete", + ), + "fixup completion was not reported", + ); + await src.runReadWriteTx(async (tx) => { + const matching = (await tx.listAllReserves()).filter( + (r) => r.reservePub === reservePub, + ); + assert.strictEqual(matching.length, 1); + assert.strictEqual(matching[0].rowId, ids[0]); + assert.strictEqual( + (await tx.getExchange("https://exchange.example.com/")) + ?.currentMergeReserveRowId, + ids[0], + ); + }); + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + await src.close(); + await dst.close(); +}); + +test("IndexedDB fixup retains the richer duplicate reserve", async () => { + const src = await makeIdbRunner(); + const reservePub = encodeCrock(getRandomBytes(32)); + const reservePriv = encodeCrock(getRandomBytes(32)); + const ids = await src.runReadWriteTx(async (tx) => { + const plain = await tx.upsertReserve({ reservePub, reservePriv }); + const rich = await tx.upsertReserve({ + reservePub, + reservePriv, + status: ReserveRecordStatus.Done, + thresholdGranted: "TESTKUDOS:10", + amlReview: false, + }); + await tx.upsertReserve({ reservePub, reservePriv }); + await tx.upsertExchange({ + baseUrl: "https://exchange.example.com/", + detailsPointer: undefined, + entryStatus: ExchangeEntryDbRecordStatus.Preset, + updateStatus: ExchangeEntryDbUpdateStatus.Initial, + tosCurrentEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + lastUpdate: undefined, + nextUpdateStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + lastKeysEtag: undefined, + nextRefreshCheckStamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), + currentMergeReserveRowId: plain, + }); + return { plain, rich }; + }); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260820DuplicateReserveMetadata"), + ); + await applyFixups(raw); + + await src.runReadWriteTx(async (tx) => { + const matching = (await tx.listAllReserves()).filter( + (r) => r.reservePub === reservePub, + ); + assert.strictEqual(matching.length, 1); + assert.strictEqual(matching[0].rowId, ids.rich); + assert.strictEqual(matching[0].status, ReserveRecordStatus.Done); + assert.strictEqual(matching[0].thresholdGranted, "TESTKUDOS:10"); + assert.strictEqual(matching[0].amlReview, false); + assert.strictEqual( + (await tx.getExchange("https://exchange.example.com/")) + ?.currentMergeReserveRowId, + ids.rich, + ); + }); + const dst = await makeSqliteRunner(); + await convertWalletDb(src, dst); + await src.close(); + await dst.close(); +}); + +test("converter: conflicting duplicate reserve metadata is rejected", async () => { + // The fixup must leave differing defined values untouched. Without a + // revision on either row, selecting a winner would hide corruption. + const src = await makeIdbRunner(); + const reservePub = encodeCrock(getRandomBytes(32)); + const reservePriv = encodeCrock(getRandomBytes(32)); + await src.runReadWriteTx(async (tx) => { + await tx.upsertReserve({ + reservePub, + reservePriv, + status: ReserveRecordStatus.PendingLegi, + }); + await tx.upsertReserve({ + reservePub, + reservePriv, + status: ReserveRecordStatus.Done, + }); + // A second, genuinely different reserve, which must survive untouched. + await tx.upsertReserve({ + reservePub: encodeCrock(getRandomBytes(32)), + reservePriv: encodeCrock(getRandomBytes(32)), + }); + }); + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260820DuplicateReserveMetadata"), + ); + await applyFixups(raw); + + const dst = await makeSqliteRunner(); + await assert.rejects( + () => convertWalletDb(src, dst), + /multiple reserve rows have public key/, + ); + assert.strictEqual( + (await src.runReadWriteTx((tx) => tx.listAllReserves())).length, + 3, + "refusal modified the source", + ); + + await src.close(); + await dst.close(); +}); + +test("converter: the copy plan covers every table in the schema", async () => { + // An empty conversion still visits every step, so the report's keys are + // the plan's coverage. Comparing them against the schema's table list + // means a table added later cannot silently miss conversion: this test + // fails until the plan (and the mapping here) says what happens to it. + const src = await makeIdbRunner(); + const dst = await makeSqliteRunner(); + const report = await convertWalletDb(src, dst); + await src.close(); + await dst.close(); + + // table -> step that carries it, or the reason no step is needed. + const coverage: Record<string, string> = { + schema_migrations: "EXCLUDED: describes the schema, not wallet data", + idb_migration: + "EXCLUDED: describes where this file's data came from, not the data", + purchase_exchanges: "purchases", // stored inside the purchase record + config: "config", + currency_info: "currencyInfo", + contacts: "contacts", + mailbox_messages: "mailboxMessages", + mailbox_configurations: "mailboxConfigurations", + contract_terms: "contractTerms", + tombstones: "tombstones", + operation_retries: "operationRetries", + reserves: "reserves", + denominations: "denominations", + global_currency_exchanges: "globalCurrencyExchanges", + global_currency_auditors: "globalCurrencyAuditors", + bank_accounts: "bankAccounts", + tokens: "tokens", + slates: "slates", + refresh_sessions: "refreshSessions", + recoup_groups: "recoupGroups", + donation_summaries: "donationSummaries", + donation_planchets: "donationPlanchets", + donation_receipts: "donationReceipts", + purchases: "purchases", + deposit_groups: "depositGroups", + refresh_groups: "refreshGroups", + denom_loss_events: "denomLossEvents", + peer_push_debit: "peerPushDebit", + peer_push_credit: "peerPushCredit", + peer_pull_debit: "peerPullDebit", + peer_pull_credit: "peerPullCredit", + transactions_meta: "transactionsMeta", + transaction_local_id_counters: + "EXCLUDED: local transaction identifiers are re-assigned on conversion", + transaction_local_ids: + "EXCLUDED: local transaction identifiers are re-assigned on conversion", + exchanges: "exchanges", + exchange_details: "exchangeDetails", + exchange_sign_keys: "exchangeSignKeys", + denomination_families: "denominationFamilies", + exchange_base_url_fixups: "exchangeBaseUrlFixups", + exchange_base_url_migration_log: "exchangeBaseUrlMigrationLog", + withdrawal_groups: "withdrawalGroups", + planchets: "planchets", + coins: "coins", + coin_availability: "coinAvailability", + coin_history: "coinHistory", + refund_groups: "refundGroups", + refund_items: "refundItems", + }; + + const tables = [ + ...SQLITE_BASELINE_SCHEMA.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g), + ].map((m) => m[1]); + assert.ok(tables.length >= 44, "schema parse failed"); + + const visited = new Set(Object.keys(report.copied)); + for (const table of tables) { + const mapped = coverage[table]; + assert.ok( + mapped !== undefined, + `table ${table} is not accounted for in the conversion plan --` + + ` add a copy step for it, or record here why none is needed`, + ); + if (!mapped.startsWith("EXCLUDED")) { + assert.ok( + visited.has(mapped), + `table ${table} maps to step ${mapped}, which the plan did not visit`, + ); + } + } +}); + +test("converter: every IndexedDB store is copied or explicitly obsolete", async () => { + const src = await makeIdbRunner(); + const dst = await makeSqliteRunner(); + const visited = new Set( + Object.keys((await convertWalletDb(src, dst)).copied), + ); + await src.close(); + await dst.close(); + + const renamed: Record<string, string> = { + coinAvailabilityV2: "coinAvailability", + denominationsV2: "denominations", + bankAccountsV2: "bankAccounts", + }; + const excluded: Record<string, string> = { + coinAvailability: "obsolete pre-master-key store retained for fixups", + denominations: "obsolete pre-master-key store retained for fixups", + bankWithdrawUris: "obsolete unused legacy URI cache", + fixups: "schema repair log, not wallet data", + obsolete_backupProviders: "obsolete", + _obsolete_transactions: "obsolete materialized view", + _obsolete_bankAccounts: "obsolete pre-V2 store", + _obsolete_rewards: "obsolete", + obsolete_userAttention: "obsolete", + }; + for (const store of Object.keys(WalletIndexedDbStoresV1)) { + const step = renamed[store] ?? store; + assert.ok( + visited.has(step) || excluded[store] !== undefined, + `IndexedDB store ${store} is neither copied nor explicitly classified`, + ); + } +}); diff --git a/packages/taler-wallet-core/src/db/migration/converter.ts b/packages/taler-wallet-core/src/db/migration/converter.ts @@ -0,0 +1,774 @@ +/* + 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/> + */ + +/** + * Conversion between wallet database backends. + * + * The converter copies records through the DAL: it reads every entity from + * one {@link WalletDbHandle} and writes it into another, knowing nothing + * about either storage layout. Opening the IndexedDB source runs the fixup + * log, so the converted database contains repaired records by construction -- + * the native schema has no fixup log to replay. + * + * Auto-generated serials (reserves, exchange details, denomination families, + * refund items) are preserved: other records reference them, and every upsert + * honours a supplied key. + * + * The caller supplies a fresh, empty destination and swaps files afterwards; + * nothing here mutates the source. + */ + +import { + DatabaseMaintenanceProgressNotification, + Logger, + NotificationType, + sha256, + stringToBytes, +} from "@gnu-taler/taler-util"; + +import { + WalletCoin, + WalletCoinAvailability, + WalletPlanchet, + WalletPurchase, + WalletReserve, +} from "../records.js"; +import { WalletDbHandle } from "../handle.js"; +import { WalletDbMigrationStore, WalletDbTransaction } from "../transaction.js"; + +const logger = new Logger("db/migration/converter.ts"); + +/** + * One entity to copy. + * + * `read` identifies the existing DAL enumeration used by the backend's + * bounded migration scanner; `write` stores one record. + * `normalize` is applied before the verification comparison, for the few + * stores where the destination deliberately canonicalises a representation + * (for example global-currency ids, which nothing else references). + */ +interface CopyStep { + name: WalletDbMigrationStore; + read: (tx: WalletDbTransaction) => Promise<unknown[]>; + write: (tx: WalletDbTransaction, rec: unknown) => Promise<unknown>; + normalize?: (rec: unknown) => unknown; + validate?: (tx: WalletDbTransaction, rec: unknown) => Promise<void>; +} + +function step<T>( + name: WalletDbMigrationStore, + read: (tx: WalletDbTransaction) => Promise<T[]>, + write: (tx: WalletDbTransaction, rec: T) => Promise<unknown>, + normalize?: (rec: T) => unknown, + validate?: (tx: WalletDbTransaction, rec: T) => Promise<void>, +): CopyStep { + return { + name, + read: read as CopyStep["read"], + write: write as CopyStep["write"], + normalize: normalize as CopyStep["normalize"], + validate: validate as CopyStep["validate"], + }; +} + +const stripId = (rec: unknown): unknown => { + const { id, ...rest } = rec as Record<string, unknown>; + return rest; +}; + +/** + * Fields old databases still carry that no current record type has. + * + * The IndexedDB backend returns stored records as-is, unknown fields + * included; the modern code ignores them and the sqlite mapper cannot store + * them. Conversion drops them -- that is the "as if every fixup had run" + * shape of the data -- but only fields on this list: an unknown field NOT + * listed here fails verification, so a genuinely lost field cannot be + * mistaken for legacy junk without a human putting its name here. + */ +const LEGACY_FIELDS: Record<string, string[]> = { + // Removed when exchange update retry state moved to operation_retries. + exchanges: ["updateRetryCounter"], + // Removed 2024-06-13 ("remove coinAllocationId, simplify coin history"). + coins: ["spendAllocation"], + // Dropped when denomination records were restructured; nothing reads it. + denominations: ["listIssueDate"], + // Superseded by per-selection UIDs inside denomsSel; removed 2024-06-10. + withdrawalGroups: ["denomSelUid"], + // Documented in records.ts as a reserved legacy field (v1 refresh); the + // current protocol derives a public seed on demand instead. + refreshSessions: ["sessionSecretSeed"], +}; + +function stripLegacy( + stepName: string, +): ((rec: unknown) => unknown) | undefined { + const fields = LEGACY_FIELDS[stepName]; + if (!fields) return undefined; + return (rec: unknown) => { + const out = { ...(rec as Record<string, unknown>) }; + for (const f of fields) { + delete out[f]; + } + return out; + }; +} + +/** + * The IndexedDB schema allowed old wallet versions to persist an empty + * `exchanges` array. The native schema represents that field with rows in + * `purchase_exchanges`, so both an absent field and an empty array have zero + * rows and consequently read back as absent. The field is optional and the + * wallet itself does not create empty arrays, making absent the native + * canonical form. + */ +function normalizePurchase(rec: WalletPurchase): unknown { + if (rec.exchanges?.length !== 0) { + return rec; + } + const { exchanges: _exchanges, ...rest } = rec; + return rest; +} + +/** + * Crockford encoding is case-insensitive. IndexedDB records from an older + * wallet may contain this capability key in lowercase, whereas the native + * BLOB representation always decodes it in its canonical uppercase form. + */ +function normalizeContractPriv<T extends { contractPriv: string }>(rec: T): T { + return { ...rec, contractPriv: rec.contractPriv.toUpperCase() }; +} + +/** + * Require the one-reserve-per-public-key invariant of the native schema. + * + * The IndexedDB fixup may remove byte-identical duplicates after remapping all + * references. Anything left here is ambiguous; conversion never picks a + * winner merely because one row happens to be referenced or older. + */ +async function validateReservePub( + tx: WalletDbTransaction, + reserve: WalletReserve, +): Promise<void> { + const selected = await tx.getReserveByReservePub(reserve.reservePub); + if (!selected || selected.rowId !== reserve.rowId) { + throw Error( + `conversion refused: multiple reserve rows have public key ${reserve.reservePub}`, + ); + } +} + +const rsaWithdrawValues = { cipher: "RSA" } as const; + +function normalizePlanchet(rec: WalletPlanchet): WalletPlanchet { + return rec.exchangeWithdrawValues === undefined + ? ({ ...rec, exchangeWithdrawValues: rsaWithdrawValues } as WalletPlanchet) + : rec; +} + +function normalizeCoin(rec: WalletCoin): WalletCoin { + const stripped = stripLegacy("coins")!(rec) as WalletCoin; + return { + ...stripped, + exchangeMasterPub: stripped.exchangeMasterPub ?? "", + exchangeWithdrawValues: + stripped.exchangeWithdrawValues ?? rsaWithdrawValues, + }; +} + +function normalizeCoinAvailability( + rec: WalletCoinAvailability, +): WalletCoinAvailability { + return { ...rec, hasFreshCoins: rec.freshCoinCount > 0 ? 1 : 0 }; +} + +/** + * The copy plan, in groups. + * + * Each group runs in one destination transaction, and groups run in + * ownership order (parents before their children), so every commit leaves + * the destination's deferred foreign keys satisfied. + */ +const COPY_PLAN: CopyStep[][] = [ + [ + step( + "config", + (tx) => tx.listAllConfig(), + (tx, r) => tx.upsertConfig(r), + ), + step( + "currencyInfo", + (tx) => tx.listAllCurrencyInfo(), + (tx, r) => tx.upsertCurrencyInfoEntry(r), + ), + step( + "contacts", + (tx) => tx.listContacts(), + (tx, r) => tx.addContact(r), + ), + step( + "mailboxMessages", + (tx) => tx.listMailboxMessages(), + (tx, r) => tx.upsertMailboxMessage(r), + ), + step( + "mailboxConfigurations", + (tx) => tx.listAllMailboxConfigurations(), + (tx, r) => tx.upsertMailboxConfiguration(r), + ), + step( + "contractTerms", + (tx) => tx.listAllContractTerms(), + (tx, r) => tx.upsertContractTerms(r), + ), + step( + "tombstones", + (tx) => tx.listAllTombstones(), + (tx, r) => tx.upsertTombstone(r), + ), + step( + "operationRetries", + (tx) => tx.listAllOperationRetries(), + (tx, r) => tx.upsertOperationRetry(r), + ), + step( + "bankAccounts", + (tx) => tx.listBankAccounts(), + (tx, r) => tx.upsertBankAccount(r), + ), + step( + "globalCurrencyExchanges", + (tx) => tx.listGlobalCurrencyExchanges(), + (tx, r) => tx.upsertGlobalCurrencyExchange(r), + stripId, + ), + step( + "globalCurrencyAuditors", + (tx) => tx.listGlobalCurrencyAuditors(), + (tx, r) => tx.upsertGlobalCurrencyAuditor(r), + stripId, + ), + step( + "exchangeBaseUrlFixups", + (tx) => tx.listAllExchangeBaseUrlFixups(), + (tx, r) => tx.upsertExchangeBaseUrlFixup(r), + ), + step( + "exchangeBaseUrlMigrationLog", + (tx) => tx.listAllExchangeMigrationLogEntries(), + (tx, r) => tx.upsertExchangeMigrationLog(r), + ), + ], + [ + // Reserves before exchanges: an exchange entry may reference its merge + // reserve by row id. + step( + "reserves", + (tx) => tx.listAllReserves(), + (tx, r) => tx.upsertReserve(r), + undefined, + validateReservePub, + ), + ], + [ + step( + "exchanges", + (tx) => tx.getExchanges(), + (tx, r) => tx.upsertExchange(stripLegacy("exchanges")!(r) as any), + stripLegacy("exchanges"), + async (tx, r) => { + if ( + r.currentMergeReserveRowId != null && + !(await tx.getReserve(r.currentMergeReserveRowId)) + ) { + throw Error( + `conversion refused: exchange ${r.baseUrl} references missing reserve ${r.currentMergeReserveRowId}`, + ); + } + }, + ), + ], + [ + step( + "exchangeDetails", + (tx) => tx.listAllExchangeDetails(), + (tx, r) => tx.upsertExchangeDetails(r), + ), + ], + [ + step( + "exchangeSignKeys", + (tx) => tx.listAllExchangeSignKeys(), + (tx, r) => tx.upsertExchangeSignKey(r), + undefined, + async (tx, r) => { + if (!(await tx.getExchangeDetailsByRowId(r.exchangeDetailsRowId))) { + throw Error( + `conversion refused: signing key references missing exchange details ${r.exchangeDetailsRowId}`, + ); + } + }, + ), + step( + "denominationFamilies", + (tx) => tx.listAllDenominationFamilies(), + (tx, r) => tx.upsertDenominationFamily(r), + ), + ], + [ + step( + "denominations", + (tx) => tx.listAllDenominations(), + (tx, r) => tx.upsertDenomination(stripLegacy("denominations")!(r) as any), + stripLegacy("denominations"), + ), + ], + [ + step( + "withdrawalGroups", + (tx) => tx.listAllWithdrawalGroups(), + (tx, r) => + tx.upsertWithdrawalGroup(stripLegacy("withdrawalGroups")!(r) as any), + stripLegacy("withdrawalGroups"), + ), + step( + "purchases", + (tx) => tx.listAllPurchases(), + (tx, r) => tx.upsertPurchase(r), + normalizePurchase, + ), + step( + "refreshGroups", + (tx) => tx.listAllRefreshGroups(), + (tx, r) => tx.upsertRefreshGroup(r), + ), + step( + "coins", + (tx) => tx.listAllCoins(), + // Written through the strip too, so an IndexedDB destination does not + // re-preserve the junk the conversion exists to shed. + (tx, r) => tx.upsertCoin(normalizeCoin(r)), + normalizeCoin, + ), + ], + [ + step( + "planchets", + (tx) => tx.listAllPlanchets(), + (tx, r) => tx.upsertPlanchet(normalizePlanchet(r)), + normalizePlanchet, + async (tx, r) => { + if (!(await tx.getWithdrawalGroup(r.withdrawalGroupId))) { + throw Error( + `conversion refused: planchet references missing withdrawal group ${r.withdrawalGroupId}`, + ); + } + }, + ), + step( + "refreshSessions", + (tx) => tx.listAllRefreshSessions(), + (tx, r) => + tx.upsertRefreshSession(stripLegacy("refreshSessions")!(r) as any), + stripLegacy("refreshSessions"), + async (tx, r) => { + if (!(await tx.getRefreshGroup(r.refreshGroupId))) { + throw Error( + `conversion refused: refresh session references missing refresh group ${r.refreshGroupId}`, + ); + } + }, + ), + step( + "coinHistory", + (tx) => tx.listAllCoinHistories(), + (tx, r) => tx.upsertCoinHistory(r), + undefined, + async (tx, r) => { + if (!(await tx.getCoin(r.coinPub))) { + throw Error( + `conversion refused: coin history references missing coin ${r.coinPub}`, + ); + } + }, + ), + step( + "coinAvailability", + (tx) => tx.getCoinAvailabilities(), + (tx, r) => tx.upsertCoinAvailability(normalizeCoinAvailability(r)), + normalizeCoinAvailability, + ), + step( + "refundGroups", + (tx) => tx.listAllRefundGroups(), + (tx, r) => tx.upsertRefundGroup(r), + ), + step( + "tokens", + (tx) => tx.listTokens(), + (tx, r) => tx.upsertToken(r), + ), + step( + "slates", + (tx) => tx.listAllSlates(), + (tx, r) => tx.upsertSlate(r), + ), + step( + "depositGroups", + (tx) => tx.listAllDepositGroups(), + (tx, r) => tx.upsertDepositGroup(r), + ), + step( + "recoupGroups", + (tx) => tx.listAllRecoupGroups(), + (tx, r) => tx.upsertRecoupGroup(r), + ), + step( + "denomLossEvents", + (tx) => tx.listAllDenomLossEvents(), + (tx, r) => tx.upsertDenomLossEvent(r), + ), + step( + "peerPushDebit", + (tx) => tx.listAllPeerPushDebits(), + (tx, r) => tx.upsertPeerPushDebit(r), + normalizeContractPriv, + ), + step( + "peerPushCredit", + (tx) => tx.listAllPeerPushCredits(), + (tx, r) => tx.upsertPeerPushCredit(r), + normalizeContractPriv, + ), + step( + "peerPullDebit", + (tx) => tx.listAllPeerPullDebits(), + (tx, r) => tx.upsertPeerPullDebit(r), + normalizeContractPriv, + ), + step( + "peerPullCredit", + (tx) => tx.listAllPeerPullCredits(), + (tx, r) => tx.upsertPeerPullCredit(r), + normalizeContractPriv, + async (tx, r) => { + if (!(await tx.getReserve(r.mergeReserveRowId))) { + throw Error( + `conversion refused: peer pull credit references missing reserve ${r.mergeReserveRowId}`, + ); + } + }, + ), + step( + "donationSummaries", + (tx) => tx.getDonationSummaries(), + (tx, r) => tx.upsertDonationSummary(r), + ), + step( + "donationPlanchets", + (tx) => tx.listAllDonationPlanchets(), + (tx, r) => tx.upsertDonationPlanchet(r), + ), + step( + "donationReceipts", + (tx) => tx.listAllDonationReceipts(), + (tx, r) => tx.upsertDonationReceipt(r), + ), + step( + "transactionsMeta", + (tx) => tx.listTransactionMetaByTimestamp({}), + (tx, r) => tx.upsertTransactionMeta(r), + ), + ], + [ + step( + "refundItems", + (tx) => tx.listAllRefundItems(), + (tx, r) => tx.upsertRefundItem(r), + undefined, + async (tx, r) => { + if (!(await tx.getRefundGroup(r.refundGroupId))) { + throw Error( + `conversion refused: refund item references missing refund group ${r.refundGroupId}`, + ); + } + }, + ), + ], +]; + +export const DB_CONVERSION_STEP_COUNT = COPY_PLAN.flat().length; + +export interface DbConversionReport { + /** Records copied, per store. */ + copied: Record<string, number>; + /** Total number of records copied. */ + totalRecords: number; +} + +/** Optional hooks for observing or deliberately interrupting a conversion. */ +export interface DbConversionOptions { + /** + * Called after a progress notification has been delivered to the source + * handle. Throwing aborts the conversion, which lets callers inject a + * controlled interruption without relying on host notification callbacks. + */ + onProgress?: (notification: DatabaseMaintenanceProgressNotification) => void; +} + +/** Small enough to bound retained records while amortising transaction setup. */ +export const DB_CONVERSION_BATCH_SIZE = 128; + +/** Record interval at which migration progress is reported. */ +export const DB_CONVERSION_PROGRESS_RECORDS = 100; + +/** + * JSON stringification with sorted object keys, so structurally equal + * records compare equal regardless of property insertion order -- the two + * backends do not construct records in the same order. + */ +function stableStringify(v: unknown): string { + return JSON.stringify(v, (_k, val) => { + if (val !== null && typeof val === "object" && !Array.isArray(val)) { + const sorted: Record<string, unknown> = {}; + for (const key of Object.keys(val).sort()) { + // JSON.stringify drops undefined-valued properties on its own, but + // only at the top of each value; do it explicitly so a record with + // an explicitly-undefined key compares equal to one without it. + if (val[key] !== undefined) { + sorted[key] = val[key]; + } + } + return sorted; + } + return val; + }); +} + +/** + * Order-independent SHA-256 multiset digest. + * + * Each canonical record is hashed separately and the 256-bit hashes are + * added modulo 2^256. Addition preserves multiplicity but does not require + * records from the two different physical schemas to arrive in the same + * order. Only this fixed 32-byte accumulator is retained between batches. + */ +class RecordMultisetDigest { + private sum = new Uint8Array(32); + count = 0; + + add(record: unknown): void { + const encoded = stableStringify(record); + const h = sha256(stringToBytes(encoded)); + let carry = 0; + for (let i = this.sum.length - 1; i >= 0; i--) { + const n = this.sum[i] + h[i] + carry; + this.sum[i] = n & 0xff; + carry = n >>> 8; + } + this.count++; + } + + equals(other: RecordMultisetDigest): boolean { + if (this.count !== other.count) return false; + let difference = 0; + for (let i = 0; i < this.sum.length; i++) { + difference |= this.sum[i] ^ other.sum[i]; + } + return difference === 0; + } + + describe(): string { + return `${this.count}:${Array.from(this.sum, (x) => + x.toString(16).padStart(2, "0"), + ).join("")}`; + } +} + +async function readPage( + handle: WalletDbHandle, + step: CopyStep, + cursor: unknown | undefined, + validate: boolean, +): Promise<{ records: unknown[]; nextCursor?: unknown }> { + return await handle.runReadWriteTx(async (tx) => { + const page = await tx.scanMigrationRecords( + step.name, + step.read, + cursor, + DB_CONVERSION_BATCH_SIZE, + ); + if (validate && step.validate) { + for (const record of page.records) { + await step.validate(tx, record); + } + } + return page; + }); +} + +async function digestStore( + handle: WalletDbHandle, + step: CopyStep, + progress?: (processed: number) => void, +): Promise<RecordMultisetDigest> { + const digest = new RecordMultisetDigest(); + let cursor: unknown | undefined; + while (true) { + const page = await readPage(handle, step, cursor, false); + if (page.records.length === 0) break; + const normalize = step.normalize ?? ((r: unknown) => r); + for (const record of page.records) { + digest.add(normalize(record)); + } + progress?.(digest.count); + cursor = page.nextCursor; + if (cursor === undefined) break; + } + return digest; +} + +/** + * Copy every record from src into dst, then verify the copy. + * + * Verification re-enumerates both databases through the same bounded + * accessors and compares count plus an order-independent digest of every + * normalised record. A mapper that drops a field produces equal counts and + * unequal digests without retaining either store in memory. + * + * Throws on any difference; the destination should then be discarded. + */ +export async function convertWalletDb( + src: WalletDbHandle, + dst: WalletDbHandle, + options: DbConversionOptions = {}, +): Promise<DbConversionReport> { + const copied: Record<string, number> = {}; + + // Inventorying the source up front makes the progress denominator known + // before the first write. Keep the digests: verification can compare the + // destination against these instead of scanning the source a second time, + // so global progress does not add another full database pass. + const sourceDigests = new Map<WalletDbMigrationStore, RecordMultisetDigest>(); + let totalRecords = 0; + for (const group of COPY_PLAN) { + for (const st of group) { + const digest = await digestStore(src, st); + sourceDigests.set(st.name, digest); + totalRecords += digest.count; + } + } + + const progressInterval = DB_CONVERSION_PROGRESS_RECORDS; + + const notify = ( + phase: "copy" | "verify", + completedSteps: number, + step?: CopyStep, + processedRecords?: number, + ): void => { + const notification: DatabaseMaintenanceProgressNotification = { + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-to-native-migration", + phase, + completedSteps, + totalSteps: DB_CONVERSION_STEP_COUNT, + ...(step ? { step: step.name } : {}), + ...(processedRecords !== undefined ? { processedRecords } : {}), + totalRecords, + }; + src.emitNotification(notification); + options.onProgress?.(notification); + }; + + const makeRecordProgress = (phase: "copy" | "verify") => { + let next = progressInterval; + let last = -1; + return ( + completedSteps: number, + processedRecords: number, + step?: CopyStep, + force = false, + ): void => { + if (!force && processedRecords < next) return; + if (processedRecords === last) return; + notify(phase, completedSteps, step, processedRecords); + last = processedRecords; + next = + (Math.floor(processedRecords / progressInterval) + 1) * + progressInterval; + }; + }; + + const copyProgress = makeRecordProgress("copy"); + let copiedRecords = 0; + let stepIndex = 0; + copyProgress(stepIndex, 0, undefined, true); + for (const group of COPY_PLAN) { + for (const st of group) { + let cursor: unknown | undefined; + let storeCount = 0; + while (true) { + const page = await readPage(src, st, cursor, true); + if (page.records.length === 0) break; + await dst.runReadWriteTx(async (tx) => { + for (const rec of page.records) { + await st.write(tx, rec); + } + }); + storeCount += page.records.length; + copiedRecords += page.records.length; + copyProgress(stepIndex, copiedRecords, st); + cursor = page.nextCursor; + if (cursor === undefined) break; + } + copied[st.name] = storeCount; + stepIndex++; + notify("copy", stepIndex, st); + logger.trace(`copied ${storeCount} ${st.name}`); + } + } + copyProgress(stepIndex, copiedRecords, undefined, true); + + // Verify using fixed-size multiset digests. Source and destination have + // different primary keys/orderings for some entities, so comparing page + // boundaries would be incorrect even though both scans are bounded. + const verifyProgress = makeRecordProgress("verify"); + let verifiedRecords = 0; + stepIndex = 0; + verifyProgress(stepIndex, 0, undefined, true); + for (const group of COPY_PLAN) { + for (const st of group) { + const sourceDigest = sourceDigests.get(st.name)!; + const beforeStore = verifiedRecords; + const destinationDigest = await digestStore(dst, st, (processed) => + verifyProgress(stepIndex, beforeStore + processed, st), + ); + if (!sourceDigest.equals(destinationDigest)) { + throw Error( + `conversion verification failed: ${st.name} differs between` + + ` source and destination (${sourceDigest.describe()} versus` + + ` ${destinationDigest.describe()})`, + ); + } + verifiedRecords += destinationDigest.count; + stepIndex++; + notify("verify", stepIndex, st); + } + } + verifyProgress(stepIndex, verifiedRecords, undefined, true); + return { copied, totalRecords: copiedRecords }; +} diff --git a/packages/taler-wallet-core/src/db/migration/native.test.ts b/packages/taler-wallet-core/src/db/migration/native.test.ts @@ -0,0 +1,699 @@ +/* + 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 for the in-place migration to the native schema. + * + * The source database is populated by running the whole conformance corpus + * against it, so the migration faces every record type the suite can produce. + * The copy itself is verified record by record by the converter underneath; + * what these cases are about is the part that only in-place migration has -- + * which schema a file is opened with afterwards, what happens to the tables + * that were migrated away from, and what an interrupted attempt leaves behind. + */ + +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { + BridgeIDBFactory, + createSqliteBackendOverDb, + Sqlite3Database, +} from "@gnu-taler/idb-bridge"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import { + DatabaseMaintenanceProgressNotification, + NotificationType, + WalletNotification, +} from "@gnu-taler/taler-util"; + +import { + dropExpiredMigrationBackup, + inspectWalletDbFile, + inspectWalletDbFileDetails, + migrateWalletDbToNative, + readNativeMigrationInfo, + resolveAmbiguousWalletDb, + restoreMigrationBackup, +} from "./native.js"; +import { + IDB_BACKUP_PREFIX, + IDB_EMULATION_TABLES, + schemaMigrations, +} from "../sqlite/schema.js"; +import { DB_CONVERSION_PROGRESS_RECORDS } from "./converter.js"; +import { IdbWalletDbHandle } from "../indexeddb/handle.js"; +import { + initSqliteWalletDb, + openNativeSqliteWalletDb, +} from "../sqlite/database.js"; +import { + inspectWalletDbPath, + resolveWalletDbMigration, +} from "../../host-impl.node.js"; +import { acquireSqliteWalletDbOwnership } from "../../host-common.js"; +import { conformanceCases } from "../testing/conformance-cases.js"; +import { ConformanceAsserts } from "../testing/conformance.js"; + +/** Assertions that ignore case-internal failures: only the data matters. */ +const quietAsserts: ConformanceAsserts = { + equal: () => {}, + deepEqual: () => {}, + ok: () => {}, + fail: () => { + throw Error("unreachable"); + }, +}; + +async function listTables(db: Sqlite3Database): Promise<string[]> { + const rows = await ( + await db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ) + ).getAll(); + return rows.map((r) => String(r.name)); +} + +async function countRows(db: Sqlite3Database, table: string): Promise<number> { + const row = await ( + await db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`) + ).getFirst({}); + return Number(row?.n); +} + +/** + * An emulation-backed wallet database with the conformance corpus in it, over + * a connection the caller keeps: the migration needs that same connection. + */ +async function makePopulatedIdbDb(filename = ":memory:"): Promise<{ + db: Sqlite3Database; + handle: IdbWalletDbHandle; +}> { + const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await imp.open(filename); + const backend = await createSqliteBackendOverDb(imp, db); + BridgeIDBFactory.enableTracing = false; + const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); + await handle.ensureOpen(); + for (const c of conformanceCases) { + try { + await c.run(quietAsserts, handle as any); + } catch (e) { + // A case failing its own assertions is the conformance suite's concern; + // what matters here is whatever data it managed to write. + } + } + return { db, handle }; +} + +async function openIdbDb(filename: string): Promise<{ + db: Sqlite3Database; + handle: IdbWalletDbHandle; +}> { + const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await imp.open(filename); + const backend = await createSqliteBackendOverDb(imp, db); + const handle = new IdbWalletDbHandle(new BridgeIDBFactory(backend)); + await handle.ensureOpen(); + return { db, handle }; +} + +async function makeMinimalIdbDb(filename = ":memory:"): Promise<{ + db: Sqlite3Database; + handle: IdbWalletDbHandle; +}> { + const { db, handle } = await openIdbDb(filename); + await handle.runReadWriteTx((tx) => + tx.upsertConfig({ key: "fault-test" as any, value: 1 }), + ); + return { db, handle }; +} + +test("wallet database ownership excludes another SQLite connection", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "wallet-db-owner-")); + const filename = path.join(directory, "wallet.sqlite3"); + const firstImpl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const secondImpl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const first = await firstImpl.open(filename); + const second = await secondImpl.open(filename); + try { + await acquireSqliteWalletDbOwnership(first); + // Native initialization switches to WAL; ownership must survive that + // transition because migrated wallets use WAL for their whole lifetime. + await openNativeSqliteWalletDb(first); + // Keep this conflict test fast; production uses the adapter's normal busy + // timeout so a wallet that is just closing can drain cleanly. + await second.exec("PRAGMA busy_timeout = 1"); + await assert.rejects( + acquireSqliteWalletDbOwnership(second), + /another wallet process may still be using it/, + ); + + await first.close(); + await acquireSqliteWalletDbOwnership(second); + } finally { + // first.close() is intentionally reached in the success path above. A + // second close is harmless for the node helper and ensures failure paths + // do not retain the test database lock. + await first.close().catch(() => {}); + await second.close().catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("native migration: happens in the same file and switches it over", async () => { + const { db, handle } = await makePopulatedIdbDb(); + const progress: WalletNotification[] = []; + handle.setNotificationSink((n) => progress.push(n)); + + assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); + + const { + handle: native, + report, + info, + } = await migrateWalletDbToNative(db, handle); + + assert.ok( + report.totalRecords >= 100, + `only ${report.totalRecords} records migrated -- the corpus did not` + + ` populate the source, so the migration proved nothing`, + ); + assert.strictEqual(info.status, "complete"); + assert.strictEqual(info.recordsCopied, report.totalRecords); + assert.strictEqual(info.backupStatus, "retained"); + assert.ok(info.backupExpiresAt! > info.finishedAt!); + assert.ok( + progress.some( + (n) => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-to-native-migration" && + n.phase === "complete", + ), + "successful migration did not report completion", + ); + + // The file now opens natively, without being told to. + assert.strictEqual(await inspectWalletDbFile(db), "native"); + + const tables = await listTables(db); + for (const t of IDB_EMULATION_TABLES) { + assert.ok( + !tables.includes(t), + `${t} is still there, so the emulation would keep being used`, + ); + assert.ok( + tables.includes(`${IDB_BACKUP_PREFIX}${t}`), + `${t} was not retained as a backup`, + ); + } + // The retained copy still holds the records it held before. + assert.ok((await countRows(db, `${IDB_BACKUP_PREFIX}object_data`)) > 0); + + // The migrated database is usable through the handle the wallet gets. + const coins = await native.runReadWriteTx((tx) => tx.listAllCoins()); + assert.ok(coins.length > 0, "no coins survived the migration"); + + await native.close(); +}); + +test("native migration recognizes a restored IndexedDB generation", async () => { + const { db, handle } = await makeMinimalIdbDb(); + const dump = await handle.exportDatabase(); + await handle.importDatabase(dump, async (tx) => { + await tx.upsertConfig({ key: "fault-test" as any, value: 2 }); + }); + + assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); + const { handle: native } = await migrateWalletDbToNative(db, handle); + assert.strictEqual( + (await native.runReadWriteTx((tx) => tx.getConfig("fault-test" as any))) + ?.value, + 2, + ); + await native.close(); +}); + +test("native migration: clearing the wallet leaves the retained backup", async () => { + const { db, handle } = await makePopulatedIdbDb(); + const { handle: native } = await migrateWalletDbToNative(db, handle); + + const before = await countRows(db, `${IDB_BACKUP_PREFIX}object_data`); + assert.ok(before > 0); + + // clearDatabase enumerates the tables in the file; the emulation's retained + // tables are in that same file and are not the wallet's data. + await native.clearDatabase(); + + assert.strictEqual( + await countRows(db, `${IDB_BACKUP_PREFIX}object_data`), + before, + ); + await native.close(); +}); + +test("native migration: the backup is dropped only once it expires", async () => { + const { db, handle } = await makePopulatedIdbDb(); + const { handle: native, info } = await migrateWalletDbToNative(db, handle); + + assert.strictEqual( + await dropExpiredMigrationBackup(db, info.backupExpiresAt! - 1), + false, + "the backup went away before its retention was over", + ); + assert.ok((await listTables(db)).includes(`${IDB_BACKUP_PREFIX}object_data`)); + + assert.strictEqual( + await dropExpiredMigrationBackup(db, info.backupExpiresAt!), + true, + ); + const tables = await listTables(db); + for (const t of IDB_EMULATION_TABLES) { + assert.ok(!tables.includes(`${IDB_BACKUP_PREFIX}${t}`)); + } + assert.strictEqual( + (await readNativeMigrationInfo(db))?.backupStatus, + "dropped", + ); + // Dropping the backup does not change which schema the file is read with. + assert.strictEqual(await inspectWalletDbFile(db), "native"); + + // And a second call has nothing left to do. + assert.strictEqual( + await dropExpiredMigrationBackup(db, info.backupExpiresAt!), + false, + ); + await native.close(); +}); + +test("native migration: the retained backup can be put back", async () => { + const { db, handle } = await makePopulatedIdbDb(); + const rowsBefore = await countRows(db, "object_data"); + await migrateWalletDbToNative(db, handle); + + // Not closing the handle first: closing it closes the connection this test + // still holds, and the file is what the restore works on. In production + // the restore runs against a wallet that is not running at all. + await restoreMigrationBackup(db); + + assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); + assert.strictEqual(await countRows(db, "object_data"), rowsBefore); + const info = await readNativeMigrationInfo(db); + assert.strictEqual(info?.status, "rolled-back"); + assert.strictEqual(info?.backupStatus, "restored"); + + // A rolled-back database is not migrated again behind the user's back. + await assert.rejects( + () => migrateWalletDbToNative(db, handle), + /rolled back/, + ); +}); + +test("native migration: an interrupted attempt restarts after reopening", async () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "wallet-db-migration-restart-"), + ); + const filename = path.join(directory, "wallet.sqlite3"); + let firstDb: Sqlite3Database | undefined; + let reopenedDb: Sqlite3Database | undefined; + try { + const first = await makeMinimalIdbDb(filename); + firstDb = first.db; + const expectedTombstones = DB_CONVERSION_PROGRESS_RECORDS * 2 + 17; + await first.handle.runReadWriteTx(async (tx) => { + for (let i = 0; i < expectedTombstones; i++) { + await tx.upsertTombstone({ id: `restart-${i}` }); + } + }); + + // Throw only after a committed destination batch has advanced global + // progress. This leaves the same durable state as process termination: + // untouched IndexedDB tables, a running marker and partial native rows. + let interruptionInjected = false; + const progress: WalletNotification[] = []; + first.handle.setNotificationSink((n) => { + progress.push(n); + }); + await assert.rejects( + () => + migrateWalletDbToNative(first.db, first.handle, { + onProgress(n) { + if ( + !interruptionInjected && + n.phase === "copy" && + (n.processedRecords ?? 0) >= DB_CONVERSION_PROGRESS_RECORDS + ) { + interruptionInjected = true; + throw Error("simulated migration interruption"); + } + }, + }), + /simulated migration interruption/, + ); + assert.ok( + interruptionInjected, + "migration was not interrupted after a copy", + ); + const failed = progress.find( + (n): n is DatabaseMaintenanceProgressNotification => + n.type === NotificationType.DatabaseMaintenanceProgress && + n.operation === "indexeddb-to-native-migration" && + n.phase === "failed", + ); + assert.ok(failed, "interrupted migration did not report failure"); + assert.match( + failed.error?.hint ?? "", + /simulated migration interruption/, + "failed migration notification did not include the exception", + ); + const interrupted = await inspectWalletDbFileDetails(first.db); + assert.strictEqual(interrupted.kind, "indexeddb"); + assert.ok(interrupted.nativeRecords > 0, "no partial native copy was left"); + assert.strictEqual( + (await readNativeMigrationInfo(first.db))?.status, + "running", + ); + + await first.handle.close(); + await first.db.close(); + firstDb = undefined; + + // A new factory and connection exercise the path that previously surfaced + // only as "database opening error", rather than reusing an already-open + // IndexedDB handle as the old regression did. + const reopened = await openIdbDb(filename); + reopenedDb = reopened.db; + assert.strictEqual(await inspectWalletDbFile(reopened.db), "indexeddb"); + const { handle: native, report } = await migrateWalletDbToNative( + reopened.db, + reopened.handle, + ); + const tombstones = await native.runReadWriteTx((tx) => + tx.listAllTombstones(), + ); + assert.strictEqual(tombstones.length, expectedTombstones); + assert.strictEqual( + new Set(tombstones.map((t) => t.id)).size, + expectedTombstones, + "the restarted copy duplicated records", + ); + assert.ok(report.totalRecords >= expectedTombstones); + assert.strictEqual(await inspectWalletDbFile(reopened.db), "native"); + await native.close(); + reopenedDb = undefined; + } finally { + await firstDb?.close().catch(() => {}); + await reopenedDb?.close().catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("native migration: a legacy interrupted attempt remains restartable", async () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "wallet-db-migration-legacy-"), + ); + const filename = path.join(directory, "wallet.sqlite3"); + let firstDb: Sqlite3Database | undefined; + let reopenedDb: Sqlite3Database | undefined; + try { + const first = await makeMinimalIdbDb(filename); + firstDb = first.db; + await initSqliteWalletDb( + first.db, + schemaMigrations.filter((m) => m.version < 7), + ); + await ( + await first.db.prepare( + "INSERT INTO config (key, value) VALUES ('partial-only', '\"discard\"')", + ) + ).run({}); + await ( + await first.db.prepare( + "INSERT INTO idb_migration (id, status, started_at)" + + " VALUES (1, 'running', 1)", + ) + ).run({}); + assert.strictEqual( + (await readNativeMigrationInfo(first.db))?.cleanupSafe, + undefined, + ); + assert.strictEqual(await inspectWalletDbFile(first.db), "indexeddb"); + + await first.handle.close(); + await first.db.close(); + firstDb = undefined; + + const reopened = await openIdbDb(filename); + reopenedDb = reopened.db; + const { handle: native, info } = await migrateWalletDbToNative( + reopened.db, + reopened.handle, + ); + assert.strictEqual(info.status, "complete"); + assert.strictEqual(info.cleanupSafe, true); + const config = await native.runReadWriteTx((tx) => tx.listAllConfig()); + assert.ok(config.some((r) => r.key === ("fault-test" as any))); + assert.ok(!config.some((r) => r.key === ("partial-only" as any))); + await native.close(); + reopenedDb = undefined; + } finally { + await firstDb?.close().catch(() => {}); + await reopenedDb?.close().catch(() => {}); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("native migration: mixed schemas without ownership fail closed", async () => { + const { db, handle } = await makePopulatedIdbDb(); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO config (key, value) VALUES ('native-only', '\"keep-me\"')", + ) + ).run({}); + + const inspection = await inspectWalletDbFileDetails(db); + assert.strictEqual(inspection.kind, "ambiguous"); + assert.ok(inspection.indexedDbRecords > 0); + assert.strictEqual(inspection.nativeRecords, 1); + await assert.rejects( + () => migrateWalletDbToNative(db, handle), + /native schema already contains wallet records/, + ); + assert.strictEqual(await countRows(db, "config"), 1); + assert.ok((await countRows(db, "object_data")) > 0); +}); + +test("native migration: an untrusted running marker never clears native rows", async () => { + const { db, handle } = await makePopulatedIdbDb(); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO config (key, value) VALUES ('native-only', '\"keep-me\"')", + ) + ).run({}); + await ( + await db.prepare( + "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + + " VALUES (1, 'running', 1, 0)", + ) + ).run({}); + + assert.strictEqual(await inspectWalletDbFile(db), "ambiguous"); + await assert.rejects( + () => migrateWalletDbToNative(db, handle), + /untrusted running marker/, + ); + assert.strictEqual(await countRows(db, "config"), 1); +}); + +test("native migration: an empty untrusted retry acquires cleanup ownership", async () => { + const { db, handle } = await makePopulatedIdbDb(); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + + " VALUES (1, 'running', 1, 0)", + ) + ).run({}); + const { handle: native, info } = await migrateWalletDbToNative(db, handle); + assert.strictEqual(info.cleanupSafe, true); + assert.strictEqual((await readNativeMigrationInfo(db))?.cleanupSafe, true); + await native.close(); +}); + +test("native migration: unrelated emulated databases are not wallet records", async () => { + const imp = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await imp.open(":memory:"); + const backend = await createSqliteBackendOverDb(imp, db); + const factory = new BridgeIDBFactory(backend); + const req = factory.open("not-the-wallet", 1); + req.addEventListener("upgradeneeded", () => { + req.result.createObjectStore("records").put({ unrelated: true }, "one"); + }); + await new Promise<void>((resolve, reject) => { + req.addEventListener("success", () => resolve()); + req.addEventListener("error", () => reject(req.error)); + }); + const inspection = await inspectWalletDbFileDetails(db); + assert.strictEqual(inspection.indexedDbRecords, 0); + assert.strictEqual(inspection.kind, "indexeddb"); + await db.close(); +}); + +test("native migration: explicit resolution can keep IndexedDB", async () => { + const { db } = await makePopulatedIdbDb(); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO config (key, value) VALUES ('native-only', '\"discard\"')", + ) + ).run({}); + await resolveAmbiguousWalletDb(db, "indexeddb"); + assert.strictEqual(await inspectWalletDbFile(db), "indexeddb"); + assert.strictEqual(await countRows(db, "config"), 0); + assert.strictEqual( + (await readNativeMigrationInfo(db))?.status, + "rolled-back", + ); + assert.ok((await countRows(db, "object_data")) > 0); + await db.close(); +}); + +test("native migration: explicit resolution can keep native", async () => { + const { db } = await makePopulatedIdbDb(); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO config (key, value) VALUES ('native-only', '\"keep\"')", + ) + ).run({}); + await resolveAmbiguousWalletDb(db, "native"); + assert.strictEqual(await inspectWalletDbFile(db), "native"); + assert.strictEqual(await countRows(db, "config"), 1); + assert.ok((await countRows(db, `${IDB_BACKUP_PREFIX}object_data`)) > 0); + await db.close(); +}); + +test("native migration: rollback preflight failure preserves native rows", async () => { + const { db, handle } = await makePopulatedIdbDb(); + const { handle: native } = await migrateWalletDbToNative(db, handle); + const coinsBefore = await countRows(db, "coins"); + await ( + await db.prepare(`DROP TABLE "${IDB_BACKUP_PREFIX}index_data"`) + ).run({}); + await assert.rejects( + () => restoreMigrationBackup(db), + /backup table .* is missing/, + ); + assert.strictEqual(await countRows(db, "coins"), coinsBefore); + assert.strictEqual((await readNativeMigrationInfo(db))?.status, "complete"); + await native.close(); +}); + +test("native migration: every rollback mutation failure is atomic", async () => { + // Six backup renames plus the final status update: failing any one must roll + // the earlier deletions/renames back with the native wallet and its complete + // marker intact. + for (let failAt = 0; failAt <= IDB_EMULATION_TABLES.length; failAt++) { + const { db, handle } = await makeMinimalIdbDb(); + await migrateWalletDbToNative(db, handle); + let mutation = 0; + const faultDb: Sqlite3Database = { + internalDbHandle: db.internalDbHandle, + exec: (sql) => db.exec(sql), + close: () => db.close(), + prepare: async (sql) => { + const stmt = await db.prepare(sql); + return { + ...stmt, + run: async (params) => { + if ( + sql.startsWith('ALTER TABLE "idb_backup_') || + sql.startsWith( + "UPDATE idb_migration SET backup_status = 'restored'", + ) + ) { + if (mutation++ === failAt) { + throw Error(`injected rollback failure ${failAt}`); + } + } + return await stmt.run(params); + }, + }; + }, + }; + await assert.rejects( + () => restoreMigrationBackup(faultDb), + new RegExp(`injected rollback failure ${failAt}`), + ); + assert.strictEqual(await countRows(db, "config"), 1); + assert.strictEqual((await readNativeMigrationInfo(db))?.status, "complete"); + const tables = await listTables(db); + for (const table of IDB_EMULATION_TABLES) { + assert.ok(tables.includes(`${IDB_BACKUP_PREFIX}${table}`)); + assert.ok(!tables.includes(table)); + } + await db.close(); + } +}); + +test("native migration: offline resolution creates the mandatory full backup", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taler-db-resolution-")); + const dbPath = path.join(dir, "wallet.sqlite3"); + const backupPath = path.join(dir, "before.sqlite3"); + try { + const { db, handle } = await makePopulatedIdbDb(dbPath); + await openNativeSqliteWalletDb(db); + await ( + await db.prepare( + "INSERT INTO config (key, value) VALUES ('native-only', '\"discard\"')", + ) + ).run({}); + await handle.close(); + await db.close(); + + await assert.rejects( + () => + resolveWalletDbMigration( + dbPath, + "indexeddb", + path.join(dir, "missing", "backup.sqlite3"), + ), + /unable to open database|cannot open|SQLITE_CANTOPEN/i, + ); + assert.strictEqual((await inspectWalletDbPath(dbPath)).kind, "ambiguous"); + + await resolveWalletDbMigration(dbPath, "indexeddb", backupPath); + assert.ok(fs.statSync(backupPath).size > 0); + assert.strictEqual((await inspectWalletDbPath(dbPath)).kind, "indexeddb"); + assert.strictEqual( + (await inspectWalletDbPath(backupPath)).kind, + "ambiguous", + ); + await assert.rejects( + () => resolveWalletDbMigration(dbPath, "native", backupPath), + /backup destination .* already exists/, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taler-wallet-core/src/db/migration/native.ts b/packages/taler-wallet-core/src/db/migration/native.ts @@ -0,0 +1,686 @@ +/* + 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/> + */ + +/** + * In-place migration of a wallet database from the IndexedDB emulation to the + * native schema. + * + * Both schemas are sqlite tables and their names do not overlap, so the + * migration happens inside the one file the wallet already has: nothing is + * written next to it and nothing is swapped afterwards. That matters because + * the platforms this migration exists for -- the mobile wallets -- hand + * wallet-core a database and no filesystem to put a second one in. + * + * The order of operations is what makes an interrupted migration safe. The + * emulation's tables are read-only throughout and are renamed out of the way + * only after the copy has been verified, in the same transaction that records + * the migration as complete. So at every instant exactly one of the two + * schemas is the authoritative copy, and which one it is can be read back + * from the file: + * + * - no idb_migration row: the emulation's tables are the wallet. + * - status 'running': an attempt was interrupted. The emulation's tables are + * still the wallet; the native tables hold a partial copy and are discarded + * when the migration is retried. + * - status 'complete': the native tables are the wallet. The emulation's + * tables are still in the file under their idb_backup_ names. + * + * The backup is kept for {@link MIGRATION_BACKUP_RETENTION} rather than + * dropped at the end: a migration that copies every record and verifies it can + * still turn out to have produced a wallet that misbehaves for a reason nobody + * anticipated, and until that window closes the original is one statement + * away. {@link restoreMigrationBackup} is that statement. + */ + +import { + Duration, + getErrorDetailFromException, + Logger, + NotificationType, +} from "@gnu-taler/taler-util"; +import type { Sqlite3Database } from "@gnu-taler/idb-bridge"; + +import { + convertWalletDb, + DB_CONVERSION_STEP_COUNT, + DbConversionOptions, + DbConversionReport, +} from "./converter.js"; +import { + IDB_BACKUP_PREFIX, + IDB_EMULATION_TABLES, + NATIVE_DATA_TABLES, +} from "../sqlite/schema.js"; +import { SqliteWalletDbHandle } from "../sqlite/handle.js"; +import { WalletDbHandle } from "../handle.js"; +import { + clearNativeSqliteWalletDb, + clearNativeSqliteWalletDbInTransaction, + openNativeSqliteWalletDb, + SqliteTxControl, +} from "../sqlite/database.js"; + +const logger = new Logger("db/migration/native.ts"); + +/** + * How long the renamed emulation tables are kept after a successful + * migration. + * + * Long enough that a wallet used every few days gets several chances to + * expose a problem before the original goes away, short enough that a + * wallet's storage does not carry two copies of itself indefinitely. + */ +export const MIGRATION_BACKUP_RETENTION = Duration.fromSpec({ months: 1 }); + +/** The retention as the microseconds the schema's timestamps are in. */ +function retentionMicros(): number { + const ms = MIGRATION_BACKUP_RETENTION.d_ms; + // fromSpec never yields "forever", but narrowing rather than casting means + // a retention that later becomes configurable cannot silently overflow into + // a negative expiry. MAX_SAFE_INTEGER is this schema's "never". + return ms === "forever" ? Number.MAX_SAFE_INTEGER : ms * 1000; +} + +/** Which schema the records in a wallet database file are stored in. */ +export type WalletDbFileKind = "empty" | "indexeddb" | "native" | "ambiguous"; + +/** + * 'rolled-back' is terminal: the emulation tables were put back by + * {@link restoreMigrationBackup}, and the wallet does not migrate again on its + * own, because whoever rolled back did so to stop using the native schema. + */ +export type MigrationStatus = "running" | "complete" | "rolled-back"; + +export type MigrationBackupStatus = "retained" | "dropped" | "restored"; + +export interface NativeMigrationInfo { + status: MigrationStatus; + /** Microseconds since the epoch, as everywhere in the native schema. */ + startedAt: number; + finishedAt?: number; + recordsCopied?: number; + backupStatus?: MigrationBackupStatus; + backupExpiresAt?: number; + /** + * Whether native rows are known to be only a disposable partial copy. + * Undefined identifies the released legacy schema from before this column + * was added; its running marker carried the same cleanup guarantee. + */ + cleanupSafe?: boolean; +} + +export interface WalletDbFileInspection { + kind: WalletDbFileKind; + indexedDbRecords: number; + nativeRecords: number; + ambiguityReason?: string; +} + +/** Current time in the microseconds the native schema's timestamps use. */ +function nowMicros(): number { + return Date.now() * 1000; +} + +function backupTableName(table: string): string { + return `${IDB_BACKUP_PREFIX}${table}`; +} + +async function tableExists( + db: Sqlite3Database, + name: string, +): Promise<boolean> { + const row = await ( + await db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = $name", + ) + ).getFirst({ name }); + return row != null; +} + +/** + * Read the migration bookkeeping, if this file has any. + * + * Tolerates a file that has never seen the native schema: the table itself is + * absent there, which is not an error but the most common case. + */ +export async function readNativeMigrationInfo( + db: Sqlite3Database, +): Promise<NativeMigrationInfo | undefined> { + if (!(await tableExists(db, "idb_migration"))) { + return undefined; + } + const row = await ( + await db.prepare("SELECT * FROM idb_migration WHERE id = 1") + ).getFirst({}); + if (!row) { + return undefined; + } + const optNum = (v: unknown): number | undefined => + v == null ? undefined : Number(v); + return { + status: String(row.status) as MigrationStatus, + startedAt: Number(row.started_at), + finishedAt: optNum(row.finished_at), + recordsCopied: optNum(row.records_copied), + backupStatus: (row.backup_status ?? undefined) as + | MigrationBackupStatus + | undefined, + backupExpiresAt: optNum(row.backup_expires_at), + cleanupSafe: + row.cleanup_safe == null ? undefined : Number(row.cleanup_safe) === 1, + }; +} + +async function countRows(db: Sqlite3Database, table: string): Promise<number> { + const row = await ( + await db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`) + ).getFirst({}); + return Number(row?.n ?? 0); +} + +async function countNativeRecords(db: Sqlite3Database): Promise<number> { + let total = 0; + for (const table of NATIVE_DATA_TABLES) { + if (await tableExists(db, table)) total += await countRows(db, table); + } + return total; +} + +async function countMainIndexedDbRecords(db: Sqlite3Database): Promise<number> { + if ( + !(await tableExists(db, "object_data")) || + !(await tableExists(db, "object_stores")) + ) { + return 0; + } + const row = await ( + await db.prepare( + "SELECT COUNT(*) AS n FROM object_data od" + + " JOIN object_stores os ON os.id = od.object_store_id" + + " WHERE os.database_name = 'taler-wallet-main-v10'" + + " OR os.database_name LIKE 'taler-wallet-main-v10-generation-%'", + ) + ).getFirst({}); + return Number(row?.n ?? 0); +} + +export async function inspectWalletDbFileDetails( + db: Sqlite3Database, +): Promise<WalletDbFileInspection> { + const info = await readNativeMigrationInfo(db); + const indexedDbRecords = await countMainIndexedDbRecords(db); + const nativeRecords = await countNativeRecords(db); + const result = (kind: WalletDbFileKind, ambiguityReason?: string) => ({ + kind, + indexedDbRecords, + nativeRecords, + ...(ambiguityReason ? { ambiguityReason } : undefined), + }); + + if (info?.status === "complete") return result("native"); + if (info?.status === "running" && info.cleanupSafe !== false) + return result("indexeddb"); + if (info?.status === "running" && nativeRecords > 0) { + return result( + "ambiguous", + "an untrusted running migration marker coexists with native wallet records", + ); + } + if (info?.status === "rolled-back" && nativeRecords > 0) { + return result( + "ambiguous", + "a rolled-back migration still has native wallet records", + ); + } + if (indexedDbRecords > 0 && nativeRecords > 0) { + return result( + "ambiguous", + "both IndexedDB and native schemas contain wallet records without a trustworthy authority marker", + ); + } + if (await tableExists(db, "object_data")) return result("indexeddb"); + if (await tableExists(db, "schema_migrations")) return result("native"); + return result("empty"); +} + +/** + * Decide which schema holds the wallet's records in an open database file. + * + * The host has to ask before it opens either backend over the file, because + * both create their tables with IF NOT EXISTS: opening the wrong one does not + * fail, it produces an empty wallet. + */ +export async function inspectWalletDbFile( + db: Sqlite3Database, +): Promise<WalletDbFileKind> { + return (await inspectWalletDbFileDetails(db)).kind; +} + +/** + * Run f inside one native sqlite transaction on db. + * + * The migration owns the connection while it runs, so it does not go through + * the wallet's transaction queue; it does need the same explicit + * BEGIN/COMMIT, since exec() would commit implicitly between statements. + */ +async function inTransaction( + txc: SqliteTxControl, + f: () => Promise<void>, +): Promise<void> { + await txc.begin(); + try { + await f(); + await txc.commit(); + } catch (e) { + try { + await txc.rollback(); + } catch (rollbackErr) { + logger.warn(`rollback failed: ${rollbackErr}`); + } + throw e; + } +} + +export interface NativeMigrationResult { + handle: SqliteWalletDbHandle; + report: DbConversionReport; + info: NativeMigrationInfo; +} + +/** + * Migrate the wallet records in db from the emulation to the native schema. + * + * `src` must be the open IndexedDB-emulation handle over the same connection: + * opening it is what replays the fixup log, so the records this copies are + * already repaired -- the native schema has no fixup log of its own. + * + * Returns the handle the wallet is to use from here on. The caller keeps + * using `src` if this throws: nothing destructive has happened, and the file + * still opens as an emulation database. + */ +export async function migrateWalletDbToNative( + db: Sqlite3Database, + src: WalletDbHandle, + conversionOptions: DbConversionOptions = {}, +): Promise<NativeMigrationResult> { + // Read this before native initialization upgrades the schema. A running + // marker written by versions before cleanup_safe existed is trustworthy: + // those versions also cleared the native tables before recording it. Once + // migration 7 adds the column its DEFAULT 0 deliberately cannot make that + // distinction for us anymore. + const previous = await readNativeMigrationInfo(db); + const previousPartialIsCleanupSafe = + previous?.status === "running" && previous.cleanupSafe !== false; + + const ndb = await openNativeSqliteWalletDb(db); + const dst = new SqliteWalletDbHandle(ndb); + const txc = ndb.txc; + + if (previous?.status === "complete") { + throw Error( + "this wallet database has already been migrated to the native schema", + ); + } + if (previous?.status === "rolled-back") { + throw Error( + "this wallet database was rolled back to the IndexedDB schema and is" + + " not migrated again automatically", + ); + } + if (previous?.status === "running") { + if (previousPartialIsCleanupSafe) { + logger.warn( + previous.cleanupSafe === undefined + ? "discarding the partial copy left by an interrupted legacy migration attempt" + : "discarding the cleanup-safe partial copy left by an interrupted migration attempt", + ); + await clearNativeSqliteWalletDb(ndb); + } else if ((await countNativeRecords(db)) !== 0) { + throw Error( + "migration refused: an untrusted running marker has native wallet records; use db-migration-resolve after making a backup", + ); + } + } else if ((await countNativeRecords(db)) !== 0) { + throw Error( + "migration refused: the native schema already contains wallet records; use db-migration-resolve after making a backup", + ); + } + + const startedAt = nowMicros(); + await ndb.lock.run(() => + inTransaction(txc, async () => { + if ((await countNativeRecords(db)) !== 0) { + throw Error( + "migration refused: native wallet records appeared before cleanup ownership could be recorded", + ); + } + await ( + await db.prepare( + "INSERT INTO idb_migration (id, status, started_at, cleanup_safe)" + + " VALUES (1, 'running', $started_at, 1)" + + " ON CONFLICT (id) DO UPDATE SET status = 'running'," + + " started_at = $started_at, finished_at = NULL," + + " records_copied = NULL, backup_status = NULL," + + " backup_expires_at = NULL, cleanup_safe = 1", + ) + ).run({ started_at: startedAt }); + }), + ); + + try { + logger.info("migrating the wallet database to the native schema"); + // Verifies its own copy record by record and throws on any difference, so + // reaching the next statement means the native tables hold the wallet. + const report = await convertWalletDb(src, dst, conversionOptions); + + await ndb.lock.run(async () => { + const violations = await ( + await db.prepare("PRAGMA foreign_key_check") + ).getAll({}); + if (violations.length !== 0) { + throw Error( + `migration refused: native foreign-key validation found ${violations.length} violation(s)`, + ); + } + }); + + const finishedAt = nowMicros(); + const backupExpiresAt = finishedAt + retentionMicros(); + + // One transaction: the renames and the record of them being done cannot come + // apart. A crash between them would leave a file whose emulation tables are + // gone and whose bookkeeping still says the emulation is authoritative, and + // the retry would then wipe the only remaining copy. + await ndb.lock.run(() => + inTransaction(txc, async () => { + for (const table of IDB_EMULATION_TABLES) { + await ( + await db.prepare( + `ALTER TABLE "${table}" RENAME TO "${backupTableName(table)}"`, + ) + ).run({}); + } + await ( + await db.prepare( + "UPDATE idb_migration SET status = 'complete'," + + " finished_at = $finished_at, records_copied = $records_copied," + + " backup_status = 'retained'," + + " backup_expires_at = $backup_expires_at WHERE id = 1", + ) + ).run({ + finished_at: finishedAt, + records_copied: report.totalRecords, + backup_expires_at: backupExpiresAt, + }); + }), + ); + + logger.info( + `migrated ${report.totalRecords} records to the native schema;` + + ` the previous database is kept in this file until` + + ` ${new Date(backupExpiresAt / 1000).toISOString()}`, + ); + + // The bookkeeping is reported from what was just written rather than read + // back: past the transaction above the emulation's tables are gone, so a + // caller that treats a throw as "nothing happened, keep using the old + // handle" would be wrong from here on. Nothing after this can throw. + src.emitNotification({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-to-native-migration", + phase: "complete", + completedSteps: DB_CONVERSION_STEP_COUNT, + totalSteps: DB_CONVERSION_STEP_COUNT, + processedRecords: report.totalRecords, + totalRecords: report.totalRecords, + }); + return { + handle: dst, + report, + info: { + status: "complete", + startedAt, + finishedAt, + recordsCopied: report.totalRecords, + backupStatus: "retained", + backupExpiresAt, + cleanupSafe: true, + }, + }; + } catch (e) { + src.emitNotification({ + type: NotificationType.DatabaseMaintenanceProgress, + operation: "indexeddb-to-native-migration", + phase: "failed", + completedSteps: 0, + totalSteps: DB_CONVERSION_STEP_COUNT, + error: getErrorDetailFromException(e), + }); + throw e; + } +} + +/** + * Drop the retained emulation tables once their retention has passed. + * + * Called when a migrated database is opened, which is the only moment at + * which nothing is using it and a schema change is free. Returns whether it + * dropped anything. + */ +export async function dropExpiredMigrationBackup( + db: Sqlite3Database, + now: number = nowMicros(), +): Promise<boolean> { + const info = await readNativeMigrationInfo(db); + if (info?.status !== "complete" || info.backupStatus !== "retained") { + return false; + } + if (info.backupExpiresAt == null || now < info.backupExpiresAt) { + return false; + } + // Bring the native schema fully up to date and validate it before deleting + // the last pre-migration copy. + const ndb = await openNativeSqliteWalletDb(db); + logger.info("dropping the retained pre-migration database tables"); + await ndb.lock.run(async () => { + const violations = await ( + await db.prepare("PRAGMA foreign_key_check") + ).getAll({}); + if (violations.length !== 0) { + throw Error( + "native database failed foreign-key validation; retained backup was not dropped", + ); + } + await inTransaction(ndb.txc, async () => { + for (const table of IDB_EMULATION_TABLES) { + if (!(await tableExists(db, backupTableName(table)))) { + throw Error( + `retained backup table ${backupTableName(table)} is missing`, + ); + } + } + for (const table of IDB_EMULATION_TABLES) { + await ( + await db.prepare(`DROP TABLE "${backupTableName(table)}"`) + ).run({}); + } + await ( + await db.prepare( + "UPDATE idb_migration SET backup_status = 'dropped' WHERE id = 1", + ) + ).run({}); + }); + }); + return true; +} + +/** + * Undo a migration, putting the retained emulation tables back in place. + * + * The wallet database must not be open: this renames the tables both backends + * read. Afterwards the file is an emulation database again and the native + * tables are empty, so it opens the way it did before the migration. + * + * Deliberately not automatic. A wallet that migrated and then misbehaved has + * no way to tell whether the migration caused it, and rolling back on its own + * would discard whatever the wallet did since -- the emulation tables stopped + * being written the moment the migration completed. + */ +export async function restoreMigrationBackup( + db: Sqlite3Database, +): Promise<void> { + const info = await readNativeMigrationInfo(db); + if (info?.status !== "complete") { + throw Error("this database was not migrated to the native schema"); + } + if (info.backupStatus !== "retained") { + throw Error( + `the pre-migration tables are not available (backup is` + + ` ${info.backupStatus ?? "absent"})`, + ); + } + const ndb = await openNativeSqliteWalletDb(db); + await ndb.lock.run(async () => { + await inTransaction(ndb.txc, async () => { + // Preflight is inside the same transaction as deletion and renaming, so + // every failure leaves the complete native wallet authoritative. + for (const table of IDB_EMULATION_TABLES) { + if (await tableExists(db, table)) { + throw Error(`cannot restore: table ${table} already exists`); + } + if (!(await tableExists(db, backupTableName(table)))) { + throw Error( + `cannot restore: backup table ${backupTableName(table)} is missing`, + ); + } + } + await clearNativeSqliteWalletDbInTransaction(ndb); + for (const table of IDB_EMULATION_TABLES) { + await ( + await db.prepare( + `ALTER TABLE "${backupTableName(table)}" RENAME TO "${table}"`, + ) + ).run({}); + } + await ( + await db.prepare( + "UPDATE idb_migration SET backup_status = 'restored'," + + " status = 'rolled-back' WHERE id = 1 AND status = 'complete'", + ) + ).run({}); + const updated = await ( + await db.prepare("SELECT status FROM idb_migration WHERE id = 1") + ).getFirst({}); + if (updated?.status !== "rolled-back") { + throw Error( + "migration status changed while rollback was being prepared", + ); + } + }); + }); + logger.info("restored the pre-migration wallet database"); +} + +export type MigrationAuthority = "indexeddb" | "native"; + +async function requireResolutionTables(db: Sqlite3Database): Promise<void> { + for (const table of IDB_EMULATION_TABLES) { + if (!(await tableExists(db, table))) { + throw Error( + `cannot resolve migration: expected IndexedDB table ${table} is missing`, + ); + } + if (await tableExists(db, backupTableName(table))) { + throw Error( + `cannot resolve migration: backup table ${backupTableName(table)} already exists`, + ); + } + } + for (const table of NATIVE_DATA_TABLES) { + if (!(await tableExists(db, table))) { + throw Error( + `cannot resolve migration: expected native table ${table} is missing`, + ); + } + } +} + +/** Select authority in an ambiguous file. The caller must create a backup first. */ +export async function resolveAmbiguousWalletDb( + db: Sqlite3Database, + keep: MigrationAuthority, +): Promise<void> { + const inspection = await inspectWalletDbFileDetails(db); + if (inspection.kind !== "ambiguous") { + throw Error( + `migration resolution requires an ambiguous database, got ${inspection.kind}`, + ); + } + await requireResolutionTables(db); + const ndb = await openNativeSqliteWalletDb(db); + await ndb.lock.run(async () => { + if (keep === "native") { + const violations = await ( + await db.prepare("PRAGMA foreign_key_check") + ).getAll({}); + if (violations.length !== 0) { + throw Error( + `cannot keep native: foreign-key validation found ${violations.length} violation(s)`, + ); + } + } + await inTransaction(ndb.txc, async () => { + // Repeat preflight under the mutation transaction. + await requireResolutionTables(db); + const at = nowMicros(); + if (keep === "indexeddb") { + await clearNativeSqliteWalletDbInTransaction(ndb); + await ( + await db.prepare( + "INSERT INTO idb_migration" + + " (id, status, started_at, finished_at, backup_status, cleanup_safe)" + + " VALUES (1, 'rolled-back', $at, $at, 'restored', 1)" + + " ON CONFLICT(id) DO UPDATE SET status='rolled-back'," + + " finished_at=$at, backup_status='restored', cleanup_safe=1", + ) + ).run({ at }); + } else { + for (const table of IDB_EMULATION_TABLES) { + await ( + await db.prepare( + `ALTER TABLE "${table}" RENAME TO "${backupTableName(table)}"`, + ) + ).run({}); + } + await ( + await db.prepare( + "INSERT INTO idb_migration" + + " (id, status, started_at, finished_at, records_copied," + + " backup_status, backup_expires_at, cleanup_safe)" + + " VALUES (1, 'complete', $at, $at, $records, 'retained', $expires, 0)" + + " ON CONFLICT(id) DO UPDATE SET status='complete'," + + " started_at=$at, finished_at=$at, records_copied=$records," + + " backup_status='retained', backup_expires_at=$expires, cleanup_safe=0", + ) + ).run({ + at, + records: inspection.nativeRecords, + expires: at + retentionMicros(), + }); + } + }); + }); +} diff --git a/packages/taler-wallet-core/src/query-sqlite-error-recovery.test.ts b/packages/taler-wallet-core/src/db/query-sqlite-error-recovery.test.ts diff --git a/packages/taler-wallet-core/src/query.ts b/packages/taler-wallet-core/src/db/query.ts diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts @@ -0,0 +1,2973 @@ +/* + 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/> + */ + +import { + AmountString, + TalerProtocolTimestamp, + MerchantContractTokenKind, + TokenIssuePublicKey, + UnblindedDenominationSignature, + TokenUseSig, + MerchantContractTokenDetails, + ScopeInfo, + TalerErrorDetail, + DenominationPubKey, + Amounts, + DenominationInfo, + TransferOptionRaw, + CoinRefreshRequest, + ExchangeRefundRequest, + ExchangeEntrySource, + RefreshReason, + WithdrawalExchangeAccountDetails, + CoinEnvelope, + TalerProtocolDuration, + DenomSelectionState, + DonationReceiptSignature, + HashCodeString, + BlindedUniqueDonationIdentifier, + SignedTokenEnvelope, + CurrencySpecification, + ExchangeAuditor, + ExchangeWithdrawValue, + ExchangeGlobalFees, + WireInfo, + AccountLimit, + ZeroLimitedOperation, + DenomLossEventType, + EddsaPublicKeyString, + EddsaSignatureString, + CoinStatus, + AgeCommitmentProof, + TransactionIdStr, + TokenEnvelope, + encodeCrock, + hash, + stringToBytes, + canonicalJson, +} from "@gnu-taler/taler-util"; +import { + DbPreciseTimestamp, + DbProtocolTimestamp, + timestampProtocolFromDb, +} from "./timestamps.js"; + +export * from "./timestamps.js"; + +/** + * Metadata for a transaction. + * This object store is effectively a materialzed view of transactions gathered + * from various other object stores. + * + * Primary key: transactionId + */ +export interface WalletTransactionMeta { + /** + * Transaction identifier. + * Also determines the type of the transaction. + */ + transactionId: string; + + timestamp: DbPreciseTimestamp; + + /** + * Status of the transaction, matches the status enum of the + * transaction of the type determined by the transaction ID. + */ + status: number; + + /** + * Exchanges involved in the transaction. + */ + exchanges: string[]; + + currency: string; +} + +/** Stable database cursor for transaction metadata pagination. */ +export interface WalletTransactionMetaCursor { + timestamp: DbPreciseTimestamp; + transactionId: string; +} + +/** + * Retry state of a task. + * + * The policy that computes these timestamps lives in common.ts; this is only + * the stored shape. + */ +export interface WalletRetryInfo { + firstTry: DbPreciseTimestamp; + nextRetry: DbPreciseTimestamp; + retryCounter: number; +} + +export interface WalletOperationRetry { + /** + * Unique identifier for the operation. Typically of + * the format `${opType}-${opUniqueKey}` + * + * @see {@link TaskIdentifiers} + */ + id: string; + + lastError?: TalerErrorDetail; + + retryInfo: WalletRetryInfo; +} + +export interface WalletContractTerms { + /** + * Contract terms hash. + */ + h: string; + + /** + * Contract terms JSON. + * + * Deliberately untyped: this is arbitrary JSON as received from the + * merchant, and there is nothing to validate it against at this layer. + */ + contractTermsRaw: any; +} + +export interface WalletCoinSelection { + coinPubs: string[]; + coinContributions: AmountString[]; +} + +export interface WalletDepositKycInfo { + accessToken?: string; + paytoHash: string; + exchangeBaseUrl: string; + lastCheckStatus?: number | undefined; + lastCheckCode?: number | undefined; + lastRuleGen?: number | undefined; + lastAmlReview?: boolean | undefined; + lastDeny?: DbPreciseTimestamp | undefined; + lastBadKycAuth?: boolean; +} + +export interface WalletDepositTrackingInfo { + // Raw wire transfer identifier of the deposit. + wireTransferId: string; + // When was the wire transfer given to the bank. + timestampExecuted: DbProtocolTimestamp; + // Total amount transfer for this wtid (including fees) + amountRaw: AmountString; + // Wire fee amount for this exchange + wireFee: AmountString; + + exchangePub: string; +} + +/** + * Group of deposits made by the wallet. + */ +export interface WalletDepositInfoPerExchange { + /** + * Expected effective amount that will be deposited + * from coins of this exchange. + */ + amountEffective: AmountString; +} + +export interface WalletDepositGroup { + depositGroupId: string; + + currency: string; + + /** + * Instructed amount. + */ + amount: AmountString; + + wireTransferDeadline: DbProtocolTimestamp; + + merchantPub: string; + merchantPriv: string; + + noncePriv: string; + noncePub: string; + + /** + * Wire information used by all deposits in this + * deposit group. + */ + wire: { + payto_uri: string; + salt: string; + }; + + contractTermsHash: string; + + payCoinSelection?: WalletCoinSelection; + + payCoinSelectionUid?: string; + + totalPayCost: AmountString; + + /** + * The counterparty effective deposit amount. + */ + counterpartyEffectiveDepositAmount: AmountString; + + timestampCreated: DbPreciseTimestamp; + + timestampFinished: DbPreciseTimestamp | undefined; + + /** + * When did the wallet last try a deposit request? + */ + timestampLastDepositAttempt: DbPreciseTimestamp | undefined; + + operationStatus: DepositOperationStatus; + + statusPerCoin?: DepositElementStatus[]; + + infoPerExchange?: Record<string, WalletDepositInfoPerExchange>; + + /** + * When the deposit transaction was aborted and + * refreshes were tried, we create a refresh + * group and store the ID here. + */ + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + kycInfo?: WalletDepositKycInfo; + kycAuthTransferOptions?: KycAuthTransferOptionRaw[]; + kycAuthTransferExpiry?: TalerProtocolTimestamp; + + // FIXME: Do we need this and should it be in this object store? + trackingState?: { + [signature: string]: WalletDepositTrackingInfo; + }; +} + +/** + * KYC auth transfer option persisted in the legacy flat options field. + * + * The optional metadata lets us recover the per-account withdrawal-style + * representation without duplicating the exchange account details in the + * deposit record. Older records contain plain TransferOptionRaw values and + * remain valid. + */ +export type KycAuthTransferOptionRaw = TransferOptionRaw & { + kycAuthAccountPaytoUri?: string; + kycAuthTransferExpiry?: TalerProtocolTimestamp; +}; + +/** + * Status of recoup operations that were grouped together. + * + * The remaining amount of involved coins should be set to zero + * in the same transaction that inserts the WalletRecoupGroup. + */ +export interface WalletRecoupGroup { + /** + * Unique identifier for the recoup group record. + */ + recoupGroupId: string; + + exchangeBaseUrl: string; + + operationStatus: RecoupOperationStatus; + + timestampStarted: DbPreciseTimestamp; + + timestampFinished: DbPreciseTimestamp | undefined; + + /** + * Public keys that identify the coins being recouped + * as part of this session. + * + * (Structured like this to enable multiEntry indexing in IndexedDB.) + */ + coinPubs: string[]; + + /** + * Array of flags to indicate whether the recoup finished on each individual coin. + */ + recoupFinishedPerCoin: boolean[]; + + /** + * Public keys of coins that should be scheduled for refreshing + * after all individual recoups are done. + */ + scheduleRefreshCoins: CoinRefreshRequest[]; +} + +/** + * Store for extra information about a reserve. + * + * Mostly used to store the private key for a reserve and to allow + * other records to reference the reserve key pair via a small row ID. + * + * In the future, we might also store KYC info about a reserve here. + * + * FIXME: Should reference exchange. + */ +export interface WalletReserve { + rowId?: number; + + reservePub: string; + + reservePriv: string; + + status?: ReserveRecordStatus; + + requirementRow?: number; + + /** + * Balance threshold that we're currently requesting KYC for. + */ + thresholdRequested?: AmountString; + + /** + * Balance threshold that we already have passed KYC for. + */ + thresholdGranted?: AmountString; + + /** + * Threshold that will trigger the next KYC. + */ + thresholdNext?: AmountString; + + kycAccessToken?: string; + + amlReview?: boolean; +} + +export interface WalletRefreshGroupPerExchangeInfo { + /** + * (Expected) output once the refresh group succeeded. + */ + outputEffective: AmountString; +} + +/** + * Group of refresh operations. The refreshed coins do not + * have to belong to the same exchange, but must have the same + * currency. + */ +export interface WalletRefreshGroup { + operationStatus: RefreshOperationStatus; + + /** + * Unique, randomly generated identifier for this group of + * refresh operations. + */ + refreshGroupId: string; + + /** + * Currency of this refresh group. + */ + currency: string; + + /** + * Reason why this refresh group has been created. + */ + reason: RefreshReason; + + originatingTransactionId?: string; + + oldCoinPubs: string[]; + + inputPerCoin: AmountString[]; + + expectedOutputPerCoin: AmountString[]; + + infoPerExchange?: Record<string, WalletRefreshGroupPerExchangeInfo>; + + /** + * Flag for each coin whether refreshing finished. + * If a coin can't be refreshed (remaining value too small), + * it will be marked as finished, but no refresh session will + * be created. + */ + statusPerCoin: RefreshCoinStatus[]; + + /** + * Refund requests that might still be necessary + * before the refresh can work. + */ + refundRequests: { [n: number]: ExchangeRefundRequest }; + + timestampCreated: DbPreciseTimestamp; + + failReason?: TalerErrorDetail; + + /** + * Timestamp when the refresh session finished. + */ + timestampFinished: DbPreciseTimestamp | undefined; +} + +/** + * Ongoing refresh + */ +export interface WalletRefreshSession { + refreshGroupId: string; + + /** + * Index of the coin in the refresh group. + */ + coinIndex: number; + + /** + * If this field is set, it's a V2 refresh session. + */ + sessionPublicSeed?: string; + + /** + * Exchange protocol version whose refresh protocol this session speaks, + * fixed when the melt request is prepared. + * + * The melt and the reveal step must agree on it, so it cannot be re-derived + * from the exchange's advertised version later: the exchange may have been + * upgraded in between. Absent means 27, which is what sessions written + * before this field existed use. + */ + refreshProtocolVersion?: number; + + /** + * Sum of the value of denominations we want + * to withdraw in this session, without fees. + */ + amountRefreshOutput: AmountString; + + /** + * Hashed denominations of the newly requested coins. + */ + newDenoms: { + denomPubHash: string; + count: number; + }[]; + + /** + * The no-reveal-index after we've done the melting. + */ + norevealIndex?: number; + + /** + * Last error response from the exchange. + * + * FIXME: We don't store the last HTTP status yet. + */ + lastError?: TalerErrorDetail; + + // Reserved legacy fields: + // * sessionSecretSeed: string + // (legacy v1 refresh) +} + +export const enum WithdrawalRecordType { + BankManual = "bank-manual", + BankIntegrated = "bank-integrated", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + Recoup = "recoup", +} + +/** + * Extra info about a withdrawal that is used + * with a bank-integrated withdrawal. + */ +export interface ReserveBankInfo { + talerWithdrawUri: string; + + /** + * URL that the user can be redirected to, and allows + * them to confirm (or abort) the bank-integrated withdrawal. + */ + confirmUrl: string | undefined; + + /** + * Exchange payto URI that the bank will use to fund the reserve. + */ + exchangePaytoUri?: string; + + /** + * Time when the information about this reserve was posted to the bank. + * + * Only applies if bankWithdrawStatusUrl is defined. + * + * Set to undefined if that hasn't happened yet. + */ + timestampReserveInfoPosted: DbPreciseTimestamp | undefined; + + /** + * Time when the reserve was confirmed by the bank. + * + * Set to undefined if not confirmed yet. + */ + timestampBankConfirmed: DbPreciseTimestamp | undefined; + + wireTypes: string[] | undefined; + + currency: string | undefined; + + externalConfirmation?: boolean; + + senderWire?: string; +} + +export interface WgInfoBankIntegrated { + withdrawalType: WithdrawalRecordType.BankIntegrated; + + /** + * Extra state for when this is a withdrawal involving + * a Taler-integrated bank. + */ + bankInfo: ReserveBankInfo; + + /** + * Info about withdrawal accounts, possibly including currency conversion. + */ + exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[]; +} + +export interface WgInfoBankManual { + withdrawalType: WithdrawalRecordType.BankManual; + + /** + * Info about withdrawal accounts, possibly including currency conversion. + */ + exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[]; +} + +export interface WgInfoBankPeerPull { + withdrawalType: WithdrawalRecordType.PeerPullCredit; + + // FIXME: include a transaction ID here? + + /** + * Needed to quickly construct the taler:// URI for the counterparty + * without a join. + */ + contractPriv: string; +} + +export interface WgInfoBankPeerPush { + withdrawalType: WithdrawalRecordType.PeerPushCredit; + + // FIXME: include a transaction ID here? +} + +export interface WgInfoBankRecoup { + withdrawalType: WithdrawalRecordType.Recoup; +} + +export type WgInfo = + | WgInfoBankIntegrated + | WgInfoBankManual + | WgInfoBankPeerPull + | WgInfoBankPeerPush + | WgInfoBankRecoup; + +/** + * Group of withdrawal operations that need to be executed. + * (Either for a normal withdrawal or from a reward.) + * + * The withdrawal group record is only created after we know + * the coin selection we want to withdraw. + */ +export interface WalletWithdrawalGroup { + /** + * Unique identifier for the withdrawal group. + */ + withdrawalGroupId: string; + + wgInfo: WgInfo; + + /** + * If set to true, the account used during withdrawal is treated as an + * account that does not belong to the user. It won't be shown in + * the list of know bank accounts. + * + * Defaults to false. + */ + isForeignAccount?: boolean; + + kycPaytoHash?: string; + + kycAccessToken?: string; + + kycLastCheckStatus?: number | undefined; + kycLastCheckCode?: number | undefined; + kycLastRuleGen?: number | undefined; + kycLastAmlReview?: boolean | undefined; + kycLastDeny?: DbPreciseTimestamp | undefined; + + /** + * Delay to wait until the next withdrawal attempt. + * + * @deprecated by https://bugs.gnunet.org/view.php?id=9694 + */ + kycWithdrawalDelay?: TalerProtocolDuration; + + /** + * Secret seed used to derive planchets. + * Stored since planchets are created lazily. + */ + secretSeed: string; + + /** + * Public key of the reserve that we're withdrawing from. + */ + reservePub: string; + + /** + * The reserve private key. + * + * FIXME: Already in the reserves object store, redundant! + */ + reservePriv: string; + + /** + * The exchange base URL that we're withdrawing from. + * (Redundantly stored, as the reserve record also has this info.) + */ + exchangeBaseUrl?: string; + + /** + * When was the withdrawal operation started started? + * Timestamp in milliseconds. + */ + timestampStart: DbPreciseTimestamp; + + /** + * When was the withdrawal operation completed? + */ + timestampFinish?: DbPreciseTimestamp; + + /** + * Current status of the reserve. + */ + status: WithdrawalGroupStatus; + + /** + * Restrict withdrawals from this reserve to this age. + */ + restrictAge?: number; + + /** + * Amount that was sent by the user to fund the reserve. + */ + instructedAmount?: AmountString; + + /** + * Amount that was observed when querying the reserve that + * we are withdrawing from. + * + * Useful for diagnostics. + */ + reserveBalanceAmount?: AmountString; + + /** + * Amount including fees (i.e. the amount subtracted from the + * reserve to withdraw all coins in this withdrawal session). + * + * (Initial amount confirmed by the user, might differ with denomSel + * on reselection.) + */ + rawWithdrawalAmount?: AmountString; + + /** + * Amount that will be added to the balance when the withdrawal succeeds. + * + * (Initial amount confirmed by the user, might differ with denomSel + * on reselection.) + */ + effectiveWithdrawalAmount?: AmountString; + + /** + * Denominations selected for withdrawal. + */ + denomsSel?: DenomSelectionState; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; +} + +/** + * A coin that isn't yet signed by an exchange. + */ +export interface WalletPlanchet { + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key of the coin. + */ + coinPriv: string; + + /** + * Withdrawal group that this planchet belongs to + * (or the empty string). + */ + withdrawalGroupId: string; + + /** + * Index within the withdrawal group (or -1). + */ + coinIdx: number; + + planchetStatus: PlanchetStatus; + + lastError: TalerErrorDetail | undefined; + + denomPubHash: string; + + blindingKey: string; + + withdrawSig: string; + + coinEv: CoinEnvelope; + + coinEvHash: string; + + ageCommitmentProof?: AgeCommitmentProof; + + exchangeWithdrawValues: ExchangeWithdrawValue; +} + +export interface WalletDonationSummary { + donauBaseUrl: string; + legalDomain?: string; + year: number; + currency: string; + amountReceiptsAvailable: AmountString; + amountReceiptsSubmitted: AmountString; +} + +/** + * Record for donation receipts. + */ +export interface WalletDonationReceipt { + status: DonationReceiptStatus; + donauBaseUrl: string; + udiNonce: HashCodeString; + proposalId: string; + donationYear: number; + donationUnitPubHash: HashCodeString; + donationUnitSig: DonationReceiptSignature; + donorTaxIdHash: HashCodeString; + donorHashSalt: string; + donorTaxId: string; + value: AmountString; + /** Index of this udi within the selected donation units for the purchase. */ + udiIndex: number; +} + +/** + * Record for donation planchets. + */ +export interface WalletDonationPlanchet { + donauBaseUrl: string; + udiNonce: HashCodeString; + donorTaxIdHash: HashCodeString; + donorHashSalt: string; + donorTaxId: string; + donationYear: number; + proposalId: string; + /** Index of this udi within the selected donation units for the purchase. */ + udiIndex: number; + blindedUdi: BlindedUniqueDonationIdentifier; + /** blinding key secret */ + bks: string; + donationUnitPubHash: HashCodeString; + value: AmountString; +} + +/** + * Partial information about the downloaded proposal. + * Only contains data that is relevant for indexing on the + * "purchases" object stores. + */ +export interface WalletProposalDownloadInfo { + contractTermsHash: string; + fulfillmentUrl?: string; + currency: string; + contractTermsMerchantSig: string; +} + +export interface WalletTokenSelection { + tokenPubs: string[]; +} + +export interface WalletPurchasePayInfo { + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelection?: WalletCoinSelection; + /** + * Undefined if payment is blocked by a pending refund. + */ + payCoinSelectionUid?: string; + + payTokenSelection?: WalletTokenSelection; + + /** + * Token signatures from merchant. + */ + slateTokenSigs?: SignedTokenEnvelope[]; + + /** + * Whether token selection should be forced + * e.g. when merchant URL is not in `expected_domains' + */ + payTokenForcedSel?: boolean; + + totalPayCost: AmountString; +} + +/** + * Record that stores status information about one purchase, starting from when + * the customer accepts a proposal. Includes refund status if applicable. + * + * Key: {@link proposalId} + * Operation status: {@link purchaseStatus} + */ +export interface WalletPurchase { + /** + * Proposal ID for this purchase. Uniquely identifies the + * purchase and the proposal. + * Assigned by the wallet. + */ + proposalId: string; + + /** + * Order ID, assigned by the merchant. + */ + orderId: string; + + merchantBaseUrl: string; + + /** + * Claim token used when downloading the contract terms. + */ + claimToken: string | undefined; + + /** + * Session ID we got when downloading the contract. + */ + downloadSessionId: string | undefined; + + /** + * If this purchase is a repurchase, this field identifies the original purchase. + */ + repurchaseProposalId: string | undefined; + + purchaseStatus: PurchaseStatus; + + /** + * Refresh group ID of the refresh transaction that + * has been created to abort the payment. + */ + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + /** + * Private key for the nonce. + */ + noncePriv: string; + + /** + * Public key for the nonce. + */ + noncePub: string; + + /** + * Index of selected choice in the choices array. + */ + choiceIndex?: number | undefined; + + /** + * Secret seed used to derive slates. + * Stored since slates are created lazily. + */ + secretSeed: string | undefined; + + /** + * Downloaded and parsed proposal data. + */ + download: WalletProposalDownloadInfo | undefined; + + payInfo: WalletPurchasePayInfo | undefined; + + /** + * Exchanges involved in this purchase. + * Used as a multiEntry index to find all purchases for + * an exchange. + */ + exchanges?: string[]; + + /** + * Pending removals from pay coin selection. + * + * Used when a the pay coin selection needs to be changed + * because a coin became known as double-spent or invalid, + * but a new coin selection can't immediately be done, as + * there is not enough balance (e.g. when waiting for a refresh). + */ + pendingRemovedCoinPubs?: string[]; + + /** + * Timestamp of the first time that sending a payment to the merchant + * for this purchase was successful. + */ + timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined; + + merchantPaySig: string | undefined; + + posConfirmation: string | undefined; + + donauOutputIndex?: number; + donauBaseUrl?: string; + donauAmount?: AmountString; + donauTaxIdHash?: string; + donauTaxIdSalt?: string; + donauTaxId?: string; + donauYear?: number; + + /** + * This purchase was shared with another wallet + * that is now supposed to finish the payment. + */ + shared: boolean; + + /** + * This purchase was created by reading + * a payment share or the wallet + * the nonce public by a payment share + * + * Defaults to false. + */ + createdFromShared?: boolean; + + /** + * When was the purchase record created? + */ + timestamp: DbPreciseTimestamp; + + /** + * When was the purchase made? + * Refers to the time that the user accepted. + */ + timestampAccept: DbPreciseTimestamp | undefined; + + /** + * When was the last refund made? + * Set to 0 if no refund was made on the purchase. + */ + timestampLastRefundStatus: DbPreciseTimestamp | undefined; + + /** + * Timestamp when the wallet noticed that the transaction expired. + * May be later than the pay deadline. + */ + timestampExpired?: DbPreciseTimestamp; + + /** + * Last session signature that we submitted to /pay (if any). + */ + lastSessionId: string | undefined; + + /** + * Continue querying the refund status until this deadline has expired. + */ + autoRefundDeadline: DbProtocolTimestamp | undefined; + + /** + * How much merchant has refund to be taken but the wallet + * did not picked up yet + */ + refundAmountAwaiting: AmountString | undefined; + + /** + * Taler URI that started this purchase, if available. + */ + talerUri?: string; +} + +/** + * Metadata about a group of refunds with the merchant. + */ +export interface WalletRefundGroup { + status: RefundGroupStatus; + + /** + * Timestamp when the refund group was created. + */ + timestampCreated: DbPreciseTimestamp; + + proposalId: string; + + refundGroupId: string; + + refreshGroupId?: string; + + amountRaw: AmountString; + + /** + * Estimated effective amount, based on + * refund fees and refresh costs. + */ + amountEffective: AmountString; +} + +/** + * Refund for a single coin in a payment with a merchant. + */ +export interface WalletRefundItem { + /** + * Auto-increment DB record ID. + */ + id?: number; + + status: RefundItemStatus; + + /** + * Mandatory since DB minor version 15. + */ + proposalId?: string; + + refundGroupId: string; + + /** + * Execution time as claimed by the merchant + */ + executionTime: DbProtocolTimestamp; + + /** + * Time when the wallet became aware of the refund. + */ + obtainedTime: DbPreciseTimestamp; + + refundAmount: AmountString; + + coinPub: string; + + rtxid: number; +} + +export interface WalletTombstone { + /** + * Tombstone ID, with the syntax "tmb:<type>:<key>". + */ + id: string; +} + +export interface WalletExchangeDetailsPointer { + masterPublicKey: string; + + currency: string; + + /** + * Timestamp when the (masterPublicKey, currency) pointer + * has been updated. + */ + updateClock: DbPreciseTimestamp; +} + +/** + * Exchange record as stored in the wallet's database. + */ +/** + * A key set an exchange replaced, pending the user's confirmation. + */ +export interface WalletSupersededKeySet { + masterPublicKey: string; + currency: string; + /** When the change was first observed. */ + firstSeen: DbPreciseTimestamp; + /** + * Whether the new key set re-advertises denominations the wallet holds + * coins of. + * + * A claim, not proof: denomination public keys are public, so anyone can + * re-publish them under a new master key. It says whether the exchange + * offers to settle the older coins at all, which is what decides if they + * are worth selecting. + */ + sharesDenominations: boolean; +} + +export interface WalletExchangeEntry { + /** + * Base url of the exchange. + */ + baseUrl: string; + + /** + * Currency hint for a preset exchange, relevant + * when we didn't contact a preset exchange yet. + */ + presetCurrencyHint?: string; + + /** + * Currency spec for a preset exchange, relevant + * when we didn't contact a preset exchange yet. + */ + presetCurrencySpec?: CurrencySpecification; + + /** + * Type of the exchange, if it was a preset entry. + */ + presetType?: string; + + /** How this exchange entry became known to the wallet. */ + source?: ExchangeEntrySource; + + /** + * When did we confirm the last withdrawal from this exchange? + * + * Used mostly in the UI to suggest exchanges. + */ + lastWithdrawal?: DbPreciseTimestamp; + + /** + * Pointer to the current exchange details. + * + * Should usually not change. Only changes when the + * exchange advertises a different master public key and/or + * currency. + * + * We could use a rowID here, but having the currency in the + * details pointer lets us do fewer DB queries + */ + detailsPointer: WalletExchangeDetailsPointer | undefined; + + /** + * The key set this exchange used before it changed keys, kept until the + * user confirms the change. + * + * The new key set is adopted immediately -- {@link detailsPointer} moves -- + * so the entry keeps working and the coins already held stay spendable. + * What is withheld is only the part that sends money to the exchange: the + * wire details a withdrawal pays into are signed by the master key, so a + * URL taken over by someone else would otherwise redirect the next + * withdrawal. Absent once the change has been confirmed, and never set on + * first contact, which is not a change. + */ + supersededKeySet?: WalletSupersededKeySet; + + entryStatus: ExchangeEntryDbRecordStatus; + + updateStatus: ExchangeEntryDbUpdateStatus; + + unavailableReason?: TalerErrorDetail; + + /** + * If set to true, the next update to the exchange + * status will request /keys with no-cache headers set. + */ + cachebreakNextUpdate?: boolean; + + /** + * Etag of the current ToS of the exchange. + */ + tosCurrentEtag: string | undefined; + + tosAcceptedEtag: string | undefined; + + tosAcceptedTimestamp: DbPreciseTimestamp | undefined; + + /** + * Last time when the exchange /keys info was updated + * successfully. + */ + lastUpdate: DbPreciseTimestamp | undefined; + + /** + * Next scheduled update for the exchange. + */ + nextUpdateStamp: DbPreciseTimestamp; + + lastKeysEtag: string | undefined; + + /** + * Next time that we should check if coins need to be refreshed. + * + * Updated whenever the exchange's denominations are updated or when + * the refresh check has been done. + */ + nextRefreshCheckStamp: DbPreciseTimestamp; + + /** + * Public key of the reserve that we're currently using for + * receiving P2P payments. + */ + currentMergeReserveRowId?: number; + + /** + * Current account private key. The corresponding public + * key is used as the merchant public key in deposits. + * + * When unset or reset, we use a heuristic to find an + * account priv/pub that likely already has KYC auth. + */ + currentAccountPriv?: string; + + /** + * @see currentAccountPriv + */ + currentAccountPub?: string; + + /** + * Defaults to false. + */ + peerPaymentsDisabled?: boolean; + + /** + * Are direct deposits using this exchange disabled? + * Defaults to false. + */ + directDepositDisabled?: boolean; + + /** + * Defaults to false. + */ + noFees?: boolean; +} + +/** + * Exchange details for a particular + * (exchangeBaseUrl, masterPublicKey, currency) tuple. + */ +export interface WalletExchangeDetails { + rowId?: number; + + /** + * Master public key of the exchange. + */ + masterPublicKey: string; + + exchangeBaseUrl: string; + + /** + * Currency that the exchange offers. + */ + currency: string; + + /** + * Auditors (partially) auditing the exchange. + */ + auditors: WalletExchangeAuditor[]; + + /** + * Last observed protocol version. + */ + protocolVersionRange: string; + + tinyAmount: AmountString; + + reserveClosingDelay: TalerProtocolDuration; + + shoppingUrl?: string; + + /** + * Fees for exchange services + */ + globalFees: ExchangeGlobalFees[]; + + wireInfo: WireInfo; + + /** + * Age restrictions supported by the exchange (bitmask). + */ + ageMask?: number; + + walletBalanceLimits?: AmountString[]; + + hardLimits?: AccountLimit[]; + + zeroLimits?: ZeroLimitedOperation[]; + + /** + * Instructs wallets to use certain bank-specific + * language (for buttons) and/or other UI/UX customization + * for compliance with the rules of that bank. + */ + bankComplianceLanguage: string | undefined; + + defaultPeerPushExpiration: TalerProtocolDuration | undefined; +} + +/** + * Auditor metadata persisted by the wallet. + * + * Older wallet versions stored the exchange's unverified /keys payload here. + * Only entries carrying this marker have had every remaining denomination + * signature checked by wallet-core. Keeping the marker inside the existing + * JSON column makes old databases fail closed without a schema migration. + */ +export interface WalletExchangeAuditor extends ExchangeAuditor { + walletAuditorSignaturesVerified?: true; +} + +export interface WalletDenomLossEvent { + denomLossEventId: string; + currency: string; + denomPubHashes: string[]; + status: DenomLossStatus; + timestampCreated: DbPreciseTimestamp; + amount: string; + eventType: DenomLossEventType; + exchangeBaseUrl: string; +} + +/** + * Denomination record as stored in the wallet's database. + */ + +export interface WalletExchangeSignkeys { + stampStart: DbProtocolTimestamp; + stampExpire: DbProtocolTimestamp; + stampEnd: DbProtocolTimestamp; + signkeyPub: EddsaPublicKeyString; + masterSig: EddsaSignatureString; + + /** + * Exchange details that thiis signkeys record belongs to. + */ + exchangeDetailsRowId: number; +} + +export interface WalletDenomFamilyParams { + exchangeBaseUrl: string; + exchangeMasterPub: string; + value: AmountString; + feeWithdraw: AmountString; + feeDeposit: AmountString; + feeRefresh: AmountString; + feeRefund: AmountString; +} + +export interface WalletDenominationFamily { + denominationFamilySerial?: number; + familyParams: WalletDenomFamilyParams; + + // Reserved legacy fields: + // * familyParamsHash +} + +export interface WalletExchangeBaseUrlFixup { + exchangeBaseUrl: string; + replacement: string; +} + +export interface WalletExchangeMigrationLog { + oldExchangeBaseUrl: string; + newExchangeBaseUrl: string; + timestamp: DbPreciseTimestamp; + /** + * Reason that triggered the exchange base URL migration. + */ + reason: ExchangeMigrationReason; +} + +export interface WalletGlobalCurrencyAuditor { + id?: number; + currency: string; + auditorBaseUrl: string; + auditorPub: string; +} + +export interface WalletGlobalCurrencyExchange { + id?: number; + currency: string; + exchangeBaseUrl: string; + exchangeMasterPub: string; +} + +/** + * User accounts + */ +export interface WalletBankAccount { + /** + * Opaque identifier for the bank account. + */ + bankAccountId: string; + + /** + * Payto URI of the bank account. + */ + paytoUri: string; + + /** + * User-defined label for the account. + */ + label: string | undefined; + + currencies: string[] | undefined; + + /** + * FIXME: Provide more info here. + */ + kycCompleted: boolean; +} + +export interface WalletWithdrawCoinSource { + type: CoinSourceType.Withdraw; + + /** + * Can be the empty string for orphaned coins. + */ + withdrawalGroupId: string; + + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + + /** + * Reserve public key for the reserve we got this coin from. + */ + reservePub: string; +} + +export interface WalletRefreshCoinSource { + type: CoinSourceType.Refresh; + refreshGroupId: string; + oldCoinPub: string; +} + +export interface WalletRewardCoinSource { + type: CoinSourceType.Reward; + walletRewardId: string; + coinIndex: number; +} + +/** + * WalletCoin as stored in the "coins" data store + * of the wallet database. + */ +export interface WalletCoin { + /** + * Where did the coin come from? Used for recouping coins. + */ + coinSource: WalletCoinSource; + + /** + * Source transaction ID of the coin. + * + * Used to make the coin visible after the transaction + * has entered a final state. + */ + sourceTransactionId?: string; + + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key to authorize operations on the coin. + */ + coinPriv: string; + + /** + * Hash of the public key that signs the coin. + */ + denomPubHash: string; + + /** + * Unblinded signature by the exchange. + */ + denomSig: UnblindedDenominationSignature; + + /** + * Base URL that identifies the exchange from which we got the + * coin. + */ + exchangeBaseUrl: string; + + /** + * Master public key that signed the denomination this coin was issued + * under. + * + * This, not the base URL, is what ties a coin to the keys that can settle + * it: the URL is where the exchange currently answers, and it can change + * without the coin changing. + */ + exchangeMasterPub: string; + + /** + * Blinding key used when withdrawing the coin. + * Potentionally used again during payback. + */ + blindingKey: string; + + exchangeWithdrawValues: ExchangeWithdrawValue; + + /** + * Hash of the coin envelope. + * + * Stored here for indexing purposes, so that when looking at a + * reserve history, we can quickly find the coin for a withdrawal transaction. + */ + coinEvHash: string; + + /** + * Status of the coin. + */ + status: CoinStatus; + + /** + * Non-zero for visible. + * + * A coin is visible when it is fresh and the + * source transaction is in a final state. + */ + visible?: number; + + /** + * Maximum age of purchases that can be made with this coin. + * + * (Used for indexing, redundant with {@link ageCommitmentProof}). + */ + maxAge: number; + + ageCommitmentProof: AgeCommitmentProof | undefined; +} + +/** + * Availability of coins of a given denomination (and age restriction!). + * + * We can't store this information with the denomination record, as one denomination + * can be withdrawn with multiple age restrictions. + */ +export interface WalletCoinAvailability { + currency: string; + value: AmountString; + denomPubHash: string; + exchangeBaseUrl: string; + /** + * Master public key that signed the denomination. + * + * Required: together with the hash it names the denomination these coins + * belong to. Rows written before it was recorded are backfilled from that + * denomination; the empty string means it could no longer be found. + */ + exchangeMasterPub: string; + + /** + * Age restriction on the coin, or 0 for no age restriction (or + * denomination without age restriction support). + */ + maxAge: number; + + /** + * Number of fresh coins of this denomination that are available. + */ + freshCoinCount: number; + + /** + * Numeric boolean derived from freshCoinCount for compound database indexes. + * IndexedDB booleans are not valid keys, hence the 0/1 representation. + */ + hasFreshCoins: 0 | 1; + + /** + * Number of fresh coins that are available + * and visible, i.e. the source transaction is in + * a final state. + */ + visibleCoinCount: number; + + /** + * Number of coins that we expect to obtain via a pending refresh. + */ + pendingRefreshOutputCount?: number; +} + +/** + * History event for a coin from the wallet's perspective. + * + * The history might reference transactions that were already deleted from the wallet. + */ +export interface WalletCoinHistory { + coinPub: string; + /** + * History items for the coin. + * + * We store this as an array in the object store, as the coin history + * is pretty much always very small. + */ + history: WalletCoinHistoryItem[]; +} + +export type WalletCoinSource = + | WalletWithdrawCoinSource + | WalletRefreshCoinSource + | WalletRewardCoinSource; + +/** + * History item for a coin. + * + * DB-specific format, + */ +export type WalletCoinHistoryItem = + | { + type: "withdraw"; + transactionId: TransactionIdStr; + } + | { + type: "spend"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refresh"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "recoup"; + transactionId: TransactionIdStr; + amount: AmountString; + } + | { + type: "refund"; + transactionId: TransactionIdStr; + amount: AmountString; + }; + +/** + * How a coin came into the wallet. + */ +export enum CoinSourceType { + Withdraw = "withdraw", + Refresh = "refresh", + Reward = "reward", +} + +export enum RefundReason { + /** + * Normal refund given by the merchant. + */ + NormalRefund = "normal-refund", + /** + * Refund from an aborted payment. + */ + AbortRefund = "abort-pay-refund", +} + +export enum ExchangeMigrationReason { + MismatchedBaseUrl = "mismatched-base-url", + UnavailableOldUrl = "unavailable-old-url", +} + +/** + * Status of a denomination. + */ +export enum DenominationVerificationStatus { + /** + * Verification was delayed (pending). + */ + Unverified = 0x0100_0000, + + /** + * Verified as valid. + */ + VerifiedGood = 0x0500_0000, + + /** + * Verified as invalid. + */ + VerifiedBad = 0x0501_0000, +} + +/** + * Format of the operation status code: 0x0abc_nnnn + + * a=1: active + * 0x0100_nnnn: pending + * 0x0101_nnnn: dialog + * 0x0102_nnnn: (reserved) + * 0x0103_nnnn: aborting + * 0x0110_nnnn: suspended + * 0x0113_nnnn: suspended-aborting + * a=2: finalizing + * 0x0200_nnnn: finalizing + * 0x0210_nnnn: suspended-finalizing + * a=5: final + * 0x0500_nnnn: done + * 0x0501_nnnn: failed + * 0x0502_nnnn: expired + * 0x0503_nnnn: aborted + * + * nnnn=0000 should always be the most generic minor state for the major state + */ + +/** + * First possible operation status in the active range (inclusive). + */ +export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; + +/** + * LAST possible operation status in the active range (inclusive). + */ +export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; + +export const OPERATION_STATUS_DIALOG_FIRST = 0x0101_0000; +export const OPERATION_STATUS_DIALOG_LAST = 0x0101_ffff; + +export const OPERATION_STATUS_DONE_FIRST = 0x0500_0000; +export const OPERATION_STATUS_DONE_LAST = 0x0500_ffff; + +/** + * Status of a withdrawal. + */ +export enum WithdrawalGroupStatus { + /** + * Reserve must be registered with the bank. + */ + PendingRegisteringBank = 0x0100_0001, + SuspendedRegisteringBank = 0x0110_0001, + + /** + * We've registered reserve's information with the bank + * and are now waiting for the user to confirm the withdraw + * with the bank (typically 2nd factor auth). + */ + PendingWaitConfirmBank = 0x0100_0002, + SuspendedWaitConfirmBank = 0x0110_0002, + + /** + * Querying reserve status with the exchange. + */ + PendingQueryingStatus = 0x0100_0003, + SuspendedQueryingStatus = 0x0110_0003, + + /** + * Ready for withdrawal. + */ + PendingReady = 0x0100_0004, + SuspendedReady = 0x0110_0004, + + /** + * Redenominate the withdrawal + * after the exchange entry is ready again. + */ + PendingRedenominate = 0x0100_0008, + SuspendedRedenominate = 0x0110_0008, + + /** + * Exchange wants KYC info from the user. + */ + PendingKyc = 0x0100_0005, + SuspendedKyc = 0x0110_0005, + + /** + * Exchange wants KYC info from the user. + * KYC link is ready. + */ + PendingBalanceKyc = 0x0100_0006, + SuspendedBalanceKyc = 0x0110_0006, + + /** + * Exchange wants KYC info from the user. + * + * KYC link is not ready yet, the KYC process is still initializing. + */ + PendingBalanceKycInit = 0x0100_0007, + SuspendedBalanceKycInit = 0x0110_0007, + + /** + * Proposed to the user, has can choose to accept/refuse. + */ + DialogProposed = 0x0101_0000, + + /** + * We are telling the bank that we don't want to complete + * the withdrawal! + */ + AbortingBank = 0x0103_0001, + SuspendedAbortingBank = 0x0113_0001, + + /** Closing a funded reserve after KYC reports an unraisable hard limit. */ + FinalizingKycHardLimit = 0x0200_0000, + + /** + * The corresponding withdraw record has been created. + * No further processing is done, unless explicitly requested + * by the user. + */ + Done = 0x0500_0000, + + /** + * The bank aborted the withdrawal. + */ + FailedBankAborted = 0x0501_0001, + + FailedAbortingBank = 0x0501_0002, + + FailedKycHardLimit = 0x0501_0003, + FailedKycHardLimitRecovery = 0x0501_0004, + + /** + * Aborted in a state where we were supposed to + * talk to the exchange. Money might have been + * wired or not. + */ + AbortedExchange = 0x0503_0001, + + AbortedBank = 0x0503_0002, + + /** + * User didn't refused the withdrawal. + */ + AbortedUserRefused = 0x0503_0003, + + /** + * Another wallet confirmed the withdrawal + * (by POSTing the reserve pub to the bank) + * before we had the chance. + * + * In this situation, we'll let the other wallet continue + * and give up ourselves. + */ + AbortedOtherWallet = 0x0503_0004, +} + +export enum ExchangeEntryDbRecordStatus { + Preset = 1, + Ephemeral = 2, + Used = 3, +} + +// FIXME: Use status ranges for this as well? +export enum ExchangeEntryDbUpdateStatus { + Initial = 1, + InitialUpdate = 2, + Suspended = 3, + UnavailableUpdate = 4, + // Reserved 5 for backwards compatibility. + Ready = 6, + ReadyUpdate = 7, + OutdatedUpdate = 8, +} + +export enum PlanchetStatus { + Pending = 0x0100_0000, + KycRequired = 0x0100_0001, + WithdrawalDone = 0x0500_0000, + AbortedReplaced = 0x0503_0001, +} + +export enum RefreshCoinStatus { + Pending = 0x0100_0000, + + /** + * Re-try the melt with a new target denomination. + */ + PendingRedenominate = 0x0100_0001, + + Finished = 0x0500_0000, + + /** + * The refresh for this coin has been frozen, because of a permanent error. + * More info in lastErrorPerCoin. + */ + Failed = 0x0501_0000, +} + +export enum RefreshOperationStatus { + Pending = 0x0100_0000, + /** + * Entire output coin selection was bad, re-select + * and potentially revive finished coins with zero output. + */ + PendingRedenominate = 0x0100_0001, + Suspended = 0x0110_0000, + SuspendedRedenominate = 0x0110_0001, + + Finished = 0x0500_0000, + Failed = 0x0501_0000, +} + +/** + * Status of a single element of a deposit group. + */ +export enum DepositElementStatus { + DepositPending = 0x0100_0000, + /** + * Accepted, but tracking. + */ + Tracking = 0x0100_0001, + KycRequired = 0x0100_0002, + Wired = 0x0500_0000, + /** The exchange has already wired the deposit to the target account. */ + RefundTooLate = 0x0500_0001, + RefundSuccess = 0x0503_0000, + RefundFailed = 0x0501_0000, + RefundNotFound = 0x0501_0001, +} + +export enum PurchaseStatus { + /** + * Not downloaded yet. + */ + PendingDownloadingProposal = 0x0100_0000, + SuspendedDownloadingProposal = 0x0110_0000, + + /** + * The user has accepted the proposal. + */ + PendingPaying = 0x0100_0001, + SuspendedPaying = 0x0110_0001, + + /** + * Currently in the process of aborting with a refund. + */ + AbortingWithRefund = 0x0103_0000, + SuspendedAbortingWithRefund = 0x0113_0000, + + /** + * Paying a second time, likely with different session ID + */ + PendingPayingReplay = 0x0100_0002, + SuspendedPayingReplay = 0x0110_0002, + + /** + * Query for refunds (until query succeeds). + */ + PendingQueryingRefund = 0x0100_0003, + SuspendedQueryingRefund = 0x0110_0003, + + /** + * Query for refund (until auto-refund deadline is reached). + * + * Legacy state for compatibility. + */ + PendingQueryingAutoRefund = 0x0100_0004, + SuspendedQueryingAutoRefund = 0x0110_0004, + + FinalizingQueryingAutoRefund = 0x0200_0001, + SuspendedFinalizingQueryingAutoRefund = 0x0210_0001, + + PendingAcceptRefund = 0x0100_0005, + SuspendedPendingAcceptRefund = 0x0110_0005, + + /** + * Proposal downloaded, but the user needs to accept/reject it. + */ + DialogProposed = 0x0101_0000, + + /** + * Proposal shared to other wallet or read from other wallet + * the user needs to accept/reject it. + */ + DialogShared = 0x0101_0001, + + /** + * Generic failure, check error code. + */ + Failed = 0x0501_0000, + + /** + * Tried to abort, but aborting failed or was cancelled. + */ + FailedAbort = 0x0501_0001, + + FailedPaidByOther = 0x0501_0002, + + /** + * Downloading or processing the proposal has failed permanently. + */ + FailedClaim = 0x0501_0003, + + /** + * Payment was successful. + */ + Done = 0x0500_0000, + + /** + * Downloaded proposal was detected as a re-purchase. + */ + DoneRepurchaseDetected = 0x0500_0001, + + Expired = 0x0502_0000, + + /** + * The user has rejected the proposal. + */ + AbortedProposalRefused = 0x0503_0000, + + AbortedRefunded = 0x0503_0001, + + AbortedOrderDeleted = 0x0503_0002, + + /** + * The payment has been aborted. + */ + AbortedIncompletePayment = 0x0503_0003, +} + +export enum ConfigRecordKey { + WalletBackupState = "walletBackupState", + CurrencyDefaultsApplied = "currencyDefaultsApplied", + // Only for testing, do not use! + TestLoopTx = "testTxLoop", + LastInitInfo = "lastInitInfo", + LastResumed = "lastResumed", + MaterializedTransactionsVersion = "materializedTransactionsVersion", + DonauConfig = "donauConfig", +} + +export interface DonauConfig { + donauBaseUrl: string; + donauTaxId: string; + /** Tax ID hash, salted with donauSalt */ + donauTaxIdHash: string; + /** 32 byte salt, base32crockford encoded */ + donauSalt: string; +} + +export interface WalletBackupConfState { + deviceId: string; + walletRootPub: string; + walletRootPriv: string; + + /** + * Last hash of the canonicalized plain-text backup. + */ + lastBackupPlainHash?: string; + + /** + * Timestamp stored in the last backup. + */ + lastBackupTimestamp?: DbPreciseTimestamp; + + /** + * Last time we tried to do a backup. + */ + lastBackupCheckTimestamp?: DbPreciseTimestamp; + lastBackupNonce?: string; +} + +/** + * Configuration key/value entries to configure + * the wallet. + */ +export type ConfigRecord = + | { + key: ConfigRecordKey.WalletBackupState; + value: WalletBackupConfState; + } + | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean | number } + | { key: ConfigRecordKey.TestLoopTx; value: number } + | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp } + | { key: ConfigRecordKey.LastResumed; value: DbProtocolTimestamp } + | { key: ConfigRecordKey.MaterializedTransactionsVersion; value: number } + | { key: ConfigRecordKey.DonauConfig; value: DonauConfig }; + +export enum RecoupOperationStatus { + Pending = 0x0100_0000, + Suspended = 0x0110_0000, + + Finished = 0x0500_0000, + Failed = 0x0501_0000, +} +export enum DepositOperationStatus { + PendingDeposit = 0x0100_0000, + SuspendedDeposit = 0x0110_0000, + + // Legacy states, we we now show + // the tracking state as a finalizing state. + LegacyPendingTrack = 0x0100_0001, + LegacySuspendedTrack = 0x0110_0001, + + PendingAggregateKyc = 0x0100_0002, + SuspendedAggregateKyc = 0x0110_0002, + + PendingDepositKyc = 0x0100_0003, + SuspendedDepositKyc = 0x0110_0003, + + PendingDepositKycAuth = 0x0100_0005, + SuspendedDepositKycAuth = 0x0110_0005, + + Aborting = 0x0103_0000, + SuspendedAborting = 0x0113_0000, + + FinalizingTrack = 0x0200_0001, + SuspendedFinalizingTrack = 0x0210_0001, + + Finished = 0x0500_0000, + + /** The abort lost the race: every selected coin was already wired. */ + FinishedAbortTooLate = 0x0500_0001, + + FailedDeposit = 0x0501_0000, + + FailedTrack = 0x0501_0001, + + /** Some selected coins were recovered and others were already wired. */ + FailedAbortPartial = 0x0501_0002, + + /** The abort refund succeeded, but the recovery refresh did not. */ + FailedAbortRecovery = 0x0501_0003, + + /** A permanent refund response did not prove recovery or delivery. */ + FailedAbortRefund = 0x0501_0004, + + AbortedDeposit = 0x0503_0000, +} + +export enum PeerPushDebitStatus { + /** + * Initiated, but no purse created yet. + */ + PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */, + PendingReady = 0x0100_0001, + AbortingDeletePurse = 0x0103_0000, + + /** + * The purse is gone because it expired, and the coins that went into it + * have to be reclaimed. Same clean-up as AbortingDeletePurse, but since + * nobody called the payment off it ends up expired instead of aborted. + */ + ExpiredDeletePurse = 0x0103_0003, + + SuspendedCreatePurse = 0x0110_0000, + SuspendedReady = 0x0110_0001, + SuspendedAbortingDeletePurse = 0x0113_0000, + SuspendedExpiredDeletePurse = 0x0113_0003, + + Done = 0x0500_0000, + Aborted = 0x0503_0000, + Failed = 0x0501_0000, + Expired = 0x0502_0000, + + // Legacy / reserved: + // SuspendedAbortingRefreshDeleted = 0x0113_0001, + // SuspendedAbortingRefreshExpired = 0x0113_0002, + // AbortingRefreshDeleted = 0x0103_0001, + // AbortingRefreshExpired = 0x0103_0002, +} + +export enum PeerPullPaymentCreditStatus { + /** + * Typically the initial state of the peer-pull-credit transaction, + * purse will be created. + */ + PendingCreatePurse = 0x0100_0000, + SuspendedCreatePurse = 0x0110_0000, + + /** + * Purse created, waiting for the other party to accept the + * invoice and deposit money into it. + */ + PendingReady = 0x0100_0001, + SuspendedReady = 0x0110_0001, + + PendingMergeKycRequired = 0x0100_0002, + SuspendedMergeKycRequired = 0x0110_0002, + + PendingWithdrawing = 0x0100_0003, + SuspendedWithdrawing = 0x0110_0003, + + PendingBalanceKycRequired = 0x0100_0004, + SuspendedBalanceKycRequired = 0x0110_0004, + + PendingBalanceKycInit = 0x0100_0005, + SuspendedBalanceKycInit = 0x0110_0005, + + AbortingDeletePurse = 0x0103_0000, + SuspendedAbortingDeletePurse = 0x0113_0000, + + /** Deleting the purse after an unraisable merge hard limit. */ + FinalizingKycHardLimit = 0x0200_0000, + + Done = 0x0500_0000, + Failed = 0x0501_0000, + FailedKycHardLimit = 0x0501_0001, + Expired = 0x0502_0000, + Aborted = 0x0503_0000, +} + +export enum PeerPushCreditStatus { + PendingMerge = 0x0100_0000, + SuspendedMerge = 0x0110_0000, + + PendingMergeKycRequired = 0x0100_0001, + SuspendedMergeKycRequired = 0x0110_0001, + + /** + * Merge was successful and withdrawal group has been created, now + * everything is in the hand of the withdrawal group. + */ + PendingWithdrawing = 0x0100_0002, + SuspendedWithdrawing = 0x0110_0002, + + PendingBalanceKycRequired = 0x0100_0003, + SuspendedBalanceKycRequired = 0x0110_0003, + + PendingBalanceKycInit = 0x0100_0004, + SuspendedBalanceKycInit = 0x0110_0004, + + DialogProposed = 0x0101_0000, + + /** Waiting for the rejected purse to expire and refund its payer. */ + FinalizingKycHardLimit = 0x0200_0000, + + Done = 0x0500_0000, + Aborted = 0x0503_0000, + Failed = 0x0501_0000, + FailedKycHardLimit = 0x0501_0001, + Expired = 0x0502_0000, +} + +export enum PeerPullDebitRecordStatus { + PendingDeposit = 0x0100_0001, + AbortingRefresh = 0x0103_0001, + + SuspendedDeposit = 0x0110_0001, + SuspendedAbortingRefresh = 0x0113_0001, + + DialogProposed = 0x0101_0001, + + Done = 0x0500_0000, + Expired = 0x0502_0000, + Aborted = 0x0503_0000, + Failed = 0x0501_0000, +} + +export enum ReserveRecordStatus { + // Need to call the "/kyc-wallet" endpoint + PendingLegiInit = 0x0100_0001, + SuspendedLegiInit = 0x0110_0001, + // Need to wait for user to pass legitimization + PendingLegi = 0x0100_0002, + SuspendedLegi = 0x0110_0002, + + /** + * Done with KYC. + */ + Done = 0x0500_0000, +} + +export enum RefundGroupStatus { + Pending = 0x0100_0000, + Done = 0x0500_0000, + Failed = 0x0501_0000, + Aborted = 0x0503_0000, + Expired = 0x0502_0000, +} + +export enum RefundItemStatus { + /** + * Intermittent error that the merchant is + * reporting from the exchange. + * + * We'll try again! + */ + Pending = 0x0100_0000, + /** + * Refund was obtained successfully. + */ + Done = 0x0500_0000, + /** + * Permanent error reported by the exchange + * for the refund. + */ + Failed = 0x0501_0000, +} + +export enum DenomLossStatus { + /** + * Done indicates that the loss happened. + */ + Done = 0x0500_0000, + + /** + * Aborted in the sense that the loss was reversed. + */ + Aborted = 0x0503_0001, +} + +export enum DonationReceiptStatus { + /** + * Done indicates that the receipt + * has been successfully submitted. + */ + DoneSubmitted = 0x0500_0000, + + /** + * Pending indicates that the + * receipt still needs to be submitted. + */ + Pending = 0x0100_0000, +} + +export interface DbPeerPushPaymentCoinSelection { + contributions: AmountString[]; + coinPubs: string[]; +} + +export interface PeerPullPaymentCoinSelection { + contributions: AmountString[]; + coinPubs: string[]; + totalCost: AmountString | undefined; + + /** Number of leading entries confirmed by a signed exchange response. */ + depositedCoinCount?: number; + + /** Latest purse balance covered by a verified deposit confirmation. */ + confirmedPurseBalance?: AmountString; +} + +/** + * Record for a push P2P payment that this wallet initiated. + */ +export interface WalletPeerPushDebit { + /** + * What exchange are funds coming from? + */ + exchangeBaseUrl: string; + + /** + * Restricted scope for this transaction. + * + * Relevant for coin reselection. + */ + restrictScope?: ScopeInfo; + + /** + * Instructed amount. + */ + amount: AmountString; + + /** + * Effective amount. + * + * (Called totalCost for historical reasons.) + */ + totalCost: AmountString; + + coinSel?: DbPeerPushPaymentCoinSelection; + + contractTermsHash: string; + + /** + * Purse public key. Used as the primary key to look + * up this record. + */ + pursePub: string; + + /** + * Purse private key. + */ + pursePriv: string; + + /** + * Public key of the merge capability of the purse. + */ + mergePub: string; + + /** + * Private key of the merge capability of the purse. + */ + mergePriv: string; + + contractPriv: string; + contractPub: string; + + /** + * 24 byte nonce. + */ + contractEncNonce: string; + + purseExpiration: DbProtocolTimestamp; + + timestampCreated: DbPreciseTimestamp; + + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + /** + * Status of the peer push payment initiation. + */ + status: PeerPushDebitStatus; +} + +/** + * Record for a pull P2P payment that this wallet initiated. + */ +export interface WalletPeerPullCredit { + /** + * What exchange are we using for the payment request? + */ + exchangeBaseUrl: string; + + /** + * Amount requested. + * FIXME: What type of instructed amount is i? + */ + amount: AmountString; + + estimatedAmountEffective: AmountString; + + /** + * Purse public key. Used as the primary key to look + * up this record. + */ + pursePub: string; + + /** + * Purse private key. + */ + pursePriv: string; + + /** + * Hash of the contract terms. Also + * used to look up the contract terms in the DB. + */ + contractTermsHash: string; + + mergePub: string; + mergePriv: string; + + contractPub: string; + contractPriv: string; + + contractEncNonce: string; + + mergeTimestamp: DbPreciseTimestamp; + + mergeReserveRowId: number; + + /** + * Status of the peer pull payment initiation. + */ + status: PeerPullPaymentCreditStatus; + + kycPaytoHash?: string; + + kycAccessToken?: string; + + kycLastCheckStatus?: number; + kycLastCheckCode?: number; + kycLastRuleGen?: number; + kycLastAmlReview?: boolean; + kycLastDeny?: DbPreciseTimestamp; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + withdrawalGroupId: string | undefined; +} + +/** + * Record for a push P2P payment that this wallet was offered. + */ +export interface WalletPeerPushCredit { + peerPushCreditId: string; + + exchangeBaseUrl: string; + + pursePub: string; + + mergePriv: string; + + contractPriv: string; + + timestamp: DbPreciseTimestamp; + + estimatedAmountEffective: AmountString; + + /** + * Hash of the contract terms. Also + * used to look up the contract terms in the DB. + */ + contractTermsHash: string; + + /** + * Status of the peer push payment incoming initiation. + */ + status: PeerPushCreditStatus; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + /** + * Associated withdrawal group. + */ + withdrawalGroupId: string | undefined; + + /** + * Currency of the peer push payment credit transaction. + * + * Mandatory in current schema version, optional for compatibility + * with older (ver_minor<4) DB versions. + */ + currency: string | undefined; + + kycPaytoHash?: string; + + kycAccessToken?: string; + + kycLastCheckStatus?: number; + kycLastCheckCode?: number; + kycLastRuleGen?: number; + kycLastAmlReview?: boolean; + kycLastDeny?: DbPreciseTimestamp; +} + +/** + * AKA PeerPullDebit. Record for a pull P2P payment that this wallet was offered. + */ +export interface WalletPeerPullDebit { + peerPullDebitId: string; + + pursePub: string; + + exchangeBaseUrl: string; + + amount: AmountString; + + contractTermsHash: string; + + timestampCreated: DbPreciseTimestamp; + + /** + * Contract priv that we got from the other party. + */ + contractPriv: string; + + /** + * Status of the peer push payment incoming initiation. + */ + status: PeerPullDebitRecordStatus; + + /** + * Estimated total cost when the record was created. + */ + totalCostEstimated: AmountString; + + abortRefreshGroupId?: string; + + abortReason?: TalerErrorDetail; + failReason?: TalerErrorDetail; + + coinSel?: PeerPullPaymentCoinSelection; +} + +/** + * Object to be hashed for use as a grouping key for token listings, such that + * any change in token family details results in a separate list item. + */ +export interface TokenFamilyInfo { + /** + * Identifier for the token family consisting of + * unreserved characters according to RFC 3986. + */ + slug: string; + + /** + * Human-readable name for the token family. + */ + name: string; + + /** + * Human-readable description for the token family. + */ + description: string; + + /** + * Optional map from IETF BCP 47 language tags to localized descriptions. + */ + descriptionI18n: any | undefined; + + /** + * Additional meta data, such as the trusted_domains + * or expected_domains. Depends on the kind. + */ + extraData: MerchantContractTokenDetails; + + /** + * Token issue public key used by merchant to verify tokens. + */ + tokenIssuePub: TokenIssuePublicKey; +} + +/** + * A token as stored in the "tokens" object store. + * + * This is the full stored shape: it carries the blinding material + * (tokenEv, tokenEvHash, blindingKey) alongside the issue signature, so a + * read-modify-write round trip through it is lossless. + */ +export interface WalletToken extends TokenFamilyInfo { + /** + * Source purchase of the token. + */ + purchaseId: string; + + /** + * Transaction where token is being used. + */ + transactionId?: string; + + /** + * Index of token in choices array. + */ + choiceIndex?: number; + + /** + * Index of token in outputs array. + */ + outputIndex?: number; + + /** + * For token outputs with a count>1, this stores + * the index of this token within the same output + * index. + * + * If missing, assumed to be 0. + */ + repeatIndex?: number; + + /** + * URL of the merchant issuing the token. + */ + merchantBaseUrl: string; + + /** + * Kind of the token. + */ + kind: MerchantContractTokenKind; + + /** + * Hash of token issue public key. + */ + tokenIssuePubHash: string; + + /** + * Hash of {@link TokenFamilyInfo} object. + */ + tokenFamilyHash?: string; + + /** + * Start time of the token family's validity period. + */ + validAfter: DbProtocolTimestamp; + + /** + * End time of the token family's validity period. + */ + validBefore: DbProtocolTimestamp; + + /** + * Unblinded token issue signature made by the merchant. + */ + tokenIssueSig: UnblindedDenominationSignature; + + /** + * Token use public key used to confirm usage of tokens. + */ + tokenUsePub: string; + + /** + * Token use private key used to verify usage of tokens. + */ + tokenUsePriv: string; + + /** + * Signature on token use request. + */ + tokenUseSig?: TokenUseSig; + + /** + * Envelope of the token. + */ + tokenEv: TokenEnvelope; + + /** + * Hash of the envelope. + */ + tokenEvHash: string; + + /** + * Blinding secret for token. + */ + blindingKey: string; +} + +/** + * A slate as stored in the "slates" object store. + * + * A slate is a token that has not been issued yet, so it has every token + * field except tokenIssueSig. It is spelled out rather than derived from + * WalletToken so that each store has its own record type. + */ +export interface WalletSlate extends TokenFamilyInfo { + /** + * Source purchase of the token. + */ + purchaseId: string; + + /** + * Transaction where token is being used. + */ + transactionId?: string; + + /** + * Index of token in choices array. + */ + choiceIndex?: number; + + /** + * Index of token in outputs array. + */ + outputIndex?: number; + + /** + * For token outputs with a count>1, this stores + * the index of this token within the same output + * index. + * + * If missing, assumed to be 0. + */ + repeatIndex?: number; + + /** + * URL of the merchant issuing the token. + */ + merchantBaseUrl: string; + + /** + * Kind of the token. + */ + kind: MerchantContractTokenKind; + + /** + * Hash of token issue public key. + */ + tokenIssuePubHash: string; + + /** + * Hash of {@link TokenFamilyInfo} object. + */ + tokenFamilyHash?: string; + + /** + * Start time of the token family's validity period. + */ + validAfter: DbProtocolTimestamp; + + /** + * End time of the token family's validity period. + */ + validBefore: DbProtocolTimestamp; + + /** + * Token use public key used to confirm usage of tokens. + */ + tokenUsePub: string; + + /** + * Token use private key used to verify usage of tokens. + */ + tokenUsePriv: string; + + /** + * Signature on token use request. + */ + tokenUseSig?: TokenUseSig; + + /** + * Envelope of the token. + */ + tokenEv: TokenEnvelope; + + /** + * Hash of the envelope. + */ + tokenEvHash: string; + + /** + * Blinding secret for token. + */ + blindingKey: string; +} + +export namespace WalletToken { + export function hashInfo(r: WalletToken | WalletSlate): string { + const info: TokenFamilyInfo = { + slug: r.slug, + name: r.name, + description: r.description, + descriptionI18n: r.descriptionI18n, + extraData: r.extraData, + tokenIssuePub: r.tokenIssuePub, + }; + return encodeCrock(hash(stringToBytes(canonicalJson(info) + "\0"))); + } +} +/** + * Denomination record as stored in the wallet's database. + */ +export interface WalletDenomination { + /** + * Currency of the denomination. + * + * Stored separately as we have an index on it. + */ + currency: string; + + value: AmountString; + + /** + * The denomination public key. + */ + denomPub: DenominationPubKey; + + /** + * Hash of the denomination public key. + * Stored in the database for faster lookups. + */ + denomPubHash: string; + + fees: DenomFees; + + /** + * Family the denomination belongs to. + * + * Absent for denominations stored before the family was known. + */ + denominationFamilySerial?: number; + + /** + * Validity start date of the denomination. + */ + stampStart: DbProtocolTimestamp; + + /** + * Date after which the currency can't be withdrawn anymore. + */ + stampExpireWithdraw: DbProtocolTimestamp; + + /** + * Date after the denomination officially doesn't exist anymore. + */ + stampExpireLegal: DbProtocolTimestamp; + + /** + * Data after which coins of this denomination can't be deposited anymore. + */ + stampExpireDeposit: DbProtocolTimestamp; + + /** + * Signature by the exchange's master key over the denomination + * information. + */ + masterSig: string; + + /** + * Did we verify the signature on the denomination? + */ + verificationStatus: DenominationVerificationStatus; + + /** + * Was this denomination still offered by the exchange the last time + * we checked? + * Only false when the exchange redacts a previously published denomination. + */ + isOffered: boolean; + + /** + * Did the exchange revoke the denomination? + * When this field is set to true in the database, the same transaction + * should also mark all affected coins as revoked. + */ + isRevoked: boolean; + + /** + * If set to true, the exchange announced that the private key for this + * denomination is lost. Thus it can't be used to sign new coins + * during withdrawal/refresh/..., but the coins can still be spent. + */ + isLost?: boolean; + + /** + * Base URL of the exchange. + */ + exchangeBaseUrl: string; + + /** + * Master public key of the exchange that made the signature + * on the denomination. + */ + exchangeMasterPub: string; +} + +export interface DenomFees { + /** + * Fee for withdrawing. + */ + feeWithdraw: AmountString; + + /** + * Fee for depositing. + */ + feeDeposit: AmountString; + + /** + * Fee for refreshing. + */ + feeRefresh: AmountString; + + /** + * Fee for refunding. + */ + feeRefund: AmountString; +} + +export namespace WalletDenomination { + export function toDenomInfo(d: WalletDenomination): DenominationInfo { + return { + denomPub: d.denomPub, + exchangeMasterPub: d.exchangeMasterPub, + denomPubHash: d.denomPubHash, + feeDeposit: Amounts.stringify(d.fees.feeDeposit), + feeRefresh: Amounts.stringify(d.fees.feeRefresh), + feeRefund: Amounts.stringify(d.fees.feeRefund), + feeWithdraw: Amounts.stringify(d.fees.feeWithdraw), + stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit), + stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal), + stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw), + stampStart: timestampProtocolFromDb(d.stampStart), + value: Amounts.stringify(d.value), + exchangeBaseUrl: d.exchangeBaseUrl, + isLost: d.isLost ?? false, + masterSig: d.masterSig, + isOffered: d.isOffered, + }; + } +} diff --git a/packages/taler-wallet-core/src/db/shared-cache-invalidation.test.ts b/packages/taler-wallet-core/src/db/shared-cache-invalidation.test.ts @@ -0,0 +1,234 @@ +/* + 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/> + */ + +/** + * The wallet caches exchange entries, denomination info and refresh costs in + * memory. Those caches are dropped after any transaction that changed a + * record they are derived from, which the DAL detects by watching for calls + * to the methods named in CACHE_INVALIDATING_METHODS. + * + * That list is maintained by hand, and a stale entry fails silently: the + * wallet keeps serving a cached denomination that no longer exists in the + * database, with no error anywhere. This test derives the list that *should* + * be there from the IndexedDB implementation and compares. + */ + +import assert from "node:assert"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; + +import { runnerFactories } from "./testing/runners.js"; +import { + CACHE_INVALIDATING_METHODS, + watchForCacheInvalidation, +} from "./shared.js"; + +/** + * The IndexedDB object stores the three caches are derived from, and the + * sqlite tables holding the same entities. A write to any of these can + * invalidate a cached value; a write to anything else cannot. + */ +const CACHE_BACKING_STORES = [ + "exchanges", + "exchangeDetails", + // The live store; "denominations" is the pre-re-key one, written only by + // the fixup that copies out of it. + "denominationsV2", + "globalCurrencyAuditors", + "globalCurrencyExchanges", +]; +const CACHE_BACKING_TABLES = [ + "exchanges", + "exchange_details", + "denominations", + "global_currency_auditors", + "global_currency_exchanges", +]; + +/** + * Locate a file under src/, from wherever this test happens to be running. + * It normally runs from the compiled lib/, so "next to me" is not the answer. + */ +function readSourceFile(name: string): string { + let dir = dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 5; i++) { + const candidate = join(dir, "src", name); + if (existsSync(candidate)) { + return readFileSync(candidate, "utf-8"); + } + dir = dirname(dir); + } + throw Error(`could not locate src/${name}`); +} + +/** + * Split a transaction implementation into its methods, keyed by name. + * + * Whole methods rather than single lines, because an SQL statement is often + * built across several concatenated string literals, with the table name on a + * different line from the verb. + */ +function methodBodies(src: string): Map<string, string> { + // Class members sit at exactly two spaces of indentation. + const methodRe = /^ {2}(?:async )?([A-Za-z_$][\w$]*)\s*[(<]/; + const bodies = new Map<string, string>(); + let current: string | undefined; + let buf: string[] = []; + const flush = () => { + if (current !== undefined) { + bodies.set(current, (bodies.get(current) ?? "") + buf.join("\n")); + } + }; + for (const line of src.split("\n")) { + const m = methodRe.exec(line); + if (m) { + flush(); + current = m[1]; + buf = []; + } + buf.push(line); + } + flush(); + return bodies; +} + +/** + * Find the methods of one implementation that write a cache-backing entity. + */ +function findMutators(src: string, writeRe: RegExp): Set<string> { + const found = new Set<string>(); + for (const [name, body] of methodBodies(src)) { + // Collapse the string concatenation SQL is assembled from, so a statement + // split across lines reads as one. + const flat = body.replace(/"\s*\+\s*"/g, "").replace(/\s+/g, " "); + if (writeRe.test(flat)) { + found.add(name); + } + } + return found; +} + +test("every mutator of a cache-backing store invalidates the caches", () => { + const stores = CACHE_BACKING_STORES.join("|"); + const tables = CACHE_BACKING_TABLES.join("|"); + + const impls: Array<{ file: string; writeRe: RegExp }> = [ + { + // Writes look like `tx.denominations.put(rec)`, or `this.tx.exchanges + // .delete(baseUrl)` where the store handle was taken off `this`. + file: "db/indexeddb/transaction.ts", + writeRe: new RegExp( + `\\b(?:this\\.)?tx\\.(?:${stores})\\.(?:put|add|delete|clear)\\(`, + ), + }, + { + // Both implementations are scanned, not just IndexedDB: a method that + // writes one of these tables only on the sqlite side would otherwise + // never be noticed, and would silently stop invalidating caches on the + // backend it applies to. + file: "db/sqlite/transaction.ts", + writeRe: new RegExp( + `(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|DELETE\\s+FROM|UPDATE)\\s+"?(?:${tables})"?\\b`, + ), + }, + ]; + + const mutators = new Set<string>(); + for (const impl of impls) { + const found = findMutators(readSourceFile(impl.file), impl.writeRe); + // A scan that finds nothing would make this test vacuously pass, which is + // exactly the failure mode a source-scanning test has to rule out. + assert.ok( + found.size >= 8, + `only found ${found.size} cache-backing mutators in ${impl.file}` + + ` — the scan is probably broken, not the code`, + ); + for (const name of found) { + mutators.add(name); + } + } + + assert.deepStrictEqual( + [...mutators].sort(), + [...CACHE_INVALIDATING_METHODS].sort(), + "methods that write a cache-backing entity must be listed in" + + " CACHE_INVALIDATING_METHODS (left: found in the implementations," + + " right: declared in db/shared.ts)", + ); +}); + +/** + * The static test above only checks that the list is complete. This one + * checks that the wrapper acting on it actually works, against both real + * transaction implementations rather than a stub — the two differ in how + * their methods are defined (prototype methods vs. own properties), which is + * exactly what a Proxy `get` trap is sensitive to. + */ +for (const makeRunner of runnerFactories) { + test(`cache invalidation fires on writes only`, async (t) => { + const runner = await makeRunner(); + await t.test(runner.name, async () => { + // A read must not invalidate. The old store-based trigger got this + // wrong: it fired whenever a readwrite transaction so much as *looked* + // at the denominations store, dropping every cache on a pure read. + const readFlag = { dirty: false }; + await runner.runReadWriteTx(async (tx) => { + await watchForCacheInvalidation( + tx, + readFlag, + ).listGlobalCurrencyExchanges(); + }); + assert.strictEqual( + readFlag.dirty, + false, + "a read-only transaction must not invalidate the caches", + ); + + // A write to a cache-backing store must. + const writeFlag = { dirty: false }; + await runner.runReadWriteTx(async (tx) => { + await watchForCacheInvalidation( + tx, + writeFlag, + ).upsertGlobalCurrencyExchange({ + currency: "TESTKUDOS", + exchangeBaseUrl: "https://exchange.test/", + exchangeMasterPub: encodeCrock(stringToBytes("master-pub-0000")), + }); + }); + assert.strictEqual( + writeFlag.dirty, + true, + "a transaction that wrote a cache-backing store must invalidate", + ); + + // The wrapper must not otherwise change behaviour: results still come + // back, and methods still see the right `this`. + const rows = await runner.runReadWriteTx(async (tx) => { + return await watchForCacheInvalidation(tx, { + dirty: false, + }).listGlobalCurrencyExchanges(); + }); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0].currency, "TESTKUDOS"); + }); + await runner.close(); + }); +} diff --git a/packages/taler-wallet-core/src/db/shared.ts b/packages/taler-wallet-core/src/db/shared.ts @@ -0,0 +1,217 @@ +/* + 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/> + */ + +/** + * Operations built on top of {@link WalletDbTransaction} rather than on any + * one backend. + * + * Scope resolution is business logic that happens to read from the database: + * it is identical for every backend, so it lives here once instead of being + * reimplemented (and drifting) in each. + */ + +import { assertUnreachable, ScopeInfo, ScopeType } from "@gnu-taler/taler-util"; +import { WalletDbTransaction } from "./transaction.js"; +import { PurchaseStatus, WalletPurchase } from "./records.js"; +import { auditorProvidesVerifiedTrust } from "../auditorTrust.js"; + +/** + * Does the exchange fall within the given scope? + */ +export async function checkExchangeInScopeGeneric( + tx: WalletDbTransaction, + exchangeBaseUrl: string, + scope: ScopeInfo, + denomPubHash?: string, +): Promise<boolean> { + switch (scope.type) { + case ScopeType.Exchange: + return scope.url === exchangeBaseUrl; + case ScopeType.Global: { + const details = await tx.getExchangeDetails(exchangeBaseUrl); + if (!details) { + return false; + } + const gr = await tx.getGlobalCurrencyExchange( + details.currency, + exchangeBaseUrl, + details.masterPublicKey, + ); + return gr != null; + } + case ScopeType.Auditor: { + const details = await tx.getExchangeDetails(exchangeBaseUrl); + if (!details || details.currency !== scope.currency) { + return false; + } + for (const auditor of details.auditors) { + if ( + !auditorProvidesVerifiedTrust(auditor, { + auditorBaseUrl: scope.url, + denomPubHash, + }) + ) { + continue; + } + if ( + await tx.getGlobalCurrencyAuditor( + details.currency, + auditor.auditor_url, + auditor.auditor_pub, + ) + ) { + return true; + } + } + return false; + } + case ScopeType.ExchangeLegacyKeys: + // Asked of an exchange entry, which always stands for the key set it + // currently uses. That is by definition not a superseded one, so the + // answer is no even when the URLs agree. + return false; + default: + assertUnreachable(scope); + } +} + +/** + * Compute the scope (global, auditor or exchange) an exchange belongs to. + */ +export async function getExchangeScopeInfoGeneric( + tx: WalletDbTransaction, + exchangeBaseUrl: string, + currency: string, + denomPubHash?: string, +): Promise<ScopeInfo> { + const det = await tx.getExchangeDetails(exchangeBaseUrl); + if (!det) { + return { + type: ScopeType.Exchange, + currency, + url: exchangeBaseUrl, + }; + } + const globalExchangeRec = await tx.getGlobalCurrencyExchange( + det.currency, + det.exchangeBaseUrl, + det.masterPublicKey, + ); + if (globalExchangeRec) { + return { + currency: det.currency, + type: ScopeType.Global, + }; + } + for (const aud of denomPubHash == null ? [] : det.auditors) { + if ( + !auditorProvidesVerifiedTrust(aud, { + denomPubHash, + }) + ) { + continue; + } + const globalAuditorRec = await tx.getGlobalCurrencyAuditor( + det.currency, + aud.auditor_url, + aud.auditor_pub, + ); + if (globalAuditorRec) { + return { + currency: det.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + return { + type: ScopeType.Exchange, + currency: det.currency, + url: det.exchangeBaseUrl, + }; +} + +/** + * DAL methods after which the wallet's in-memory caches are stale. + * + * The caches hold exchange summaries, denomination info and refresh costs, + * all derived from these entities. Listing the methods here rather than + * having each backend decide keeps the two implementations from drifting: a + * cache that is dropped on one backend and not the other is a bug that only + * shows up as stale data much later. + * + * Mutations only. Reading a denomination cannot invalidate anything derived + * from denominations. + */ +export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ + "upsertExchange", + "deleteExchange", + "upsertExchangeDetails", + "deleteExchangeDetails", + "upsertDenomination", + "deleteDenomination", + // Cascades to denominations, so it invalidates the same caches. + "deleteDenominationFamily", + "upsertGlobalCurrencyExchange", + "deleteGlobalCurrencyExchange", + "upsertGlobalCurrencyAuditor", + "deleteGlobalCurrencyAuditor", +]); + +/** + * Wrap a transaction so that calls to cache-invalidating methods are noticed. + * + * `flag.dirty` is set as a side effect; the caller drops the caches after the + * transaction commits, never before, so a rolled-back transaction does not + * invalidate anything. + */ +export function watchForCacheInvalidation<T extends WalletDbTransaction>( + tx: T, + flag: { dirty: boolean; terminalPaymentIds?: Set<string> }, +): T { + return new Proxy(tx, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop === "upsertPurchase" && typeof value === "function") { + return (...args: unknown[]) => { + const purchase = args[0] as WalletPurchase; + if (purchase.purchaseStatus >= PurchaseStatus.Done) { + flag.terminalPaymentIds?.add(purchase.proposalId); + } + return value.apply(target, args); + }; + } + if (prop === "deletePurchase" && typeof value === "function") { + return (...args: unknown[]) => { + flag.terminalPaymentIds?.add(args[0] as string); + return value.apply(target, args); + }; + } + if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) { + if (typeof value === "function") { + return (...args: unknown[]) => { + flag.dirty = true; + return value.apply(target, args); + }; + } + } + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }); +} diff --git a/packages/taler-wallet-core/src/db/sqlite/database.ts b/packages/taler-wallet-core/src/db/sqlite/database.ts @@ -0,0 +1,593 @@ +/* + 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/> + */ + +import { + ResultRow, + Sqlite3Database, + Sqlite3Statement, + Sqlite3Value, +} from "@gnu-taler/idb-bridge"; +import { + decodeCrock, + encodeCrock, + Logger, + WalletNotification, +} from "@gnu-taler/taler-util"; +import { WalletDbTransaction } from "../transaction.js"; +import { + DATA_TABLES_CONDITION, + SchemaMigration, + schemaMigrations, + SQLITE_BASELINE_SCHEMA, + SQLITE_SCHEMA_VERSION, +} from "./schema.js"; +import { SqliteAccessStats, SqliteWalletTransaction } from "./transaction.js"; + +const logger = new Logger("db/sqlite/database.ts"); + +/** + * Transaction control for the helper protocol. + * + * These must go through prepared statements. `exec` commits implicitly, which + * has two consequences worth stating plainly: + * + * 1. A BEGIN issued with `exec` reports success and then does nothing. The + * following COMMIT fails with "no transaction is active" and the writes + * have already been committed individually — indistinguishable from a + * working transaction until something needs to roll back, which is exactly + * when a wallet can least afford it. + * 2. An `exec` *inside* an explicit transaction ends that transaction. So + * `exec` must not be used for anything that has to be atomic with + * surrounding work; use prepared statements throughout instead. + */ +export class SqliteTxControl { + private constructor( + private beginStmt: Sqlite3Statement, + private commitStmt: Sqlite3Statement, + private rollbackStmt: Sqlite3Statement, + ) {} + + static async create(db: Sqlite3Database): Promise<SqliteTxControl> { + return new SqliteTxControl( + await db.prepare("BEGIN"), + await db.prepare("COMMIT"), + await db.prepare("ROLLBACK"), + ); + } + + async begin(): Promise<void> { + await this.beginStmt.run({}); + } + async commit(): Promise<void> { + await this.commitStmt.run({}); + } + async rollback(): Promise<void> { + await this.rollbackStmt.run({}); + } +} + +/** + * How long to wait for a lock held by another connection before giving up. + * + * Without this a concurrent writer surfaces as an immediate SQLITE_BUSY. + * Within one process TxQueue serialises transactions so it cannot happen, but + * nothing stops a second process -- a CLI command run against a wallet the + * shepherd has open, say -- from touching the same file. Five seconds is long + * enough to outlast any transaction this code issues and short enough that a + * genuine deadlock still surfaces as an error rather than a hang. + */ +const SQLITE_BUSY_TIMEOUT_MS = 5000; + +/** Current time in the microseconds the schema's INTEGER timestamps use. */ +function nowMicros(): number { + return Date.now() * 1000; +} + +/** + * Check the migration list before running any of it. + * + * A duplicate or out-of-order version does not fail on its own: migrations are + * skipped by looking up the version in schema_migrations, so a reused version + * silently never runs, and the database ends up missing a change while + * claiming to have applied it. Better to refuse to open. + */ +function validateSchemaMigrations(migrations: SchemaMigration[]): void { + let prev = 1; // the baseline occupies version 1 + for (const mig of migrations) { + if (mig.version <= prev) { + throw Error( + `schema migration ${mig.version} (${mig.name}) is not greater than` + + ` the preceding version ${prev}: versions must strictly increase` + + ` and may not be reused`, + ); + } + prev = mig.version; + } +} + +function validateAppliedSchemaMigrations( + applied: ResultRow[], + extraMigrations: SchemaMigration[], +): void { + const expected = new Map<number, string>([[1, "baseline"]]); + for (const migration of [...schemaMigrations, ...extraMigrations]) { + const previous = expected.get(migration.version); + if (previous !== undefined && previous !== migration.name) { + throw Error( + `schema migration ${migration.version} has conflicting names` + + ` (${previous} and ${migration.name})`, + ); + } + expected.set(migration.version, migration.name); + } + for (const row of applied) { + const version = Number(row.version); + const name = String(row.name); + const expectedName = expected.get(version); + if (expectedName === undefined) { + throw Error(`database records unknown schema migration ${version}`); + } + if (name !== expectedName) { + throw Error( + `database schema migration ${version} is named ${name},` + + ` expected ${expectedName}`, + ); + } + } +} + +/** + * Open the database and bring its schema up to date. + * + * Each migration runs in its own transaction with its schema_migrations row + * written inside that transaction, so a crash part-way through cannot leave a + * half-applied migration recorded as done. + * + * The migration list is a parameter so that tests can exercise the path with + * a synthetic migration. Until a real one exists this is the only thing that + * runs it at all. + */ +export async function initSqliteWalletDb( + db: Sqlite3Database, + migrations: SchemaMigration[] = schemaMigrations, +): Promise<void> { + validateSchemaMigrations(migrations); + await db.exec("PRAGMA foreign_keys = ON"); + await db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`); + const versionRows = await (await db.prepare("PRAGMA user_version")).getAll(); + const databaseVersion = Number(versionRows[0]?.user_version ?? 0); + if (databaseVersion > SQLITE_SCHEMA_VERSION) { + throw Error( + `database schema version ${databaseVersion} is newer than this wallet` + + ` (version ${SQLITE_SCHEMA_VERSION})`, + ); + } + const migrationTable = await ( + await db.prepare( + "SELECT 1 AS present FROM sqlite_master" + + " WHERE type = 'table' AND name = 'schema_migrations'", + ) + ).getAll(); + if (migrationTable.length !== 0) { + const recorded = await ( + await db.prepare("SELECT version, name FROM schema_migrations") + ).getAll(); + validateAppliedSchemaMigrations(recorded, migrations); + } + // WAL: readers do not block the writer, and a commit appends to the log + // instead of fsyncing the whole database. + // + // The cost is that a WAL database is three files (db, -wal, -shm), so + // copying just the database file is not a snapshot. That matters because + // callers do treat the wallet DB as one file they can copy. Rather than + // give up WAL, {@link runNativeSqliteWalletTx} checkpoints with TRUNCATE + // whenever the transaction queue drains: under load the WAL accumulates + // normally, and the moment the wallet goes idle the main file is complete + // again and the -wal is empty. Idle is also the only moment at which an + // external copy could be coherent at all, so this makes single-file copies + // valid exactly when they can be. + // + // It also means a killed process leaves an empty -wal behind, so restoring + // a database file over it cannot replay stale frames from the old one. + await db.exec("PRAGMA journal_mode = WAL"); + // NORMAL is safe against process crashes in WAL mode; only a power loss + // can cost the most recent commits, and never corruption. + await db.exec("PRAGMA synchronous = NORMAL"); + + const txc = await SqliteTxControl.create(db); + + // The baseline is exec'd outside a transaction: every statement is + // CREATE ... IF NOT EXISTS, so re-running it is a no-op, and exec would end + // an enclosing transaction anyway. + await db.exec(SQLITE_BASELINE_SCHEMA); + const baselineStmt = await db.prepare( + "INSERT OR IGNORE INTO schema_migrations (version, name, applied_at)" + + " VALUES ($version, $name, $applied_at)", + ); + await baselineStmt.run({ + version: 1, + name: "baseline", + applied_at: nowMicros(), + }); + + const applied = await ( + await db.prepare("SELECT version, name FROM schema_migrations") + ).getAll(); + validateAppliedSchemaMigrations(applied, migrations); + const have = new Set(applied.map((r) => Number(r.version))); + + for (const mig of migrations) { + if (have.has(mig.version)) { + continue; + } + logger.info(`applying schema migration ${mig.version} (${mig.name})`); + await txc.begin(); + try { + for (const sql of mig.statements) { + await (await db.prepare(sql)).run({}); + } + const stmt = await db.prepare( + "INSERT INTO schema_migrations (version, name, applied_at)" + + " VALUES ($version, $name, $applied_at)", + ); + await stmt.run({ + version: mig.version, + name: mig.name, + applied_at: nowMicros(), + }); + await txc.commit(); + } catch (e) { + await txc.rollback(); + throw e; + } + } + await db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`); +} + +/** + * A native sqlite wallet database, ready to run transactions against. + * + * Holds the connection together with its prepared transaction-control + * statements, because those must be prepared once per connection and must not + * go through `exec` (see {@link SqliteTxControl}). + */ +export interface NativeSqliteWalletDb { + db: Sqlite3Database; + txc: SqliteTxControl; + /** Cumulative across transactions on this connection. */ + stats: SqliteAccessStats; + /** Shared across transactions; see SqliteWalletTransaction.stmtCache. */ + stmtCache: Map<string, Sqlite3Statement>; + /** + * Serialises transactions on this connection. + * + * One sqlite connection can only have one transaction open at a time, so + * two overlapping callers produce "cannot start a transaction within a + * transaction". IndexedDB does not have this problem because its + * scheduler queues transactions; this queue is the equivalent, and keeps + * the DAL contract ("runWalletDbTx runs f in its own transaction") true + * for concurrent callers. + */ + lock: TxQueue; +} + +/** + * A minimal FIFO async mutex. + * + * Deliberately not reentrant: a transaction opened while another is already + * held on the same connection is a bug in the caller, and blocking makes it + * visible instead of silently merging two transactions into one — where a + * rollback of the inner would discard the outer's writes. + */ +export class TxQueue { + private tail: Promise<void> = Promise.resolve(); + + /** + * Transactions queued but not finished, including the running one. + * + * Used to detect the moment the queue drains, which is when the database + * can be checkpointed without stalling anybody. + */ + private outstanding = 0; + + /** + * True when the caller is the only transaction in the queue. + * + * Checked from inside a running transaction, which is still counted in + * `outstanding` at that point -- the decrement happens in this class's + * finally block, after the transaction body returns. So "nobody else is + * waiting" is 1, not 0. + */ + get noOtherWaiting(): boolean { + return this.outstanding <= 1; + } + + async run<T>(f: () => Promise<T>): Promise<T> { + this.outstanding++; + const prev = this.tail; + let release: () => void; + this.tail = new Promise<void>((resolve) => { + release = resolve; + }); + await prev; + try { + return await f(); + } finally { + this.outstanding--; + release!(); + } + } +} + +/** + * Open a native sqlite wallet database and bring its schema up to date. + */ +export async function openNativeSqliteWalletDb( + db: Sqlite3Database, +): Promise<NativeSqliteWalletDb> { + await initSqliteWalletDb(db); + const txc = await SqliteTxControl.create(db); + return { + db, + txc, + lock: new TxQueue(), + stmtCache: new Map(), + stats: { rowsRead: 0 }, + }; +} + +/** + * Run f in one native sqlite transaction. + * + * Notifications and commit hooks are released only after COMMIT succeeds: a + * transaction that rolls back must not have told anyone it happened. + */ +export async function runNativeSqliteWalletTx<T>( + ndb: NativeSqliteWalletDb, + notifyFn: (n: WalletNotification) => void, + f: (tx: SqliteWalletTransaction) => Promise<T>, +): Promise<T> { + return await ndb.lock.run(() => + runNativeSqliteWalletTxLocked(ndb, notifyFn, f), + ); +} + +async function runNativeSqliteWalletTxLocked<T>( + ndb: NativeSqliteWalletDb, + notifyFn: (n: WalletNotification) => void, + f: (tx: SqliteWalletTransaction) => Promise<T>, +): Promise<T> { + const tx = new SqliteWalletTransaction(ndb.db, ndb.stmtCache, ndb.stats); + await ndb.txc.begin(); + let res: T; + try { + res = await f(tx); + } catch (e) { + // Best-effort rollback: if ROLLBACK itself fails, the original error is + // the interesting one and must not be masked by it. + try { + await ndb.txc.rollback(); + } catch (rollbackErr) { + logger.warn(`rollback failed: ${rollbackErr}`); + } + throw e; + } + await ndb.txc.commit(); + await checkpointIfIdle(ndb); + // Same order as the IndexedDB backend: the handlers run before a client + // can observe the notification. + for (const h of tx.afterCommitHandlers) { + h(); + } + for (const notif of tx.pendingNotifications) { + notifyFn(notif); + } + return res; +} + +/** + * Fold the write-ahead log back into the database file, if nothing else is + * queued. + * + * This is what keeps the database file self-contained between operations, so + * that copying it is a valid snapshot (see the WAL note in + * {@link initSqliteWalletDb}). Skipped whenever another transaction is + * waiting: under load the log is allowed to grow, which is the point of WAL. + * + * TRUNCATE rather than PASSIVE so the -wal ends up zero-length rather than + * merely folded in; a leftover non-empty -wal next to a restored database + * file is the failure mode this exists to prevent. + * + * Best-effort: a failed checkpoint costs a stale snapshot, not correctness, + * and must not fail the transaction that already committed. + */ +async function checkpointIfIdle(ndb: NativeSqliteWalletDb): Promise<void> { + if (!ndb.lock.noOtherWaiting) { + return; + } + try { + await ndb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch (e) { + logger.warn(`WAL checkpoint failed: ${e}`); + } +} + +/** + * Delete every row from the native wallet database. + * + * The schema itself is kept, including schema_migrations: this clears data, + * it does not reset the database to "never initialised". + */ +export async function clearNativeSqliteWalletDb( + ndb: NativeSqliteWalletDb, +): Promise<void> { + await ndb.lock.run(async () => { + await ndb.txc.begin(); + try { + await clearNativeSqliteWalletDbInTransaction(ndb); + await ndb.txc.commit(); + } catch (e) { + try { + await ndb.txc.rollback(); + } catch (rollbackErr) { + logger.warn(`rollback failed: ${rollbackErr}`); + } + throw e; + } + }); +} + +/** Delete native wallet rows inside a transaction already owned by caller. */ +export async function clearNativeSqliteWalletDbInTransaction( + ndb: NativeSqliteWalletDb, +): Promise<void> { + const rows = await ( + await ndb.db.prepare( + `SELECT name FROM sqlite_master WHERE ${DATA_TABLES_CONDITION}`, + ) + ).getAll(); + for (const row of rows) { + // Table names come from sqlite_master, not from user input. + await (await ndb.db.prepare(`DELETE FROM "${row.name}"`)).run({}); + } +} + +/** + * Names of the tables holding wallet data, in a stable order. + * + * See DATA_TABLES_CONDITION for what is left out and why. + */ +async function listDataTables(ndb: NativeSqliteWalletDb): Promise<string[]> { + const rows = await ( + await ndb.db.prepare( + `SELECT name FROM sqlite_master WHERE ${DATA_TABLES_CONDITION}` + + ` ORDER BY name`, + ) + ).getAll(); + return rows.map((r) => r.name as string); +} + +/** + * A dump of every row in the native wallet database. + * + * Deliberately a plain row dump rather than a file copy so that it can be + * represented as JSON, like an IndexedDB export. + */ +export interface NativeSqliteDbDump { + schemaVersion: number; + tables: Record<string, Record<string, DumpValue>[]>; +} + +/** + * A BLOB column in a dump. + * + * Tagged rather than raw bytes because the dump must be JSON-compatible; + * typed arrays otherwise lose their type when serialized. + */ +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( + ndb: NativeSqliteWalletDb, +): Promise<NativeSqliteDbDump> { + return await ndb.lock.run(async () => { + const tables = await listDataTables(ndb); + const out: NativeSqliteDbDump = { + schemaVersion: SQLITE_SCHEMA_VERSION, + tables: {}, + }; + for (const table of tables) { + const rows = await ( + await ndb.db.prepare(`SELECT * FROM "${table}"`) + ).getAll(); + out.tables[table] = rows.map((row) => { + const clean: Record<string, DumpValue> = {}; + for (const [k, v] of Object.entries(row)) { + 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; + }); + } + return out; + }); +} + +export async function importNativeSqliteDb( + ndb: NativeSqliteWalletDb, + dump: NativeSqliteDbDump, + finalize: (tx: WalletDbTransaction) => Promise<void>, + notifyFn: (n: WalletNotification) => void, +): Promise<void> { + if (dump.schemaVersion !== SQLITE_SCHEMA_VERSION) { + throw Error( + `cannot import a native wallet DB dump of schema version` + + ` ${dump.schemaVersion} into version ${SQLITE_SCHEMA_VERSION}`, + ); + } + await ndb.lock.run(async () => { + const tables = await listDataTables(ndb); + await runNativeSqliteWalletTxLocked(ndb, notifyFn, async (tx) => { + // Clear and refill in one transaction: a partial import would leave + // the wallet with a mix of two databases. + for (const table of tables) { + await (await ndb.db.prepare(`DELETE FROM "${table}"`)).run({}); + } + for (const table of tables) { + const rows = dump.tables[table]; + if (!rows || rows.length === 0) { + continue; + } + for (const row of rows) { + const cols = Object.keys(row); + const params: Record<string, Sqlite3Value> = {}; + for (const c of cols) { + 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(", "); + await ( + await ndb.db.prepare( + `INSERT INTO "${table}" (${colList}) VALUES (${valList})`, + ) + ).run(params); + } + } + // Derived wallet state is part of the restore. Running this before the + // same COMMIT means an error cannot expose imported records with stale + // or absent materialized transactions. + await finalize(tx); + }); + }); +} diff --git a/packages/taler-wallet-core/src/db/sqlite/handle.ts b/packages/taler-wallet-core/src/db/sqlite/handle.ts @@ -0,0 +1,130 @@ +/* + 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/> + */ + +import { Logger, WalletNotification } from "@gnu-taler/taler-util"; +import { + WalletDbAccessStats, + WalletDbHandle, + WalletDbImportFinalizer, +} from "../handle.js"; +import { WalletDbTransaction } from "../transaction.js"; +import { + clearNativeSqliteWalletDb, + exportNativeSqliteDb, + importNativeSqliteDb, + NativeSqliteWalletDb, + runNativeSqliteWalletTx, +} from "./database.js"; + +const logger = new Logger("db/sqlite/handle.ts"); + +function notifySafely( + sink: (notification: WalletNotification) => void, + notification: WalletNotification, +): void { + try { + sink(notification); + } catch (e) { + logger.warn( + `ignoring exception from wallet notification sink: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } +} + +/** WalletDbHandle over the native sqlite database. */ +export class SqliteWalletDbHandle implements WalletDbHandle { + readonly name = "sqlite"; + + private notify: (n: WalletNotification) => void = () => {}; + + setNotificationSink(sink: (n: WalletNotification) => void): void { + this.notify = sink; + } + + emitNotification(notification: WalletNotification): void { + notifySafely(this.notify, notification); + } + + constructor(private ndb: NativeSqliteWalletDb) {} + + async exportToFile( + directory: string, + stem: string, + forceFormat?: string, + ): Promise<{ path: string }> { + if (forceFormat != null && forceFormat !== "sqlite3") { + throw Error( + `the native backend can only export sqlite3, not ${forceFormat}`, + ); + } + const path = `${directory}/${stem}.sqlite3`; + await this.ndb.lock.run(async () => { + await ( + await this.ndb.db.prepare("VACUUM INTO $filename") + ).run({ + filename: path, + }); + }); + return { path }; + } + + getDiagnosticStats(): unknown { + return { rowsRead: this.ndb.stats.rowsRead }; + } + + async runReadWriteTx<T>( + f: (tx: WalletDbTransaction) => Promise<T>, + ): Promise<T> { + return await runNativeSqliteWalletTx( + this.ndb, + (n) => this.emitNotification(n), + async (tx) => await f(tx), + ); + } + + async exportDatabase(): Promise<any> { + return await exportNativeSqliteDb(this.ndb); + } + + async importDatabase( + dump: any, + finalize: WalletDbImportFinalizer, + ): Promise<void> { + if (dump != null && typeof dump === "object" && "databases" in dump) { + throw Error( + "this dump is from the IndexedDB backend and cannot be imported" + + " into the native sqlite backend; convert the database instead", + ); + } + await importNativeSqliteDb(this.ndb, dump, finalize, (n) => + this.emitNotification(n), + ); + } + + async clearDatabase(): Promise<void> { + await clearNativeSqliteWalletDb(this.ndb); + } + + getAccessStats(): WalletDbAccessStats | undefined { + return { recordsRead: this.ndb.stats.rowsRead }; + } + + async close(): Promise<void> { + await this.ndb.db.close(); + } +} diff --git a/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts b/packages/taler-wallet-core/src/db/sqlite/schema-migrations.test.ts @@ -0,0 +1,521 @@ +/* + 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 for the sqlite schema migration mechanism. + * + * These tests supplement the wallet's real migrations with synthetic ones in + * order to exercise DDL backfills, rollback and validation behavior. + */ + +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import assert from "node:assert"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; + +import { + SchemaMigration, + SQLITE_SCHEMA_VERSION, + schemaMigrations, +} from "./schema.js"; +import { initSqliteWalletDb } from "./database.js"; + +/** + * A migration must survive the database being closed and reopened, so these + * run against a file rather than :memory:. + */ +function withTempDb(): { path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "wallet-migration-test-")); + return { + path: join(dir, "wallet.sqlite3"), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +async function openRaw(path: string) { + const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + return await impl.open(path); +} + +async function queryAll(db: any, sql: string): Promise<any[]> { + return await (await db.prepare(sql)).getAll(); +} + +const addColumn: SchemaMigration = { + version: 2, + name: "add-tombstone-note", + statements: [ + "ALTER TABLE tombstones ADD COLUMN note TEXT", + // A migration is DDL *and* the backfill that makes the new column true of + // rows written before it existed. + "UPDATE tombstones SET note = 'backfilled' WHERE note IS NULL", + ], +}; + +test("migration applies DDL and backfills existing rows", async () => { + const { path, cleanup } = withTempDb(); + try { + // A database at baseline, with a row written before the new column exists. + let db = await openRaw(path); + await initSqliteWalletDb(db); + await ( + await db.prepare("INSERT INTO tombstones (id) VALUES ($id)") + ).run({ id: "pre-existing" }); + await db.close(); + + // Reopened by a build that has the migration. + db = await openRaw(path); + await initSqliteWalletDb(db, [addColumn]); + + const rows = await queryAll(db, "SELECT id, note FROM tombstones"); + assert.strictEqual(rows.length, 1); + assert.strictEqual( + rows[0].note, + "backfilled", + "the row written before the migration must be backfilled", + ); + + const applied = await queryAll( + db, + "SELECT version, name, applied_at FROM schema_migrations ORDER BY version", + ); + assert.deepStrictEqual( + applied.map((r) => Number(r.version)), + [1, 2, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), + "the baseline, synthetic migration and wallet migration must be recorded", + ); + assert.strictEqual(applied[1].name, "add-tombstone-note"); + // Microseconds, per the schema's convention for INTEGER timestamps. A + // millisecond value would be ~1000x too small and still look plausible. + const appliedAt = Number(applied[1].applied_at); + const nowMicros = Date.now() * 1000; + assert.ok( + appliedAt > nowMicros - 60_000_000 && appliedAt <= nowMicros + 1_000_000, + `applied_at ${appliedAt} is not a plausible microsecond timestamp`, + ); + + await db.close(); + } finally { + cleanup(); + } +}); + +test("exchange source migration upgrades an existing native database", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 8), + ); + let columns = await queryAll(db, "PRAGMA table_info(exchanges)"); + assert.ok(!columns.some((x) => x.name === "source")); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + columns = await queryAll(db, "PRAGMA table_info(exchanges)"); + assert.ok(columns.some((x) => x.name === "source")); + const applied = await queryAll( + db, + "SELECT name FROM schema_migrations WHERE version = 8", + ); + assert.deepStrictEqual( + applied.map((x) => x.name), + ["exchange-entry-source"], + ); + await db.close(); + } finally { + cleanup(); + } +}); + +test("wallet query migration backfills availability and creates indexes", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 9), + ); + await ( + await db.prepare( + `INSERT INTO coin_availability ( + exchange_base_url, denom_pub_hash, max_age, currency, value, + exchange_master_pub, fresh_coin_count, visible_coin_count + ) VALUES ($url, $dph, $age, 'TESTKUDOS', 'TESTKUDOS:1', $mpk, $fresh, 0)`, + ) + ).run({ + url: "https://migration.example/", + dph: new Uint8Array([1]), + age: 0, + mpk: new Uint8Array([2]), + fresh: 3, + }); + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + const rows = await queryAll( + db, + "SELECT has_fresh_coins FROM coin_availability", + ); + assert.strictEqual(Number(rows[0].has_fresh_coins), 1); + const indexes = await queryAll(db, "PRAGMA index_list(coin_availability)"); + assert.ok( + indexes.some((x) => x.name === "coin_availability_by_exchange_fresh_age"), + ); + const txIndexes = await queryAll( + db, + "PRAGMA index_list(transactions_meta)", + ); + assert.ok( + txIndexes.some((x) => x.name === "transactions_meta_by_timestamp_id"), + ); + await db.close(); + } finally { + cleanup(); + } +}); + +test("peer capability migration deterministically removes legacy duplicates", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb( + db, + schemaMigrations.filter((x) => x.version < 10), + ); + + const exchange = "https://migration.example/"; + const sharedPushContract = new Uint8Array([1]); + const insertPush = await db.prepare( + `INSERT INTO peer_push_credit ( + peer_push_credit_id, exchange_base_url, purse_pub, merge_priv, + contract_priv, timestamp, estimated_amount_effective, + contract_terms_hash, status + ) VALUES ($id, $exchange, $purse, $merge, $contract, $timestamp, + 'TESTKUDOS:1', $hash, 0)`, + ); + await insertPush.run({ + id: "push-older", + exchange, + purse: new Uint8Array([2]), + merge: new Uint8Array([3]), + contract: sharedPushContract, + timestamp: 100, + hash: new Uint8Array([4]), + }); + await insertPush.run({ + id: "push-newer", + exchange, + purse: new Uint8Array([5]), + merge: new Uint8Array([6]), + contract: sharedPushContract, + timestamp: 200, + hash: new Uint8Array([7]), + }); + + const sharedPullContract = new Uint8Array([8]); + const insertPull = await db.prepare( + `INSERT INTO peer_pull_debit ( + peer_pull_debit_id, purse_pub, exchange_base_url, amount, + contract_terms_hash, timestamp_created, contract_priv, status, + total_cost_estimated + ) VALUES ($id, $purse, $exchange, 'TESTKUDOS:1', $hash, $timestamp, + $contract, 0, 'TESTKUDOS:1')`, + ); + // Equal timestamps deliberately exercise the stable primary-key tie-break. + await insertPull.run({ + id: "pull-z", + purse: new Uint8Array([9]), + exchange, + hash: new Uint8Array([10]), + timestamp: 300, + contract: sharedPullContract, + }); + await insertPull.run({ + id: "pull-a", + purse: new Uint8Array([11]), + exchange, + hash: new Uint8Array([12]), + timestamp: 300, + contract: sharedPullContract, + }); + + const insertMeta = await db.prepare( + `INSERT INTO transactions_meta + (transaction_id, timestamp, status, currency, exchanges) + VALUES ($id, $timestamp, 0, 'TESTKUDOS', '[]')`, + ); + const insertLocalId = await db.prepare( + `INSERT INTO transaction_local_ids + (transaction_id, transaction_type, local_ident) + VALUES ($id, $type, $localId)`, + ); + const insertRetry = await db.prepare( + `INSERT INTO operation_retries (id, retry_info) + VALUES ($id, '{}')`, + ); + for (const [type, id, timestamp, localId] of [ + ["peer-push-credit", "push-older", 100, 1], + ["peer-push-credit", "push-newer", 200, 2], + ["peer-pull-debit", "pull-z", 300, 3], + ["peer-pull-debit", "pull-a", 300, 4], + ] as const) { + await insertMeta.run({ + id: `txn:${type}:${id}`, + timestamp, + }); + await insertLocalId.run({ + id: `txn:${type}:${id}`, + type, + localId, + }); + await insertRetry.run({ id: `${type}:${id}` }); + } + await db.close(); + + db = await openRaw(path); + await initSqliteWalletDb(db); + + assert.deepStrictEqual( + await queryAll( + db, + "SELECT peer_push_credit_id FROM peer_push_credit ORDER BY peer_push_credit_id", + ), + [{ peer_push_credit_id: "push-older" }], + ); + assert.deepStrictEqual( + await queryAll( + db, + "SELECT peer_pull_debit_id FROM peer_pull_debit ORDER BY peer_pull_debit_id", + ), + [{ peer_pull_debit_id: "pull-a" }], + ); + const applied = await queryAll( + db, + "SELECT name FROM schema_migrations WHERE version = 10", + ); + assert.deepStrictEqual(applied, [ + { name: "unique-peer-payment-capabilities" }, + ]); + assert.deepStrictEqual( + await queryAll( + db, + "SELECT transaction_id FROM transactions_meta ORDER BY transaction_id", + ), + [ + { transaction_id: "txn:peer-pull-debit:pull-a" }, + { transaction_id: "txn:peer-push-credit:push-older" }, + ], + "metadata for discarded duplicate transactions must be removed", + ); + assert.deepStrictEqual( + await queryAll( + db, + "SELECT transaction_id FROM transaction_local_ids ORDER BY transaction_id", + ), + [ + { transaction_id: "txn:peer-pull-debit:pull-a" }, + { transaction_id: "txn:peer-push-credit:push-older" }, + ], + "local identifiers for discarded duplicates must be removed", + ); + assert.deepStrictEqual( + await queryAll(db, "SELECT id FROM operation_retries ORDER BY id"), + [{ id: "peer-pull-debit:pull-a" }, { id: "peer-push-credit:push-older" }], + "retry records for discarded duplicates must be removed", + ); + await db.close(); + } finally { + cleanup(); + } +}); + +test("a migration already recorded is not applied twice", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb(db, [addColumn]); + const first = await queryAll( + db, + "SELECT applied_at FROM schema_migrations WHERE version = 2", + ); + await ( + await db.prepare("INSERT INTO tombstones (id, note) VALUES ($id, $n)") + ).run({ id: "later", n: "written-by-hand" }); + await db.close(); + + // Opening again must not re-run the migration: the second statement of + // this one would overwrite the note of any row where it is NULL, but more + // importantly re-running arbitrary DDL fails outright (the column already + // exists), so a backend that ignored the log could not open at all. + db = await openRaw(path); + await initSqliteWalletDb(db, [addColumn]); + + const second = await queryAll( + db, + "SELECT applied_at FROM schema_migrations WHERE version = 2", + ); + assert.strictEqual( + Number(second[0].applied_at), + Number(first[0].applied_at), + "applied_at must not change: the migration should not have re-run", + ); + const rows = await queryAll( + db, + "SELECT note FROM tombstones WHERE id = 'later'", + ); + assert.strictEqual(rows[0].note, "written-by-hand"); + await db.close(); + } finally { + cleanup(); + } +}); + +test("a failing migration rolls back and is not recorded", async () => { + const { path, cleanup } = withTempDb(); + try { + const broken: SchemaMigration = { + version: 2, + name: "half-broken", + statements: [ + "ALTER TABLE tombstones ADD COLUMN note TEXT", + "UPDATE tombstones SET note = 'x'", + "THIS IS NOT SQL", + ], + }; + + let db = await openRaw(path); + await initSqliteWalletDb(db); + await ( + await db.prepare("INSERT INTO tombstones (id) VALUES ($id)") + ).run({ id: "row" }); + await db.close(); + + db = await openRaw(path); + await assert.rejects( + async () => await initSqliteWalletDb(db, [broken]), + "opening must fail rather than continue with a half-applied migration", + ); + + // The DDL must have rolled back too, not just the bookkeeping. Checking + // only schema_migrations would pass even if the transaction were + // committed on failure, because the row is inserted after the statements + // and so is never written either way -- sqlite makes ALTER TABLE + // transactional, and this is the assertion that depends on it. + const cols = await queryAll(db, "PRAGMA table_info(tombstones)"); + assert.ok( + !cols.some((c) => c.name === "note"), + "the column added by the failed migration must not survive", + ); + + // Nothing recorded, so the next attempt starts from a known state rather + // than skipping the migration as done. + const applied = await queryAll( + db, + "SELECT version FROM schema_migrations ORDER BY version", + ); + assert.deepStrictEqual( + applied.map((r) => Number(r.version)), + [1, ...schemaMigrations.map((m) => m.version)].sort((a, b) => a - b), + "a failed migration must not be recorded as applied", + ); + await db.close(); + } finally { + cleanup(); + } +}); + +test("migration versions must strictly increase", async () => { + const { path, cleanup } = withTempDb(); + try { + const db = await openRaw(path); + for (const bad of [ + [ + { version: 3, name: "c", statements: [] }, + { version: 2, name: "b", statements: [] }, + ], + [ + { version: 2, name: "a", statements: [] }, + { version: 2, name: "b", statements: [] }, + ], + // 1 is the baseline; reusing it would make the migration a silent no-op. + [{ version: 1, name: "clashes-with-baseline", statements: [] }], + ] as SchemaMigration[][]) { + await assert.rejects( + async () => await initSqliteWalletDb(db, bad), + `must reject ${JSON.stringify(bad.map((m) => m.version))}`, + ); + } + await db.close(); + } finally { + cleanup(); + } +}); + +test("a newer sqlite schema is rejected without being relabeled", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb(db); + const futureVersion = SQLITE_SCHEMA_VERSION + 1; + await db.exec(`PRAGMA user_version = ${futureVersion}`); + await db.close(); + + db = await openRaw(path); + await assert.rejects( + initSqliteWalletDb(db), + /newer.*schema|schema.*newer|version/i, + ); + const version = await queryAll(db, "PRAGMA user_version"); + assert.strictEqual(Number(version[0].user_version), futureVersion); + await db.close(); + } finally { + cleanup(); + } +}); + +test("a mismatched schema migration name is rejected", async () => { + const { path, cleanup } = withTempDb(); + try { + let db = await openRaw(path); + await initSqliteWalletDb(db); + await ( + await db.prepare( + "UPDATE schema_migrations SET name = 'impostor' WHERE version = 9", + ) + ).run({}); + await db.close(); + + db = await openRaw(path); + await assert.rejects( + initSqliteWalletDb(db), + /expected wallet-query-indexes/, + ); + const rows = await queryAll( + db, + "SELECT name FROM schema_migrations WHERE version = 9", + ); + assert.strictEqual(rows[0].name, "impostor"); + await db.close(); + } finally { + cleanup(); + } +}); diff --git a/packages/taler-wallet-core/src/db/sqlite/schema.ts b/packages/taler-wallet-core/src/db/sqlite/schema.ts @@ -0,0 +1,1406 @@ +/* + 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/> + */ + +/** + * Relational schema for the native sqlite3 wallet database. + * + * This is a clean-slate schema: it is not a translation of the IndexedDB + * object stores at the storage level, and it carries none of the historical + * fixups. Converting an existing IndexedDB wallet is a separate concern, and + * that converter is responsible for producing data as if every fixup had + * already run. + * + * Naming: snake_case tables and columns. The mapping to camelCase record + * fields is written out explicitly in transaction.ts, never derived by string + * munging, so a rename on either side is a compile error rather than a + * column that silently stops being read. + * + * The declared type of a column is not just documentation here: sqlite's type + * affinity means a value of the "wrong" type is usually stored happily and + * only misbehaves at comparison time. The conventions below say what each + * declared type actually means, and every column in this file is one of them. + * + * - INTEGER timestamps are microseconds since the epoch. + * Number.MAX_SAFE_INTEGER is the sentinel for "never"; NULL means "not + * known / not set", which is a different thing and is used deliberately. + * Column names end in _time, _stamp, or say so where declared. + * + * - TEXT amounts are the canonical Taler amount string, "CURRENCY:X.Y", not + * a number. They are compared for equality and grouped, never summed in + * SQL: amount arithmetic is done in TypeScript, where the currency is + * checked. Column names end in _amount, or name the amount they hold. + * + * - BLOB is key material: public and private keys, hashes, signatures, + * blinding factors, nonces. The record types expose these as Crockford + * base32 strings and transaction.ts converts at the field mapping. + * BLOB_COLUMNS below is the single source of truth for which columns these + * are. A new column holding key material must be BLOB and listed there; a + * TEXT one silently fails to match a correctly-encoded parameter, because + * sqlite never compares a TEXT value equal to a BLOB one. + * + * - TEXT that is neither of the above is a plain string: a base URL, a + * currency name, a label, an order or transaction id supplied by a + * merchant or exchange. + * + * - INTEGER status enums come from the numeric enums in records.ts, whose + * values are laid out so that "still active" is a contiguous range and the + * query is a BETWEEN. Six enums are *string* enums upstream and their + * columns are TEXT accordingly: CoinStatus, ExchangeMigrationReason, + * RefreshReason, DenomLossEventType, MerchantContractTokenKind and + * WithdrawalRecordType. Each such column says so where it is declared. + * + * - INTEGER 0/1 is a boolean; sqlite has no boolean type. Every column the + * mappers convert with boolToDb carries a CHECK constraint restricting it + * to 0, 1 or NULL, so a stray value cannot be stored and later read back as + * a surprising truthy number. Not every INTEGER column is a boolean: + * status enums and counts are integers too, and have their own rules below. + * + * - "JSON" in a column comment means TEXT holding a JSON document, written + * and read whole by JSON.stringify/JSON.parse and never inspected by SQL. + * Anything that has to be filtered, sorted or joined on is a real column + * instead, even where it conceptually belongs to such a payload -- and in + * a few places it is deliberately both, with the column authoritative for + * queries and the payload authoritative for the record. Those say so. + * + * - INTEGER PRIMARY KEY columns named *_serial are sqlite rowids, handed out + * by the database and stable for the life of a row. They are the targets + * of every foreign key here. + */ + +/** + * Schema version of a freshly created database. + * + * Bump this when adding a migration to {@link schemaMigrations}. + */ +export const SQLITE_SCHEMA_VERSION = 10; + +/** + * Tables of the IndexedDB emulation, children before parents. + * + * A migrated wallet keeps them in the same file, renamed out of the way, so + * the native schema has to know their names: the emulation creates them with + * IF NOT EXISTS, and a file it opened after they were renamed would look like + * a brand-new, empty wallet rather than like a mistake. + * + * The order is the one in which they can be dropped with foreign keys + * enforced: index_data and unique_index_data reference indexes, indexes + * references object_stores, object_stores references databases. + */ +export const IDB_EMULATION_TABLES = [ + "index_data", + "unique_index_data", + "object_data", + "indexes", + "object_stores", + "databases", +]; + +/** Prefix the migration renames the emulation's tables to. */ +export const IDB_BACKUP_PREFIX = "idb_backup_"; + +/** + * Tables that live in the file but are not wallet data. + * + * Both describe the file rather than the wallet: schema_migrations says which + * schema changes ran, idb_migration says where the data came from. Restoring + * either from a backup would state something untrue about the file it was + * restored into. + */ +export const NON_DATA_TABLES = ["schema_migrations", "idb_migration"]; + +/** + * SQL condition selecting the tables that hold wallet data. + * + * Written once and used by export, import and clear alike: each of them + * enumerates tables from sqlite_master, and one of them forgetting the + * emulation's retained backup would silently destroy or export it. + */ +export const DATA_TABLES_CONDITION = `type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE '${IDB_BACKUP_PREFIX}%' + AND name NOT IN (${[...NON_DATA_TABLES, ...IDB_EMULATION_TABLES] + .map((n) => `'${n}'`) + .join(", ")})`; + +/** + * A single, ordered schema evolution step. + * + * Replaces both mechanisms the IndexedDB backend needs (versionAdded for + * structure, walletDbFixups for data): in a relational schema adding a column + * is DDL and backfilling it is a statement in the same migration. + */ +export interface SchemaMigration { + /** Strictly increasing. Gaps are allowed; reuse is not. */ + version: number; + /** Stable name, for the audit trail in schema_migrations. */ + name: string; + /** + * DDL and/or data statements, one per entry. + * + * A list rather than one string because the helper's `exec` commits each + * call implicitly: statements that must be atomic have to be issued as + * prepared statements inside an explicit transaction, and those take one + * statement at a time. + */ + statements: string[]; +} + +/** + * The baseline schema, version 1. + * + * Complete: every store the wallet uses has a table here, and every method of + * WalletDbTransaction is implemented against it. + */ + +/** + * 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. + * + * Every column holding key material is listed, not just the ones in the + * high-row-count tables: a value of the same kind stored as TEXT in one table + * and BLOB in another compares unequal across the two, so a partial + * conversion is a worse state to be in than none at all. + */ +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", + "exchange_master_pub", + ], + 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, + name TEXT NOT NULL, + applied_at INTEGER NOT NULL +); + +-- State of the one-way migration from the IndexedDB emulation, which happens +-- inside this same file. +-- +-- The table exists in every native database; a row exists only where this +-- database was produced by migrating an emulation database in place. That +-- row is what decides which backend a file is opened with: the emulation's +-- tables are still present under their idb_backup_ names, and the emulation +-- would recreate the originals empty rather than report that they are gone. +-- +-- Not wallet data: excluded from export, import and clear alike, because it +-- describes this file's history and not the wallet's contents. +CREATE TABLE IF NOT EXISTS idb_migration ( + -- One row, ever. + id INTEGER PRIMARY KEY CHECK (id = 1), + -- 'running', 'complete' or 'rolled-back'. 'running' means an attempt was + -- interrupted before the emulation tables were renamed away, so those + -- tables are still the authoritative copy and the native tables are a + -- partial write. 'rolled-back' means they were deliberately put back. + status TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER, + -- Records copied, for the log. + records_copied INTEGER, + -- 'retained', 'dropped' or 'restored': what became of the renamed + -- emulation tables. + backup_status TEXT, + -- After this time the retained backup tables may be dropped. Keeping them + -- for a while is what makes a migration that succeeded but produced a + -- broken wallet recoverable. + backup_expires_at INTEGER +); + +-- Config is a key to JSON mapping. The DAL narrows the value type by key, +-- so typed columns would buy nothing. +CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS currency_info ( + scope_info_str TEXT PRIMARY KEY, + -- JSON: CurrencySpecification + currency_spec TEXT NOT NULL, + source TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS contacts ( + alias TEXT NOT NULL, + alias_type TEXT NOT NULL, + -- NOT NULL: ContactEntry declares all of these as required, and the mapper + -- reads them unguarded, so a NULL would surface as null typed as string. + mailbox_base_uri TEXT NOT NULL, + mailbox_address TEXT NOT NULL, + source TEXT NOT NULL, + petname TEXT NOT NULL, + PRIMARY KEY (alias, alias_type) +); + +CREATE TABLE IF NOT EXISTS mailbox_messages ( + origin_mailbox_base_url TEXT NOT NULL, + taler_uri TEXT NOT NULL, + -- The record type carries a protocol Timestamp ({ t_s }); the mapper + -- converts, as it does for every other time in this schema. + downloaded_at INTEGER NOT NULL, + PRIMARY KEY (origin_mailbox_base_url, taler_uri) +); + +CREATE TABLE IF NOT EXISTS mailbox_configurations ( + mailbox_base_url TEXT PRIMARY KEY, + -- JSON: MailboxConfiguration + payload TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS contract_terms ( + h TEXT PRIMARY KEY, + -- JSON: the raw contract terms, as received + contract_terms_raw TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tombstones ( + id TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS operation_retries ( + id TEXT PRIMARY KEY, + -- JSON: TalerErrorDetail + last_error TEXT, + -- JSON: WalletRetryInfo + retry_info TEXT NOT NULL +); + +-- Reserves are auto-increment: upsertReserve returns the generated row id and +-- 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 BLOB NOT NULL, + reserve_priv BLOB NOT NULL, + status INTEGER, + requirement_row INTEGER, + threshold_requested TEXT, + threshold_granted TEXT, + threshold_next TEXT, + kyc_access_token TEXT, + aml_review INTEGER CHECK (aml_review IN (0, 1)) +); +-- UNIQUE: getReserveByPub is a single-row lookup, so a duplicate would make +-- it return an arbitrary one of the matches. +CREATE UNIQUE INDEX IF NOT EXISTS reserves_by_reserve_pub + ON reserves (reserve_pub); + +-- Fees are flattened rather than JSON: byFamilyParms indexes four of them, +-- and five of that index's seven components are AmountString, so named +-- columns turn a transposition into a compile error rather than a silent +-- wrong lookup. +CREATE TABLE IF NOT EXISTS denominations ( + exchange_base_url TEXT NOT NULL, + denom_pub_hash BLOB NOT NULL, + -- JSON: DenominationPubKey + denom_pub TEXT NOT NULL, + exchange_master_pub BLOB NOT NULL, + currency TEXT NOT NULL, + value TEXT NOT NULL, + -- Nullable: a denomination may be stored before its family is known. + denomination_family_serial INTEGER + REFERENCES denomination_families(denomination_family_serial) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + stamp_start INTEGER NOT NULL, + stamp_expire_withdraw INTEGER NOT NULL, + stamp_expire_deposit INTEGER NOT NULL, + stamp_expire_legal INTEGER NOT NULL, + fee_deposit TEXT NOT NULL, + fee_refresh TEXT NOT NULL, + fee_refund TEXT NOT NULL, + fee_withdraw TEXT NOT NULL, + is_offered INTEGER NOT NULL CHECK (is_offered IN (0, 1)), + is_revoked INTEGER NOT NULL CHECK (is_revoked IN (0, 1)), + is_lost INTEGER CHECK (is_lost IN (0, 1)), + master_sig BLOB NOT NULL, + verification_status INTEGER NOT NULL, + -- Keyed by the master public key that signed the denomination, not by the + -- exchange's URL: the URL is where the exchange currently answers and can + -- change, while the key is what decides whether a coin can be settled. + PRIMARY KEY (exchange_master_pub, denom_pub_hash) +); +-- Only for the queries that mean every key set a URL has served; the +-- denomination's identity is the key. +CREATE INDEX IF NOT EXISTS denominations_by_exchange_base_url + ON denominations (exchange_base_url); +CREATE INDEX IF NOT EXISTS denominations_by_verification_status + ON denominations (verification_status); +-- Serves findDenominationByFamilyFromExpiry. denom_pub_hash is part of the +-- index so a keyset continuation is total: rows sharing an expiry would +-- otherwise be skipped by a strictly-greater cursor. +CREATE INDEX IF NOT EXISTS denominations_by_family_and_expiry + ON denominations (denomination_family_serial, stamp_expire_withdraw, denom_pub_hash); + +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 BLOB NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS global_currency_exchanges_by_cur_url_pub + ON global_currency_exchanges (currency, exchange_base_url, + exchange_master_pub); + +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 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); + +CREATE TABLE IF NOT EXISTS bank_accounts ( + bank_account_id TEXT PRIMARY KEY, + payto_uri TEXT NOT NULL, + label TEXT, + -- JSON: string[] + currencies TEXT, + kyc_completed INTEGER NOT NULL CHECK (kyc_completed IN (0, 1)) +); +CREATE INDEX IF NOT EXISTS bank_accounts_by_payto_uri + ON bank_accounts (payto_uri); + +-- An issued token. See slates below for the pre-issuance form, which +-- carries the same columns except token_issue_sig. +CREATE TABLE IF NOT EXISTS tokens ( + token_use_pub BLOB PRIMARY KEY, + token_use_priv BLOB NOT NULL, + purchase_id TEXT NOT NULL, + transaction_id TEXT, + choice_index INTEGER, + output_index INTEGER, + repeat_index INTEGER, + merchant_base_url TEXT NOT NULL, + kind TEXT NOT NULL, + token_issue_pub_hash BLOB NOT NULL, + token_family_hash BLOB, + valid_after INTEGER NOT NULL, + valid_before INTEGER NOT NULL, + -- JSON: UnblindedDenominationSignature + token_issue_sig TEXT NOT NULL, + -- JSON: TokenUseSig + token_use_sig TEXT, + -- JSON: TokenEnvelope + token_ev 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, + description TEXT NOT NULL, + -- JSON: MerchantContractTokenDetails + extra_data TEXT NOT NULL, + -- JSON: TokenIssuePublicKey + token_issue_pub TEXT NOT NULL, + -- JSON: translations, keyed by IETF language tag + description_i18n TEXT +); +CREATE INDEX IF NOT EXISTS tokens_by_issue_pub_hash + ON tokens (token_issue_pub_hash); +CREATE INDEX IF NOT EXISTS tokens_by_purchase_and_choice + ON tokens (purchase_id, choice_index); +CREATE INDEX IF NOT EXISTS tokens_by_family_hash + ON tokens (token_family_hash); + +-- A slate is a token that has not been issued yet: the same record minus +-- token_issue_sig, which is what the merchant adds on issuance. The columns +-- are repeated rather than shared with tokens because the two are separate +-- stores with their own record types, and a slate becoming a token is a move +-- between them rather than a column being filled in. +CREATE TABLE IF NOT EXISTS slates ( + token_use_pub BLOB PRIMARY KEY, + token_use_priv BLOB NOT NULL, + purchase_id TEXT NOT NULL, + transaction_id TEXT, + choice_index INTEGER, + output_index INTEGER, + repeat_index INTEGER, + merchant_base_url TEXT NOT NULL, + kind TEXT NOT NULL, + token_issue_pub_hash BLOB NOT NULL, + token_family_hash BLOB, + valid_after INTEGER NOT NULL, + valid_before INTEGER NOT NULL, + -- JSON: TokenUseSig + token_use_sig TEXT, + -- JSON: TokenEnvelope + token_ev 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, + description TEXT NOT NULL, + -- JSON: MerchantContractTokenDetails + extra_data TEXT NOT NULL, + -- JSON: TokenIssuePublicKey + token_issue_pub TEXT NOT NULL, + -- JSON: translations, keyed by IETF language tag + description_i18n TEXT +); +CREATE INDEX IF NOT EXISTS slates_by_purchase_choice_output_repeat + ON slates (purchase_id, choice_index, output_index, repeat_index); + +CREATE TABLE IF NOT EXISTS refresh_sessions ( + refresh_group_id TEXT NOT NULL + REFERENCES refresh_groups(refresh_group_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + coin_index INTEGER NOT NULL, + session_public_seed BLOB, + -- Exchange protocol version of the refresh protocol the session melted + -- with; NULL means the v27 one. + refresh_protocol_version INTEGER, + amount_refresh_output TEXT NOT NULL, + -- JSON: { denomPubHash, count }[] + new_denoms TEXT NOT NULL, + noreveal_index INTEGER, + -- JSON: TalerErrorDetail + last_error TEXT, + PRIMARY KEY (refresh_group_id, coin_index) +); + +CREATE TABLE IF NOT EXISTS recoup_groups ( + recoup_group_id TEXT PRIMARY KEY, + exchange_base_url TEXT NOT NULL, + operation_status INTEGER NOT NULL, + timestamp_started INTEGER NOT NULL, + timestamp_finished INTEGER, + -- JSON: string[] + coin_pubs TEXT NOT NULL, + -- JSON: boolean[] + recoup_finished_per_coin TEXT NOT NULL, + -- JSON: CoinRefreshRequest[] + schedule_refresh_coins TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS recoup_groups_by_status + ON recoup_groups (operation_status); +CREATE INDEX IF NOT EXISTS recoup_groups_by_exchange + ON recoup_groups (exchange_base_url); + +CREATE TABLE IF NOT EXISTS donation_summaries ( + donau_base_url TEXT NOT NULL, + year INTEGER NOT NULL, + currency TEXT NOT NULL, + legal_domain TEXT, + amount_receipts_available TEXT NOT NULL, + amount_receipts_submitted TEXT NOT NULL, + PRIMARY KEY (donau_base_url, year, currency) +); + +CREATE TABLE IF NOT EXISTS donation_planchets ( + udi_nonce BLOB PRIMARY KEY, + donau_base_url TEXT NOT NULL, + donor_tax_id_hash BLOB NOT NULL, + -- TEXT, not BLOB, unlike its neighbours: this salt comes from the donau + -- service rather than from an encodeCrock call here, so nothing guarantees + -- it is Crockford at all. Converting it would risk an EncodingError on + -- real data. Same for purchases.donau_tax_id_salt. + 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, + -- JSON: BlindedUniqueDonationIdentifier + blinded_udi 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 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 BLOB NOT NULL, + -- JSON: DonationReceiptSignature + donation_unit_sig TEXT NOT NULL, + donor_tax_id_hash BLOB NOT NULL, + -- TEXT, not BLOB: see the note in donation_planchets. + donor_hash_salt TEXT NOT NULL, + donor_tax_id TEXT NOT NULL, + value TEXT NOT NULL, + udi_index INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS donation_receipts_by_status_and_donau + ON donation_receipts (status, donau_base_url); + +CREATE TABLE IF NOT EXISTS purchases ( + proposal_id TEXT PRIMARY KEY, + order_id TEXT NOT NULL, + merchant_base_url TEXT NOT NULL, + claim_token TEXT, + download_session_id TEXT, + repurchase_proposal_id TEXT, + purchase_status INTEGER NOT NULL, + abort_refresh_group_id TEXT, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + nonce_priv BLOB NOT NULL, + nonce_pub BLOB NOT NULL, + choice_index INTEGER, + secret_seed BLOB, + -- JSON: WalletPurchaseDownloadInfo + 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 + -- the payload. Same rule as withdrawal_groups.taler_withdraw_uri. + download_fulfillment_url TEXT, + -- JSON: WalletPurchasePayInfo + pay_info TEXT, + -- JSON: string[] + pending_removed_coin_pubs TEXT, + timestamp_first_successful_pay INTEGER, + merchant_pay_sig BLOB, + pos_confirmation TEXT, + donau_output_index INTEGER, + donau_base_url TEXT, + donau_amount TEXT, + donau_tax_id_hash BLOB, + -- TEXT, not BLOB: supplied by the donau service, not encodeCrock'd here. + donau_tax_id_salt TEXT, + donau_tax_id TEXT, + donau_year INTEGER, + shared INTEGER NOT NULL CHECK (shared IN (0, 1)), + created_from_shared INTEGER CHECK (created_from_shared IN (0, 1)), + timestamp INTEGER NOT NULL, + timestamp_accept INTEGER, + timestamp_last_refund_status INTEGER, + timestamp_expired INTEGER, + last_session_id TEXT, + auto_refund_deadline INTEGER, + refund_amount_awaiting TEXT, + taler_uri TEXT +); +CREATE INDEX IF NOT EXISTS purchases_by_status + ON purchases (purchase_status); +CREATE INDEX IF NOT EXISTS purchases_by_fulfillment_url + ON purchases (download_fulfillment_url); +CREATE INDEX IF NOT EXISTS purchases_by_url_and_order_id + ON purchases (merchant_base_url, order_id); + +-- Replaces the multiEntry byExchange index. This is the only copy of +-- WalletPurchase.exchanges: idx preserves array order so the mapper can +-- rebuild it exactly, and there is no parallel JSON column to drift from. +CREATE TABLE IF NOT EXISTS purchase_exchanges ( + proposal_id TEXT NOT NULL + REFERENCES purchases(proposal_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + idx INTEGER NOT NULL, + exchange_base_url TEXT NOT NULL, + PRIMARY KEY (proposal_id, idx) +); +CREATE INDEX IF NOT EXISTS purchase_exchanges_by_exchange + ON purchase_exchanges (exchange_base_url); + +-- Deposit and refresh groups keep most of their structure as JSON: the +-- nested pieces (wire details, per-coin status, per-exchange info) are read +-- and written whole, and no query filters on them. +CREATE TABLE IF NOT EXISTS deposit_groups ( + deposit_group_id TEXT PRIMARY KEY, + currency TEXT NOT NULL, + amount TEXT NOT NULL, + wire_transfer_deadline INTEGER NOT NULL, + merchant_pub BLOB NOT NULL, + merchant_priv BLOB NOT NULL, + nonce_priv BLOB NOT NULL, + nonce_pub BLOB NOT NULL, + -- JSON: { payto_uri, salt } + wire TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, + -- JSON: WalletCoinSelection + pay_coin_selection TEXT, + pay_coin_selection_uid TEXT, + total_pay_cost TEXT NOT NULL, + counterparty_effective_deposit_amount TEXT NOT NULL, + timestamp_created INTEGER NOT NULL, + timestamp_finished INTEGER, + timestamp_last_deposit_attempt INTEGER, + operation_status INTEGER NOT NULL, + -- JSON: DepositElementStatus[] + status_per_coin TEXT, + -- JSON: Record<string, WalletDepositInfoPerExchange> + info_per_exchange TEXT, + abort_refresh_group_id TEXT, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + -- JSON: WalletDepositKycInfo + kyc_info TEXT, + -- JSON: KycAuthTransferOptionRaw[] (legacy TransferOptionRaw[] is valid) + kyc_auth_transfer_options TEXT, + kyc_auth_transfer_expiry INTEGER, + -- JSON: wire transfer tracking, keyed by signature + tracking_state TEXT +); +CREATE INDEX IF NOT EXISTS deposit_groups_by_status + ON deposit_groups (operation_status); + +CREATE TABLE IF NOT EXISTS refresh_groups ( + refresh_group_id TEXT PRIMARY KEY, + operation_status INTEGER NOT NULL, + currency TEXT NOT NULL, + reason TEXT NOT NULL, + originating_transaction_id TEXT, + -- JSON: string[] + old_coin_pubs TEXT NOT NULL, + -- JSON: AmountString[] + input_per_coin TEXT NOT NULL, + -- JSON: AmountString[] + expected_output_per_coin TEXT NOT NULL, + -- JSON: Record<string, WalletRefreshGroupPerExchangeInfo> + info_per_exchange TEXT, + -- JSON: RefreshCoinStatus[] + status_per_coin TEXT NOT NULL, + -- JSON: ExchangeRefundRequest, keyed by index + refund_requests TEXT NOT NULL, + timestamp_created INTEGER NOT NULL, + -- JSON: TalerErrorDetail + fail_reason TEXT, + timestamp_finished INTEGER +); +CREATE INDEX IF NOT EXISTS refresh_groups_by_status + ON refresh_groups (operation_status); +CREATE INDEX IF NOT EXISTS refresh_groups_by_originating_transaction + ON refresh_groups (originating_transaction_id); + +CREATE TABLE IF NOT EXISTS denom_loss_events ( + denom_loss_event_id TEXT PRIMARY KEY, + currency TEXT NOT NULL, + -- JSON: string[] + denom_pub_hashes TEXT NOT NULL, + status INTEGER NOT NULL, + timestamp_created INTEGER NOT NULL, + amount TEXT NOT NULL, + event_type TEXT NOT NULL, + exchange_base_url TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS denom_loss_events_by_currency + ON denom_loss_events (currency); +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 BLOB PRIMARY KEY, + exchange_base_url TEXT NOT NULL, + -- JSON: ScopeInfo + restrict_scope TEXT, + amount TEXT NOT NULL, + total_cost TEXT NOT NULL, + -- JSON: DbPeerPushPaymentCoinSelection + coin_sel TEXT, + 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, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + status INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS peer_push_debit_by_status + ON peer_push_debit (status); + +CREATE TABLE IF NOT EXISTS peer_push_credit ( + peer_push_credit_id TEXT PRIMARY KEY, + exchange_base_url 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 BLOB NOT NULL, + status INTEGER NOT NULL, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + withdrawal_group_id TEXT, + currency TEXT, + kyc_payto_hash BLOB, + kyc_access_token TEXT, + kyc_last_check_status INTEGER, + kyc_last_check_code INTEGER, + kyc_last_rule_gen INTEGER, + kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), + kyc_last_deny INTEGER +); +CREATE INDEX IF NOT EXISTS peer_push_credit_by_status + ON peer_push_credit (status); +CREATE INDEX IF NOT EXISTS peer_push_credit_by_exchange_and_purse + ON peer_push_credit (exchange_base_url, purse_pub); +CREATE INDEX IF NOT EXISTS peer_push_credit_by_exchange_and_contract_priv + ON peer_push_credit (exchange_base_url, contract_priv); +CREATE INDEX IF NOT EXISTS peer_push_credit_by_withdrawal_group + ON peer_push_credit (withdrawal_group_id); + +CREATE TABLE IF NOT EXISTS peer_pull_debit ( + peer_pull_debit_id TEXT PRIMARY KEY, + purse_pub BLOB NOT NULL, + exchange_base_url TEXT NOT NULL, + amount TEXT NOT NULL, + contract_terms_hash BLOB NOT NULL, + timestamp_created INTEGER NOT NULL, + contract_priv BLOB NOT NULL, + status INTEGER NOT NULL, + total_cost_estimated TEXT NOT NULL, + abort_refresh_group_id TEXT, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + -- JSON: PeerPullPaymentCoinSelection + coin_sel TEXT +); +CREATE INDEX IF NOT EXISTS peer_pull_debit_by_status + ON peer_pull_debit (status); +CREATE INDEX IF NOT EXISTS peer_pull_debit_by_exchange_and_purse + ON peer_pull_debit (exchange_base_url, purse_pub); +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 BLOB PRIMARY KEY, + exchange_base_url TEXT NOT NULL, + amount TEXT NOT NULL, + estimated_amount_effective 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 BLOB, + kyc_access_token TEXT, + kyc_last_check_status INTEGER, + kyc_last_check_code INTEGER, + kyc_last_rule_gen INTEGER, + kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), + kyc_last_deny INTEGER, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + withdrawal_group_id TEXT +); +CREATE INDEX IF NOT EXISTS peer_pull_credit_by_status + ON peer_pull_credit (status); +CREATE INDEX IF NOT EXISTS peer_pull_credit_by_withdrawal_group + ON peer_pull_credit (withdrawal_group_id); + +CREATE TABLE IF NOT EXISTS transactions_meta ( + transaction_id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + status INTEGER NOT NULL, + currency TEXT NOT NULL, + -- JSON array. The IndexedDB schema indexes this multiEntry, but no DAL + -- query uses that index (nor byCurrency), so no junction table is needed + -- until one appears. + exchanges TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS transactions_meta_by_timestamp + ON transactions_meta (timestamp); +CREATE INDEX IF NOT EXISTS transactions_meta_by_status + ON transactions_meta (status); + +-- Local transaction identifiers deliberately live outside the materialized +-- view. Re-materializing transactions must not renumber user-facing IDs. +CREATE TABLE IF NOT EXISTS transaction_local_id_counters ( + transaction_type TEXT PRIMARY KEY, + next_ident INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS transaction_local_ids ( + transaction_id TEXT PRIMARY KEY, + transaction_type TEXT NOT NULL, + local_ident INTEGER NOT NULL, + UNIQUE (transaction_type, local_ident) +); + +CREATE TABLE IF NOT EXISTS exchanges ( + base_url TEXT PRIMARY KEY, + preset_currency_hint TEXT, + -- JSON: CurrencySpecification + preset_currency_spec TEXT, + preset_type TEXT, + last_withdrawal INTEGER, + -- detailsPointer is flattened. It is declared as + -- 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 BLOB, + details_pointer_currency TEXT, + details_pointer_update_clock INTEGER, + entry_status INTEGER NOT NULL, + update_status INTEGER NOT NULL, + -- JSON: TalerErrorDetail + unavailable_reason TEXT, + cachebreak_next_update INTEGER CHECK (cachebreak_next_update IN (0, 1)), + tos_current_etag TEXT, + tos_accepted_etag TEXT, + tos_accepted_timestamp INTEGER, + last_update INTEGER, + next_update_stamp INTEGER NOT NULL, + last_keys_etag TEXT, + next_refresh_check_stamp INTEGER NOT NULL, + current_merge_reserve_row_id INTEGER, + current_account_priv BLOB, + current_account_pub BLOB, + peer_payments_disabled INTEGER CHECK (peer_payments_disabled IN (0, 1)), + direct_deposit_disabled INTEGER CHECK (direct_deposit_disabled IN (0, 1)), + no_fees INTEGER CHECK (no_fees IN (0, 1)), + -- Key set this exchange used before it changed keys, kept until the user + -- confirms the change. Flattened like details_pointer. + superseded_master_pub BLOB, + superseded_currency TEXT, + superseded_first_seen INTEGER, + superseded_shares_denoms INTEGER + CHECK (superseded_shares_denoms IN (0, 1)), + -- The three details_pointer columns are one value. The mapper checks only + -- the master pub and then reads the other two unguarded, so a partially + -- set pointer would yield null typed as string. + CHECK ( + (details_pointer_master_pub IS NULL) = (details_pointer_currency IS NULL) + AND (details_pointer_master_pub IS NULL) + = (details_pointer_update_clock IS NULL) + ), + CHECK ( + (superseded_master_pub IS NULL) = (superseded_currency IS NULL) + AND (superseded_master_pub IS NULL) = (superseded_first_seen IS NULL) + ) +); + +CREATE TABLE IF NOT EXISTS exchange_details ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + exchange_base_url TEXT NOT NULL, + master_public_key BLOB NOT NULL, + currency TEXT NOT NULL, + -- JSON: ExchangeAuditor[] + auditors TEXT NOT NULL, + protocol_version_range TEXT NOT NULL, + tiny_amount TEXT NOT NULL, + -- JSON: TalerProtocolDuration + reserve_closing_delay TEXT NOT NULL, + shopping_url TEXT, + -- JSON: ExchangeGlobalFees[] + global_fees TEXT NOT NULL, + -- JSON: WireInfo + wire_info TEXT NOT NULL, + age_mask INTEGER, + -- JSON: AmountString[] + wallet_balance_limits TEXT, + -- JSON: AccountLimit[] + hard_limits TEXT, + -- JSON: ZeroLimitedOperation[] + zero_limits TEXT, + bank_compliance_language TEXT, + -- JSON: TalerProtocolDuration + default_peer_push_expiration TEXT +); +-- The pointer identifies at most one details row. +CREATE UNIQUE INDEX IF NOT EXISTS exchange_details_by_pointer + ON exchange_details (exchange_base_url, currency, master_public_key); +-- Not unique: the same exchange can be known under two base URLs while a +-- migration between them is still in progress. +CREATE INDEX IF NOT EXISTS exchange_details_by_master_pub + ON exchange_details (master_public_key); + +CREATE TABLE IF NOT EXISTS exchange_sign_keys ( + exchange_details_row_id INTEGER NOT NULL + REFERENCES exchange_details(row_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + signkey_pub BLOB NOT NULL, + stamp_start INTEGER NOT NULL, + stamp_expire INTEGER NOT NULL, + stamp_end INTEGER NOT NULL, + master_sig BLOB NOT NULL, + PRIMARY KEY (exchange_details_row_id, signkey_pub) +); + +-- familyParams is flattened into its seven components because the lookup is +-- on the whole tuple; keeping it as JSON would make that query a scan. +CREATE TABLE IF NOT EXISTS denomination_families ( + denomination_family_serial INTEGER PRIMARY KEY AUTOINCREMENT, + exchange_base_url TEXT NOT NULL, + exchange_master_pub BLOB NOT NULL, + value TEXT NOT NULL, + fee_withdraw TEXT NOT NULL, + fee_deposit TEXT NOT NULL, + fee_refresh TEXT NOT NULL, + fee_refund TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS denomination_families_by_params + ON denomination_families ( + exchange_base_url, exchange_master_pub, value, + fee_withdraw, fee_deposit, fee_refresh, fee_refund + ); + +CREATE TABLE IF NOT EXISTS exchange_base_url_fixups ( + exchange_base_url TEXT PRIMARY KEY, + replacement TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS exchange_base_url_migration_log ( + old_exchange_base_url TEXT NOT NULL, + new_exchange_base_url TEXT NOT NULL, + timestamp INTEGER NOT NULL, + -- TEXT: ExchangeMigrationReason is a string enum. + reason TEXT NOT NULL, + PRIMARY KEY (old_exchange_base_url, new_exchange_base_url) +); + +-- The wgInfo union is stored as a discriminant, two promoted scalars and two +-- JSON columns, rather than as a side table per variant or one opaque blob: +-- the promoted columns are the ones queries filter on, and the variants differ +-- too little to justify a table each. +CREATE TABLE IF NOT EXISTS withdrawal_groups ( + withdrawal_group_id TEXT PRIMARY KEY, + -- The wgInfo discriminant (WithdrawalRecordType, a string enum). + withdrawal_type TEXT NOT NULL, + -- The ONLY copy of this value: the mapper strips it from the JSON payload + -- on write and re-inserts it on read. Keeping a second copy in bank_info + -- 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 BLOB, + bank_info TEXT, + -- JSON: WithdrawalExchangeAccountDetails[] + exchange_credit_accounts TEXT, + is_foreign_account INTEGER CHECK (is_foreign_account IN (0, 1)), + kyc_payto_hash BLOB, + kyc_access_token TEXT, + kyc_last_check_status INTEGER, + kyc_last_check_code INTEGER, + kyc_last_rule_gen INTEGER, + kyc_last_aml_review INTEGER CHECK (kyc_last_aml_review IN (0, 1)), + kyc_last_deny INTEGER, + -- JSON: TalerProtocolDuration + kyc_withdrawal_delay TEXT, + 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, + status INTEGER NOT NULL, + restrict_age INTEGER, + instructed_amount TEXT, + reserve_balance_amount TEXT, + raw_withdrawal_amount TEXT, + effective_withdrawal_amount TEXT, + -- JSON: DenomSelectionState + denoms_sel TEXT, + -- JSON: TalerErrorDetail + abort_reason TEXT, + -- JSON: TalerErrorDetail + fail_reason TEXT, + -- Variant correctness lives here rather than in a side table: bank_info and + -- taler_withdraw_uri are present exactly for the bank-integrated variant. + -- The URI is included because the mapper reads it unguarded for that + -- variant, and it is the only copy of the value. + CHECK ((withdrawal_type = 'bank-integrated') = (bank_info IS NOT NULL)), + CHECK ( + (withdrawal_type = 'bank-integrated') = (taler_withdraw_uri IS NOT NULL) + ) +); +CREATE INDEX IF NOT EXISTS withdrawal_groups_by_status + ON withdrawal_groups (status); +CREATE INDEX IF NOT EXISTS withdrawal_groups_by_exchange + ON withdrawal_groups (exchange_base_url); +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 BLOB PRIMARY KEY, + coin_priv BLOB NOT NULL, + withdrawal_group_id TEXT NOT NULL + REFERENCES withdrawal_groups(withdrawal_group_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + coin_idx INTEGER NOT NULL, + planchet_status INTEGER NOT NULL, + -- JSON: TalerErrorDetail + last_error TEXT, + denom_pub_hash BLOB NOT NULL, + blinding_key BLOB NOT NULL, + withdraw_sig BLOB NOT NULL, + -- JSON: CoinEnvelope + coin_ev TEXT NOT NULL, + coin_ev_hash BLOB NOT NULL, + -- JSON: AgeCommitmentProof + age_commitment_proof TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS planchets_by_group_and_index + ON planchets (withdrawal_group_id, coin_idx); +CREATE INDEX IF NOT EXISTS planchets_by_coin_ev + ON planchets (coin_ev_hash); + +CREATE TABLE IF NOT EXISTS coins ( + coin_pub BLOB PRIMARY KEY, + coin_priv BLOB NOT NULL, + exchange_base_url TEXT NOT NULL, + -- Nullable: a coin whose denomination was already gone when the field was + -- introduced has no key recorded, and the mapper reads that as unknown. + exchange_master_pub BLOB, + denom_pub_hash BLOB NOT NULL, + -- JSON: UnblindedDenominationSignature + denom_sig 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, + visible INTEGER, + max_age INTEGER NOT NULL, + -- Absent for coins without age restriction; the record type spells this + -- as a required property that may hold undefined, so the mapper always + -- sets the key. + -- JSON: AgeCommitmentProof + age_commitment_proof TEXT, + -- JSON: WalletCoinSource + coin_source TEXT NOT NULL, + source_transaction_id TEXT +); +CREATE INDEX IF NOT EXISTS coins_by_denom_pub_hash + ON coins (denom_pub_hash); +CREATE INDEX IF NOT EXISTS coins_by_coin_ev_hash + ON coins (coin_ev_hash); +CREATE INDEX IF NOT EXISTS coins_by_source_transaction_id + ON coins (source_transaction_id); +-- Serves getFreshCoinsByDenomAndAge, which looks up an exact four-tuple. +CREATE INDEX IF NOT EXISTS coins_by_master_pub_denom_age_status + ON coins (exchange_master_pub, denom_pub_hash, max_age, status); + +CREATE TABLE IF NOT EXISTS coin_availability ( + exchange_base_url 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 BLOB NOT NULL, + -- Counts, not flags. A negative value means a decrement ran without a + -- matching increment, which is a bug worth failing on rather than + -- storing: the coin selector reads these to decide what is spendable. + fresh_coin_count INTEGER NOT NULL CHECK (fresh_coin_count >= 0), + visible_coin_count INTEGER NOT NULL CHECK (visible_coin_count >= 0), + pending_refresh_output_count INTEGER + CHECK (pending_refresh_output_count >= 0), + PRIMARY KEY (exchange_master_pub, denom_pub_hash, max_age) +); +-- Retained for compatibility with existing databases. Migration 9 adds the +-- correctness-preserving exchange/has-fresh/age index used by current code. +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 BLOB PRIMARY KEY + REFERENCES coins(coin_pub) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + -- JSON: WalletCoinHistoryItem[] + history TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS refund_groups ( + refund_group_id TEXT PRIMARY KEY, + proposal_id TEXT NOT NULL + REFERENCES purchases(proposal_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + status INTEGER NOT NULL, + timestamp_created INTEGER NOT NULL, + amount_raw TEXT NOT NULL, + amount_effective TEXT NOT NULL, + refresh_group_id TEXT +); +CREATE INDEX IF NOT EXISTS refund_groups_by_proposal + ON refund_groups (proposal_id); +CREATE INDEX IF NOT EXISTS refund_groups_by_status + ON refund_groups (status); + +CREATE TABLE IF NOT EXISTS refund_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- Deferred: pay-merchant.ts writes refund items before the group they + -- belong to, within one transaction (upsertRefundItem then, ~30 lines + -- later, upsertRefundGroup). An immediate constraint would reject that + -- ordering; a deferred one still guarantees no orphans at commit. + refund_group_id TEXT NOT NULL + REFERENCES refund_groups(refund_group_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + status INTEGER NOT NULL, + proposal_id TEXT, + execution_time INTEGER NOT NULL, + obtained_time INTEGER NOT NULL, + refund_amount TEXT NOT NULL, + coin_pub BLOB NOT NULL, + rtxid INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS refund_items_by_group + ON refund_items (refund_group_id); +CREATE UNIQUE INDEX IF NOT EXISTS refund_items_by_coin_and_rtxid + ON refund_items (coin_pub, rtxid); +`; + +const legacyPeerPushCreditDuplicate = + "EXISTS (SELECT 1 FROM peer_push_credit AS canonical" + + " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" + + " AND canonical.contract_priv = duplicate.contract_priv" + + " AND (canonical.timestamp < duplicate.timestamp" + + " OR (canonical.timestamp = duplicate.timestamp" + + " AND canonical.peer_push_credit_id < duplicate.peer_push_credit_id)))"; + +const legacyPeerPullDebitDuplicate = + "EXISTS (SELECT 1 FROM peer_pull_debit AS canonical" + + " WHERE canonical.exchange_base_url = duplicate.exchange_base_url" + + " AND canonical.contract_priv = duplicate.contract_priv" + + " AND (canonical.timestamp_created < duplicate.timestamp_created" + + " OR (canonical.timestamp_created = duplicate.timestamp_created" + + " AND canonical.peer_pull_debit_id < duplicate.peer_pull_debit_id)))"; + +/** + * Migrations applied on top of the baseline. + * + * Empty: no native database exists yet that has to survive a schema change, + * so the baseline is still edited directly. + * + * That stops being true the moment one does. The baseline is all + * CREATE ... IF NOT EXISTS and is re-executed on every open, so an existing + * table keeps the definition it was created with, and a column added only to + * the baseline would be missing from every database created before the edit. + * From then on, every change appends an entry here and bumps + * {@link SQLITE_SCHEMA_VERSION} -- and goes in one place only, since a fresh + * database runs the baseline *and* the migrations. + */ +export const schemaMigrations: SchemaMigration[] = [ + { + version: 6, + name: "clause-schnorr-exchange-withdraw-values", + statements: [ + 'ALTER TABLE planchets ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', + 'ALTER TABLE coins ADD COLUMN exchange_withdraw_values TEXT NOT NULL DEFAULT \'{"cipher":"RSA"}\'', + ], + }, + { + version: 7, + name: "indexeddb-migration-cleanup-ownership", + statements: [ + "ALTER TABLE idb_migration ADD COLUMN cleanup_safe INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_safe IN (0, 1))", + ], + }, + { + version: 8, + name: "exchange-entry-source", + statements: ["ALTER TABLE exchanges ADD COLUMN source TEXT"], + }, + { + version: 9, + name: "wallet-query-indexes", + statements: [ + "ALTER TABLE coin_availability ADD COLUMN has_fresh_coins INTEGER NOT NULL DEFAULT 0 CHECK (has_fresh_coins IN (0, 1))", + "UPDATE coin_availability SET has_fresh_coins = CASE WHEN fresh_coin_count > 0 THEN 1 ELSE 0 END", + "CREATE INDEX coin_availability_by_exchange_fresh_age ON coin_availability (exchange_base_url, has_fresh_coins, max_age)", + "CREATE INDEX transactions_meta_by_timestamp_id ON transactions_meta (timestamp, transaction_id)", + "CREATE INDEX coins_by_exchange_base_url ON coins (exchange_base_url)", + "CREATE INDEX coins_by_master_pub_denom_age_status_pub ON coins (exchange_master_pub, denom_pub_hash, max_age, status, coin_pub)", + ], + }, + { + version: 10, + name: "unique-peer-payment-capabilities", + statements: [ + // Older SQLite wallets could commit the same URI twice because these + // indexes were not unique. Retain the first-created record (breaking an + // equal-timestamp tie by primary key) before strengthening the indexes. + `DELETE FROM transactions_meta WHERE transaction_id IN + (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id + FROM peer_push_credit AS duplicate + WHERE ${legacyPeerPushCreditDuplicate})`, + `DELETE FROM transaction_local_ids WHERE transaction_id IN + (SELECT 'txn:peer-push-credit:' || duplicate.peer_push_credit_id + FROM peer_push_credit AS duplicate + WHERE ${legacyPeerPushCreditDuplicate})`, + `DELETE FROM operation_retries WHERE id IN + (SELECT 'peer-push-credit:' || duplicate.peer_push_credit_id + FROM peer_push_credit AS duplicate + WHERE ${legacyPeerPushCreditDuplicate})`, + `DELETE FROM peer_push_credit AS duplicate + WHERE ${legacyPeerPushCreditDuplicate}`, + "DROP INDEX peer_push_credit_by_exchange_and_contract_priv", + "CREATE UNIQUE INDEX peer_push_credit_by_exchange_and_contract_priv ON peer_push_credit (exchange_base_url, contract_priv)", + `DELETE FROM transactions_meta WHERE transaction_id IN + (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id + FROM peer_pull_debit AS duplicate + WHERE ${legacyPeerPullDebitDuplicate})`, + `DELETE FROM transaction_local_ids WHERE transaction_id IN + (SELECT 'txn:peer-pull-debit:' || duplicate.peer_pull_debit_id + FROM peer_pull_debit AS duplicate + WHERE ${legacyPeerPullDebitDuplicate})`, + `DELETE FROM operation_retries WHERE id IN + (SELECT 'peer-pull-debit:' || duplicate.peer_pull_debit_id + FROM peer_pull_debit AS duplicate + WHERE ${legacyPeerPullDebitDuplicate})`, + `DELETE FROM peer_pull_debit AS duplicate + WHERE ${legacyPeerPullDebitDuplicate}`, + "DROP INDEX peer_pull_debit_by_exchange_and_contract_priv", + "CREATE UNIQUE INDEX peer_pull_debit_by_exchange_and_contract_priv ON peer_pull_debit (exchange_base_url, contract_priv)", + ], + }, +]; + +/** Native tables that contain wallet records (not schema bookkeeping). */ +export const NATIVE_DATA_TABLES = [ + ...SQLITE_BASELINE_SCHEMA.matchAll(/CREATE TABLE IF NOT EXISTS (\w+)/g), +] + .map((m) => m[1]) + .filter((name) => !NON_DATA_TABLES.includes(name)); diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.test.ts @@ -0,0 +1,180 @@ +/* + 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 "./schema.js"; +import { conformanceCases } from "../testing/conformance-cases.js"; +import { makeSqliteRunner } from "../testing/runners.js"; +import { initSqliteWalletDb } from "./database.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.runReadWriteTx(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(", ")}`); + } +}); + +/** + * The schema's CHECK and UNIQUE constraints, exercised directly. + * + * A constraint that is silently dropped -- a typo in the DDL, a column + * rewritten during a migration -- looks exactly like one that is holding, + * because correct code never trips it. These probes write the bad values on + * purpose. + */ +test("sqlite: schema constraints reject invalid rows", async () => { + const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); + const db = await impl.open(":memory:"); + await initSqliteWalletDb(db); + + const run = async (sql: string): Promise<string> => { + try { + await (await db.prepare(sql)).run({}); + return "accepted"; + } catch (e) { + return `rejected: ${e instanceof Error ? e.message : String(e)}`; + } + }; + + // Booleans are 0/1/NULL; sqlite would otherwise store any integer, and a + // stray 2 reads back as a truthy value that is not `true`. + assert.match( + await run( + "INSERT INTO bank_accounts (bank_account_id, payto_uri, kyc_completed)" + + " VALUES ('bad', 'payto://x', 7)", + ), + /CHECK constraint failed/, + "a boolean column must reject a value outside 0/1", + ); + assert.strictEqual( + await run( + "INSERT INTO bank_accounts (bank_account_id, payto_uri, kyc_completed)" + + " VALUES ('good', 'payto://x', 1)", + ), + "accepted", + "a boolean column must still accept 1", + ); + + // Counts drive coin selection, and are decremented in places without a + // floor, so a negative value is a bug rather than a state to store. + assert.match( + await run( + "INSERT INTO coin_availability (exchange_base_url, exchange_master_pub," + + " denom_pub_hash, max_age, currency, value, fresh_coin_count," + + " visible_coin_count)" + + " VALUES ('https://e/', x'01', x'00', 0, 'C', 'C:1', -1, 0)", + ), + /CHECK constraint failed/, + "a negative coin count must be rejected", + ); + + // getReserveByPub is a single-row lookup. + assert.strictEqual( + await run( + "INSERT INTO reserves (reserve_pub, reserve_priv) VALUES (x'11', x'22')", + ), + "accepted", + ); + assert.match( + await run( + "INSERT INTO reserves (reserve_pub, reserve_priv) VALUES (x'11', x'33')", + ), + /UNIQUE constraint failed/, + "two reserves must not share a public key", + ); + + await db.close(); +}); diff --git a/packages/taler-wallet-core/src/db/sqlite/transaction.ts b/packages/taler-wallet-core/src/db/sqlite/transaction.ts @@ -0,0 +1,5375 @@ +/* + 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. + */ + +/** + * Native sqlite3 implementation of {@link WalletDbTransaction}. + * + * Talks to sqlite directly through {@link Sqlite3Interface} — no IndexedDB + * emulation. The interface was shaped during the DAL migration to make this + * possible: no cursors, no key ranges, compound keys as separate parameters, + * generated ids returned from upserts. + * + * Work in progress. Methods that are not implemented yet throw + * {@link NotImplementedError} rather than being silently wrong; the + * conformance suite is the checklist. + */ + +import { + ResultRow, + Sqlite3Database, + Sqlite3Statement, + Sqlite3Value, +} from "@gnu-taler/idb-bridge"; +import { + AmountString, + CoinStatus, + decodeCrock, + encodeCrock, + MerchantContractTokenKind, + stringifyScopeInfo, + DenomLossEventType, + RefreshReason, + Logger, + WalletNotification, + ContactEntry, + ExchangeEntrySource, + MailboxConfiguration, + MailboxMessageRecord, + ScopeInfo, +} from "@gnu-taler/taler-util"; +import { + checkExchangeInScopeGeneric, + getExchangeScopeInfoGeneric, +} from "../shared.js"; +import { + GetCurrencyInfoDbResult, + StoreCurrencyInfoDbRequest, + WalletCoinAvailabilityRef, + WalletDbRecordCounts, + WalletDbTransaction, + WalletDbMigrationPage, + WalletDbMigrationStore, + WalletDenomRef, + WalletCurrencyInfoEntry, +} from "../transaction.js"; +import { + ConfigRecord, + DbPreciseTimestamp, + DbProtocolTimestamp, + DenominationVerificationStatus, + WalletCoin, + WalletCoinAvailability, + WalletCoinHistory, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, + ReserveBankInfo, + WalletContractTerms, + WalletPlanchet, + WalletProposalDownloadInfo, + WalletWithdrawalGroup, + WgInfo, + WgInfoBankIntegrated, + WgInfoBankManual, + WgInfoBankPeerPull, + WgInfoBankPeerPush, + WgInfoBankRecoup, + WithdrawalRecordType, + WalletDenomFamilyParams, + WalletDenominationFamily, + WalletExchangeBaseUrlFixup, + WalletExchangeDetails, + WalletExchangeEntry, + WalletExchangeMigrationLog, + WalletExchangeSignkeys, + ExchangeMigrationReason, + WalletDenomination, + WalletOperationRetry, + WalletTransactionMetaCursor, + WalletRefundGroup, + WalletRefundItem, + WalletReserve, + WalletTombstone, + DonationReceiptStatus, + PurchaseStatus, + WalletBankAccount, + WalletDenomLossEvent, + WalletDepositGroup, + WalletDonationPlanchet, + WalletDonationReceipt, + WalletDonationSummary, + WalletGlobalCurrencyAuditor, + WalletGlobalCurrencyExchange, + WalletPeerPullCredit, + WalletPeerPullDebit, + WalletPeerPushCredit, + WalletPeerPushDebit, + WalletPurchase, + WalletRecoupGroup, + WalletRefreshGroup, + WalletRefreshSession, + WalletSlate, + WalletToken, + WalletTransactionMeta, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "../records.js"; + +const logger = new Logger("db/sqlite/transaction.ts"); + +export class NotImplementedError extends Error { + constructor(method: string) { + super(`sqlite DAL: ${method} is not implemented yet`); + } +} + +/** Encode an optional boolean the way sqlite wants it. */ +function boolToDb(b: boolean | undefined): number | null { + if (b === undefined) return null; + return b ? 1 : 0; +} + +function dbToBool(v: Sqlite3Value | undefined): boolean { + return v === 1 || v === 1n; +} + +function dbToOptBool(v: Sqlite3Value | undefined): boolean | undefined { + if (v == null) return undefined; + return dbToBool(v); +} + +function num(v: Sqlite3Value | undefined): number { + return Number(v); +} + +/** + * Restore a branded timestamp read back from the database. + * + * DbProtocolTimestamp and DbPreciseTimestamp are compile-time brands over a + * microsecond number: there is nothing to convert at runtime. The brand + * exists to stop the two being mixed up in business logic, not to prevent + * construction, so re-applying it at the deserialisation boundary is the one + * place an assertion is legitimate. Keeping it in a single named helper means + * it cannot spread into the mapping code. + */ +function dbTimestamp<T extends DbProtocolTimestamp | DbPreciseTimestamp>( + v: Sqlite3Value | undefined, +): T { + return Number(v) as T; +} + +/** + * Restore a branded AmountString read back from the database. + * + * Same reasoning as {@link dbTimestamp}: the brand is a compile-time marker + * over a string, and the deserialisation boundary is where it is reapplied. + */ +function dbAmount(v: Sqlite3Value | undefined): AmountString { + return v as AmountString; +} + +function optNum(v: Sqlite3Value | undefined): number | undefined { + return v == null ? undefined : Number(v); +} + +function str(v: Sqlite3Value | undefined): string { + return v as string; +} + +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. + */ +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); +} + +function dbToJson<T>(v: Sqlite3Value | undefined): T { + return JSON.parse(v as string) as T; +} + +function dbToOptJson<T>(v: Sqlite3Value | undefined): T | undefined { + return v == null ? undefined : (JSON.parse(v as string) as T); +} + +/** Native table enumerated for each backend-conversion store. */ +const SQLITE_MIGRATION_TABLES: Record<WalletDbMigrationStore, string> = { + config: "config", + currencyInfo: "currency_info", + contacts: "contacts", + mailboxMessages: "mailbox_messages", + mailboxConfigurations: "mailbox_configurations", + contractTerms: "contract_terms", + tombstones: "tombstones", + operationRetries: "operation_retries", + bankAccounts: "bank_accounts", + globalCurrencyExchanges: "global_currency_exchanges", + globalCurrencyAuditors: "global_currency_auditors", + exchangeBaseUrlFixups: "exchange_base_url_fixups", + exchangeBaseUrlMigrationLog: "exchange_base_url_migration_log", + reserves: "reserves", + exchanges: "exchanges", + exchangeDetails: "exchange_details", + exchangeSignKeys: "exchange_sign_keys", + denominationFamilies: "denomination_families", + denominations: "denominations", + withdrawalGroups: "withdrawal_groups", + purchases: "purchases", + refreshGroups: "refresh_groups", + coins: "coins", + planchets: "planchets", + refreshSessions: "refresh_sessions", + coinHistory: "coin_history", + coinAvailability: "coin_availability", + refundGroups: "refund_groups", + tokens: "tokens", + slates: "slates", + depositGroups: "deposit_groups", + recoupGroups: "recoup_groups", + denomLossEvents: "denom_loss_events", + peerPushDebit: "peer_push_debit", + peerPushCredit: "peer_push_credit", + peerPullDebit: "peer_pull_debit", + peerPullCredit: "peer_pull_credit", + donationSummaries: "donation_summaries", + donationPlanchets: "donation_planchets", + donationReceipts: "donation_receipts", + transactionsMeta: "transactions_meta", + refundItems: "refund_items", +}; + +/** Rows handed back by the statement layer, counted per connection. */ +export interface SqliteAccessStats { + rowsRead: number; +} + +/** + * One sqlite transaction. + * + * Notifications and commit hooks are buffered and only released by the + * runner after COMMIT succeeds, matching the IndexedDB implementation: a + * transaction that rolls back must not have told anyone it happened. + */ +export class SqliteWalletTransaction implements WalletDbTransaction { + readonly pendingNotifications: WalletNotification[] = []; + readonly afterCommitHandlers: (() => void)[] = []; + + /** + * Prepared-statement cache. + * + * Owned by the connection, not by this object: a transaction is a new + * SqliteWalletTransaction every time, so a per-instance cache re-prepared + * every statement on every transaction — one wasted round-trip to the + * sqlite helper per distinct statement per transaction. + */ + private stmtCache: Map<string, Sqlite3Statement>; + + /** + * Row counter shared with the connection, when one was supplied. + */ + private stats: SqliteAccessStats; + + /** Keyset page applied to the root SELECT of one migration enumeration. */ + private migrationPage: + | { + table: string; + afterRowId: number; + limit: number; + consumed: boolean; + nextRowId?: number; + } + | undefined; + + constructor( + private db: Sqlite3Database, + stmtCache?: Map<string, Sqlite3Statement>, + stats?: SqliteAccessStats, + ) { + this.stmtCache = stmtCache ?? new Map(); + this.stats = stats ?? { rowsRead: 0 }; + } + + private async prep(sql: string): Promise<Sqlite3Statement> { + let stmt = this.stmtCache.get(sql); + if (!stmt) { + stmt = await this.db.prepare(sql); + this.stmtCache.set(sql, stmt); + } + return stmt; + } + + private async run(sql: string, params: Record<string, any> = {}) { + return await (await this.prep(sql)).run(params); + } + + private async first( + sql: string, + params: Record<string, any> = {}, + ): Promise<ResultRow | undefined> { + const row = await (await this.prep(sql)).getFirst(params); + if (row !== undefined) { + this.stats.rowsRead++; + } + return row; + } + + private async all( + sql: string, + params: Record<string, any> = {}, + ): Promise<ResultRow[]> { + let isMigrationRoot = false; + if (this.migrationPage && !this.migrationPage.consumed) { + isMigrationRoot = true; + this.migrationPage.consumed = true; + sql = + `SELECT rowid AS __migration_rowid, * FROM ${this.migrationPage.table}` + + " WHERE rowid > $migration_after" + + " ORDER BY rowid LIMIT $migration_limit"; + params = { + migration_after: this.migrationPage.afterRowId, + migration_limit: this.migrationPage.limit, + }; + } + const rows = await (await this.prep(sql)).getAll(params); + if (isMigrationRoot && this.migrationPage && rows.length > 0) { + this.migrationPage.nextRowId = num( + rows[rows.length - 1].__migration_rowid, + ); + } + this.stats.rowsRead += rows.length; + return rows; + } + + async scanMigrationRecords<T>( + store: WalletDbMigrationStore, + read: (tx: WalletDbTransaction) => Promise<T[]>, + cursor: unknown | undefined, + limit: number, + ): Promise<WalletDbMigrationPage<T>> { + const afterRowId = cursor === undefined ? 0 : Number(cursor); + if (!Number.isSafeInteger(afterRowId) || afterRowId < 0) { + throw Error("invalid sqlite migration cursor"); + } + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw Error("migration page size must be a positive integer"); + } + if (this.migrationPage) { + throw Error("nested migration scan is not supported"); + } + this.migrationPage = { + table: SQLITE_MIGRATION_TABLES[store], + afterRowId, + limit, + consumed: false, + }; + try { + const records = await read(this); + if (!this.migrationPage.consumed) { + throw Error("migration enumeration did not issue a SELECT"); + } + return { + records, + ...(records.length === limit && this.migrationPage.nextRowId != null + ? { nextCursor: this.migrationPage.nextRowId } + : {}), + }; + } finally { + this.migrationPage = undefined; + } + } + + // Bound as an instance property for the same reason as the IndexedDB + // implementation: call sites pass it around unbound. + notify = (notif: WalletNotification): void => { + this.pendingNotifications.push(notif); + }; + + scheduleOnCommit(f: () => void): void { + this.afterCommitHandlers.push(f); + } + + // ------------------------------------------------------------- config + + async getConfig<T extends ConfigRecord["key"]>( + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { + const row = await this.first("SELECT value FROM config WHERE key = $key", { + key, + }); + if (!row) return undefined; + return dbToJson(row.value); + } + + async listAllConfig(): Promise<ConfigRecord[]> { + const rows = await this.all("SELECT value FROM config"); + return rows.map((r) => dbToJson<ConfigRecord>(r.value)); + } + + async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { + const rows = await this.all("SELECT * FROM currency_info"); + return rows.map((r) => ({ + scopeInfoStr: str(r.scope_info_str), + currencySpec: dbToJson(r.currency_spec), + source: str(r.source) as WalletCurrencyInfoEntry["source"], + })); + } + + async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { + await this.run( + "INSERT INTO currency_info (scope_info_str, currency_spec, source)" + + " VALUES ($s, $spec, $src)" + + " ON CONFLICT(scope_info_str) DO UPDATE SET" + + " currency_spec = excluded.currency_spec," + + " source = excluded.source", + { + s: entry.scopeInfoStr, + spec: jsonToDb(entry.currencySpec), + src: entry.source, + }, + ); + } + + async upsertConfig(record: ConfigRecord): Promise<void> { + await this.run( + "INSERT INTO config (key, value) VALUES ($key, $value)" + + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", + { key: record.key, value: jsonToDb(record) }, + ); + } + + // ------------------------------------------------------ contract terms + + async getContractTerms( + contractTermsHash: string, + ): Promise<WalletContractTerms | undefined> { + const row = await this.first( + "SELECT h, contract_terms_raw FROM contract_terms WHERE h = $h", + { h: contractTermsHash }, + ); + if (!row) return undefined; + return { + h: str(row.h), + contractTermsRaw: dbToJson(row.contract_terms_raw), + }; + } + + async upsertContractTerms(rec: WalletContractTerms): Promise<void> { + await this.run( + "INSERT INTO contract_terms (h, contract_terms_raw)" + + " VALUES ($h, $raw)" + + " ON CONFLICT(h) DO UPDATE SET contract_terms_raw = excluded.contract_terms_raw", + { h: rec.h, raw: jsonToDb(rec.contractTermsRaw) }, + ); + } + + // --------------------------------------------------------- tombstones + + async upsertTombstone(rec: WalletTombstone): Promise<void> { + await this.run("INSERT OR REPLACE INTO tombstones (id) VALUES ($id)", { + id: rec.id, + }); + } + + async listAllTombstones(): Promise<WalletTombstone[]> { + const rows = await this.all("SELECT id FROM tombstones"); + return rows.map((r) => ({ id: str(r.id) })); + } + + // --------------------------------------------------- operation retries + + async getOperationRetry( + taskId: string, + ): Promise<WalletOperationRetry | undefined> { + const row = await this.first( + "SELECT id, last_error, retry_info FROM operation_retries WHERE id = $id", + { id: taskId }, + ); + if (!row) return undefined; + return { + id: str(row.id), + lastError: dbToOptJson(row.last_error), + retryInfo: dbToJson(row.retry_info), + }; + } + + async upsertOperationRetry(rec: WalletOperationRetry): Promise<void> { + await this.run( + "INSERT INTO operation_retries (id, last_error, retry_info)" + + " VALUES ($id, $last_error, $retry_info)" + + " ON CONFLICT(id) DO UPDATE SET" + + " last_error = excluded.last_error," + + " retry_info = excluded.retry_info", + { + id: rec.id, + last_error: rec.lastError == null ? null : jsonToDb(rec.lastError), + retry_info: jsonToDb(rec.retryInfo), + }, + ); + } + + async listAllOperationRetries(): Promise<WalletOperationRetry[]> { + const rows = await this.all( + "SELECT id, last_error, retry_info FROM operation_retries", + ); + return rows.map((r) => ({ + id: str(r.id), + lastError: dbToOptJson(r.last_error), + retryInfo: dbToJson(r.retry_info), + })); + } + + async deleteOperationRetry(taskId: string): Promise<void> { + await this.run("DELETE FROM operation_retries WHERE id = $id", { + id: taskId, + }); + } + + // ------------------------------------------------------------ reserves + + async upsertReserve(rec: WalletReserve): Promise<number> { + const cols = { + pub: crockToDb(rec.reservePub), + priv: crockToDb(rec.reservePriv), + status: rec.status ?? null, + requirement_row: rec.requirementRow ?? null, + threshold_requested: rec.thresholdRequested ?? null, + threshold_granted: rec.thresholdGranted ?? null, + threshold_next: rec.thresholdNext ?? null, + kyc_access_token: rec.kycAccessToken ?? null, + aml_review: boolToDb(rec.amlReview), + }; + const names = + "reserve_pub, reserve_priv, status, requirement_row," + + " threshold_requested, threshold_granted, threshold_next," + + " kyc_access_token, aml_review"; + const values = + "$pub, $priv, $status, $requirement_row," + + " $threshold_requested, $threshold_granted, $threshold_next," + + " $kyc_access_token, $aml_review"; + if (rec.rowId != null) { + await this.run( + `INSERT INTO reserves (row_id, ${names}) VALUES ($row_id, ${values})` + + " ON CONFLICT(row_id) DO UPDATE SET" + + " reserve_pub = excluded.reserve_pub," + + " reserve_priv = excluded.reserve_priv," + + " status = excluded.status," + + " requirement_row = excluded.requirement_row," + + " threshold_requested = excluded.threshold_requested," + + " threshold_granted = excluded.threshold_granted," + + " threshold_next = excluded.threshold_next," + + " kyc_access_token = excluded.kyc_access_token," + + " aml_review = excluded.aml_review", + { row_id: rec.rowId, ...cols }, + ); + return rec.rowId; + } + const res = await this.run( + `INSERT INTO reserves (${names}) VALUES (${values})`, + cols, + ); + return Number(res.lastInsertRowid); + } + + async getReserve(reserveRowId: number): Promise<WalletReserve | undefined> { + const row = await this.first( + "SELECT * FROM reserves" + " WHERE row_id = $row_id", + { row_id: reserveRowId }, + ); + return row ? this.rowToReserve(row) : undefined; + } + + async getReserveByReservePub( + reservePub: string, + ): Promise<WalletReserve | undefined> { + const row = await this.first( + "SELECT * FROM reserves" + " WHERE reserve_pub = $pub", + { pub: crockToDb(reservePub) }, + ); + return row ? this.rowToReserve(row) : undefined; + } + + async getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]> { + if (reservePubs.length === 0) { + return []; + } + const params: Record<string, Uint8Array> = {}; + const blobs = reservePubs.map((pub) => crockToDb(pub)); + const placeholders = blobs.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM reserves WHERE reserve_pub IN (${placeholders.join(", ")})`, + params, + ); + const byPub = new Map( + rows.map((r) => { + const reservePub = r.reserve_pub; + if (!(reservePub instanceof Uint8Array)) { + throw Error("reserves.reserve_pub must be a BLOB column"); + } + return [blobKey(reservePub), r] as const; + }), + ); + return blobs.flatMap((blob) => { + const row = byPub.get(blobKey(blob)); + return row ? [this.rowToReserve(row)] : []; + }); + } + + async listAllReserves(): Promise<WalletReserve[]> { + const rows = await this.all("SELECT * FROM reserves"); + return rows.map((r) => this.rowToReserve(r)); + } + + private rowToReserve(row: ResultRow): WalletReserve { + return { + rowId: num(row.row_id), + 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) } + : undefined), + ...(row.threshold_requested != null + ? { thresholdRequested: dbAmount(row.threshold_requested) } + : undefined), + ...(row.threshold_granted != null + ? { thresholdGranted: dbAmount(row.threshold_granted) } + : undefined), + ...(row.threshold_next != null + ? { thresholdNext: dbAmount(row.threshold_next) } + : undefined), + ...(row.kyc_access_token != null + ? { kycAccessToken: str(row.kyc_access_token) } + : undefined), + ...(row.aml_review != null + ? { amlReview: dbToBool(row.aml_review) } + : undefined), + }; + } + + // ------------------------------------------------------- denominations + + async upsertDenomination(rec: WalletDenomination): Promise<void> { + await this.run( + `INSERT INTO denominations ( + exchange_base_url, denom_pub_hash, denom_pub, exchange_master_pub, + currency, value, denomination_family_serial, + stamp_start, stamp_expire_withdraw, stamp_expire_deposit, + stamp_expire_legal, + fee_deposit, fee_refresh, fee_refund, fee_withdraw, + is_offered, is_revoked, is_lost, master_sig, + verification_status + ) VALUES ( + $exchange_base_url, $denom_pub_hash, $denom_pub, $exchange_master_pub, + $currency, $value, $family_serial, + $stamp_start, $stamp_expire_withdraw, $stamp_expire_deposit, + $stamp_expire_legal, + $fee_deposit, $fee_refresh, $fee_refund, $fee_withdraw, + $is_offered, $is_revoked, $is_lost, $master_sig, + $verification_status + ) + ON CONFLICT(exchange_master_pub, denom_pub_hash) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + denom_pub = excluded.denom_pub, + exchange_master_pub = excluded.exchange_master_pub, + currency = excluded.currency, + value = excluded.value, + denomination_family_serial = excluded.denomination_family_serial, + stamp_start = excluded.stamp_start, + stamp_expire_withdraw = excluded.stamp_expire_withdraw, + stamp_expire_deposit = excluded.stamp_expire_deposit, + stamp_expire_legal = excluded.stamp_expire_legal, + fee_deposit = excluded.fee_deposit, + fee_refresh = excluded.fee_refresh, + fee_refund = excluded.fee_refund, + fee_withdraw = excluded.fee_withdraw, + is_offered = excluded.is_offered, + is_revoked = excluded.is_revoked, + is_lost = excluded.is_lost, + master_sig = excluded.master_sig, + verification_status = excluded.verification_status`, + { + exchange_base_url: rec.exchangeBaseUrl, + denom_pub_hash: crockToDb(rec.denomPubHash), + denom_pub: jsonToDb(rec.denomPub), + exchange_master_pub: crockToDb(rec.exchangeMasterPub), + currency: rec.currency, + value: rec.value, + family_serial: rec.denominationFamilySerial ?? null, + stamp_start: rec.stampStart, + stamp_expire_withdraw: rec.stampExpireWithdraw, + stamp_expire_deposit: rec.stampExpireDeposit, + stamp_expire_legal: rec.stampExpireLegal, + fee_deposit: rec.fees.feeDeposit, + fee_refresh: rec.fees.feeRefresh, + fee_refund: rec.fees.feeRefund, + fee_withdraw: rec.fees.feeWithdraw, + is_offered: boolToDb(rec.isOffered), + is_revoked: boolToDb(rec.isRevoked), + is_lost: boolToDb(rec.isLost), + master_sig: crockToDb(rec.masterSig), + verification_status: rec.verificationStatus, + }, + ); + } + + async getDenomination( + ref: WalletDenomRef, + ): Promise<WalletDenomination | undefined> { + const row = await this.first( + "SELECT * FROM denominations" + + " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $hash", + { + mpk: crockToDb(ref.exchangeMasterPub), + hash: crockToDb(ref.denomPubHash), + }, + ); + return row ? this.rowToDenomination(row) : undefined; + } + + async getDenominationsByRefs( + refs: WalletDenomRef[], + ): Promise<WalletDenomination[]> { + if (refs.length === 0) { + return []; + } + const encoded = refs.map((ref) => { + const masterPub = crockToDb(ref.exchangeMasterPub); + const denomPubHash = crockToDb(ref.denomPubHash); + return { + masterPub, + denomPubHash, + key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, + }; + }); + const byKey = new Map<string, WalletDenomination>(); + // Two parameters per reference. Staying below 999 keeps this compatible + // with sqlite builds that use the traditional bind-parameter limit. + for (let offset = 0; offset < encoded.length; offset += 400) { + const chunk = encoded.slice(offset, offset + 400); + const params: Record<string, Sqlite3Value> = {}; + const values = chunk.map((ref, i) => { + params[`mpk${i}`] = ref.masterPub; + params[`dph${i}`] = ref.denomPubHash; + return `($mpk${i}, $dph${i})`; + }); + const rows = await this.all( + "SELECT * FROM denominations" + + ` WHERE (exchange_master_pub, denom_pub_hash) IN (${values.join(", ")})`, + params, + ); + for (const row of rows) { + const masterPub = row.exchange_master_pub; + const denomPubHash = row.denom_pub_hash; + if ( + !(masterPub instanceof Uint8Array) || + !(denomPubHash instanceof Uint8Array) + ) { + throw Error("denomination identity columns must be BLOBs"); + } + byKey.set( + `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, + this.rowToDenomination(row), + ); + } + } + return encoded.flatMap((ref) => { + const record = byKey.get(ref.key); + return record ? [record] : []; + }); + } + + async getDenominationsByMasterPub( + exchangeMasterPub: string, + ): Promise<WalletDenomination[]> { + const rows = await this.all( + "SELECT * FROM denominations WHERE exchange_master_pub = $mpk", + { mpk: crockToDb(exchangeMasterPub) }, + ); + return rows.map((r) => this.rowToDenomination(r)); + } + + async getDenominationsByVerificationStatus( + verificationStatus: DenominationVerificationStatus, + ): Promise<WalletDenomination[]> { + const rows = await this.all( + "SELECT * FROM denominations WHERE verification_status = $st", + { st: verificationStatus }, + ); + return rows.map((r) => this.rowToDenomination(r)); + } + + async deleteDenomination(ref: WalletDenomRef): Promise<void> { + await this.run( + "DELETE FROM denominations" + + " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $hash", + { + mpk: crockToDb(ref.exchangeMasterPub), + hash: crockToDb(ref.denomPubHash), + }, + ); + } + + /** + * Scan a family in expiry order and stop at the first record the caller + * accepts. + * + * The helper protocol has no cursor, so this pages with LIMIT and a keyset + * continuation. denom_pub_hash is part of the ordering and of the + * continuation predicate: rows can share a stamp_expire_withdraw, and a + * strictly-greater continuation on expiry alone would skip the siblings. + * + * The batch size is the read amplification for a single lookup, so it is + * deliberately small. The conformance suite asserts the record count stays + * bounded. + */ + async findDenominationByFamilyFromExpiry( + denominationFamilySerial: number, + minStampExpireWithdraw: DbProtocolTimestamp, + match: (d: WalletDenomination) => boolean, + ): Promise<WalletDenomination | undefined> { + const batchSize = 1; + let afterExpiry: number = minStampExpireWithdraw; + // 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 + ? await this.all( + "SELECT * FROM denominations" + + " WHERE denomination_family_serial = $serial" + + " AND stamp_expire_withdraw >= $expiry" + + " ORDER BY stamp_expire_withdraw, denom_pub_hash" + + " LIMIT $limit", + { + serial: denominationFamilySerial, + expiry: afterExpiry, + limit: batchSize, + }, + ) + : await this.all( + "SELECT * FROM denominations" + + " WHERE denomination_family_serial = $serial" + + " AND (stamp_expire_withdraw, denom_pub_hash) > ($expiry, $hash)" + + " ORDER BY stamp_expire_withdraw, denom_pub_hash" + + " LIMIT $limit", + { + serial: denominationFamilySerial, + expiry: afterExpiry, + hash: afterHash, + limit: batchSize, + }, + ); + if (rows.length === 0) { + return undefined; + } + for (const row of rows) { + const d = this.rowToDenomination(row); + if (match(d)) { + return d; + } + } + const last = rows[rows.length - 1]; + afterExpiry = num(last.stamp_expire_withdraw); + const lastHash = last.denom_pub_hash; + if (!(lastHash instanceof Uint8Array)) { + throw Error("denominations.denom_pub_hash must be a BLOB column"); + } + afterHash = lastHash; + } + } + + async listAllDenominations(): Promise<WalletDenomination[]> { + const rows = await this.all("SELECT * FROM denominations"); + return rows.map((r) => this.rowToDenomination(r)); + } + + async listAllContractTerms(): Promise<WalletContractTerms[]> { + const rows = await this.all( + "SELECT h, contract_terms_raw FROM contract_terms", + ); + return rows.map((r) => ({ + h: str(r.h), + contractTermsRaw: dbToJson(r.contract_terms_raw), + })); + } + + private rowToDenomination(row: ResultRow): WalletDenomination { + const denom: WalletDenomination = { + exchangeBaseUrl: str(row.exchange_base_url), + denomPubHash: dbToCrock(row.denom_pub_hash), + denomPub: dbToJson(row.denom_pub), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), + currency: str(row.currency), + value: dbAmount(row.value), + ...(row.denomination_family_serial != null + ? { + denominationFamilySerial: num(row.denomination_family_serial), + } + : undefined), + stampStart: dbTimestamp(row.stamp_start), + stampExpireWithdraw: dbTimestamp(row.stamp_expire_withdraw), + stampExpireDeposit: dbTimestamp(row.stamp_expire_deposit), + stampExpireLegal: dbTimestamp(row.stamp_expire_legal), + fees: { + feeDeposit: dbAmount(row.fee_deposit), + feeRefresh: dbAmount(row.fee_refresh), + feeRefund: dbAmount(row.fee_refund), + feeWithdraw: dbAmount(row.fee_withdraw), + }, + isOffered: dbToBool(row.is_offered), + isRevoked: dbToBool(row.is_revoked), + isLost: dbToOptBool(row.is_lost), + masterSig: dbToCrock(row.master_sig), + verificationStatus: num(row.verification_status), + }; + return denom; + } + + // ------------------------------------------------------------- refunds + + async getRefundGroup( + refundGroupId: string, + ): Promise<WalletRefundGroup | undefined> { + const row = await this.first( + "SELECT * FROM refund_groups WHERE refund_group_id = $id", + { id: refundGroupId }, + ); + return row ? this.rowToRefundGroup(row) : undefined; + } + + async upsertRefundGroup(rec: WalletRefundGroup): Promise<void> { + await this.run( + `INSERT INTO refund_groups ( + refund_group_id, proposal_id, status, timestamp_created, + amount_raw, amount_effective, refresh_group_id + ) VALUES ($id, $proposal_id, $status, $ts, $raw, $eff, $refresh_group_id) + ON CONFLICT(refund_group_id) DO UPDATE SET + proposal_id = excluded.proposal_id, + status = excluded.status, + timestamp_created = excluded.timestamp_created, + amount_raw = excluded.amount_raw, + amount_effective = excluded.amount_effective, + refresh_group_id = excluded.refresh_group_id`, + { + id: rec.refundGroupId, + proposal_id: rec.proposalId, + status: rec.status, + ts: rec.timestampCreated, + raw: rec.amountRaw, + eff: rec.amountEffective, + refresh_group_id: rec.refreshGroupId ?? null, + }, + ); + } + + async deleteRefundGroup(refundGroupId: string): Promise<void> { + await this.run("DELETE FROM refund_groups WHERE refund_group_id = $id", { + id: refundGroupId, + }); + } + + async getRefundGroupsByProposal( + proposalId: string, + ): Promise<WalletRefundGroup[]> { + const rows = await this.all( + "SELECT * FROM refund_groups WHERE proposal_id = $pid", + { pid: proposalId }, + ); + return rows.map((r) => this.rowToRefundGroup(r)); + } + + // Returns the record type, not `unknown`: an earlier version returned + // `unknown` and every call site cast it, which let a `reason` field that + // does not exist on WalletRefundGroup survive review. + private rowToRefundGroup(row: ResultRow): WalletRefundGroup { + return { + refundGroupId: str(row.refund_group_id), + proposalId: str(row.proposal_id), + status: num(row.status), + timestampCreated: dbTimestamp(row.timestamp_created), + amountRaw: dbAmount(row.amount_raw), + amountEffective: dbAmount(row.amount_effective), + ...(row.refresh_group_id != null + ? { refreshGroupId: str(row.refresh_group_id) } + : undefined), + }; + } + + async getRefundItemsByGroup( + refundGroupId: string, + ): Promise<WalletRefundItem[]> { + const rows = await this.all( + "SELECT * FROM refund_items WHERE refund_group_id = $id", + { id: refundGroupId }, + ); + return rows.map((r) => this.rowToRefundItem(r)); + } + + async listAllRefundItems(): Promise<WalletRefundItem[]> { + const rows = await this.all("SELECT * FROM refund_items"); + return rows.map((r) => this.rowToRefundItem(r)); + } + + async upsertRefundItem(rec: WalletRefundItem): Promise<number> { + if (rec.id != null) { + await this.run( + `INSERT INTO refund_items ( + id, refund_group_id, status, proposal_id, execution_time, + obtained_time, refund_amount, coin_pub, rtxid + ) VALUES ($id, $gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid) + ON CONFLICT(id) DO UPDATE SET + refund_group_id = excluded.refund_group_id, + status = excluded.status, + proposal_id = excluded.proposal_id, + execution_time = excluded.execution_time, + obtained_time = excluded.obtained_time, + refund_amount = excluded.refund_amount, + coin_pub = excluded.coin_pub, + rtxid = excluded.rtxid`, + this.refundItemParams(rec, rec.id), + ); + return rec.id; + } + const res = await this.run( + `INSERT INTO refund_items ( + refund_group_id, status, proposal_id, execution_time, + obtained_time, refund_amount, coin_pub, rtxid + ) VALUES ($gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid)`, + this.refundItemParams(rec, undefined), + ); + return Number(res.lastInsertRowid); + } + + private refundItemParams( + rec: WalletRefundItem, + id: number | undefined, + ): Record<string, any> { + const p: Record<string, any> = { + gid: rec.refundGroupId, + status: rec.status, + pid: rec.proposalId ?? null, + exec: rec.executionTime, + obt: rec.obtainedTime, + amt: rec.refundAmount, + coin: crockToDb(rec.coinPub), + rtxid: rec.rtxid, + }; + if (id !== undefined) { + p.id = id; + } + return p; + } + + async deleteRefundItem(id: number): Promise<void> { + await this.run("DELETE FROM refund_items WHERE id = $id", { id }); + } + + async getRefundItemByCoinAndRtxid( + coinPub: string, + rtxid: number, + ): Promise<WalletRefundItem | undefined> { + const row = await this.first( + "SELECT * FROM refund_items WHERE coin_pub = $coin AND rtxid = $rtxid", + { coin: crockToDb(coinPub), rtxid }, + ); + return row ? this.rowToRefundItem(row) : undefined; + } + + private rowToRefundItem(row: ResultRow): WalletRefundItem { + return { + id: num(row.id), + refundGroupId: str(row.refund_group_id), + status: num(row.status), + proposalId: optStr(row.proposal_id), + executionTime: dbTimestamp(row.execution_time), + obtainedTime: dbTimestamp(row.obtained_time), + refundAmount: dbAmount(row.refund_amount), + coinPub: dbToCrock(row.coin_pub), + rtxid: num(row.rtxid), + }; + } + + // --------------------------------------------------------------- coins + + private rowToCoin(row: ResultRow): WalletCoin { + return { + coinPub: dbToCrock(row.coin_pub), + coinPriv: dbToCrock(row.coin_priv), + exchangeBaseUrl: str(row.exchange_base_url), + // Coins written before the column existed and whose denomination had + // already been deleted have no key recorded. Empty rather than absent: + // the field is required on the record. + exchangeMasterPub: + row.exchange_master_pub == null + ? "" + : dbToCrock(row.exchange_master_pub), + denomPubHash: dbToCrock(row.denom_pub_hash), + denomSig: dbToJson(row.denom_sig), + blindingKey: dbToCrock(row.blinding_key), + exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), + 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 + // `AgeCommitmentProof | undefined`, i.e. the key is required. + ageCommitmentProof: dbToOptJson(row.age_commitment_proof), + coinSource: dbToJson(row.coin_source), + ...(row.visible != null ? { visible: num(row.visible) } : undefined), + ...(row.source_transaction_id != null + ? { sourceTransactionId: str(row.source_transaction_id) } + : undefined), + }; + } + + async getCoin(coinPub: string): Promise<WalletCoin | undefined> { + const row = await this.first("SELECT * FROM coins WHERE coin_pub = $pub", { + pub: crockToDb(coinPub), + }); + return row ? this.rowToCoin(row) : undefined; + } + + async upsertCoin(coin: WalletCoin): Promise<void> { + await this.run( + `INSERT INTO coins ( + coin_pub, coin_priv, exchange_base_url, exchange_master_pub, + denom_pub_hash, denom_sig, + blinding_key, exchange_withdraw_values, coin_ev_hash, status, visible, max_age, + age_commitment_proof, coin_source, source_transaction_id + ) VALUES ( + $pub, $priv, $url, $emp, $dph, $sig, $bk, $ewv, $ceh, $status, $visible, + $age, $acp, $source, $stid + ) + ON CONFLICT(coin_pub) DO UPDATE SET + coin_priv = excluded.coin_priv, + exchange_base_url = excluded.exchange_base_url, + exchange_master_pub = excluded.exchange_master_pub, + denom_pub_hash = excluded.denom_pub_hash, + denom_sig = excluded.denom_sig, + blinding_key = excluded.blinding_key, + exchange_withdraw_values = excluded.exchange_withdraw_values, + coin_ev_hash = excluded.coin_ev_hash, + status = excluded.status, + visible = excluded.visible, + max_age = excluded.max_age, + age_commitment_proof = excluded.age_commitment_proof, + coin_source = excluded.coin_source, + source_transaction_id = excluded.source_transaction_id`, + { + pub: crockToDb(coin.coinPub), + priv: crockToDb(coin.coinPriv), + url: coin.exchangeBaseUrl, + emp: optCrockToDb(coin.exchangeMasterPub) ?? null, + dph: crockToDb(coin.denomPubHash), + sig: jsonToDb(coin.denomSig), + bk: crockToDb(coin.blindingKey), + ewv: jsonToDb(coin.exchangeWithdrawValues), + ceh: crockToDb(coin.coinEvHash), + status: coin.status, + visible: coin.visible ?? null, + age: coin.maxAge, + acp: + coin.ageCommitmentProof === undefined + ? null + : jsonToDb(coin.ageCommitmentProof), + source: jsonToDb(coin.coinSource), + stid: coin.sourceTransactionId ?? null, + }, + ); + } + + async listAllCoins(): Promise<WalletCoin[]> { + const rows = await this.all("SELECT * FROM coins"); + return rows.map((r) => this.rowToCoin(r)); + } + + async getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]> { + const rows = await this.all( + "SELECT * FROM coins WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToCoin(r)); + } + + async countCoinsByExchange(exchangeBaseUrl: string): Promise<number> { + const row = await this.first( + "SELECT COUNT(*) AS n FROM coins WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return num(row?.n); + } + + async getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]> { + const rows = await this.all( + "SELECT * FROM coins WHERE denom_pub_hash = $dph", + { dph: crockToDb(denomPubHash) }, + ); + return rows.map((r) => this.rowToCoin(r)); + } + + async getCoinsByDenomPubHashes( + denomPubHashes: string[], + ): Promise<WalletCoin[]> { + const unique = new Map<string, Uint8Array>(); + for (const hash of denomPubHashes) { + const blob = crockToDb(hash); + unique.set(blobKey(blob), blob); + } + const blobs = [...unique.values()]; + const coins: WalletCoin[] = []; + for (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM coins WHERE denom_pub_hash IN (${placeholders.join(", ")})`, + params, + ); + coins.push(...rows.map((row) => this.rowToCoin(row))); + } + return coins; + } + + async getCoinsBySourceTransaction( + transactionId: string, + ): Promise<WalletCoin[]> { + const rows = await this.all( + "SELECT * FROM coins WHERE source_transaction_id = $tid", + { tid: transactionId }, + ); + return rows.map((r) => this.rowToCoin(r)); + } + + async getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]> { + // One statement for the whole batch, then reordered in memory. The + // IndexedDB version loops per pub, which is fine there but is a network + // round-trip each on this backend -- refresh passes every coin of a + // group through here. Contract is unchanged: missing pubs are skipped + // rather than yielding holes, and the result follows argument order. + if (coinPubs.length === 0) { + return []; + } + const blobs = coinPubs.map((pub) => crockToDb(pub)); + // 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 (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.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, + ); + for (const row of rows) { + 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 blob of blobs) { + const coin = byKey.get(blobKey(blob)); + if (coin) { + coins.push(coin); + } + } + return coins; + } + + async getFreshCoinsByDenomAndAge( + ref: WalletCoinAvailabilityRef, + limit: number, + ): Promise<WalletCoin[]> { + const rows = await this.all( + "SELECT * FROM coins" + + " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + + " AND max_age = $age AND status = $status" + + " ORDER BY coin_pub" + + " LIMIT $limit", + { + mpk: crockToDb(ref.exchangeMasterPub), + dph: crockToDb(ref.denomPubHash), + age: ref.maxAge, + status: CoinStatus.Fresh, + limit, + }, + ); + return rows.map((r) => this.rowToCoin(r)); + } + + async deleteCoin(coinPub: string): Promise<void> { + await this.run("DELETE FROM coins WHERE coin_pub = $pub", { + pub: crockToDb(coinPub), + }); + } + + // ------------------------------------------------------ coin history + + async getCoinHistory( + coinPub: string, + ): Promise<WalletCoinHistory | undefined> { + const row = await this.first( + "SELECT * FROM coin_history WHERE coin_pub = $pub", + { pub: crockToDb(coinPub) }, + ); + if (!row) { + return undefined; + } + return this.rowToCoinHistory(row); + } + + private rowToCoinHistory(row: ResultRow): WalletCoinHistory { + return { + coinPub: dbToCrock(row.coin_pub), + history: dbToJson(row.history), + }; + } + + async getCoinHistoriesByPubs( + coinPubs: string[], + ): Promise<WalletCoinHistory[]> { + if (coinPubs.length === 0) { + return []; + } + const blobs = coinPubs.map((coinPub) => crockToDb(coinPub)); + const byKey = new Map<string, WalletCoinHistory>(); + for (let offset = 0; offset < blobs.length; offset += 500) { + const chunk = blobs.slice(offset, offset + 500); + const params: Record<string, Uint8Array> = {}; + const placeholders = chunk.map((blob, i) => { + params[`p${i}`] = blob; + return `$p${i}`; + }); + const rows = await this.all( + `SELECT * FROM coin_history WHERE coin_pub IN (${placeholders.join(", ")})`, + params, + ); + for (const row of rows) { + const raw = row.coin_pub; + if (!(raw instanceof Uint8Array)) { + throw Error("coin_history.coin_pub must be a BLOB column"); + } + byKey.set(blobKey(raw), this.rowToCoinHistory(row)); + } + } + return blobs.flatMap((blob) => { + const record = byKey.get(blobKey(blob)); + return record ? [record] : []; + }); + } + + async listAllCoinHistories(): Promise<WalletCoinHistory[]> { + const rows = await this.all("SELECT * FROM coin_history"); + return rows.map((row) => this.rowToCoinHistory(row)); + } + + async upsertCoinHistory(rec: WalletCoinHistory): Promise<void> { + await this.run( + "INSERT INTO coin_history (coin_pub, history) VALUES ($pub, $h)" + + " ON CONFLICT(coin_pub) DO UPDATE SET history = excluded.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: crockToDb(coinPub), + }); + } + + // -------------------------------------------------- coin availability + + private rowToCoinAvailability(row: ResultRow): WalletCoinAvailability { + return { + exchangeBaseUrl: str(row.exchange_base_url), + 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), + hasFreshCoins: num(row.has_fresh_coins) === 1 ? 1 : 0, + visibleCoinCount: num(row.visible_coin_count), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), + ...(row.pending_refresh_output_count != null + ? { pendingRefreshOutputCount: num(row.pending_refresh_output_count) } + : undefined), + }; + } + + async getCoinAvailability( + ref: WalletCoinAvailabilityRef, + ): Promise<WalletCoinAvailability | undefined> { + const row = await this.first( + "SELECT * FROM coin_availability" + + " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + + " AND max_age = $age", + { + mpk: crockToDb(ref.exchangeMasterPub), + dph: crockToDb(ref.denomPubHash), + age: ref.maxAge, + }, + ); + return row ? this.rowToCoinAvailability(row) : undefined; + } + + async getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]> { + if (refs.length === 0) { + return []; + } + const encoded = refs.map((ref) => { + const masterPub = crockToDb(ref.exchangeMasterPub); + const denomPubHash = crockToDb(ref.denomPubHash); + return { + masterPub, + denomPubHash, + maxAge: ref.maxAge, + key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${ref.maxAge}`, + }; + }); + const byKey = new Map<string, WalletCoinAvailability>(); + // Three parameters per reference, again below sqlite's traditional limit. + for (let offset = 0; offset < encoded.length; offset += 300) { + const chunk = encoded.slice(offset, offset + 300); + const params: Record<string, Sqlite3Value> = {}; + const values = chunk.map((ref, i) => { + params[`mpk${i}`] = ref.masterPub; + params[`dph${i}`] = ref.denomPubHash; + params[`age${i}`] = ref.maxAge; + return `($mpk${i}, $dph${i}, $age${i})`; + }); + const rows = await this.all( + "SELECT * FROM coin_availability" + + ` WHERE (exchange_master_pub, denom_pub_hash, max_age) IN (${values.join(", ")})`, + params, + ); + for (const row of rows) { + const masterPub = row.exchange_master_pub; + const denomPubHash = row.denom_pub_hash; + if ( + !(masterPub instanceof Uint8Array) || + !(denomPubHash instanceof Uint8Array) + ) { + throw Error("coin availability identity columns must be BLOBs"); + } + byKey.set( + `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${num(row.max_age)}`, + this.rowToCoinAvailability(row), + ); + } + } + return encoded.flatMap((ref) => { + const record = byKey.get(ref.key); + return record ? [record] : []; + }); + } + + async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { + await this.run( + `INSERT INTO coin_availability ( + exchange_base_url, denom_pub_hash, max_age, currency, value, + exchange_master_pub, fresh_coin_count, has_fresh_coins, + visible_coin_count, + pending_refresh_output_count + ) VALUES ( + $url, $dph, $age, $cur, $val, $emp, $fresh, $hasFresh, $vis, $pend + ) + ON CONFLICT(exchange_master_pub, denom_pub_hash, max_age) DO UPDATE SET + currency = excluded.currency, + value = excluded.value, + exchange_master_pub = excluded.exchange_master_pub, + fresh_coin_count = excluded.fresh_coin_count, + has_fresh_coins = excluded.has_fresh_coins, + visible_coin_count = excluded.visible_coin_count, + pending_refresh_output_count = + excluded.pending_refresh_output_count`, + { + url: rec.exchangeBaseUrl, + dph: crockToDb(rec.denomPubHash), + age: rec.maxAge, + cur: rec.currency, + val: rec.value, + emp: optCrockToDb(rec.exchangeMasterPub), + fresh: rec.freshCoinCount, + hasFresh: rec.freshCoinCount > 0 ? 1 : 0, + vis: rec.visibleCoinCount, + pend: rec.pendingRefreshOutputCount ?? null, + }, + ); + } + + async getCoinAvailabilities(): Promise<WalletCoinAvailability[]> { + const rows = await this.all("SELECT * FROM coin_availability"); + return rows.map((r) => this.rowToCoinAvailability(r)); + } + + async getCoinAvailabilityByExchange( + exchangeBaseUrl: string, + ): Promise<WalletCoinAvailability[]> { + const rows = await this.all( + "SELECT * FROM coin_availability WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToCoinAvailability(r)); + } + + async getCoinAvailabilityByExchangeAndAgeRange( + exchangeBaseUrl: string, + ageLower: number, + ageUpper: number, + ): Promise<WalletCoinAvailability[]> { + const rows = await this.all( + "SELECT * FROM coin_availability" + + " WHERE exchange_base_url = $url" + + " AND has_fresh_coins = 1" + + " AND max_age BETWEEN $lower AND $upper", + { + url: exchangeBaseUrl, + lower: ageLower, + upper: ageUpper, + }, + ); + return rows.map((r) => this.rowToCoinAvailability(r)); + } + + async deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void> { + await this.run( + "DELETE FROM coin_availability" + + " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + + " AND max_age = $age", + { + mpk: crockToDb(ref.exchangeMasterPub), + dph: crockToDb(ref.denomPubHash), + age: ref.maxAge, + }, + ); + } + + // ----------------------------------------------------------- exchanges + + private rowToExchange(row: ResultRow): WalletExchangeEntry { + return { + baseUrl: str(row.base_url), + // Required key: undefined when there is no pointer. + detailsPointer: + row.details_pointer_master_pub == null + ? undefined + : { + masterPublicKey: dbToCrock(row.details_pointer_master_pub), + currency: str(row.details_pointer_currency), + updateClock: dbTimestamp(row.details_pointer_update_clock), + }, + entryStatus: num(row.entry_status), + updateStatus: num(row.update_status), + tosCurrentEtag: optStr(row.tos_current_etag), + tosAcceptedEtag: optStr(row.tos_accepted_etag), + tosAcceptedTimestamp: + row.tos_accepted_timestamp == null + ? undefined + : dbTimestamp(row.tos_accepted_timestamp), + lastUpdate: + row.last_update == null ? undefined : dbTimestamp(row.last_update), + nextUpdateStamp: dbTimestamp(row.next_update_stamp), + lastKeysEtag: optStr(row.last_keys_etag), + nextRefreshCheckStamp: dbTimestamp(row.next_refresh_check_stamp), + ...(row.preset_currency_hint != null + ? { presetCurrencyHint: str(row.preset_currency_hint) } + : undefined), + ...(row.preset_currency_spec != null + ? { presetCurrencySpec: dbToJson(row.preset_currency_spec) } + : undefined), + ...(row.preset_type != null + ? { presetType: str(row.preset_type) } + : undefined), + ...(row.source != null + ? { source: str(row.source) as ExchangeEntrySource } + : undefined), + ...(row.last_withdrawal != null + ? { lastWithdrawal: dbTimestamp(row.last_withdrawal) } + : undefined), + ...(row.unavailable_reason != null + ? { unavailableReason: dbToJson(row.unavailable_reason) } + : undefined), + ...(row.cachebreak_next_update != null + ? { cachebreakNextUpdate: dbToBool(row.cachebreak_next_update) } + : undefined), + ...(row.current_merge_reserve_row_id != null + ? { currentMergeReserveRowId: num(row.current_merge_reserve_row_id) } + : undefined), + ...(row.current_account_priv != null + ? { currentAccountPriv: dbToCrock(row.current_account_priv) } + : undefined), + ...(row.current_account_pub != null + ? { currentAccountPub: dbToCrock(row.current_account_pub) } + : undefined), + ...(row.peer_payments_disabled != null + ? { peerPaymentsDisabled: dbToBool(row.peer_payments_disabled) } + : undefined), + ...(row.direct_deposit_disabled != null + ? { directDepositDisabled: dbToBool(row.direct_deposit_disabled) } + : undefined), + ...(row.no_fees != null ? { noFees: dbToBool(row.no_fees) } : undefined), + ...(row.superseded_master_pub != null + ? { + supersededKeySet: { + masterPublicKey: dbToCrock(row.superseded_master_pub), + currency: str(row.superseded_currency), + firstSeen: dbTimestamp(row.superseded_first_seen), + sharesDenominations: dbToBool(row.superseded_shares_denoms), + }, + } + : undefined), + }; + } + + async getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined> { + const row = await this.first( + "SELECT * FROM exchanges WHERE base_url = $url", + { url: baseUrl }, + ); + return row ? this.rowToExchange(row) : undefined; + } + + async getExchanges(): Promise<WalletExchangeEntry[]> { + const rows = await this.all("SELECT * FROM exchanges"); + return rows.map((r) => this.rowToExchange(r)); + } + + async upsertExchange(rec: WalletExchangeEntry): Promise<void> { + await this.run( + `INSERT INTO exchanges ( + base_url, preset_currency_hint, preset_currency_spec, preset_type, + source, + last_withdrawal, details_pointer_master_pub, + details_pointer_currency, details_pointer_update_clock, + entry_status, update_status, unavailable_reason, + cachebreak_next_update, tos_current_etag, tos_accepted_etag, + tos_accepted_timestamp, last_update, next_update_stamp, + last_keys_etag, next_refresh_check_stamp, + current_merge_reserve_row_id, current_account_priv, + current_account_pub, peer_payments_disabled, + direct_deposit_disabled, no_fees, + superseded_master_pub, superseded_currency, + superseded_first_seen, superseded_shares_denoms + ) VALUES ( + $url, $pch, $pcs, $pt, $src, $lw, $dpmp, $dpc, $dpuc, $es, $us, $ur, + $cnu, $tce, $tae, $tat, $lu, $nus, $lke, $nrcs, $cmrri, $cap, + $capub, $ppd, $ddd, $nf, $smp, $sc, $sfs, $ssd + ) + ON CONFLICT(base_url) DO UPDATE SET + preset_currency_hint = excluded.preset_currency_hint, + preset_currency_spec = excluded.preset_currency_spec, + preset_type = excluded.preset_type, + source = excluded.source, + last_withdrawal = excluded.last_withdrawal, + details_pointer_master_pub = excluded.details_pointer_master_pub, + details_pointer_currency = excluded.details_pointer_currency, + details_pointer_update_clock = + excluded.details_pointer_update_clock, + entry_status = excluded.entry_status, + update_status = excluded.update_status, + unavailable_reason = excluded.unavailable_reason, + cachebreak_next_update = excluded.cachebreak_next_update, + tos_current_etag = excluded.tos_current_etag, + tos_accepted_etag = excluded.tos_accepted_etag, + tos_accepted_timestamp = excluded.tos_accepted_timestamp, + last_update = excluded.last_update, + next_update_stamp = excluded.next_update_stamp, + last_keys_etag = excluded.last_keys_etag, + next_refresh_check_stamp = excluded.next_refresh_check_stamp, + current_merge_reserve_row_id = + excluded.current_merge_reserve_row_id, + current_account_priv = excluded.current_account_priv, + current_account_pub = excluded.current_account_pub, + peer_payments_disabled = excluded.peer_payments_disabled, + direct_deposit_disabled = excluded.direct_deposit_disabled, + no_fees = excluded.no_fees, + superseded_master_pub = excluded.superseded_master_pub, + superseded_currency = excluded.superseded_currency, + superseded_first_seen = excluded.superseded_first_seen, + superseded_shares_denoms = excluded.superseded_shares_denoms`, + { + url: rec.baseUrl, + pch: rec.presetCurrencyHint ?? null, + pcs: + rec.presetCurrencySpec === undefined + ? null + : jsonToDb(rec.presetCurrencySpec), + pt: rec.presetType ?? null, + src: rec.source ?? null, + lw: rec.lastWithdrawal ?? null, + dpmp: optCrockToDb(rec.detailsPointer?.masterPublicKey), + dpc: rec.detailsPointer?.currency ?? null, + dpuc: rec.detailsPointer?.updateClock ?? null, + smp: optCrockToDb(rec.supersededKeySet?.masterPublicKey), + sc: rec.supersededKeySet?.currency ?? null, + sfs: rec.supersededKeySet?.firstSeen ?? null, + ssd: + rec.supersededKeySet === undefined + ? null + : boolToDb(rec.supersededKeySet.sharesDenominations), + es: rec.entryStatus, + us: rec.updateStatus, + ur: + rec.unavailableReason === undefined + ? null + : jsonToDb(rec.unavailableReason), + cnu: boolToDb(rec.cachebreakNextUpdate), + tce: rec.tosCurrentEtag ?? null, + tae: rec.tosAcceptedEtag ?? null, + tat: rec.tosAcceptedTimestamp ?? null, + lu: rec.lastUpdate ?? null, + nus: rec.nextUpdateStamp, + lke: rec.lastKeysEtag ?? null, + nrcs: rec.nextRefreshCheckStamp, + cmrri: rec.currentMergeReserveRowId ?? null, + cap: optCrockToDb(rec.currentAccountPriv), + capub: optCrockToDb(rec.currentAccountPub), + ppd: boolToDb(rec.peerPaymentsDisabled), + ddd: boolToDb(rec.directDepositDisabled), + nf: boolToDb(rec.noFees), + }, + ); + } + + async deleteExchange(baseUrl: string): Promise<void> { + await this.run("DELETE FROM exchanges WHERE base_url = $url", { + url: baseUrl, + }); + } + + // ---------------------------------------------------- exchange details + + private rowToExchangeDetails(row: ResultRow): WalletExchangeDetails { + return { + rowId: num(row.row_id), + exchangeBaseUrl: str(row.exchange_base_url), + masterPublicKey: dbToCrock(row.master_public_key), + currency: str(row.currency), + auditors: dbToJson(row.auditors), + protocolVersionRange: str(row.protocol_version_range), + tinyAmount: dbAmount(row.tiny_amount), + reserveClosingDelay: dbToJson(row.reserve_closing_delay), + globalFees: dbToJson(row.global_fees), + wireInfo: dbToJson(row.wire_info), + bankComplianceLanguage: optStr(row.bank_compliance_language), + defaultPeerPushExpiration: dbToOptJson(row.default_peer_push_expiration), + ...(row.shopping_url != null + ? { shoppingUrl: str(row.shopping_url) } + : undefined), + ...(row.age_mask != null ? { ageMask: num(row.age_mask) } : undefined), + ...(row.wallet_balance_limits != null + ? { walletBalanceLimits: dbToJson(row.wallet_balance_limits) } + : undefined), + ...(row.hard_limits != null + ? { hardLimits: dbToJson(row.hard_limits) } + : undefined), + ...(row.zero_limits != null + ? { zeroLimits: dbToJson(row.zero_limits) } + : undefined), + }; + } + + private exchangeDetailsCols(rec: WalletExchangeDetails) { + return { + url: rec.exchangeBaseUrl, + mpk: crockToDb(rec.masterPublicKey), + cur: rec.currency, + aud: jsonToDb(rec.auditors), + pvr: rec.protocolVersionRange, + tiny: rec.tinyAmount, + rcd: jsonToDb(rec.reserveClosingDelay), + surl: rec.shoppingUrl ?? null, + gf: jsonToDb(rec.globalFees), + wi: jsonToDb(rec.wireInfo), + am: rec.ageMask ?? null, + wbl: + rec.walletBalanceLimits === undefined + ? null + : jsonToDb(rec.walletBalanceLimits), + hl: rec.hardLimits === undefined ? null : jsonToDb(rec.hardLimits), + zl: rec.zeroLimits === undefined ? null : jsonToDb(rec.zeroLimits), + bcl: rec.bankComplianceLanguage ?? null, + dppe: + rec.defaultPeerPushExpiration === undefined + ? null + : jsonToDb(rec.defaultPeerPushExpiration), + }; + } + + async upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number> { + const names = + "exchange_base_url, master_public_key, currency, auditors," + + " protocol_version_range, tiny_amount, reserve_closing_delay," + + " shopping_url, global_fees, wire_info, age_mask," + + " wallet_balance_limits, hard_limits, zero_limits," + + " bank_compliance_language, default_peer_push_expiration"; + const values = + "$url, $mpk, $cur, $aud, $pvr, $tiny, $rcd, $surl, $gf, $wi, $am," + + " $wbl, $hl, $zl, $bcl, $dppe"; + const cols = this.exchangeDetailsCols(rec); + if (rec.rowId != null) { + await this.run( + `INSERT INTO exchange_details (row_id, ${names})` + + ` VALUES ($row_id, ${values})` + + " ON CONFLICT(row_id) DO UPDATE SET" + + " exchange_base_url = excluded.exchange_base_url," + + " master_public_key = excluded.master_public_key," + + " currency = excluded.currency," + + " auditors = excluded.auditors," + + " protocol_version_range = excluded.protocol_version_range," + + " tiny_amount = excluded.tiny_amount," + + " reserve_closing_delay = excluded.reserve_closing_delay," + + " shopping_url = excluded.shopping_url," + + " global_fees = excluded.global_fees," + + " wire_info = excluded.wire_info," + + " age_mask = excluded.age_mask," + + " wallet_balance_limits = excluded.wallet_balance_limits," + + " hard_limits = excluded.hard_limits," + + " zero_limits = excluded.zero_limits," + + " bank_compliance_language = excluded.bank_compliance_language," + + " default_peer_push_expiration =" + + " excluded.default_peer_push_expiration", + { row_id: rec.rowId, ...cols }, + ); + return rec.rowId; + } + const res = await this.run( + `INSERT INTO exchange_details (${names}) VALUES (${values})`, + cols, + ); + return Number(res.lastInsertRowid); + } + + async getExchangeDetailsByPointer( + exchangeBaseUrl: string, + currency: string, + masterPublicKey: string, + ): Promise<WalletExchangeDetails | undefined> { + const row = await this.first( + "SELECT * FROM exchange_details" + + " WHERE exchange_base_url = $url AND currency = $cur" + + " AND master_public_key = $mpk", + { url: exchangeBaseUrl, cur: currency, mpk: crockToDb(masterPublicKey) }, + ); + return row ? this.rowToExchangeDetails(row) : undefined; + } + + async getExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined> { + const row = await this.first( + "SELECT * FROM exchange_details WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return row ? this.rowToExchangeDetails(row) : undefined; + } + + async listExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails[]> { + const rows = await this.all( + "SELECT * FROM exchange_details WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToExchangeDetails(r)); + } + + async listExchangeDetailsByMasterPub( + masterPublicKey: string, + ): Promise<WalletExchangeDetails[]> { + const rows = await this.all( + "SELECT * FROM exchange_details WHERE master_public_key = $pub", + { pub: crockToDb(masterPublicKey) }, + ); + return rows.map((r) => this.rowToExchangeDetails(r)); + } + + async listAllExchangeDetails(): Promise<WalletExchangeDetails[]> { + const rows = await this.all("SELECT * FROM exchange_details"); + return rows.map((r) => this.rowToExchangeDetails(r)); + } + + async getExchangeDetailsByRowId( + rowId: number, + ): Promise<WalletExchangeDetails | undefined> { + const row = await this.first( + "SELECT * FROM exchange_details WHERE row_id = $id", + { id: rowId }, + ); + return row ? this.rowToExchangeDetails(row) : undefined; + } + + async deleteExchangeDetails(rowId: number): Promise<void> { + await this.run("DELETE FROM exchange_details WHERE row_id = $id", { + id: rowId, + }); + } + + async getExchangeDetails( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined> { + const exchange = await this.getExchange(exchangeBaseUrl); + if (!exchange || !exchange.detailsPointer) { + return undefined; + } + return await this.getExchangeDetailsByPointer( + exchange.baseUrl, + exchange.detailsPointer.currency, + exchange.detailsPointer.masterPublicKey, + ); + } + + // -------------------------------------------------- exchange sign keys + + async getExchangeSignKeysByDetailsRowId( + exchangeDetailsRowId: number, + ): Promise<WalletExchangeSignkeys[]> { + const rows = await this.all( + "SELECT * FROM exchange_sign_keys WHERE exchange_details_row_id = $id", + { id: exchangeDetailsRowId }, + ); + return rows.map((row) => ({ + exchangeDetailsRowId: num(row.exchange_details_row_id), + signkeyPub: dbToCrock(row.signkey_pub), + stampStart: dbTimestamp(row.stamp_start), + stampExpire: dbTimestamp(row.stamp_expire), + stampEnd: dbTimestamp(row.stamp_end), + masterSig: dbToCrock(row.master_sig), + })); + } + + async listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]> { + const rows = await this.all("SELECT * FROM exchange_sign_keys"); + return rows.map((row) => ({ + exchangeDetailsRowId: num(row.exchange_details_row_id), + signkeyPub: dbToCrock(row.signkey_pub), + stampStart: dbTimestamp(row.stamp_start), + stampExpire: dbTimestamp(row.stamp_expire), + stampEnd: dbTimestamp(row.stamp_end), + masterSig: dbToCrock(row.master_sig), + })); + } + + async upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void> { + await this.run( + `INSERT INTO exchange_sign_keys ( + exchange_details_row_id, signkey_pub, stamp_start, stamp_expire, + stamp_end, master_sig + ) VALUES ($id, $pub, $start, $expire, $end, $sig) + ON CONFLICT(exchange_details_row_id, signkey_pub) DO UPDATE SET + stamp_start = excluded.stamp_start, + stamp_expire = excluded.stamp_expire, + stamp_end = excluded.stamp_end, + master_sig = excluded.master_sig`, + { + id: rec.exchangeDetailsRowId, + pub: crockToDb(rec.signkeyPub), + start: rec.stampStart, + expire: rec.stampExpire, + end: rec.stampEnd, + sig: crockToDb(rec.masterSig), + }, + ); + } + + async deleteExchangeSignKey( + exchangeDetailsRowId: number, + signkeyPub: string, + ): Promise<void> { + await this.run( + "DELETE FROM exchange_sign_keys" + + " WHERE exchange_details_row_id = $id AND signkey_pub = $pub", + { id: exchangeDetailsRowId, pub: crockToDb(signkeyPub) }, + ); + } + + // ----------------------------------------------- denomination families + + async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { + const rows = await this.all("SELECT * FROM denomination_families"); + return rows.map((r) => this.rowToDenominationFamily(r)); + } + + private rowToDenominationFamily(row: ResultRow): WalletDenominationFamily { + return { + denominationFamilySerial: num(row.denomination_family_serial), + familyParams: { + exchangeBaseUrl: str(row.exchange_base_url), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), + value: dbAmount(row.value), + feeWithdraw: dbAmount(row.fee_withdraw), + feeDeposit: dbAmount(row.fee_deposit), + feeRefresh: dbAmount(row.fee_refresh), + feeRefund: dbAmount(row.fee_refund), + }, + }; + } + + async upsertDenominationFamily( + rec: WalletDenominationFamily, + ): Promise<number> { + const p = rec.familyParams; + const cols = { + url: p.exchangeBaseUrl, + mpub: crockToDb(p.exchangeMasterPub), + val: p.value, + fw: p.feeWithdraw, + fd: p.feeDeposit, + frs: p.feeRefresh, + frf: p.feeRefund, + }; + const names = + "exchange_base_url, exchange_master_pub, value," + + " fee_withdraw, fee_deposit, fee_refresh, fee_refund"; + const values = "$url, $mpub, $val, $fw, $fd, $frs, $frf"; + if (rec.denominationFamilySerial != null) { + await this.run( + `INSERT INTO denomination_families + (denomination_family_serial, ${names}) + VALUES ($serial, ${values}) + ON CONFLICT(denomination_family_serial) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + exchange_master_pub = excluded.exchange_master_pub, + value = excluded.value, + fee_withdraw = excluded.fee_withdraw, + fee_deposit = excluded.fee_deposit, + fee_refresh = excluded.fee_refresh, + fee_refund = excluded.fee_refund`, + { serial: rec.denominationFamilySerial, ...cols }, + ); + return rec.denominationFamilySerial; + } + const res = await this.run( + `INSERT INTO denomination_families (${names}) VALUES (${values})`, + cols, + ); + return Number(res.lastInsertRowid); + } + + async getDenominationFamilyByParams( + params: WalletDenomFamilyParams, + ): Promise<WalletDenominationFamily | undefined> { + const row = await this.first( + "SELECT * FROM denomination_families" + + " WHERE exchange_base_url = $url AND exchange_master_pub = $mpub" + + " AND value = $val AND fee_withdraw = $fw AND fee_deposit = $fd" + + " AND fee_refresh = $frs AND fee_refund = $frf", + { + url: params.exchangeBaseUrl, + mpub: crockToDb(params.exchangeMasterPub), + val: params.value, + fw: params.feeWithdraw, + fd: params.feeDeposit, + frs: params.feeRefresh, + frf: params.feeRefund, + }, + ); + return row ? this.rowToDenominationFamily(row) : undefined; + } + + async getDenominationFamiliesByExchange( + exchangeBaseUrl: string, + ): Promise<WalletDenominationFamily[]> { + const rows = await this.all( + "SELECT * FROM denomination_families WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToDenominationFamily(r)); + } + + async deleteDenominationFamily( + denominationFamilySerial: number, + ): Promise<void> { + await this.run( + "DELETE FROM denomination_families" + + " WHERE denomination_family_serial = $serial", + { serial: denominationFamilySerial }, + ); + } + + // ------------------------------------------- base URL fixups / mig log + + async getExchangeBaseUrlFixup( + exchangeBaseUrl: string, + ): Promise<WalletExchangeBaseUrlFixup | undefined> { + const row = await this.first( + "SELECT * FROM exchange_base_url_fixups WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + if (!row) { + return undefined; + } + return { + exchangeBaseUrl: str(row.exchange_base_url), + replacement: str(row.replacement), + }; + } + + async upsertExchangeBaseUrlFixup( + rec: WalletExchangeBaseUrlFixup, + ): Promise<void> { + await this.run( + "INSERT INTO exchange_base_url_fixups (exchange_base_url, replacement)" + + " VALUES ($url, $repl)" + + " ON CONFLICT(exchange_base_url) DO UPDATE SET" + + " replacement = excluded.replacement", + { url: rec.exchangeBaseUrl, repl: rec.replacement }, + ); + } + + async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { + const rows = await this.all("SELECT * FROM exchange_base_url_fixups"); + return rows.map((r) => ({ + exchangeBaseUrl: str(r.exchange_base_url), + replacement: str(r.replacement), + })); + } + + async getExchangeMigrationLog( + oldExchangeBaseUrl: string, + newExchangeBaseUrl: string, + ): Promise<WalletExchangeMigrationLog | undefined> { + const row = await this.first( + "SELECT * FROM exchange_base_url_migration_log" + + " WHERE old_exchange_base_url = $old AND new_exchange_base_url = $new", + { old: oldExchangeBaseUrl, new: newExchangeBaseUrl }, + ); + if (!row) { + return undefined; + } + return { + oldExchangeBaseUrl: str(row.old_exchange_base_url), + newExchangeBaseUrl: str(row.new_exchange_base_url), + timestamp: dbTimestamp(row.timestamp), + reason: str(row.reason) as ExchangeMigrationReason, + }; + } + + async listAllExchangeMigrationLogEntries(): Promise< + WalletExchangeMigrationLog[] + > { + const rows = await this.all( + "SELECT * FROM exchange_base_url_migration_log", + ); + return rows.map((r) => ({ + oldExchangeBaseUrl: str(r.old_exchange_base_url), + newExchangeBaseUrl: str(r.new_exchange_base_url), + timestamp: dbTimestamp(r.timestamp), + reason: str(r.reason) as ExchangeMigrationReason, + })); + } + + async upsertExchangeMigrationLog( + rec: WalletExchangeMigrationLog, + ): Promise<void> { + await this.run( + `INSERT INTO exchange_base_url_migration_log ( + old_exchange_base_url, new_exchange_base_url, timestamp, reason + ) VALUES ($old, $new, $ts, $reason) + ON CONFLICT(old_exchange_base_url, new_exchange_base_url) DO UPDATE SET + timestamp = excluded.timestamp, + reason = excluded.reason`, + { + old: rec.oldExchangeBaseUrl, + new: rec.newExchangeBaseUrl, + ts: rec.timestamp, + reason: rec.reason, + }, + ); + } + + // -------------------------------------------------- withdrawal groups + + /** + * Split wgInfo into its stored columns. + * + * talerWithdrawUri is deliberately removed from the JSON payload: the + * column is the only copy, so the indexed value and the payload cannot + * drift apart. {@link rowToWithdrawalGroup} puts it back. + */ + private wgInfoToCols(wgInfo: WgInfo) { + const cols = { + wtype: wgInfo.withdrawalType, + uri: null as string | null, + cpriv: null as Uint8Array | null, + binfo: null as string | null, + eca: null as string | null, + }; + switch (wgInfo.withdrawalType) { + case WithdrawalRecordType.BankIntegrated: { + const { talerWithdrawUri, ...rest } = wgInfo.bankInfo; + cols.uri = talerWithdrawUri; + cols.binfo = jsonToDb(rest); + cols.eca = + wgInfo.exchangeCreditAccounts === undefined + ? null + : jsonToDb(wgInfo.exchangeCreditAccounts); + break; + } + case WithdrawalRecordType.BankManual: + cols.eca = + wgInfo.exchangeCreditAccounts === undefined + ? null + : jsonToDb(wgInfo.exchangeCreditAccounts); + break; + case WithdrawalRecordType.PeerPullCredit: + cols.cpriv = crockToDb(wgInfo.contractPriv); + break; + case WithdrawalRecordType.PeerPushCredit: + case WithdrawalRecordType.Recoup: + break; + } + return cols; + } + + private rowToWgInfo(row: ResultRow): WgInfo { + const wtype = str(row.withdrawal_type) as WithdrawalRecordType; + switch (wtype) { + case WithdrawalRecordType.BankIntegrated: { + const rest = dbToJson<Omit<ReserveBankInfo, "talerWithdrawUri">>( + row.bank_info, + ); + const wg: WgInfoBankIntegrated = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + ...rest, + // Re-inserted from the column, which is the only copy. + talerWithdrawUri: str(row.taler_withdraw_uri), + }, + ...(row.exchange_credit_accounts != null + ? { + exchangeCreditAccounts: dbToJson(row.exchange_credit_accounts), + } + : undefined), + }; + return wg; + } + case WithdrawalRecordType.BankManual: { + const wg: WgInfoBankManual = { + withdrawalType: WithdrawalRecordType.BankManual, + ...(row.exchange_credit_accounts != null + ? { + exchangeCreditAccounts: dbToJson(row.exchange_credit_accounts), + } + : undefined), + }; + return wg; + } + case WithdrawalRecordType.PeerPullCredit: { + const wg: WgInfoBankPeerPull = { + withdrawalType: WithdrawalRecordType.PeerPullCredit, + contractPriv: dbToCrock(row.contract_priv), + }; + return wg; + } + case WithdrawalRecordType.PeerPushCredit: { + const wg: WgInfoBankPeerPush = { + withdrawalType: WithdrawalRecordType.PeerPushCredit, + }; + return wg; + } + case WithdrawalRecordType.Recoup: { + const wg: WgInfoBankRecoup = { + withdrawalType: WithdrawalRecordType.Recoup, + }; + return wg; + } + } + } + + private rowToWithdrawalGroup(row: ResultRow): WalletWithdrawalGroup { + return { + withdrawalGroupId: str(row.withdrawal_group_id), + wgInfo: this.rowToWgInfo(row), + 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: dbToCrock(row.kyc_payto_hash) } + : undefined), + ...(row.kyc_access_token != null + ? { kycAccessToken: str(row.kyc_access_token) } + : undefined), + ...(row.kyc_last_check_status != null + ? { kycLastCheckStatus: num(row.kyc_last_check_status) } + : undefined), + ...(row.kyc_last_check_code != null + ? { kycLastCheckCode: num(row.kyc_last_check_code) } + : undefined), + ...(row.kyc_last_rule_gen != null + ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } + : undefined), + ...(row.kyc_last_aml_review != null + ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } + : undefined), + ...(row.kyc_last_deny != null + ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } + : undefined), + ...(row.kyc_withdrawal_delay != null + ? { kycWithdrawalDelay: dbToJson(row.kyc_withdrawal_delay) } + : undefined), + ...(row.exchange_base_url != null + ? { exchangeBaseUrl: str(row.exchange_base_url) } + : undefined), + ...(row.timestamp_finish != null + ? { timestampFinish: dbTimestamp(row.timestamp_finish) } + : undefined), + ...(row.restrict_age != null + ? { restrictAge: num(row.restrict_age) } + : undefined), + ...(row.instructed_amount != null + ? { instructedAmount: dbAmount(row.instructed_amount) } + : undefined), + ...(row.reserve_balance_amount != null + ? { reserveBalanceAmount: dbAmount(row.reserve_balance_amount) } + : undefined), + ...(row.raw_withdrawal_amount != null + ? { rawWithdrawalAmount: dbAmount(row.raw_withdrawal_amount) } + : undefined), + ...(row.effective_withdrawal_amount != null + ? { + effectiveWithdrawalAmount: dbAmount( + row.effective_withdrawal_amount, + ), + } + : undefined), + ...(row.denoms_sel != null + ? { denomsSel: dbToJson(row.denoms_sel) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + }; + } + + async getWithdrawalGroup( + withdrawalGroupId: string, + ): Promise<WalletWithdrawalGroup | undefined> { + const row = await this.first( + "SELECT * FROM withdrawal_groups WHERE withdrawal_group_id = $id", + { id: withdrawalGroupId }, + ); + return row ? this.rowToWithdrawalGroup(row) : undefined; + } + + async upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void> { + const wg = this.wgInfoToCols(rec.wgInfo); + await this.run( + `INSERT INTO withdrawal_groups ( + withdrawal_group_id, withdrawal_type, taler_withdraw_uri, + contract_priv, bank_info, exchange_credit_accounts, + is_foreign_account, kyc_payto_hash, kyc_access_token, + kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, + kyc_last_aml_review, kyc_last_deny, kyc_withdrawal_delay, + secret_seed, reserve_pub, reserve_priv, exchange_base_url, + timestamp_start, timestamp_finish, status, restrict_age, + instructed_amount, reserve_balance_amount, raw_withdrawal_amount, + effective_withdrawal_amount, denoms_sel, abort_reason, fail_reason + ) VALUES ( + $id, $wtype, $uri, $cpriv, $binfo, $eca, $ifa, $kph, $kat, $klcs, + $klcc, $klrg, $klar, $kld, $kwd, $seed, $rpub, $rpriv, $url, + $tstart, $tfinish, $status, $age, $ia, $rba, $rwa, $ewa, $ds, + $abort, $fail + ) + ON CONFLICT(withdrawal_group_id) DO UPDATE SET + withdrawal_type = excluded.withdrawal_type, + taler_withdraw_uri = excluded.taler_withdraw_uri, + contract_priv = excluded.contract_priv, + bank_info = excluded.bank_info, + exchange_credit_accounts = excluded.exchange_credit_accounts, + is_foreign_account = excluded.is_foreign_account, + kyc_payto_hash = excluded.kyc_payto_hash, + kyc_access_token = excluded.kyc_access_token, + kyc_last_check_status = excluded.kyc_last_check_status, + kyc_last_check_code = excluded.kyc_last_check_code, + kyc_last_rule_gen = excluded.kyc_last_rule_gen, + kyc_last_aml_review = excluded.kyc_last_aml_review, + kyc_last_deny = excluded.kyc_last_deny, + kyc_withdrawal_delay = excluded.kyc_withdrawal_delay, + secret_seed = excluded.secret_seed, + reserve_pub = excluded.reserve_pub, + reserve_priv = excluded.reserve_priv, + exchange_base_url = excluded.exchange_base_url, + timestamp_start = excluded.timestamp_start, + timestamp_finish = excluded.timestamp_finish, + status = excluded.status, + restrict_age = excluded.restrict_age, + instructed_amount = excluded.instructed_amount, + reserve_balance_amount = excluded.reserve_balance_amount, + raw_withdrawal_amount = excluded.raw_withdrawal_amount, + effective_withdrawal_amount = excluded.effective_withdrawal_amount, + denoms_sel = excluded.denoms_sel, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason`, + { + id: rec.withdrawalGroupId, + ...wg, + ifa: boolToDb(rec.isForeignAccount), + kph: optCrockToDb(rec.kycPaytoHash), + kat: rec.kycAccessToken ?? null, + klcs: rec.kycLastCheckStatus ?? null, + klcc: rec.kycLastCheckCode ?? null, + klrg: rec.kycLastRuleGen ?? null, + klar: boolToDb(rec.kycLastAmlReview), + kld: rec.kycLastDeny ?? null, + kwd: + rec.kycWithdrawalDelay === undefined + ? null + : jsonToDb(rec.kycWithdrawalDelay), + seed: crockToDb(rec.secretSeed), + rpub: crockToDb(rec.reservePub), + rpriv: crockToDb(rec.reservePriv), + url: rec.exchangeBaseUrl ?? null, + tstart: rec.timestampStart, + tfinish: rec.timestampFinish ?? null, + status: rec.status, + age: rec.restrictAge ?? null, + ia: rec.instructedAmount ?? null, + rba: rec.reserveBalanceAmount ?? null, + rwa: rec.rawWithdrawalAmount ?? null, + ewa: rec.effectiveWithdrawalAmount ?? null, + ds: rec.denomsSel === undefined ? null : jsonToDb(rec.denomsSel), + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + }, + ); + } + + async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> { + await this.run( + "DELETE FROM withdrawal_groups WHERE withdrawal_group_id = $id", + { id: withdrawalGroupId }, + ); + } + + async listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { + const rows = await this.all("SELECT * FROM withdrawal_groups"); + return rows.map((r) => this.rowToWithdrawalGroup(r)); + } + + async getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { + const rows = await this.all( + "SELECT * FROM withdrawal_groups WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, withdrawal_group_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToWithdrawalGroup(r)); + } + + async getWithdrawalGroupByTalerWithdrawUri( + talerWithdrawUri: string, + ): Promise<WalletWithdrawalGroup | undefined> { + const row = await this.first( + "SELECT * FROM withdrawal_groups WHERE taler_withdraw_uri = $uri", + { uri: talerWithdrawUri }, + ); + return row ? this.rowToWithdrawalGroup(row) : undefined; + } + + async getWithdrawalGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]> { + const rows = await this.all( + "SELECT * FROM withdrawal_groups WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToWithdrawalGroup(r)); + } + + async getWithdrawalGroupsByExchangeForRekey( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]> { + return await this.getWithdrawalGroupsByExchange(exchangeBaseUrl); + } + + async countWithdrawalGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<number> { + const row = await this.first( + "SELECT COUNT(*) AS n FROM withdrawal_groups" + + " WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return num(row?.n); + } + + // ------------------------------------------------------------ planchets + + private rowToPlanchet(row: ResultRow): WalletPlanchet { + return { + 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: dbToCrock(row.denom_pub_hash), + blindingKey: dbToCrock(row.blinding_key), + exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), + withdrawSig: dbToCrock(row.withdraw_sig), + coinEv: dbToJson(row.coin_ev), + coinEvHash: dbToCrock(row.coin_ev_hash), + ...(row.age_commitment_proof != null + ? { ageCommitmentProof: dbToJson(row.age_commitment_proof) } + : undefined), + }; + } + + async getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined> { + const row = await this.first( + "SELECT * FROM planchets WHERE coin_pub = $pub", + { pub: crockToDb(coinPub) }, + ); + return row ? this.rowToPlanchet(row) : undefined; + } + + async upsertPlanchet(rec: WalletPlanchet): Promise<void> { + await this.run( + `INSERT INTO planchets ( + coin_pub, coin_priv, withdrawal_group_id, coin_idx, planchet_status, + last_error, denom_pub_hash, blinding_key, exchange_withdraw_values, withdraw_sig, coin_ev, + coin_ev_hash, age_commitment_proof + ) VALUES ( + $pub, $priv, $wgid, $idx, $status, $err, $dph, $bk, $ewv, $sig, $ev, + $evh, $acp + ) + ON CONFLICT(coin_pub) DO UPDATE SET + coin_priv = excluded.coin_priv, + withdrawal_group_id = excluded.withdrawal_group_id, + coin_idx = excluded.coin_idx, + planchet_status = excluded.planchet_status, + last_error = excluded.last_error, + denom_pub_hash = excluded.denom_pub_hash, + blinding_key = excluded.blinding_key, + exchange_withdraw_values = excluded.exchange_withdraw_values, + withdraw_sig = excluded.withdraw_sig, + coin_ev = excluded.coin_ev, + coin_ev_hash = excluded.coin_ev_hash, + age_commitment_proof = excluded.age_commitment_proof`, + { + 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: crockToDb(rec.denomPubHash), + bk: crockToDb(rec.blindingKey), + ewv: jsonToDb(rec.exchangeWithdrawValues), + sig: crockToDb(rec.withdrawSig), + ev: jsonToDb(rec.coinEv), + evh: crockToDb(rec.coinEvHash), + acp: + rec.ageCommitmentProof === undefined + ? null + : jsonToDb(rec.ageCommitmentProof), + }, + ); + } + + async getPlanchetByGroupAndIndex( + withdrawalGroupId: string, + coinIdx: number, + ): Promise<WalletPlanchet | undefined> { + const row = await this.first( + "SELECT * FROM planchets" + + " WHERE withdrawal_group_id = $wgid AND coin_idx = $idx", + { wgid: withdrawalGroupId, idx: coinIdx }, + ); + return row ? this.rowToPlanchet(row) : undefined; + } + + async getPlanchetsByGroup( + withdrawalGroupId: string, + ): Promise<WalletPlanchet[]> { + const rows = await this.all( + "SELECT * FROM planchets WHERE withdrawal_group_id = $wgid", + { wgid: withdrawalGroupId }, + ); + return rows.map((r) => this.rowToPlanchet(r)); + } + + async listAllPlanchets(): Promise<WalletPlanchet[]> { + const rows = await this.all("SELECT * FROM planchets"); + return rows.map((r) => this.rowToPlanchet(r)); + } + + async countPlanchetsByGroup(withdrawalGroupId: string): Promise<number> { + const row = await this.first( + "SELECT COUNT(*) AS n FROM planchets WHERE withdrawal_group_id = $wgid", + { wgid: withdrawalGroupId }, + ); + return num(row?.n); + } + + async deletePlanchet(coinPub: string): Promise<void> { + await this.run("DELETE FROM planchets WHERE coin_pub = $pub", { + pub: crockToDb(coinPub), + }); + } + + async deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void> { + await this.run("DELETE FROM planchets WHERE withdrawal_group_id = $wgid", { + wgid: withdrawalGroupId, + }); + } + + // -------------------------------------------------- transaction meta + + private rowToTransactionMeta(row: ResultRow): WalletTransactionMeta { + return { + transactionId: str(row.transaction_id), + timestamp: dbTimestamp(row.timestamp), + status: num(row.status), + currency: str(row.currency), + exchanges: dbToJson(row.exchanges), + }; + } + + /** + * Allocate a stable, per-type local number once. The mapping is not tied + * to transactions_meta because that view is periodically rebuilt. + */ + private async ensureLocalTransactionIdentifier( + transactionId: string, + ): Promise<void> { + const existing = await this.first( + "SELECT 1 FROM transaction_local_ids WHERE transaction_id = $id", + { id: transactionId }, + ); + if (existing != null) { + return; + } + const [prefix, transactionType] = transactionId.split(":", 3); + if (prefix !== "txn" || transactionType == null || transactionType === "") { + throw Error(`invalid transaction identifier '${transactionId}'`); + } + await this.run( + "INSERT OR IGNORE INTO transaction_local_id_counters" + + " (transaction_type, next_ident) VALUES ($type, 1)", + { type: transactionType }, + ); + const counter = await this.first( + "SELECT next_ident FROM transaction_local_id_counters" + + " WHERE transaction_type = $type", + { type: transactionType }, + ); + const localIdent = num(counter?.next_ident); + await this.run( + "INSERT INTO transaction_local_ids" + + " (transaction_id, transaction_type, local_ident)" + + " VALUES ($id, $type, $localIdent)", + { id: transactionId, type: transactionType, localIdent }, + ); + await this.run( + "UPDATE transaction_local_id_counters SET next_ident = next_ident + 1" + + " WHERE transaction_type = $type", + { type: transactionType }, + ); + } + + async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> { + await this.ensureLocalTransactionIdentifier(rec.transactionId); + await this.run( + `INSERT INTO transactions_meta ( + transaction_id, timestamp, status, currency, exchanges + ) VALUES ($id, $ts, $status, $cur, $ex) + ON CONFLICT(transaction_id) DO UPDATE SET + timestamp = excluded.timestamp, + status = excluded.status, + currency = excluded.currency, + exchanges = excluded.exchanges`, + { + id: rec.transactionId, + ts: rec.timestamp, + status: rec.status, + cur: rec.currency, + ex: jsonToDb(rec.exchanges), + }, + ); + } + + async getLocalTransactionIdentifiers( + transactionIds: string[], + ): Promise<Map<string, string>> { + const result = new Map<string, string>(); + // SQLite commonly limits a statement to 999 bind parameters. Chunks keep + // a large transaction history to a handful of indexed lookups. + for (let start = 0; start < transactionIds.length; start += 500) { + const ids = transactionIds.slice(start, start + 500); + const params: Record<string, string> = {}; + const placeholders = ids.map((id, i) => { + const name = `id${i}`; + params[name] = id; + return `$${name}`; + }); + const rows = await this.all( + "SELECT transaction_id, local_ident FROM transaction_local_ids" + + ` WHERE transaction_id IN (${placeholders.join(", ")})`, + params, + ); + for (const row of rows) { + result.set(str(row.transaction_id), String(row.local_ident)); + } + } + return result; + } + + async getTransactionIdByLocalIdentifier( + transactionType: string, + localIdent: string, + ): Promise<string | undefined> { + // Integer comparison deliberately accepts the canonical decimal strings + // emitted by the wallet. Future local-ID schemes can use another + // backend without exposing that storage detail in the API. + const row = await this.first( + "SELECT transaction_id FROM transaction_local_ids" + + " WHERE transaction_type = $type AND local_ident = $localIdent", + { type: transactionType, localIdent }, + ); + return row == null ? undefined : str(row.transaction_id); + } + + async deleteTransactionMeta(transactionId: string): Promise<void> { + await this.run("DELETE FROM transactions_meta WHERE transaction_id = $id", { + id: transactionId, + }); + } + + async deleteAllTransactionMeta(): Promise<void> { + await this.run("DELETE FROM transactions_meta"); + } + + async getTransactionMeta( + transactionId: string, + ): Promise<WalletTransactionMeta | undefined> { + const row = await this.first( + "SELECT * FROM transactions_meta WHERE transaction_id = $id", + { id: transactionId }, + ); + return row ? this.rowToTransactionMeta(row) : undefined; + } + + async getTransactionMetaAtTimestamp( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + // Ties broken by transaction_id, so "the record at this timestamp" is + // deterministic rather than whatever the storage engine returns first. + const row = await this.first( + "SELECT * FROM transactions_meta WHERE timestamp = $ts" + + " ORDER BY transaction_id LIMIT 1", + { ts: timestamp }, + ); + return row ? this.rowToTransactionMeta(row) : undefined; + } + + async getTransactionMetaBefore( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + // The IndexedDB version reads the whole range and takes the last entry; + // this asks for that entry directly. Inclusive upper bound, matching + // KeyRange.upperBound(timestamp, false). + const row = await this.first( + "SELECT * FROM transactions_meta WHERE timestamp <= $ts" + + " ORDER BY timestamp DESC, transaction_id DESC LIMIT 1", + { ts: timestamp }, + ); + return row ? this.rowToTransactionMeta(row) : undefined; + } + + async getTransactionMetaAfter( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined> { + const row = await this.first( + "SELECT * FROM transactions_meta WHERE timestamp >= $ts" + + " ORDER BY timestamp, transaction_id LIMIT 1", + { ts: timestamp }, + ); + return row ? this.rowToTransactionMeta(row) : undefined; + } + + async listTransactionMetaByTimestamp(req: { + afterTimestamp?: DbPreciseTimestamp; + limit?: number; + }): Promise<WalletTransactionMeta[]> { + // afterTimestamp is exclusive, matching KeyRange.lowerBound(ts, true). + const where = req.afterTimestamp != null ? " WHERE timestamp > $after" : ""; + const limit = req.limit != null ? " LIMIT $limit" : ""; + const rows = await this.all( + `SELECT * FROM transactions_meta${where}` + + ` ORDER BY timestamp, transaction_id${limit}`, + { + ...(req.afterTimestamp != null ? { after: req.afterTimestamp } : {}), + ...(req.limit != null ? { limit: req.limit } : {}), + }, + ); + return rows.map((r) => this.rowToTransactionMeta(r)); + } + + async listTransactionMetaPage(req: { + cursor?: WalletTransactionMetaCursor; + direction: "forward" | "backward"; + limit: number; + }): Promise<WalletTransactionMeta[]> { + const backwards = req.direction === "backward"; + const comparison = backwards ? "<" : ">"; + const ordering = backwards ? " DESC" : ""; + const where = req.cursor + ? ` WHERE (timestamp, transaction_id) ${comparison} ($ts, $id)` + : ""; + const rows = await this.all( + `SELECT * FROM transactions_meta${where}` + + ` ORDER BY timestamp${ordering}, transaction_id${ordering}` + + " LIMIT $limit", + { + ...(req.cursor + ? { ts: req.cursor.timestamp, id: req.cursor.transactionId } + : {}), + limit: req.limit, + }, + ); + return rows.map((r) => this.rowToTransactionMeta(r)); + } + + async listTransactionMetaByStatus(req: { + onlyActive: boolean; + }): Promise<WalletTransactionMeta[]> { + const rows = req.onlyActive + ? await this.all( + "SELECT * FROM transactions_meta WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, transaction_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ) + : await this.all( + "SELECT * FROM transactions_meta ORDER BY status, transaction_id", + ); + return rows.map((r) => this.rowToTransactionMeta(r)); + } + + // -------------------------------------------------- peer push debit + + private rowToPeerPushDebit(row: ResultRow): WalletPeerPushDebit { + return { + pursePub: dbToCrock(row.purse_pub), + exchangeBaseUrl: str(row.exchange_base_url), + amount: dbAmount(row.amount), + totalCost: dbAmount(row.total_cost), + 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), + ...(row.restrict_scope != null + ? { restrictScope: dbToJson(row.restrict_scope) } + : undefined), + ...(row.coin_sel != null + ? { coinSel: dbToJson(row.coin_sel) } + : undefined), + ...(row.abort_refresh_group_id != null + ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + }; + } + + async getPeerPushDebit( + pursePub: string, + ): Promise<WalletPeerPushDebit | undefined> { + const row = await this.first( + "SELECT * FROM peer_push_debit WHERE purse_pub = $pub", + { pub: crockToDb(pursePub) }, + ); + return row ? this.rowToPeerPushDebit(row) : undefined; + } + + async upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void> { + await this.run( + `INSERT INTO peer_push_debit ( + purse_pub, exchange_base_url, restrict_scope, amount, total_cost, + coin_sel, contract_terms_hash, purse_priv, merge_pub, merge_priv, + contract_priv, contract_pub, contract_enc_nonce, purse_expiration, + timestamp_created, abort_refresh_group_id, abort_reason, + fail_reason, status + ) VALUES ( + $pub, $url, $scope, $amt, $cost, $csel, $cth, $ppriv, $mpub, + $mpriv, $cpriv, $cpub, $nonce, $exp, $created, $argi, $abort, + $fail, $status + ) + ON CONFLICT(purse_pub) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + restrict_scope = excluded.restrict_scope, + amount = excluded.amount, + total_cost = excluded.total_cost, + coin_sel = excluded.coin_sel, + contract_terms_hash = excluded.contract_terms_hash, + purse_priv = excluded.purse_priv, + merge_pub = excluded.merge_pub, + merge_priv = excluded.merge_priv, + contract_priv = excluded.contract_priv, + contract_pub = excluded.contract_pub, + contract_enc_nonce = excluded.contract_enc_nonce, + purse_expiration = excluded.purse_expiration, + timestamp_created = excluded.timestamp_created, + abort_refresh_group_id = excluded.abort_refresh_group_id, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + status = excluded.status`, + { + 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: 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, + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + status: rec.status, + }, + ); + } + + async deletePeerPushDebit(pursePub: string): Promise<void> { + await this.run("DELETE FROM peer_push_debit WHERE purse_pub = $pub", { + pub: crockToDb(pursePub), + }); + } + + async listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]> { + const rows = await this.all("SELECT * FROM peer_push_debit"); + return rows.map((r) => this.rowToPeerPushDebit(r)); + } + + async getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]> { + const rows = await this.all( + "SELECT * FROM peer_push_debit WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, purse_pub", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToPeerPushDebit(r)); + } + + // ------------------------------------------------- peer push credit + + private rowToPeerPushCredit(row: ResultRow): WalletPeerPushCredit { + return { + peerPushCreditId: str(row.peer_push_credit_id), + exchangeBaseUrl: str(row.exchange_base_url), + 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: dbToCrock(row.contract_terms_hash), + status: num(row.status), + withdrawalGroupId: optStr(row.withdrawal_group_id), + currency: optStr(row.currency), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + ...(row.kyc_payto_hash != null + ? { kycPaytoHash: dbToCrock(row.kyc_payto_hash) } + : undefined), + ...(row.kyc_access_token != null + ? { kycAccessToken: str(row.kyc_access_token) } + : undefined), + ...(row.kyc_last_check_status != null + ? { kycLastCheckStatus: num(row.kyc_last_check_status) } + : undefined), + ...(row.kyc_last_check_code != null + ? { kycLastCheckCode: num(row.kyc_last_check_code) } + : undefined), + ...(row.kyc_last_rule_gen != null + ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } + : undefined), + ...(row.kyc_last_aml_review != null + ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } + : undefined), + ...(row.kyc_last_deny != null + ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } + : undefined), + }; + } + + async getPeerPushCredit( + peerPushCreditId: string, + ): Promise<WalletPeerPushCredit | undefined> { + const row = await this.first( + "SELECT * FROM peer_push_credit WHERE peer_push_credit_id = $id", + { id: peerPushCreditId }, + ); + return row ? this.rowToPeerPushCredit(row) : undefined; + } + + async upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void> { + await this.run( + `INSERT INTO peer_push_credit ( + peer_push_credit_id, exchange_base_url, purse_pub, merge_priv, + contract_priv, timestamp, estimated_amount_effective, + contract_terms_hash, status, abort_reason, fail_reason, + withdrawal_group_id, currency, kyc_payto_hash, kyc_access_token, + kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, + kyc_last_aml_review, kyc_last_deny + ) VALUES ( + $id, $url, $ppub, $mpriv, $cpriv, $ts, $eae, $cth, $status, + $abort, $fail, $wgid, $cur, $kph, $kat, $klcs, $klcc, $klrg, + $klar, $kld + ) + ON CONFLICT(peer_push_credit_id) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + purse_pub = excluded.purse_pub, + merge_priv = excluded.merge_priv, + contract_priv = excluded.contract_priv, + timestamp = excluded.timestamp, + estimated_amount_effective = excluded.estimated_amount_effective, + contract_terms_hash = excluded.contract_terms_hash, + status = excluded.status, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + withdrawal_group_id = excluded.withdrawal_group_id, + currency = excluded.currency, + kyc_payto_hash = excluded.kyc_payto_hash, + kyc_access_token = excluded.kyc_access_token, + kyc_last_check_status = excluded.kyc_last_check_status, + kyc_last_check_code = excluded.kyc_last_check_code, + kyc_last_rule_gen = excluded.kyc_last_rule_gen, + kyc_last_aml_review = excluded.kyc_last_aml_review, + kyc_last_deny = excluded.kyc_last_deny`, + { + id: rec.peerPushCreditId, + url: rec.exchangeBaseUrl, + ppub: crockToDb(rec.pursePub), + mpriv: crockToDb(rec.mergePriv), + cpriv: crockToDb(rec.contractPriv), + ts: rec.timestamp, + eae: rec.estimatedAmountEffective, + 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: optCrockToDb(rec.kycPaytoHash), + kat: rec.kycAccessToken ?? null, + klcs: rec.kycLastCheckStatus ?? null, + klcc: rec.kycLastCheckCode ?? null, + klrg: rec.kycLastRuleGen ?? null, + klar: boolToDb(rec.kycLastAmlReview), + kld: rec.kycLastDeny ?? null, + }, + ); + } + + async deletePeerPushCredit(peerPushCreditId: string): Promise<void> { + await this.run( + "DELETE FROM peer_push_credit WHERE peer_push_credit_id = $id", + { id: peerPushCreditId }, + ); + } + + async listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]> { + const rows = await this.all("SELECT * FROM peer_push_credit"); + return rows.map((r) => this.rowToPeerPushCredit(r)); + } + + async getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]> { + const rows = await this.all( + "SELECT * FROM peer_push_credit WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, peer_push_credit_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToPeerPushCredit(r)); + } + + async getPeerPushCreditByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPushCredit | undefined> { + const row = await this.first( + "SELECT * FROM peer_push_credit" + + " WHERE exchange_base_url = $url AND contract_priv = $priv", + { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, + ); + return row ? this.rowToPeerPushCredit(row) : undefined; + } + + // -------------------------------------------------- peer pull debit + + private rowToPeerPullDebit(row: ResultRow): WalletPeerPullDebit { + return { + peerPullDebitId: str(row.peer_pull_debit_id), + pursePub: dbToCrock(row.purse_pub), + exchangeBaseUrl: str(row.exchange_base_url), + amount: dbAmount(row.amount), + contractTermsHash: dbToCrock(row.contract_terms_hash), + timestampCreated: dbTimestamp(row.timestamp_created), + contractPriv: dbToCrock(row.contract_priv), + status: num(row.status), + totalCostEstimated: dbAmount(row.total_cost_estimated), + ...(row.abort_refresh_group_id != null + ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + ...(row.coin_sel != null + ? { coinSel: dbToJson(row.coin_sel) } + : undefined), + }; + } + + async getPeerPullDebit( + peerPullDebitId: string, + ): Promise<WalletPeerPullDebit | undefined> { + const row = await this.first( + "SELECT * FROM peer_pull_debit WHERE peer_pull_debit_id = $id", + { id: peerPullDebitId }, + ); + return row ? this.rowToPeerPullDebit(row) : undefined; + } + + async upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void> { + await this.run( + `INSERT INTO peer_pull_debit ( + peer_pull_debit_id, purse_pub, exchange_base_url, amount, + contract_terms_hash, timestamp_created, contract_priv, status, + total_cost_estimated, abort_refresh_group_id, abort_reason, + fail_reason, coin_sel + ) VALUES ( + $id, $ppub, $url, $amt, $cth, $created, $cpriv, $status, $tce, + $argi, $abort, $fail, $csel + ) + ON CONFLICT(peer_pull_debit_id) DO UPDATE SET + purse_pub = excluded.purse_pub, + exchange_base_url = excluded.exchange_base_url, + amount = excluded.amount, + contract_terms_hash = excluded.contract_terms_hash, + timestamp_created = excluded.timestamp_created, + contract_priv = excluded.contract_priv, + status = excluded.status, + total_cost_estimated = excluded.total_cost_estimated, + abort_refresh_group_id = excluded.abort_refresh_group_id, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + coin_sel = excluded.coin_sel`, + { + id: rec.peerPullDebitId, + ppub: crockToDb(rec.pursePub), + url: rec.exchangeBaseUrl, + amt: rec.amount, + cth: crockToDb(rec.contractTermsHash), + created: rec.timestampCreated, + cpriv: crockToDb(rec.contractPriv), + status: rec.status, + tce: rec.totalCostEstimated, + argi: rec.abortRefreshGroupId ?? null, + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + csel: rec.coinSel === undefined ? null : jsonToDb(rec.coinSel), + }, + ); + } + + async deletePeerPullDebit(peerPullDebitId: string): Promise<void> { + await this.run( + "DELETE FROM peer_pull_debit WHERE peer_pull_debit_id = $id", + { id: peerPullDebitId }, + ); + } + + async listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]> { + const rows = await this.all("SELECT * FROM peer_pull_debit"); + return rows.map((r) => this.rowToPeerPullDebit(r)); + } + + async getPeerPullDebitByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPullDebit | undefined> { + const row = await this.first( + "SELECT * FROM peer_pull_debit" + + " WHERE exchange_base_url = $url AND contract_priv = $priv", + { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, + ); + return row ? this.rowToPeerPullDebit(row) : undefined; + } + + // ------------------------------------------------- peer pull credit + + private rowToPeerPullCredit(row: ResultRow): WalletPeerPullCredit { + return { + pursePub: dbToCrock(row.purse_pub), + exchangeBaseUrl: str(row.exchange_base_url), + amount: dbAmount(row.amount), + estimatedAmountEffective: dbAmount(row.estimated_amount_effective), + 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: dbToCrock(row.kyc_payto_hash) } + : undefined), + ...(row.kyc_access_token != null + ? { kycAccessToken: str(row.kyc_access_token) } + : undefined), + ...(row.kyc_last_check_status != null + ? { kycLastCheckStatus: num(row.kyc_last_check_status) } + : undefined), + ...(row.kyc_last_check_code != null + ? { kycLastCheckCode: num(row.kyc_last_check_code) } + : undefined), + ...(row.kyc_last_rule_gen != null + ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } + : undefined), + ...(row.kyc_last_aml_review != null + ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } + : undefined), + ...(row.kyc_last_deny != null + ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + }; + } + + async getPeerPullCredit( + pursePub: string, + ): Promise<WalletPeerPullCredit | undefined> { + const row = await this.first( + "SELECT * FROM peer_pull_credit WHERE purse_pub = $pub", + { pub: crockToDb(pursePub) }, + ); + return row ? this.rowToPeerPullCredit(row) : undefined; + } + + async upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void> { + await this.run( + `INSERT INTO peer_pull_credit ( + purse_pub, exchange_base_url, amount, estimated_amount_effective, + purse_priv, contract_terms_hash, merge_pub, merge_priv, + contract_pub, contract_priv, contract_enc_nonce, merge_timestamp, + merge_reserve_row_id, status, kyc_payto_hash, kyc_access_token, + kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, + kyc_last_aml_review, kyc_last_deny, abort_reason, fail_reason, + withdrawal_group_id + ) VALUES ( + $pub, $url, $amt, $eae, $ppriv, $cth, $mpub, $mpriv, $cpub, + $cpriv, $nonce, $mts, $mrri, $status, $kph, $kat, $klcs, $klcc, + $klrg, $klar, $kld, $abort, $fail, $wgid + ) + ON CONFLICT(purse_pub) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + amount = excluded.amount, + estimated_amount_effective = excluded.estimated_amount_effective, + purse_priv = excluded.purse_priv, + contract_terms_hash = excluded.contract_terms_hash, + merge_pub = excluded.merge_pub, + merge_priv = excluded.merge_priv, + contract_pub = excluded.contract_pub, + contract_priv = excluded.contract_priv, + contract_enc_nonce = excluded.contract_enc_nonce, + merge_timestamp = excluded.merge_timestamp, + merge_reserve_row_id = excluded.merge_reserve_row_id, + status = excluded.status, + kyc_payto_hash = excluded.kyc_payto_hash, + kyc_access_token = excluded.kyc_access_token, + kyc_last_check_status = excluded.kyc_last_check_status, + kyc_last_check_code = excluded.kyc_last_check_code, + kyc_last_rule_gen = excluded.kyc_last_rule_gen, + kyc_last_aml_review = excluded.kyc_last_aml_review, + kyc_last_deny = excluded.kyc_last_deny, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + withdrawal_group_id = excluded.withdrawal_group_id`, + { + pub: crockToDb(rec.pursePub), + url: rec.exchangeBaseUrl, + amt: rec.amount, + eae: rec.estimatedAmountEffective, + 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: optCrockToDb(rec.kycPaytoHash), + kat: rec.kycAccessToken ?? null, + klcs: rec.kycLastCheckStatus ?? null, + klcc: rec.kycLastCheckCode ?? null, + klrg: rec.kycLastRuleGen ?? null, + klar: boolToDb(rec.kycLastAmlReview), + kld: rec.kycLastDeny ?? null, + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + wgid: rec.withdrawalGroupId ?? null, + }, + ); + } + + async deletePeerPullCredit(pursePub: string): Promise<void> { + await this.run("DELETE FROM peer_pull_credit WHERE purse_pub = $pub", { + pub: crockToDb(pursePub), + }); + } + + async listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]> { + const rows = await this.all("SELECT * FROM peer_pull_credit"); + return rows.map((r) => this.rowToPeerPullCredit(r)); + } + + // ----------------------------------------------------- deposit groups + + private rowToDepositGroup(row: ResultRow): WalletDepositGroup { + return { + depositGroupId: str(row.deposit_group_id), + currency: str(row.currency), + amount: dbAmount(row.amount), + wireTransferDeadline: dbTimestamp(row.wire_transfer_deadline), + 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: dbToCrock(row.contract_terms_hash), + totalPayCost: dbAmount(row.total_pay_cost), + counterpartyEffectiveDepositAmount: dbAmount( + row.counterparty_effective_deposit_amount, + ), + timestampCreated: dbTimestamp(row.timestamp_created), + timestampFinished: + row.timestamp_finished == null + ? undefined + : dbTimestamp(row.timestamp_finished), + timestampLastDepositAttempt: + row.timestamp_last_deposit_attempt == null + ? undefined + : dbTimestamp(row.timestamp_last_deposit_attempt), + operationStatus: num(row.operation_status), + ...(row.pay_coin_selection != null + ? { payCoinSelection: dbToJson(row.pay_coin_selection) } + : undefined), + ...(row.pay_coin_selection_uid != null + ? { payCoinSelectionUid: str(row.pay_coin_selection_uid) } + : undefined), + ...(row.status_per_coin != null + ? { statusPerCoin: dbToJson(row.status_per_coin) } + : undefined), + ...(row.info_per_exchange != null + ? { infoPerExchange: dbToJson(row.info_per_exchange) } + : undefined), + ...(row.abort_refresh_group_id != null + ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + ...(row.kyc_info != null + ? { kycInfo: dbToJson(row.kyc_info) } + : undefined), + ...(row.kyc_auth_transfer_options != null + ? { kycAuthTransferOptions: dbToJson(row.kyc_auth_transfer_options) } + : undefined), + ...(row.kyc_auth_transfer_expiry != null + ? { kycAuthTransferExpiry: dbToJson(row.kyc_auth_transfer_expiry) } + : undefined), + ...(row.tracking_state != null + ? { trackingState: dbToJson(row.tracking_state) } + : undefined), + }; + } + + async getDepositGroup( + depositGroupId: string, + ): Promise<WalletDepositGroup | undefined> { + const row = await this.first( + "SELECT * FROM deposit_groups WHERE deposit_group_id = $id", + { id: depositGroupId }, + ); + return row ? this.rowToDepositGroup(row) : undefined; + } + + async upsertDepositGroup(rec: WalletDepositGroup): Promise<void> { + await this.run( + `INSERT INTO deposit_groups ( + deposit_group_id, currency, amount, wire_transfer_deadline, + merchant_pub, merchant_priv, nonce_priv, nonce_pub, wire, + contract_terms_hash, pay_coin_selection, pay_coin_selection_uid, + total_pay_cost, counterparty_effective_deposit_amount, + timestamp_created, timestamp_finished, + timestamp_last_deposit_attempt, operation_status, status_per_coin, + info_per_exchange, abort_refresh_group_id, abort_reason, + fail_reason, kyc_info, kyc_auth_transfer_options, + kyc_auth_transfer_expiry, tracking_state + ) VALUES ( + $id, $cur, $amt, $wtd, $mpub, $mpriv, $npriv, $npub, $wire, $cth, + $pcs, $pcsu, $tpc, $ceda, $created, $finished, $lastAttempt, + $status, $spc, $ipe, $argi, $abort, $fail, $kyc, $kato, $kate, + $tracking + ) + ON CONFLICT(deposit_group_id) DO UPDATE SET + currency = excluded.currency, + amount = excluded.amount, + wire_transfer_deadline = excluded.wire_transfer_deadline, + merchant_pub = excluded.merchant_pub, + merchant_priv = excluded.merchant_priv, + nonce_priv = excluded.nonce_priv, + nonce_pub = excluded.nonce_pub, + wire = excluded.wire, + contract_terms_hash = excluded.contract_terms_hash, + pay_coin_selection = excluded.pay_coin_selection, + pay_coin_selection_uid = excluded.pay_coin_selection_uid, + total_pay_cost = excluded.total_pay_cost, + counterparty_effective_deposit_amount = + excluded.counterparty_effective_deposit_amount, + timestamp_created = excluded.timestamp_created, + timestamp_finished = excluded.timestamp_finished, + timestamp_last_deposit_attempt = + excluded.timestamp_last_deposit_attempt, + operation_status = excluded.operation_status, + status_per_coin = excluded.status_per_coin, + info_per_exchange = excluded.info_per_exchange, + abort_refresh_group_id = excluded.abort_refresh_group_id, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + kyc_info = excluded.kyc_info, + kyc_auth_transfer_options = excluded.kyc_auth_transfer_options, + kyc_auth_transfer_expiry = excluded.kyc_auth_transfer_expiry, + tracking_state = excluded.tracking_state`, + { + id: rec.depositGroupId, + cur: rec.currency, + amt: rec.amount, + wtd: rec.wireTransferDeadline, + mpub: crockToDb(rec.merchantPub), + mpriv: crockToDb(rec.merchantPriv), + npriv: crockToDb(rec.noncePriv), + npub: crockToDb(rec.noncePub), + wire: jsonToDb(rec.wire), + cth: crockToDb(rec.contractTermsHash), + pcs: + rec.payCoinSelection === undefined + ? null + : jsonToDb(rec.payCoinSelection), + pcsu: rec.payCoinSelectionUid ?? null, + tpc: rec.totalPayCost, + ceda: rec.counterpartyEffectiveDepositAmount, + created: rec.timestampCreated, + finished: rec.timestampFinished ?? null, + lastAttempt: rec.timestampLastDepositAttempt ?? null, + status: rec.operationStatus, + spc: + rec.statusPerCoin === undefined ? null : jsonToDb(rec.statusPerCoin), + ipe: + rec.infoPerExchange === undefined + ? null + : jsonToDb(rec.infoPerExchange), + argi: rec.abortRefreshGroupId ?? null, + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + kyc: rec.kycInfo === undefined ? null : jsonToDb(rec.kycInfo), + kato: + rec.kycAuthTransferOptions === undefined + ? null + : jsonToDb(rec.kycAuthTransferOptions), + kate: + rec.kycAuthTransferExpiry === undefined + ? null + : jsonToDb(rec.kycAuthTransferExpiry), + tracking: + rec.trackingState === undefined ? null : jsonToDb(rec.trackingState), + }, + ); + } + + async deleteDepositGroup(depositGroupId: string): Promise<void> { + await this.run("DELETE FROM deposit_groups WHERE deposit_group_id = $id", { + id: depositGroupId, + }); + } + + async listAllDepositGroups(): Promise<WalletDepositGroup[]> { + const rows = await this.all("SELECT * FROM deposit_groups"); + return rows.map((r) => this.rowToDepositGroup(r)); + } + + async getActiveDepositGroups(): Promise<WalletDepositGroup[]> { + const rows = await this.all( + "SELECT * FROM deposit_groups" + + " WHERE operation_status BETWEEN $lo AND $hi" + + " ORDER BY operation_status, deposit_group_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToDepositGroup(r)); + } + + // ----------------------------------------------------- refresh groups + + private rowToRefreshGroup(row: ResultRow): WalletRefreshGroup { + return { + refreshGroupId: str(row.refresh_group_id), + operationStatus: num(row.operation_status), + currency: str(row.currency), + reason: str(row.reason) as RefreshReason, + oldCoinPubs: dbToJson(row.old_coin_pubs), + inputPerCoin: dbToJson(row.input_per_coin), + expectedOutputPerCoin: dbToJson(row.expected_output_per_coin), + statusPerCoin: dbToJson(row.status_per_coin), + refundRequests: dbToJson(row.refund_requests), + timestampCreated: dbTimestamp(row.timestamp_created), + timestampFinished: + row.timestamp_finished == null + ? undefined + : dbTimestamp(row.timestamp_finished), + ...(row.originating_transaction_id != null + ? { originatingTransactionId: str(row.originating_transaction_id) } + : undefined), + ...(row.info_per_exchange != null + ? { infoPerExchange: dbToJson(row.info_per_exchange) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + }; + } + + async getRefreshGroup( + refreshGroupId: string, + ): Promise<WalletRefreshGroup | undefined> { + const row = await this.first( + "SELECT * FROM refresh_groups WHERE refresh_group_id = $id", + { id: refreshGroupId }, + ); + return row ? this.rowToRefreshGroup(row) : undefined; + } + + async upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void> { + await this.run( + `INSERT INTO refresh_groups ( + refresh_group_id, operation_status, currency, reason, + originating_transaction_id, old_coin_pubs, input_per_coin, + expected_output_per_coin, info_per_exchange, status_per_coin, + refund_requests, timestamp_created, fail_reason, timestamp_finished + ) VALUES ( + $id, $status, $cur, $reason, $otid, $ocp, $ipc, $eopc, $ipe, + $spc, $rr, $created, $fail, $finished + ) + ON CONFLICT(refresh_group_id) DO UPDATE SET + operation_status = excluded.operation_status, + currency = excluded.currency, + reason = excluded.reason, + originating_transaction_id = excluded.originating_transaction_id, + old_coin_pubs = excluded.old_coin_pubs, + input_per_coin = excluded.input_per_coin, + expected_output_per_coin = excluded.expected_output_per_coin, + info_per_exchange = excluded.info_per_exchange, + status_per_coin = excluded.status_per_coin, + refund_requests = excluded.refund_requests, + timestamp_created = excluded.timestamp_created, + fail_reason = excluded.fail_reason, + timestamp_finished = excluded.timestamp_finished`, + { + id: rec.refreshGroupId, + status: rec.operationStatus, + cur: rec.currency, + reason: rec.reason, + otid: rec.originatingTransactionId ?? null, + ocp: jsonToDb(rec.oldCoinPubs), + ipc: jsonToDb(rec.inputPerCoin), + eopc: jsonToDb(rec.expectedOutputPerCoin), + ipe: + rec.infoPerExchange === undefined + ? null + : jsonToDb(rec.infoPerExchange), + spc: jsonToDb(rec.statusPerCoin), + rr: jsonToDb(rec.refundRequests), + created: rec.timestampCreated, + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + finished: rec.timestampFinished ?? null, + }, + ); + } + + async deleteRefreshGroup(refreshGroupId: string): Promise<void> { + await this.run("DELETE FROM refresh_groups WHERE refresh_group_id = $id", { + id: refreshGroupId, + }); + } + + async listAllRefreshGroups(): Promise<WalletRefreshGroup[]> { + const rows = await this.all("SELECT * FROM refresh_groups"); + return rows.map((r) => this.rowToRefreshGroup(r)); + } + + async getActiveRefreshGroups(): Promise<WalletRefreshGroup[]> { + const rows = await this.all( + "SELECT * FROM refresh_groups" + + " WHERE operation_status BETWEEN $lo AND $hi" + + " ORDER BY operation_status, refresh_group_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToRefreshGroup(r)); + } + + async getRefreshGroupsByOriginatingTransaction( + transactionId: string, + ): Promise<WalletRefreshGroup[]> { + const rows = await this.all( + "SELECT * FROM refresh_groups WHERE originating_transaction_id = $tid", + { tid: transactionId }, + ); + return rows.map((r) => this.rowToRefreshGroup(r)); + } + + // -------------------------------------------------- denom loss events + + private rowToDenomLossEvent(row: ResultRow): WalletDenomLossEvent { + return { + denomLossEventId: str(row.denom_loss_event_id), + currency: str(row.currency), + denomPubHashes: dbToJson(row.denom_pub_hashes), + status: num(row.status), + timestampCreated: dbTimestamp(row.timestamp_created), + amount: str(row.amount), + eventType: str(row.event_type) as DenomLossEventType, + exchangeBaseUrl: str(row.exchange_base_url), + }; + } + + async getDenomLossEvent( + denomLossEventId: string, + ): Promise<WalletDenomLossEvent | undefined> { + const row = await this.first( + "SELECT * FROM denom_loss_events WHERE denom_loss_event_id = $id", + { id: denomLossEventId }, + ); + return row ? this.rowToDenomLossEvent(row) : undefined; + } + + async upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void> { + await this.run( + `INSERT INTO denom_loss_events ( + denom_loss_event_id, currency, denom_pub_hashes, status, + timestamp_created, amount, event_type, exchange_base_url + ) VALUES ($id, $cur, $dph, $status, $created, $amt, $et, $url) + ON CONFLICT(denom_loss_event_id) DO UPDATE SET + currency = excluded.currency, + denom_pub_hashes = excluded.denom_pub_hashes, + status = excluded.status, + timestamp_created = excluded.timestamp_created, + amount = excluded.amount, + event_type = excluded.event_type, + exchange_base_url = excluded.exchange_base_url`, + { + id: rec.denomLossEventId, + cur: rec.currency, + dph: jsonToDb(rec.denomPubHashes), + status: rec.status, + created: rec.timestampCreated, + amt: rec.amount, + et: rec.eventType, + url: rec.exchangeBaseUrl, + }, + ); + } + + async deleteDenomLossEvent(denomLossEventId: string): Promise<void> { + await this.run( + "DELETE FROM denom_loss_events WHERE denom_loss_event_id = $id", + { id: denomLossEventId }, + ); + } + + async listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]> { + const rows = await this.all("SELECT * FROM denom_loss_events"); + return rows.map((r) => this.rowToDenomLossEvent(r)); + } + + async listAllRefundGroups(): Promise<WalletRefundGroup[]> { + const rows = await this.all("SELECT * FROM refund_groups"); + return rows.map((r) => this.rowToRefundGroup(r)); + } + + // --------------------------------------------------------- purchases + + /** + * Rebuild a purchase from its row plus its exchange rows. + * + * Takes the exchange list separately because it lives in a junction table: + * that table is the only copy, so it has to be read to reconstruct the + * record. + */ + private rowToPurchase( + row: ResultRow, + exchanges: string[] | undefined, + ): WalletPurchase { + const download = dbToOptJson<WalletProposalDownloadInfo>(row.download); + if (download && row.download_fulfillment_url != null) { + // Re-inserted from the column, which is the only copy. + download.fulfillmentUrl = str(row.download_fulfillment_url); + } + return { + proposalId: str(row.proposal_id), + orderId: str(row.order_id), + merchantBaseUrl: str(row.merchant_base_url), + claimToken: optStr(row.claim_token), + downloadSessionId: optStr(row.download_session_id), + repurchaseProposalId: optStr(row.repurchase_proposal_id), + purchaseStatus: num(row.purchase_status), + 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: dbToOptCrock(row.merchant_pay_sig), + posConfirmation: optStr(row.pos_confirmation), + shared: dbToBool(row.shared), + timestamp: dbTimestamp(row.timestamp), + timestampAccept: + row.timestamp_accept == null + ? undefined + : dbTimestamp(row.timestamp_accept), + timestampLastRefundStatus: + row.timestamp_last_refund_status == null + ? undefined + : dbTimestamp(row.timestamp_last_refund_status), + lastSessionId: optStr(row.last_session_id), + autoRefundDeadline: + row.auto_refund_deadline == null + ? undefined + : dbTimestamp(row.auto_refund_deadline), + refundAmountAwaiting: + row.refund_amount_awaiting == null + ? undefined + : dbAmount(row.refund_amount_awaiting), + ...(exchanges !== undefined ? { exchanges } : undefined), + ...(row.abort_refresh_group_id != null + ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } + : undefined), + ...(row.abort_reason != null + ? { abortReason: dbToJson(row.abort_reason) } + : undefined), + ...(row.fail_reason != null + ? { failReason: dbToJson(row.fail_reason) } + : undefined), + ...(row.choice_index != null + ? { choiceIndex: num(row.choice_index) } + : undefined), + ...(row.pending_removed_coin_pubs != null + ? { pendingRemovedCoinPubs: dbToJson(row.pending_removed_coin_pubs) } + : undefined), + ...(row.donau_output_index != null + ? { donauOutputIndex: num(row.donau_output_index) } + : undefined), + ...(row.donau_base_url != null + ? { donauBaseUrl: str(row.donau_base_url) } + : undefined), + ...(row.donau_amount != null + ? { donauAmount: dbAmount(row.donau_amount) } + : undefined), + ...(row.donau_tax_id_hash != null + ? { donauTaxIdHash: dbToCrock(row.donau_tax_id_hash) } + : undefined), + ...(row.donau_tax_id_salt != null + ? { donauTaxIdSalt: str(row.donau_tax_id_salt) } + : undefined), + ...(row.donau_tax_id != null + ? { donauTaxId: str(row.donau_tax_id) } + : undefined), + ...(row.donau_year != null + ? { donauYear: num(row.donau_year) } + : undefined), + ...(row.created_from_shared != null + ? { createdFromShared: dbToBool(row.created_from_shared) } + : undefined), + ...(row.timestamp_expired != null + ? { timestampExpired: dbTimestamp(row.timestamp_expired) } + : undefined), + ...(row.taler_uri != null ? { talerUri: str(row.taler_uri) } : undefined), + }; + } + + private async loadPurchaseExchanges( + proposalId: string, + ): Promise<string[] | undefined> { + const rows = await this.all( + "SELECT exchange_base_url FROM purchase_exchanges" + + " WHERE proposal_id = $id ORDER BY idx", + { id: proposalId }, + ); + // No rows means the field was absent, not an empty array: an empty array + // would have produced no rows either, but the record type makes the + // field optional and the wallet never stores an empty list. + return rows.length === 0 + ? undefined + : rows.map((r) => str(r.exchange_base_url)); + } + + private async hydratePurchases(rows: ResultRow[]): Promise<WalletPurchase[]> { + if (rows.length === 0) { + return []; + } + const exchangesByProposal = new Map<string, string[]>(); + const proposalIds = rows.map((r) => str(r.proposal_id)); + for (let offset = 0; offset < proposalIds.length; offset += 400) { + const chunk = proposalIds.slice(offset, offset + 400); + const params: Record<string, string> = {}; + const placeholders = chunk.map((id, i) => { + params[`id${i}`] = id; + return `$id${i}`; + }); + const exchangeRows = await this.all( + "SELECT proposal_id, exchange_base_url FROM purchase_exchanges" + + ` WHERE proposal_id IN (${placeholders.join(", ")})` + + " ORDER BY proposal_id, idx", + params, + ); + for (const exchangeRow of exchangeRows) { + const proposalId = str(exchangeRow.proposal_id); + const exchanges = exchangesByProposal.get(proposalId) ?? []; + exchanges.push(str(exchangeRow.exchange_base_url)); + exchangesByProposal.set(proposalId, exchanges); + } + } + return rows.map((row) => + this.rowToPurchase(row, exchangesByProposal.get(str(row.proposal_id))), + ); + } + + async getPurchase(proposalId: string): Promise<WalletPurchase | undefined> { + const row = await this.first( + "SELECT * FROM purchases WHERE proposal_id = $id", + { id: proposalId }, + ); + if (!row) { + return undefined; + } + return this.rowToPurchase( + row, + await this.loadPurchaseExchanges(proposalId), + ); + } + + async upsertPurchase(rec: WalletPurchase): Promise<void> { + // download is stored without its fulfillmentUrl; the column holds it. + let downloadJson: string | null = null; + let fulfillmentUrl: string | null = null; + if (rec.download) { + const { fulfillmentUrl: fu, ...rest } = rec.download; + fulfillmentUrl = fu ?? null; + downloadJson = jsonToDb(rest); + } + await this.run( + `INSERT INTO purchases ( + proposal_id, order_id, merchant_base_url, claim_token, + download_session_id, repurchase_proposal_id, purchase_status, + abort_refresh_group_id, abort_reason, fail_reason, nonce_priv, + nonce_pub, choice_index, secret_seed, download, + download_fulfillment_url, pay_info, pending_removed_coin_pubs, + timestamp_first_successful_pay, merchant_pay_sig, pos_confirmation, + donau_output_index, donau_base_url, donau_amount, + donau_tax_id_hash, donau_tax_id_salt, donau_tax_id, donau_year, + shared, created_from_shared, timestamp, timestamp_accept, + timestamp_last_refund_status, timestamp_expired, last_session_id, + auto_refund_deadline, refund_amount_awaiting, taler_uri + ) VALUES ( + $id, $oid, $url, $ct, $dsid, $rpid, $status, $argi, $abort, $fail, + $npriv, $npub, $ci, $seed, $dl, $ffu, $pi, $prcp, $tfsp, $mps, + $posc, $doi, $dbu, $damt, $dtih, $dtis, $dti, $dy, $shared, + $cfs, $ts, $tsa, $tslrs, $tse, $lsid, $ard, $raa, $turi + ) + ON CONFLICT(proposal_id) DO UPDATE SET + order_id = excluded.order_id, + merchant_base_url = excluded.merchant_base_url, + claim_token = excluded.claim_token, + download_session_id = excluded.download_session_id, + repurchase_proposal_id = excluded.repurchase_proposal_id, + purchase_status = excluded.purchase_status, + abort_refresh_group_id = excluded.abort_refresh_group_id, + abort_reason = excluded.abort_reason, + fail_reason = excluded.fail_reason, + nonce_priv = excluded.nonce_priv, + nonce_pub = excluded.nonce_pub, + choice_index = excluded.choice_index, + secret_seed = excluded.secret_seed, + download = excluded.download, + download_fulfillment_url = excluded.download_fulfillment_url, + pay_info = excluded.pay_info, + pending_removed_coin_pubs = excluded.pending_removed_coin_pubs, + timestamp_first_successful_pay = + excluded.timestamp_first_successful_pay, + merchant_pay_sig = excluded.merchant_pay_sig, + pos_confirmation = excluded.pos_confirmation, + donau_output_index = excluded.donau_output_index, + donau_base_url = excluded.donau_base_url, + donau_amount = excluded.donau_amount, + donau_tax_id_hash = excluded.donau_tax_id_hash, + donau_tax_id_salt = excluded.donau_tax_id_salt, + donau_tax_id = excluded.donau_tax_id, + donau_year = excluded.donau_year, + shared = excluded.shared, + created_from_shared = excluded.created_from_shared, + timestamp = excluded.timestamp, + timestamp_accept = excluded.timestamp_accept, + timestamp_last_refund_status = + excluded.timestamp_last_refund_status, + timestamp_expired = excluded.timestamp_expired, + last_session_id = excluded.last_session_id, + auto_refund_deadline = excluded.auto_refund_deadline, + refund_amount_awaiting = excluded.refund_amount_awaiting, + taler_uri = excluded.taler_uri`, + { + id: rec.proposalId, + oid: rec.orderId, + url: rec.merchantBaseUrl, + ct: rec.claimToken ?? null, + dsid: rec.downloadSessionId ?? null, + rpid: rec.repurchaseProposalId ?? null, + status: rec.purchaseStatus, + argi: rec.abortRefreshGroupId ?? null, + abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), + fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), + npriv: crockToDb(rec.noncePriv), + npub: crockToDb(rec.noncePub), + ci: rec.choiceIndex ?? null, + seed: optCrockToDb(rec.secretSeed), + dl: downloadJson, + ffu: fulfillmentUrl, + pi: rec.payInfo === undefined ? null : jsonToDb(rec.payInfo), + prcp: + rec.pendingRemovedCoinPubs === undefined + ? null + : jsonToDb(rec.pendingRemovedCoinPubs), + tfsp: rec.timestampFirstSuccessfulPay ?? null, + mps: optCrockToDb(rec.merchantPaySig), + posc: rec.posConfirmation ?? null, + doi: rec.donauOutputIndex ?? null, + dbu: rec.donauBaseUrl ?? null, + damt: rec.donauAmount ?? null, + dtih: optCrockToDb(rec.donauTaxIdHash), + dtis: rec.donauTaxIdSalt ?? null, + dti: rec.donauTaxId ?? null, + dy: rec.donauYear ?? null, + shared: boolToDb(rec.shared), + cfs: boolToDb(rec.createdFromShared), + ts: rec.timestamp, + tsa: rec.timestampAccept ?? null, + tslrs: rec.timestampLastRefundStatus ?? null, + tse: rec.timestampExpired ?? null, + lsid: rec.lastSessionId ?? null, + ard: rec.autoRefundDeadline ?? null, + raa: rec.refundAmountAwaiting ?? null, + turi: rec.talerUri ?? null, + }, + ); + const oldExchanges = await this.loadPurchaseExchanges(rec.proposalId); + const newExchanges = rec.exchanges; + const oldList = oldExchanges ?? []; + const newList = newExchanges ?? []; + if ( + oldList.length === newList.length && + oldList.every((url, i) => url === newList[i]) + ) { + return; + } + await this.run("DELETE FROM purchase_exchanges WHERE proposal_id = $id", { + id: rec.proposalId, + }); + if (newExchanges?.length) { + const params: Record<string, string | number> = { id: rec.proposalId }; + const values = newExchanges.map((url, i) => { + params[`idx${i}`] = i; + params[`url${i}`] = url; + return `($id, $idx${i}, $url${i})`; + }); + await this.run( + "INSERT INTO purchase_exchanges (proposal_id, idx, exchange_base_url)" + + ` VALUES ${values.join(", ")}`, + params, + ); + } + } + + async deletePurchase(proposalId: string): Promise<void> { + await this.run("DELETE FROM purchase_exchanges WHERE proposal_id = $id", { + id: proposalId, + }); + await this.run("DELETE FROM purchases WHERE proposal_id = $id", { + id: proposalId, + }); + } + + async listAllPurchases(): Promise<WalletPurchase[]> { + return await this.hydratePurchases( + await this.all("SELECT * FROM purchases"), + ); + } + + async getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]> { + if (proposalIds.length === 0) { + return []; + } + const rows: ResultRow[] = []; + for (let offset = 0; offset < proposalIds.length; offset += 400) { + const chunk = proposalIds.slice(offset, offset + 400); + const params: Record<string, string> = {}; + const placeholders = chunk.map((id, i) => { + params[`id${i}`] = id; + return `$id${i}`; + }); + rows.push( + ...(await this.all( + `SELECT * FROM purchases WHERE proposal_id IN (${placeholders.join(", ")})`, + params, + )), + ); + } + const purchases = await this.hydratePurchases(rows); + const byId = new Map(purchases.map((p) => [p.proposalId, p])); + return proposalIds.flatMap((id) => { + const purchase = byId.get(id); + return purchase ? [purchase] : []; + }); + } + + async getPurchasesByStatus( + status: PurchaseStatus, + ): Promise<WalletPurchase[]> { + return await this.hydratePurchases( + await this.all( + "SELECT * FROM purchases WHERE purchase_status = $s" + + " ORDER BY purchase_status, proposal_id", + { + s: status, + }, + ), + ); + } + + async getActivePurchases(): Promise<WalletPurchase[]> { + return await this.hydratePurchases( + await this.all( + "SELECT * FROM purchases WHERE purchase_status BETWEEN $lo AND $hi" + + " ORDER BY purchase_status, proposal_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ), + ); + } + + async getPurchaseByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase | undefined> { + const row = await this.first( + "SELECT * FROM purchases" + + " WHERE merchant_base_url = $url AND order_id = $oid", + { url: merchantBaseUrl, oid: orderId }, + ); + if (!row) { + return undefined; + } + return this.rowToPurchase( + row, + await this.loadPurchaseExchanges(str(row.proposal_id)), + ); + } + + async getPurchasesByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase[]> { + return await this.hydratePurchases( + await this.all( + "SELECT * FROM purchases" + + " WHERE merchant_base_url = $url AND order_id = $oid", + { url: merchantBaseUrl, oid: orderId }, + ), + ); + } + + async getPurchasesByFulfillmentUrl( + fulfillmentUrl: string, + ): Promise<WalletPurchase[]> { + return await this.hydratePurchases( + await this.all( + "SELECT * FROM purchases WHERE download_fulfillment_url = $url", + { url: fulfillmentUrl }, + ), + ); + } + + async getPurchasesByExchange( + exchangeBaseUrl: string, + ): Promise<WalletPurchase[]> { + // Via the junction table, which replaces the multiEntry index. + return await this.hydratePurchases( + await this.all( + "SELECT p.* FROM purchases p" + + " JOIN purchase_exchanges pe ON pe.proposal_id = p.proposal_id" + + " WHERE pe.exchange_base_url = $url", + { url: exchangeBaseUrl }, + ), + ); + } + + // ---------------------------------------------------------- donations + + private rowToDonationSummary(row: ResultRow): WalletDonationSummary { + return { + donauBaseUrl: str(row.donau_base_url), + year: num(row.year), + currency: str(row.currency), + amountReceiptsAvailable: dbAmount(row.amount_receipts_available), + amountReceiptsSubmitted: dbAmount(row.amount_receipts_submitted), + ...(row.legal_domain != null + ? { legalDomain: str(row.legal_domain) } + : undefined), + }; + } + + async getDonationSummary( + donauBaseUrl: string, + year: number, + currency: string, + ): Promise<WalletDonationSummary | undefined> { + const row = await this.first( + "SELECT * FROM donation_summaries" + + " WHERE donau_base_url = $url AND year = $year AND currency = $cur", + { url: donauBaseUrl, year, cur: currency }, + ); + return row ? this.rowToDonationSummary(row) : undefined; + } + + async getDonationSummaries(): Promise<WalletDonationSummary[]> { + const rows = await this.all("SELECT * FROM donation_summaries"); + return rows.map((r) => this.rowToDonationSummary(r)); + } + + async upsertDonationSummary(rec: WalletDonationSummary): Promise<void> { + await this.run( + `INSERT INTO donation_summaries ( + donau_base_url, year, currency, legal_domain, + amount_receipts_available, amount_receipts_submitted + ) VALUES ($url, $year, $cur, $ld, $avail, $sub) + ON CONFLICT(donau_base_url, year, currency) DO UPDATE SET + legal_domain = excluded.legal_domain, + amount_receipts_available = excluded.amount_receipts_available, + amount_receipts_submitted = excluded.amount_receipts_submitted`, + { + url: rec.donauBaseUrl, + year: rec.year, + cur: rec.currency, + ld: rec.legalDomain ?? null, + avail: rec.amountReceiptsAvailable, + sub: rec.amountReceiptsSubmitted, + }, + ); + } + + private rowToDonationPlanchet(row: ResultRow): WalletDonationPlanchet { + return { + udiNonce: dbToCrock(row.udi_nonce), + donauBaseUrl: str(row.donau_base_url), + 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: dbToCrock(row.bks), + donationUnitPubHash: dbToCrock(row.donation_unit_pub_hash), + value: dbAmount(row.value), + }; + } + + async upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void> { + await this.run( + `INSERT INTO donation_planchets ( + udi_nonce, donau_base_url, donor_tax_id_hash, donor_hash_salt, + donor_tax_id, donation_year, proposal_id, udi_index, blinded_udi, + bks, donation_unit_pub_hash, value + ) VALUES ( + $nonce, $url, $dtih, $dhs, $dti, $year, $pid, $idx, $budi, $bks, + $duph, $val + ) + ON CONFLICT(udi_nonce) DO UPDATE SET + donau_base_url = excluded.donau_base_url, + donor_tax_id_hash = excluded.donor_tax_id_hash, + donor_hash_salt = excluded.donor_hash_salt, + donor_tax_id = excluded.donor_tax_id, + donation_year = excluded.donation_year, + proposal_id = excluded.proposal_id, + udi_index = excluded.udi_index, + blinded_udi = excluded.blinded_udi, + bks = excluded.bks, + donation_unit_pub_hash = excluded.donation_unit_pub_hash, + value = excluded.value`, + { + nonce: crockToDb(rec.udiNonce), + url: rec.donauBaseUrl, + dtih: crockToDb(rec.donorTaxIdHash), + dhs: rec.donorHashSalt, + dti: rec.donorTaxId, + year: rec.donationYear, + pid: rec.proposalId, + idx: rec.udiIndex, + budi: jsonToDb(rec.blindedUdi), + bks: crockToDb(rec.bks), + duph: crockToDb(rec.donationUnitPubHash), + val: rec.value, + }, + ); + } + + async getDonationPlanchetsByProposal( + proposalId: string, + ): Promise<WalletDonationPlanchet[]> { + const rows = await this.all( + "SELECT * FROM donation_planchets WHERE proposal_id = $pid", + { pid: proposalId }, + ); + return rows.map((r) => this.rowToDonationPlanchet(r)); + } + + async countDonationPlanchetsByProposal(proposalId: string): Promise<number> { + const row = await this.first( + "SELECT COUNT(*) AS n FROM donation_planchets WHERE proposal_id = $pid", + { pid: proposalId }, + ); + return num(row?.n); + } + + async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { + const rows = await this.all("SELECT * FROM donation_planchets"); + return rows.map((r) => this.rowToDonationPlanchet(r)); + } + + async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { + const rows = await this.all("SELECT * FROM donation_receipts"); + return rows.map((r) => this.rowToDonationReceipt(r)); + } + + private rowToDonationReceipt(row: ResultRow): WalletDonationReceipt { + return { + 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: dbToCrock(row.donation_unit_pub_hash), + donationUnitSig: dbToJson(row.donation_unit_sig), + donorTaxIdHash: dbToCrock(row.donor_tax_id_hash), + donorHashSalt: str(row.donor_hash_salt), + donorTaxId: str(row.donor_tax_id), + value: dbAmount(row.value), + udiIndex: num(row.udi_index), + }; + } + + async getDonationReceipt( + udiNonce: string, + ): Promise<WalletDonationReceipt | undefined> { + const row = await this.first( + "SELECT * FROM donation_receipts WHERE udi_nonce = $nonce", + { nonce: crockToDb(udiNonce) }, + ); + return row ? this.rowToDonationReceipt(row) : undefined; + } + + async upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void> { + await this.run( + `INSERT INTO donation_receipts ( + udi_nonce, status, donau_base_url, proposal_id, donation_year, + donation_unit_pub_hash, donation_unit_sig, donor_tax_id_hash, + donor_hash_salt, donor_tax_id, value, udi_index + ) VALUES ( + $nonce, $status, $url, $pid, $year, $duph, $dus, $dtih, $dhs, + $dti, $val, $idx + ) + ON CONFLICT(udi_nonce) DO UPDATE SET + status = excluded.status, + donau_base_url = excluded.donau_base_url, + proposal_id = excluded.proposal_id, + donation_year = excluded.donation_year, + donation_unit_pub_hash = excluded.donation_unit_pub_hash, + donation_unit_sig = excluded.donation_unit_sig, + donor_tax_id_hash = excluded.donor_tax_id_hash, + donor_hash_salt = excluded.donor_hash_salt, + donor_tax_id = excluded.donor_tax_id, + value = excluded.value, + udi_index = excluded.udi_index`, + { + nonce: crockToDb(rec.udiNonce), + status: rec.status, + url: rec.donauBaseUrl, + pid: rec.proposalId, + year: rec.donationYear, + duph: crockToDb(rec.donationUnitPubHash), + dus: jsonToDb(rec.donationUnitSig), + dtih: crockToDb(rec.donorTaxIdHash), + dhs: rec.donorHashSalt, + dti: rec.donorTaxId, + val: rec.value, + idx: rec.udiIndex, + }, + ); + } + + async getDonationReceiptsByStatus( + status: DonationReceiptStatus, + ): Promise<WalletDonationReceipt[]> { + const rows = await this.all( + "SELECT * FROM donation_receipts WHERE status = $s" + + " ORDER BY status, udi_nonce", + { s: status }, + ); + return rows.map((r) => this.rowToDonationReceipt(r)); + } + + async getDonationReceiptsByStatusAndDonau( + status: DonationReceiptStatus, + donauBaseUrl: string, + ): Promise<WalletDonationReceipt[]> { + const rows = await this.all( + "SELECT * FROM donation_receipts" + + " WHERE status = $s AND donau_base_url = $url", + { s: status, url: donauBaseUrl }, + ); + return rows.map((r) => this.rowToDonationReceipt(r)); + } + + // ----------------------------------------------------- currency info + + async getCurrencyInfo( + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined> { + const row = await this.first( + "SELECT * FROM currency_info WHERE scope_info_str = $s", + { s: stringifyScopeInfo(scopeInfo) }, + ); + if (!row) { + return undefined; + } + return { + currencySpec: dbToJson(row.currency_spec), + source: str(row.source) as GetCurrencyInfoDbResult["source"], + }; + } + + async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { + await this.run( + "INSERT INTO currency_info (scope_info_str, currency_spec, source)" + + " VALUES ($s, $spec, $src)" + + " ON CONFLICT(scope_info_str) DO UPDATE SET" + + " currency_spec = excluded.currency_spec," + + " source = excluded.source", + { + s: stringifyScopeInfo(req.scopeInfo), + spec: jsonToDb(req.currencySpec), + src: req.source, + }, + ); + } + + async insertCurrencyInfoUnlessExists( + req: StoreCurrencyInfoDbRequest, + ): Promise<void> { + // OR IGNORE rather than read-then-write: the effect is the same and it + // cannot race with itself. + await this.run( + "INSERT OR IGNORE INTO currency_info" + + " (scope_info_str, currency_spec, source) VALUES ($s, $spec, $src)", + { + s: stringifyScopeInfo(req.scopeInfo), + spec: jsonToDb(req.currencySpec), + src: req.source, + }, + ); + } + + async deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void> { + await this.run("DELETE FROM currency_info WHERE scope_info_str = $s", { + s: stringifyScopeInfo(scopeInfo), + }); + } + + // --------------------------------------------------------- contacts + + async addContact(contact: ContactEntry): Promise<void> { + await this.run( + `INSERT INTO contacts ( + alias, alias_type, mailbox_base_uri, mailbox_address, source, petname + ) VALUES ($alias, $type, $uri, $addr, $src, $pet) + ON CONFLICT(alias, alias_type) DO UPDATE SET + mailbox_base_uri = excluded.mailbox_base_uri, + mailbox_address = excluded.mailbox_address, + source = excluded.source, + petname = excluded.petname`, + { + alias: contact.alias, + type: contact.aliasType, + uri: contact.mailboxBaseUri, + addr: contact.mailboxAddress, + src: contact.source, + pet: contact.petname, + }, + ); + } + + async deleteContact(alias: string, aliasType: string): Promise<void> { + await this.run( + "DELETE FROM contacts WHERE alias = $alias AND alias_type = $type", + { alias, type: aliasType }, + ); + } + + async listContacts(): Promise<ContactEntry[]> { + const rows = await this.all("SELECT * FROM contacts"); + return rows.map((row) => ({ + alias: str(row.alias), + aliasType: str(row.alias_type), + mailboxBaseUri: str(row.mailbox_base_uri), + mailboxAddress: str(row.mailbox_address), + source: str(row.source), + petname: str(row.petname), + })); + } + + // ---------------------------------------------------------- mailbox + + async upsertMailboxMessage(message: MailboxMessageRecord): Promise<void> { + await this.run( + "INSERT INTO mailbox_messages" + + " (origin_mailbox_base_url, taler_uri, downloaded_at)" + + " VALUES ($url, $uri, $at)" + + " ON CONFLICT(origin_mailbox_base_url, taler_uri) DO UPDATE SET" + + " downloaded_at = excluded.downloaded_at", + { + url: message.originMailboxBaseUrl, + uri: message.talerUri, + at: timestampProtocolToDb(message.downloadedAt), + }, + ); + } + + async deleteMailboxMessage( + originMailboxBaseUrl: string, + talerUri: string, + ): Promise<void> { + await this.run( + "DELETE FROM mailbox_messages" + + " WHERE origin_mailbox_base_url = $url AND taler_uri = $uri", + { url: originMailboxBaseUrl, uri: talerUri }, + ); + } + + async listMailboxMessages(): Promise<MailboxMessageRecord[]> { + const rows = await this.all("SELECT * FROM mailbox_messages"); + return rows.map((row) => ({ + originMailboxBaseUrl: str(row.origin_mailbox_base_url), + talerUri: str(row.taler_uri), + downloadedAt: timestampProtocolFromDb(dbTimestamp(row.downloaded_at)), + })); + } + + async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { + const rows = await this.all("SELECT payload FROM mailbox_configurations"); + return rows.map((r) => dbToJson<MailboxConfiguration>(r.payload)); + } + + async getMailboxConfiguration( + mailboxBaseUrl: string, + ): Promise<MailboxConfiguration | undefined> { + const row = await this.first( + "SELECT * FROM mailbox_configurations WHERE mailbox_base_url = $url", + { url: mailboxBaseUrl }, + ); + return row ? dbToJson<MailboxConfiguration>(row.payload) : undefined; + } + + async upsertMailboxConfiguration( + mailboxConf: MailboxConfiguration, + ): Promise<void> { + await this.run( + "INSERT INTO mailbox_configurations (mailbox_base_url, payload)" + + " VALUES ($url, $p)" + + " ON CONFLICT(mailbox_base_url) DO UPDATE SET payload = excluded.payload", + { url: mailboxConf.mailboxBaseUrl, p: jsonToDb(mailboxConf) }, + ); + } + + // ------------------------------------------------- global currency + + async listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]> { + const rows = await this.all("SELECT * FROM global_currency_exchanges"); + return rows.map((row) => ({ + id: num(row.id), + currency: str(row.currency), + exchangeBaseUrl: str(row.exchange_base_url), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), + })); + } + + async upsertGlobalCurrencyExchange( + rec: WalletGlobalCurrencyExchange, + ): Promise<void> { + await this.run( + "INSERT OR IGNORE INTO global_currency_exchanges" + + " (currency, exchange_base_url, exchange_master_pub)" + + " VALUES ($cur, $url, $pub)", + { + cur: rec.currency, + url: rec.exchangeBaseUrl, + pub: crockToDb(rec.exchangeMasterPub), + }, + ); + } + + async deleteGlobalCurrencyExchange(id: number): Promise<void> { + await this.run("DELETE FROM global_currency_exchanges WHERE id = $id", { + id, + }); + } + + async getGlobalCurrencyExchange( + currency: string, + exchangeBaseUrl: string, + exchangeMasterPub: string, + ): Promise<WalletGlobalCurrencyExchange | undefined> { + const row = await this.first( + "SELECT * FROM global_currency_exchanges" + + " WHERE currency = $cur AND exchange_base_url = $url" + + " AND exchange_master_pub = $pub", + { + cur: currency, + url: exchangeBaseUrl, + pub: crockToDb(exchangeMasterPub), + }, + ); + if (!row) { + return undefined; + } + return { + id: num(row.id), + currency: str(row.currency), + exchangeBaseUrl: str(row.exchange_base_url), + exchangeMasterPub: dbToCrock(row.exchange_master_pub), + }; + } + + async listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]> { + const rows = await this.all("SELECT * FROM global_currency_auditors"); + return rows.map((row) => ({ + id: num(row.id), + currency: str(row.currency), + auditorBaseUrl: str(row.auditor_base_url), + auditorPub: dbToCrock(row.auditor_pub), + })); + } + + async upsertGlobalCurrencyAuditor( + rec: WalletGlobalCurrencyAuditor, + ): Promise<void> { + await this.run( + "INSERT OR IGNORE INTO global_currency_auditors" + + " (currency, auditor_base_url, auditor_pub)" + + " VALUES ($cur, $url, $pub)", + { + cur: rec.currency, + url: rec.auditorBaseUrl, + pub: crockToDb(rec.auditorPub), + }, + ); + } + + async deleteGlobalCurrencyAuditor(id: number): Promise<void> { + await this.run("DELETE FROM global_currency_auditors WHERE id = $id", { + id, + }); + } + + async getGlobalCurrencyAuditor( + currency: string, + auditorBaseUrl: string, + auditorPub: string, + ): Promise<WalletGlobalCurrencyAuditor | undefined> { + const row = await this.first( + "SELECT * FROM global_currency_auditors" + + " WHERE currency = $cur AND auditor_base_url = $url" + + " AND auditor_pub = $pub", + { cur: currency, url: auditorBaseUrl, pub: crockToDb(auditorPub) }, + ); + if (!row) { + return undefined; + } + return { + id: num(row.id), + currency: str(row.currency), + auditorBaseUrl: str(row.auditor_base_url), + auditorPub: dbToCrock(row.auditor_pub), + }; + } + + async checkExchangeInScope( + exchangeBaseUrl: string, + scope: ScopeInfo, + denomPubHash?: string, + ): Promise<boolean> { + return await checkExchangeInScopeGeneric( + this, + exchangeBaseUrl, + scope, + denomPubHash, + ); + } + + async getExchangeScopeInfo( + exchangeBaseUrl: string, + currency: string, + denomPubHash?: string, + ): Promise<ScopeInfo> { + return await getExchangeScopeInfoGeneric( + this, + exchangeBaseUrl, + currency, + denomPubHash, + ); + } + + // ---------------------------------------------------- bank accounts + + private rowToBankAccount(row: ResultRow): WalletBankAccount { + return { + bankAccountId: str(row.bank_account_id), + paytoUri: str(row.payto_uri), + label: optStr(row.label), + currencies: dbToOptJson(row.currencies), + kycCompleted: dbToBool(row.kyc_completed), + }; + } + + async listBankAccounts(): Promise<WalletBankAccount[]> { + const rows = await this.all("SELECT * FROM bank_accounts"); + return rows.map((r) => this.rowToBankAccount(r)); + } + + async getBankAccount( + bankAccountId: string, + ): Promise<WalletBankAccount | undefined> { + const row = await this.first( + "SELECT * FROM bank_accounts WHERE bank_account_id = $id", + { id: bankAccountId }, + ); + return row ? this.rowToBankAccount(row) : undefined; + } + + async getBankAccountByPaytoUri( + paytoUri: string, + ): Promise<WalletBankAccount | undefined> { + const row = await this.first( + "SELECT * FROM bank_accounts WHERE payto_uri = $uri", + { uri: paytoUri }, + ); + return row ? this.rowToBankAccount(row) : undefined; + } + + async upsertBankAccount(rec: WalletBankAccount): Promise<void> { + await this.run( + `INSERT INTO bank_accounts ( + bank_account_id, payto_uri, label, currencies, kyc_completed + ) VALUES ($id, $uri, $label, $cur, $kyc) + ON CONFLICT(bank_account_id) DO UPDATE SET + payto_uri = excluded.payto_uri, + label = excluded.label, + currencies = excluded.currencies, + kyc_completed = excluded.kyc_completed`, + { + id: rec.bankAccountId, + uri: rec.paytoUri, + label: rec.label ?? null, + cur: rec.currencies === undefined ? null : jsonToDb(rec.currencies), + kyc: boolToDb(rec.kycCompleted), + }, + ); + } + + async deleteBankAccount(bankAccountId: string): Promise<void> { + await this.run("DELETE FROM bank_accounts WHERE bank_account_id = $id", { + id: bankAccountId, + }); + } + + // ----------------------------------------------------------- tokens + + private rowToToken(row: ResultRow): WalletToken { + return { + 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: 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: dbToCrock(row.token_ev_hash), + blindingKey: dbToCrock(row.blinding_key), + slug: str(row.slug), + name: str(row.name), + description: str(row.description), + extraData: dbToJson(row.extra_data), + tokenIssuePub: dbToJson(row.token_issue_pub), + descriptionI18n: dbToOptJson(row.description_i18n), + ...(row.transaction_id != null + ? { transactionId: str(row.transaction_id) } + : undefined), + ...(row.choice_index != null + ? { choiceIndex: num(row.choice_index) } + : undefined), + ...(row.output_index != null + ? { outputIndex: num(row.output_index) } + : undefined), + ...(row.repeat_index != null + ? { repeatIndex: num(row.repeat_index) } + : undefined), + ...(row.token_family_hash != null + ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } + : undefined), + }; + } + + async listTokens(): Promise<WalletToken[]> { + const rows = await this.all("SELECT * FROM tokens"); + return rows.map((r) => this.rowToToken(r)); + } + + async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { + const row = await this.first( + "SELECT * FROM tokens WHERE token_use_pub = $pub", + { pub: crockToDb(tokenUsePub) }, + ); + return row ? this.rowToToken(row) : undefined; + } + + async upsertToken(rec: WalletToken): Promise<void> { + await this.run( + `INSERT INTO tokens ( + token_use_pub, token_use_priv, purchase_id, transaction_id, + choice_index, output_index, repeat_index, merchant_base_url, kind, + token_issue_pub_hash, token_family_hash, valid_after, valid_before, + token_issue_sig, + token_use_sig, token_ev, token_ev_hash, blinding_key, slug, name, + description, extra_data, token_issue_pub, description_i18n + ) VALUES ( + $pub, $priv, $pid, $tid, $ci, $oi, $ri, $url, $kind, $tiph, $tfh, + $va, $vb, $sig, $usig, $ev, $evh, $bk, $slug, $name, $desc, $extra, $tipub, $di18n + ) + ON CONFLICT(token_use_pub) DO UPDATE SET + token_use_priv = excluded.token_use_priv, + purchase_id = excluded.purchase_id, + transaction_id = excluded.transaction_id, + choice_index = excluded.choice_index, + output_index = excluded.output_index, + repeat_index = excluded.repeat_index, + merchant_base_url = excluded.merchant_base_url, + kind = excluded.kind, + token_issue_pub_hash = excluded.token_issue_pub_hash, + token_family_hash = excluded.token_family_hash, + valid_after = excluded.valid_after, + valid_before = excluded.valid_before, + token_issue_sig = excluded.token_issue_sig, + token_use_sig = excluded.token_use_sig, + token_ev = excluded.token_ev, + token_ev_hash = excluded.token_ev_hash, + blinding_key = excluded.blinding_key, + slug = excluded.slug, + name = excluded.name, + description = excluded.description, + extra_data = excluded.extra_data, + token_issue_pub = excluded.token_issue_pub, + description_i18n = excluded.description_i18n`, + { + pub: crockToDb(rec.tokenUsePub), + priv: crockToDb(rec.tokenUsePriv), + pid: rec.purchaseId, + tid: rec.transactionId ?? null, + ci: rec.choiceIndex ?? null, + oi: rec.outputIndex ?? null, + ri: rec.repeatIndex ?? null, + url: rec.merchantBaseUrl, + kind: rec.kind, + 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: crockToDb(rec.tokenEvHash), + bk: crockToDb(rec.blindingKey), + slug: rec.slug, + name: rec.name, + desc: rec.description, + extra: jsonToDb(rec.extraData), + tipub: jsonToDb(rec.tokenIssuePub), + di18n: + rec.descriptionI18n === undefined + ? null + : jsonToDb(rec.descriptionI18n), + }, + ); + } + + async deleteToken(tokenUsePub: string): Promise<void> { + await this.run("DELETE FROM tokens WHERE token_use_pub = $pub", { + pub: crockToDb(tokenUsePub), + }); + } + + async getTokensByIssuePubHash( + tokenIssuePubHash: string, + ): Promise<WalletToken[]> { + const rows = await this.all( + "SELECT * FROM tokens WHERE token_issue_pub_hash = $h", + { h: crockToDb(tokenIssuePubHash) }, + ); + return rows.map((r) => this.rowToToken(r)); + } + + async getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]> { + const rows = await this.all( + "SELECT * FROM tokens WHERE token_family_hash = $h", + { h: crockToDb(tokenFamilyHash) }, + ); + return rows.map((r) => this.rowToToken(r)); + } + + // ----------------------------------------------------------- slates + + private rowToSlate(row: ResultRow): WalletSlate { + return { + 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: 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: dbToCrock(row.token_ev_hash), + blindingKey: dbToCrock(row.blinding_key), + slug: str(row.slug), + name: str(row.name), + description: str(row.description), + extraData: dbToJson(row.extra_data), + tokenIssuePub: dbToJson(row.token_issue_pub), + descriptionI18n: dbToOptJson(row.description_i18n), + ...(row.transaction_id != null + ? { transactionId: str(row.transaction_id) } + : undefined), + ...(row.choice_index != null + ? { choiceIndex: num(row.choice_index) } + : undefined), + ...(row.output_index != null + ? { outputIndex: num(row.output_index) } + : undefined), + ...(row.repeat_index != null + ? { repeatIndex: num(row.repeat_index) } + : undefined), + ...(row.token_family_hash != null + ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } + : undefined), + }; + } + + async listAllSlates(): Promise<WalletSlate[]> { + const rows = await this.all("SELECT * FROM slates"); + return rows.map((r) => this.rowToSlate(r)); + } + + async getSlate( + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, + ): Promise<WalletSlate | undefined> { + const row = await this.first( + "SELECT * FROM slates" + + " WHERE purchase_id = $pid AND choice_index = $ci" + + " AND output_index = $oi AND repeat_index = $ri", + { pid: purchaseId, ci: choiceIndex, oi: outputIndex, ri: repeatIndex }, + ); + return row ? this.rowToSlate(row) : undefined; + } + + async getSlatesByPurchaseAndChoice( + purchaseId: string, + choiceIndex: number, + ): Promise<WalletSlate[]> { + const rows = await this.all( + "SELECT * FROM slates WHERE purchase_id = $pid AND choice_index = $ci", + { pid: purchaseId, ci: choiceIndex }, + ); + return rows.map((r) => this.rowToSlate(r)); + } + + async upsertSlate(rec: WalletSlate): Promise<void> { + await this.run( + `INSERT INTO slates ( + token_use_pub, token_use_priv, purchase_id, transaction_id, + choice_index, output_index, repeat_index, merchant_base_url, kind, + token_issue_pub_hash, token_family_hash, valid_after, valid_before, + token_use_sig, token_ev, token_ev_hash, blinding_key, slug, name, + description, extra_data, token_issue_pub, description_i18n + ) VALUES ( + $pub, $priv, $pid, $tid, $ci, $oi, $ri, $url, $kind, $tiph, $tfh, + $va, $vb, $usig, $ev, $evh, $bk, $slug, $name, $desc, $extra, $tipub, $di18n + ) + ON CONFLICT(token_use_pub) DO UPDATE SET + token_use_priv = excluded.token_use_priv, + purchase_id = excluded.purchase_id, + transaction_id = excluded.transaction_id, + choice_index = excluded.choice_index, + output_index = excluded.output_index, + repeat_index = excluded.repeat_index, + merchant_base_url = excluded.merchant_base_url, + kind = excluded.kind, + token_issue_pub_hash = excluded.token_issue_pub_hash, + token_family_hash = excluded.token_family_hash, + valid_after = excluded.valid_after, + valid_before = excluded.valid_before, + token_use_sig = excluded.token_use_sig, + token_ev = excluded.token_ev, + token_ev_hash = excluded.token_ev_hash, + blinding_key = excluded.blinding_key, + slug = excluded.slug, + name = excluded.name, + description = excluded.description, + extra_data = excluded.extra_data, + token_issue_pub = excluded.token_issue_pub, + description_i18n = excluded.description_i18n`, + { + pub: crockToDb(rec.tokenUsePub), + priv: crockToDb(rec.tokenUsePriv), + pid: rec.purchaseId, + tid: rec.transactionId ?? null, + ci: rec.choiceIndex ?? null, + oi: rec.outputIndex ?? null, + ri: rec.repeatIndex ?? null, + url: rec.merchantBaseUrl, + kind: rec.kind, + 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: crockToDb(rec.tokenEvHash), + bk: crockToDb(rec.blindingKey), + slug: rec.slug, + name: rec.name, + desc: rec.description, + extra: jsonToDb(rec.extraData), + tipub: jsonToDb(rec.tokenIssuePub), + di18n: + rec.descriptionI18n === undefined + ? null + : jsonToDb(rec.descriptionI18n), + }, + ); + } + + async deleteSlate(tokenUsePub: string): Promise<void> { + await this.run("DELETE FROM slates WHERE token_use_pub = $pub", { + pub: crockToDb(tokenUsePub), + }); + } + + // -------------------------------------------------- refresh sessions + + private rowToRefreshSession(row: ResultRow): WalletRefreshSession { + return { + refreshGroupId: str(row.refresh_group_id), + coinIndex: num(row.coin_index), + amountRefreshOutput: dbAmount(row.amount_refresh_output), + newDenoms: dbToJson(row.new_denoms), + ...(row.session_public_seed != null + ? { sessionPublicSeed: dbToCrock(row.session_public_seed) } + : undefined), + ...(row.refresh_protocol_version != null + ? { refreshProtocolVersion: num(row.refresh_protocol_version) } + : undefined), + ...(row.noreveal_index != null + ? { norevealIndex: num(row.noreveal_index) } + : undefined), + ...(row.last_error != null + ? { lastError: dbToJson(row.last_error) } + : undefined), + }; + } + + async getRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<WalletRefreshSession | undefined> { + const row = await this.first( + "SELECT * FROM refresh_sessions" + + " WHERE refresh_group_id = $id AND coin_index = $idx", + { id: refreshGroupId, idx: coinIndex }, + ); + return row ? this.rowToRefreshSession(row) : undefined; + } + + async upsertRefreshSession(rec: WalletRefreshSession): Promise<void> { + await this.run( + `INSERT INTO refresh_sessions ( + refresh_group_id, coin_index, session_public_seed, + refresh_protocol_version, amount_refresh_output, new_denoms, + noreveal_index, last_error + ) VALUES ($id, $idx, $seed, $rpv, $amt, $nd, $nri, $err) + ON CONFLICT(refresh_group_id, coin_index) DO UPDATE SET + session_public_seed = excluded.session_public_seed, + refresh_protocol_version = excluded.refresh_protocol_version, + amount_refresh_output = excluded.amount_refresh_output, + new_denoms = excluded.new_denoms, + noreveal_index = excluded.noreveal_index, + last_error = excluded.last_error`, + { + id: rec.refreshGroupId, + idx: rec.coinIndex, + seed: optCrockToDb(rec.sessionPublicSeed), + rpv: rec.refreshProtocolVersion ?? null, + amt: rec.amountRefreshOutput, + nd: jsonToDb(rec.newDenoms), + nri: rec.norevealIndex ?? null, + err: rec.lastError === undefined ? null : jsonToDb(rec.lastError), + }, + ); + } + + async deleteRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<void> { + await this.run( + "DELETE FROM refresh_sessions" + + " WHERE refresh_group_id = $id AND coin_index = $idx", + { id: refreshGroupId, idx: coinIndex }, + ); + } + + async getRefreshSessionsByGroup( + refreshGroupId: string, + ): Promise<WalletRefreshSession[]> { + const rows = await this.all( + "SELECT * FROM refresh_sessions WHERE refresh_group_id = $id" + + " ORDER BY coin_index", + { id: refreshGroupId }, + ); + return rows.map((r) => this.rowToRefreshSession(r)); + } + + async listAllRefreshSessions(): Promise<WalletRefreshSession[]> { + const rows = await this.all("SELECT * FROM refresh_sessions"); + return rows.map((r) => this.rowToRefreshSession(r)); + } + + // ----------------------------------------------------- recoup groups + + private rowToRecoupGroup(row: ResultRow): WalletRecoupGroup { + return { + recoupGroupId: str(row.recoup_group_id), + exchangeBaseUrl: str(row.exchange_base_url), + operationStatus: num(row.operation_status), + timestampStarted: dbTimestamp(row.timestamp_started), + timestampFinished: + row.timestamp_finished == null + ? undefined + : dbTimestamp(row.timestamp_finished), + coinPubs: dbToJson(row.coin_pubs), + recoupFinishedPerCoin: dbToJson(row.recoup_finished_per_coin), + scheduleRefreshCoins: dbToJson(row.schedule_refresh_coins), + }; + } + + async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { + const rows = await this.all("SELECT * FROM recoup_groups"); + return rows.map((r) => this.rowToRecoupGroup(r)); + } + + async getRecoupGroup( + recoupGroupId: string, + ): Promise<WalletRecoupGroup | undefined> { + const row = await this.first( + "SELECT * FROM recoup_groups WHERE recoup_group_id = $id", + { id: recoupGroupId }, + ); + return row ? this.rowToRecoupGroup(row) : undefined; + } + + async upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void> { + await this.run( + `INSERT INTO recoup_groups ( + recoup_group_id, exchange_base_url, operation_status, + timestamp_started, timestamp_finished, coin_pubs, + recoup_finished_per_coin, schedule_refresh_coins + ) VALUES ($id, $url, $status, $started, $finished, $pubs, $fin, $sched) + ON CONFLICT(recoup_group_id) DO UPDATE SET + exchange_base_url = excluded.exchange_base_url, + operation_status = excluded.operation_status, + timestamp_started = excluded.timestamp_started, + timestamp_finished = excluded.timestamp_finished, + coin_pubs = excluded.coin_pubs, + recoup_finished_per_coin = excluded.recoup_finished_per_coin, + schedule_refresh_coins = excluded.schedule_refresh_coins`, + { + id: rec.recoupGroupId, + url: rec.exchangeBaseUrl, + status: rec.operationStatus, + started: rec.timestampStarted, + finished: rec.timestampFinished ?? null, + pubs: jsonToDb(rec.coinPubs), + fin: jsonToDb(rec.recoupFinishedPerCoin), + sched: jsonToDb(rec.scheduleRefreshCoins), + }, + ); + } + + async deleteRecoupGroup(recoupGroupId: string): Promise<void> { + await this.run("DELETE FROM recoup_groups WHERE recoup_group_id = $id", { + id: recoupGroupId, + }); + } + + async getRecoupGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletRecoupGroup[]> { + const rows = await this.all( + "SELECT * FROM recoup_groups WHERE exchange_base_url = $url", + { url: exchangeBaseUrl }, + ); + return rows.map((r) => this.rowToRecoupGroup(r)); + } + + async getActiveRecoupGroups(): Promise<WalletRecoupGroup[]> { + const rows = await this.all( + "SELECT * FROM recoup_groups" + + " WHERE operation_status BETWEEN $lo AND $hi" + + " ORDER BY operation_status, recoup_group_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToRecoupGroup(r)); + } + + // ------------------------------------------------- remaining actives + + async getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]> { + const rows = await this.all( + "SELECT * FROM peer_pull_credit WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, purse_pub", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToPeerPullCredit(r)); + } + + async getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]> { + const rows = await this.all( + "SELECT * FROM peer_pull_debit WHERE status BETWEEN $lo AND $hi" + + " ORDER BY status, peer_pull_debit_id", + { + lo: OPERATION_STATUS_NONFINAL_FIRST, + hi: OPERATION_STATUS_NONFINAL_LAST, + }, + ); + return rows.map((r) => this.rowToPeerPullDebit(r)); + } + + // ------------------------------------------------------ diagnostics + + async getRecordCounts(): Promise<WalletDbRecordCounts> { + const count = async (table: string): Promise<number> => { + const row = await this.first(`SELECT COUNT(*) AS n FROM ${table}`); + return num(row?.n); + }; + return { + coins: await count("coins"), + coinAvailability: await count("coin_availability"), + denominations: await count("denominations"), + denominationFamilies: await count("denomination_families"), + exchanges: await count("exchanges"), + exchangeDetails: await count("exchange_details"), + exchangeSignKeys: await count("exchange_sign_keys"), + }; + } + + // ==================================================================== + // Not implemented yet. + // + // These exist so the class can satisfy WalletDbTransaction without a cast: + // the alternative is asserting the type, which would make a missing method + // a runtime "not a function" instead of a named error. Each throws, so a + // caller reaching one fails loudly and says which method it wanted. The + // conformance suite reports them as skipped rather than passing. + // ==================================================================== +} diff --git a/packages/taler-wallet-core/src/db/testing/benchmark.test.ts b/packages/taler-wallet-core/src/db/testing/benchmark.test.ts @@ -0,0 +1,49 @@ +/* + 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/> + */ + +import assert from "node:assert"; +import { test } from "node:test"; + +import { benchmarkOneBackend, DbBenchOptions } from "./benchmark.js"; +import { runnerFactories } from "./runners.js"; + +test("database benchmark smoke test returns real, comparable rows", async () => { + const options: DbBenchOptions = { + numCoins: 120, + numDenominations: 12, + numExchanges: 2, + repeats: 1, + }; + const results = []; + for (const makeRunner of runnerFactories) { + const runner = await makeRunner(); + try { + results.push(await benchmarkOneBackend(runner, options)); + } finally { + await runner.close(); + } + } + + assert.strictEqual(results.length, 2); + assert.deepStrictEqual( + results[0].queries.map((query) => [query.name, query.rows]), + results[1].queries.map((query) => [query.name, query.rows]), + ); + const freshQuery = results[0].queries.find((query) => + query.name.startsWith("getFreshCoinsByDenomAndAge"), + ); + assert.ok(freshQuery && freshQuery.rows > 0); +}); diff --git a/packages/taler-wallet-core/src/db/testing/benchmark.ts b/packages/taler-wallet-core/src/db/testing/benchmark.ts @@ -0,0 +1,556 @@ +/* + 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/> + */ + +/** + * Benchmark for {@link WalletDbTransaction} implementations. + * + * Populates a synthetic wallet of a given size and times the queries the + * wallet actually runs on its hot paths, against every backend. + * + * This exists because the integration suite cannot answer questions about the + * storage layer. Its timings are dominated by network long-polls -- the task + * shepherd sleeps 10s whenever a long-poller returns sooner than 20s, and both + * backends hit that in some runs -- so a storage change of a few per cent is + * invisible under that noise. It also never builds a wallet large enough for + * index size to matter. This benchmark touches no services and no scheduler. + * + * Every measurement is reported as a median of repeated runs, not a single + * sample: single samples of this system have repeatedly proved misleading. + */ + +import { + AmountString, + CoinStatus, + DenomKeyType, + encodeCrock, + Logger, +} from "@gnu-taler/taler-util"; +import { + CoinSourceType, + DenominationVerificationStatus, + DbProtocolTimestamp, + WalletCoin, + WalletCoinAvailability, + WalletDenomination, + WalletDenominationFamily, +} from "../records.js"; +import { DbTxRunner } from "./conformance.js"; + +const logger = new Logger("db/testing/benchmark.ts"); + +export interface DbBenchOptions { + /** Coins to insert. The default is small enough to run in seconds. */ + numCoins: number; + /** Denominations to spread those coins over. */ + numDenominations: number; + /** Exchanges to spread the denominations over. */ + numExchanges: number; + /** How many times each query is repeated; the median is reported. */ + repeats: number; +} + +export const defaultDbBenchOptions: DbBenchOptions = { + numCoins: 20000, + numDenominations: 200, + numExchanges: 3, + repeats: 5, +}; + +export interface DbBenchQueryResult { + name: string; + /** Median wall-clock time over `repeats` runs. */ + medianMs: number; + minMs: number; + maxMs: number; + /** Rows the query returned, to catch a "fast because it found nothing". */ + rows: number; +} + +export interface DbBenchResult { + backend: string; + options: DbBenchOptions; + populateMs: number; + /** Size of the database on disk, when it is file-backed. */ + dbSizeBytes?: number; + queries: DbBenchQueryResult[]; +} + +function median(xs: number[]): number { + const s = [...xs].sort((a, b) => a - b); + const mid = Math.floor(s.length / 2); + return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; +} + +/** + * Deterministic Crockford value of the right length. + * + * 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, 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 + // keys for 500 coins -- every insert after the fourth was an upsert over an + // existing row, and the benchmark then measured a nearly empty database. + let h = 2166136261; + for (let i = 0; i < seed.length; i++) { + h = Math.imul(h ^ seed.charCodeAt(i), 16777619); + } + for (let i = 0; i < numBytes; i++) { + h = Math.imul(h ^ (h >>> 15), 2246822519); + h = (h ^ (h >>> 13)) >>> 0; + bytes[i] = h & 0xff; + } + return encodeCrock(bytes); +} + +const key = (s: string) => crockLike(s, 32); +const hash = (s: string) => crockLike(s, 64); + +function exchangeUrl(i: number): string { + return `https://exchange-${i}.test/`; +} + +async function populate( + runner: DbTxRunner, + opts: DbBenchOptions, +): Promise<void> { + // Denominations and their availability rows. + await runner.runReadWriteTx(async (tx) => { + for (let e = 0; e < opts.numExchanges; e++) { + const family: WalletDenominationFamily = { + denominationFamilySerial: e + 1, + familyParams: { + exchangeBaseUrl: exchangeUrl(e), + exchangeMasterPub: key(`master-${e}`), + value: "TESTKUDOS:1" as AmountString, + feeDeposit: "TESTKUDOS:0.01" as AmountString, + feeRefresh: "TESTKUDOS:0.01" as AmountString, + feeRefund: "TESTKUDOS:0.01" as AmountString, + feeWithdraw: "TESTKUDOS:0.01" as AmountString, + }, + }; + await tx.upsertDenominationFamily(family); + } + for (let d = 0; d < opts.numDenominations; d++) { + const ex = exchangeUrl(d % opts.numExchanges); + const dph = hash(`denom-${d}`); + const denom: WalletDenomination = { + denomPubHash: dph, + denomPub: { + cipher: DenomKeyType.Rsa, + rsa_public_key: `rsa-${d}`, + age_mask: 0, + }, + exchangeBaseUrl: ex, + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + currency: "TESTKUDOS", + value: "TESTKUDOS:1" as AmountString, + denominationFamilySerial: (d % opts.numExchanges) + 1, + stampStart: (1000 + d) as DbProtocolTimestamp, + stampExpireWithdraw: (2000 + d) as DbProtocolTimestamp, + stampExpireDeposit: (3000 + d) as DbProtocolTimestamp, + stampExpireLegal: (4000 + d) as DbProtocolTimestamp, + fees: { + feeDeposit: "TESTKUDOS:0.01" as AmountString, + feeRefresh: "TESTKUDOS:0.01" as AmountString, + feeRefund: "TESTKUDOS:0.01" as AmountString, + feeWithdraw: "TESTKUDOS:0.01" as AmountString, + }, + isOffered: true, + isRevoked: false, + masterSig: hash(`msig-${d}`), + verificationStatus: DenominationVerificationStatus.VerifiedGood, + }; + await tx.upsertDenomination(denom); + const avail: WalletCoinAvailability = { + exchangeBaseUrl: ex, + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + denomPubHash: dph, + maxAge: d % 2 === 0 ? 0 : 21, + currency: "TESTKUDOS", + value: "TESTKUDOS:1" as AmountString, + freshCoinCount: 10, + hasFreshCoins: 1, + visibleCoinCount: 10, + }; + await tx.upsertCoinAvailability(avail); + } + }); + + // Coins, in batches so a single transaction does not grow unbounded. + const batch = 2000; + for (let start = 0; start < opts.numCoins; start += batch) { + await runner.runReadWriteTx(async (tx) => { + for (let i = start; i < Math.min(start + batch, opts.numCoins); i++) { + const d = i % opts.numDenominations; + const coin: WalletCoin = { + coinPub: key(`coin-${i}`), + coinPriv: key(`coinpriv-${i}`), + exchangeBaseUrl: exchangeUrl(d % opts.numExchanges), + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + denomPubHash: hash(`denom-${d}`), + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: `sig-${i}` }, + blindingKey: key(`bk-${i}`), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + coinEvHash: hash(`evh-${i}`), + // Derived from the row *within* a denomination, not from i: with + // `i % 4` the dormant coins land on multiples of 4, which for many + // denomination counts means every coin of some denominations is + // dormant and the fresh-coin query then measures an empty result. + status: + Math.floor(i / opts.numDenominations) % 4 === 0 + ? CoinStatus.Dormant + : CoinStatus.Fresh, + maxAge: d % 2 === 0 ? 0 : 21, + ageCommitmentProof: undefined, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: key(`wg-${i % 100}`), + coinIndex: i, + reservePub: key(`rp-${i % 100}`), + }, + }; + await tx.upsertCoin(coin); + } + }); + } +} + +/** + * Run the benchmark against one already-populated runner. + */ +async function measure( + runner: DbTxRunner, + opts: DbBenchOptions, +): Promise<DbBenchQueryResult[]> { + const results: DbBenchQueryResult[] = []; + + const time = async ( + name: string, + expectedRows: number, + f: () => Promise<number>, + ): Promise<void> => { + const samples: number[] = []; + let rows = 0; + for (let i = 0; i < opts.repeats; i++) { + const t0 = performance.now(); + rows = await f(); + samples.push(performance.now() - t0); + if (rows !== expectedRows) { + throw Error( + `benchmark query ${name} returned ${rows} rows, expected ${expectedRows}`, + ); + } + } + results.push({ + name, + medianMs: median(samples), + minMs: Math.min(...samples), + maxMs: Math.max(...samples), + rows, + }); + }; + + // A point lookup on the primary key, the single most common operation. + const someCoin = key(`coin-${Math.floor(opts.numCoins / 2)}`); + await time("getCoin (point lookup)", 1, async () => + runner.runReadWriteTx(async (tx) => ((await tx.getCoin(someCoin)) ? 1 : 0)), + ); + + // Batch lookup: this is the shape refresh uses, and was an N+1 until + // recently, so it is worth keeping an eye on. + const pubs: string[] = []; + for (let i = 0; i < Math.min(200, opts.numCoins); i++) { + pubs.push(key(`coin-${i}`)); + } + await time("getCoinsByPubs (200)", pubs.length, async () => + runner.runReadWriteTx(async (tx) => (await tx.getCoinsByPubs(pubs)).length), + ); + + const denomRefs = Array.from( + { length: Math.min(200, opts.numDenominations) }, + (_, d) => ({ + exchangeMasterPub: key(`master-${d % opts.numExchanges}`), + denomPubHash: hash(`denom-${d}`), + }), + ); + await time("getDenominationsByRefs (200)", denomRefs.length, async () => + runner.runReadWriteTx( + async (tx) => (await tx.getDenominationsByRefs(denomRefs)).length, + ), + ); + + const availabilityRefs = denomRefs.map((ref, d) => ({ + ...ref, + maxAge: d % 2 === 0 ? 0 : 21, + })); + await time( + "getCoinAvailabilitiesByRefs (200)", + availabilityRefs.length, + async () => + runner.runReadWriteTx( + async (tx) => + (await tx.getCoinAvailabilitiesByRefs(availabilityRefs)).length, + ), + ); + + const countCoinsForExchange = (exchangeIndex: number): number => { + let count = 0; + for (let i = 0; i < opts.numCoins; i++) { + if ((i % opts.numDenominations) % opts.numExchanges === exchangeIndex) { + count++; + } + } + return count; + }; + const coinsAtExchangeZero = countCoinsForExchange(0); + + await time("getCoinsByExchange", coinsAtExchangeZero, async () => + runner.runReadWriteTx( + async (tx) => (await tx.getCoinsByExchange(exchangeUrl(0))).length, + ), + ); + + await time("countCoinsByExchange", coinsAtExchangeZero, async () => + runner.runReadWriteTx(async (tx) => + tx.countCoinsByExchange(exchangeUrl(0)), + ), + ); + + const coinsForDenomZero = + Math.floor((opts.numCoins - 1) / opts.numDenominations) + 1; + await time("getCoinsByDenomPubHash", coinsForDenomZero, async () => + runner.runReadWriteTx( + async (tx) => (await tx.getCoinsByDenomPubHash(hash("denom-0"))).length, + ), + ); + + const denomHashes = Array.from({ length: opts.numDenominations }, (_, d) => + hash(`denom-${d}`), + ); + await time("getCoinsByDenomPubHashes", opts.numCoins, async () => + runner.runReadWriteTx( + async (tx) => (await tx.getCoinsByDenomPubHashes(denomHashes)).length, + ), + ); + + // Indexed multi-column lookup with a limit -- coin selection's hot path. + let freshCoinsForDenomZero = 0; + for (let i = 0; i < opts.numCoins; i += opts.numDenominations) { + if (Math.floor(i / opts.numDenominations) % 4 !== 0) { + freshCoinsForDenomZero++; + } + } + await time( + "getFreshCoinsByDenomAndAge (limit 10)", + Math.min(10, freshCoinsForDenomZero), + async () => + runner.runReadWriteTx( + async (tx) => + ( + await tx.getFreshCoinsByDenomAndAge( + { + exchangeMasterPub: key("master-0"), + denomPubHash: hash("denom-0"), + maxAge: 0, + }, + 10, + ) + ).length, + ), + ); + + const denomsAtExchangeZero = + Math.floor((opts.numDenominations - 1) / opts.numExchanges) + 1; + await time( + "getCoinAvailabilityByExchangeAndAgeRange", + denomsAtExchangeZero, + async () => + runner.runReadWriteTx( + async (tx) => + ( + await tx.getCoinAvailabilityByExchangeAndAgeRange( + exchangeUrl(0), + 0, + 21, + ) + ).length, + ), + ); + + // The early-terminating keyset scan. Deliberately matches nothing until + // late, so a backend that materialises the whole family shows up here. + await time("findDenominationByFamilyFromExpiry", 1, async () => + runner.runReadWriteTx(async (tx) => { + const found = await tx.findDenominationByFamilyFromExpiry( + 1, + 0 as DbProtocolTimestamp, + () => true, + ); + return found ? 1 : 0; + }), + ); + + await time("getDenominationsByMasterPub", denomsAtExchangeZero, async () => + runner.runReadWriteTx( + async (tx) => + (await tx.getDenominationsByMasterPub(key("master-0"))).length, + ), + ); + + // Full scans: the wallet does these on balance computation and purge. + await time("listAllCoins (full scan)", opts.numCoins, async () => + runner.runReadWriteTx(async (tx) => (await tx.listAllCoins()).length), + ); + + await time( + "getCoinAvailabilities (full scan)", + opts.numDenominations, + async () => + runner.runReadWriteTx( + async (tx) => (await tx.getCoinAvailabilities()).length, + ), + ); + + // A write-heavy transaction, to keep an eye on commit cost. + const numUpserts = Math.min(100, opts.numCoins); + await time("upsertCoin x100 (one tx)", numUpserts, async () => + runner.runReadWriteTx(async (tx) => { + let updated = 0; + for (let i = 0; i < numUpserts; i++) { + const coin = await tx.getCoin(key(`coin-${i}`)); + if (coin) { + coin.status = + coin.status === CoinStatus.Fresh + ? CoinStatus.Dormant + : CoinStatus.Fresh; + await tx.upsertCoin(coin); + updated++; + } + } + return updated; + }), + ); + + return results; +} + +/** + * Populate and measure one backend. + */ +export async function benchmarkOneBackend( + runner: DbTxRunner, + opts: DbBenchOptions, + dbSizeBytes?: () => number | undefined, +): Promise<DbBenchResult> { + if ( + !Number.isSafeInteger(opts.numCoins) || + opts.numCoins <= 0 || + !Number.isSafeInteger(opts.numDenominations) || + opts.numDenominations <= 0 || + !Number.isSafeInteger(opts.numExchanges) || + opts.numExchanges <= 0 || + !Number.isSafeInteger(opts.repeats) || + opts.repeats <= 0 + ) { + throw Error("benchmark options must be positive safe integers"); + } + logger.info(`populating ${runner.name}: ${opts.numCoins} coins`); + const t0 = performance.now(); + await populate(runner, opts); + const populateMs = performance.now() - t0; + logger.info(`populated in ${populateMs.toFixed(0)} ms, measuring`); + + // Verify the database really holds what was asked for. A benchmark on a + // database that silently failed to populate reports beautiful numbers for + // queries that match nothing, which is worse than no benchmark at all. + const actualCoins = await runner.runReadWriteTx( + async (tx) => (await tx.listAllCoins()).length, + ); + if (actualCoins !== opts.numCoins) { + throw Error( + `benchmark population is wrong: asked for ${opts.numCoins} coins, ` + + `database holds ${actualCoins}. Refusing to report timings.`, + ); + } + const queries = await measure(runner, opts); + return { + backend: runner.name, + options: opts, + populateMs, + dbSizeBytes: dbSizeBytes?.(), + queries, + }; +} + +/** + * Render results as a table, with the second and later backends shown + * relative to the first. + */ +export function formatDbBenchResults(results: DbBenchResult[]): string { + const lines: string[] = []; + const base = results[0]; + lines.push(""); + lines.push( + `wallet DB benchmark: ${base.options.numCoins} coins, ` + + `${base.options.numDenominations} denominations, ` + + `${base.options.numExchanges} exchanges, ` + + `median of ${base.options.repeats}`, + ); + lines.push(""); + for (const r of results) { + const size = + r.dbSizeBytes != null + ? `, db ${(r.dbSizeBytes / 1024 / 1024).toFixed(1)} MiB` + : ""; + lines.push( + `${r.backend}: populate ${(r.populateMs / 1000).toFixed(1)} s${size}`, + ); + } + lines.push(""); + const nameWidth = Math.max( + ...base.queries.map((q) => q.name.length), + "query".length, + ); + const head = ["query".padEnd(nameWidth), ...results.map((r) => r.backend)]; + lines.push(head.join(" | ")); + lines.push("-".repeat(head.join(" | ").length)); + for (let i = 0; i < base.queries.length; i++) { + const cells = [base.queries[i].name.padEnd(nameWidth)]; + for (let j = 0; j < results.length; j++) { + const q = results[j].queries[i]; + let cell = `${q.medianMs.toFixed(2)} ms (${q.rows})`; + if (j > 0) { + const ratio = q.medianMs / base.queries[i].medianMs; + cell += ` ${ratio.toFixed(2)}x`; + } + cells.push(cell); + } + lines.push(cells.join(" | ")); + } + lines.push(""); + lines.push( + "Row counts are shown in parentheses: a query that got faster by " + + "returning nothing is a bug, not a win.", + ); + return lines.join("\n"); +} diff --git a/packages/taler-wallet-core/src/db/testing/conformance-cases.ts b/packages/taler-wallet-core/src/db/testing/conformance-cases.ts @@ -0,0 +1,4214 @@ +/* + 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/> + */ + +/** + * Conformance cases for {@link WalletDbTransaction}. + * + * Each case states a property of the interface that any backend must satisfy. + * Where a case exists because a real bug was shipped, the case says so: those + * are the ones most worth keeping honest, because each of them passed both + * tsc and the unit tests at the time. + */ + +import { + AmountString, + CoinStatus, + decodeCrock, + encodeCrock, + MerchantContractTokenKind, + RefreshReason, + ScopeType, + DenomKeyType, + ExchangeEntrySource, + TalerPreciseTimestamp, + TransactionIdStr, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; +import { MailboxConfiguration } from "@gnu-taler/taler-util"; +import { + ConfigRecordKey, + DonationReceiptStatus, + DbPreciseTimestamp, + DbProtocolTimestamp, + DenominationVerificationStatus, + RefundGroupStatus, + RefundItemStatus, + timestampPreciseToDb, + timestampProtocolToDb, + WalletDenomination, + WalletRefundGroup, + WalletRefundItem, + WalletOperationRetry, + WalletReserve, + WalletCoin, + WalletCoinAvailability, + WalletCoinHistoryItem, + CoinSourceType, + PlanchetStatus, + ReserveBankInfo, + WalletPlanchet, + WalletProposalDownloadInfo, + WalletPurchase, + WalletTransactionMeta, + PurchaseStatus, + DepositOperationStatus, + PeerPullDebitRecordStatus, + PeerPullPaymentCreditStatus, + PeerPushCreditStatus, + PeerPushDebitStatus, + RecoupOperationStatus, + RefreshCoinStatus, + RefreshOperationStatus, + WalletDepositGroup, + WalletPeerPullCredit, + WalletPeerPullDebit, + WalletPeerPushCredit, + WalletPeerPushDebit, + WalletRecoupGroup, + WalletRefreshGroup, + WalletRefreshSession, + WalletSlate, + WalletToken, + WalletWithdrawalGroup, + WgInfo, + WithdrawalGroupStatus, + WithdrawalRecordType, + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + ExchangeMigrationReason, + WalletDenomFamilyParams, + WalletExchangeDetails, + WalletExchangeEntry, + WalletExchangeMigrationLog, + WalletExchangeSignkeys, +} from "../records.js"; +import { ConformanceCase } from "./conformance.js"; +import { WalletDbTransaction } from "../transaction.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)); + +const tsPrecise = (seconds: number): DbPreciseTimestamp => + timestampPreciseToDb(TalerPreciseTimestamp.fromSeconds(seconds)); + +const amt = (s: string): AmountString => s as AmountString; + +const txnId = (s: string): TransactionIdStr => s as TransactionIdStr; + +function makeDenomination( + exchangeBaseUrl: string, + denomPubHash: string, + opts: { + familySerial?: number; + stampExpireWithdraw?: number; + isOffered?: boolean; + } = {}, +): WalletDenomination { + // Deliberately not cast: an `as WalletDenomination` here once hid a field + // that does not exist on the record, and the mistake only surfaced when a + // second implementation tried to persist it. + const denom: WalletDenomination = { + exchangeBaseUrl, + denomPubHash: ckh(denomPubHash), + denomPub: { + cipher: DenomKeyType.Rsa, + rsa_public_key: "dummy", + age_mask: 0, + }, + exchangeMasterPub: ck("master-pub"), + currency: "TESTKUDOS", + value: amt("TESTKUDOS:1"), + denominationFamilySerial: opts.familySerial ?? 1, + stampStart: ts(1000), + stampExpireWithdraw: ts(opts.stampExpireWithdraw ?? 100000), + stampExpireDeposit: ts(200000), + stampExpireLegal: ts(300000), + fees: { + feeDeposit: amt("TESTKUDOS:0.1"), + feeRefresh: amt("TESTKUDOS:0.1"), + feeRefund: amt("TESTKUDOS:0.1"), + feeWithdraw: amt("TESTKUDOS:0.1"), + }, + isOffered: opts.isOffered ?? true, + isRevoked: false, + isLost: false, + masterSig: ckh("master-sig"), + verificationStatus: DenominationVerificationStatus.VerifiedGood, + }; + return denom; +} + +function makeCoin(coinPub: string): WalletCoin { + const coin: WalletCoin = { + coinPub: ck(coinPub), + coinPriv: ck(`priv-${coinPub}`), + exchangeBaseUrl: "https://exchange.test/", + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("dph-default"), + denomSig: { + cipher: DenomKeyType.Rsa, + rsa_signature: "sig-blob", + }, + blindingKey: ck("bk-1"), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + coinEvHash: ckh(`evh-${coinPub}`), + status: CoinStatus.Fresh, + maxAge: 0, + ageCommitmentProof: undefined, + coinSource: { + type: CoinSourceType.Withdraw, + withdrawalGroupId: "wg-default", + coinIndex: 0, + reservePub: ck("rp-default"), + }, + }; + return coin; +} + +function makeAvail( + exchangeBaseUrl: string, + denomPubHash: string, + maxAge: number, +): WalletCoinAvailability { + const rec: WalletCoinAvailability = { + exchangeBaseUrl, + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh(denomPubHash), + maxAge, + currency: "TESTKUDOS", + value: amt("TESTKUDOS:1"), + freshCoinCount: 1, + hasFreshCoins: 1, + visibleCoinCount: 1, + }; + return rec; +} + +function makeExchange(baseUrl: string): WalletExchangeEntry { + const ex: WalletExchangeEntry = { + baseUrl, + source: ExchangeEntrySource.Builtin, + detailsPointer: undefined, + entryStatus: ExchangeEntryDbRecordStatus.Preset, + updateStatus: ExchangeEntryDbUpdateStatus.Initial, + tosCurrentEtag: undefined, + tosAcceptedEtag: undefined, + tosAcceptedTimestamp: undefined, + lastUpdate: undefined, + nextUpdateStamp: tsPrecise(1000), + lastKeysEtag: undefined, + nextRefreshCheckStamp: tsPrecise(2000), + }; + return ex; +} + +function makeExchangeDetails( + exchangeBaseUrl: string, + masterPublicKey: string, +): WalletExchangeDetails { + const det: WalletExchangeDetails = { + exchangeBaseUrl, + masterPublicKey: ck(masterPublicKey), + currency: "TESTKUDOS", + auditors: [], + protocolVersionRange: "18:0:1", + tinyAmount: amt("TESTKUDOS:0.01"), + reserveClosingDelay: { d_us: 1000 }, + globalFees: [], + wireInfo: { + accounts: [], + feesForType: {}, + }, + bankComplianceLanguage: undefined, + defaultPeerPushExpiration: undefined, + }; + return det; +} + +function makeSignKey( + exchangeDetailsRowId: number, + signkeyPub: string, +): WalletExchangeSignkeys { + const k: WalletExchangeSignkeys = { + exchangeDetailsRowId, + signkeyPub: ck(signkeyPub), + stampStart: ts(100), + stampExpire: ts(200), + stampEnd: ts(300), + masterSig: ckh("sig-1"), + }; + return k; +} + +function makeFamilyParams( + exchangeBaseUrl: string, + value: string, +): WalletDenomFamilyParams { + const p: WalletDenomFamilyParams = { + exchangeBaseUrl, + exchangeMasterPub: ck("mpk-fam"), + value: amt(value), + feeWithdraw: amt("TESTKUDOS:0.01"), + feeDeposit: amt("TESTKUDOS:0.01"), + feeRefresh: amt("TESTKUDOS:0.01"), + feeRefund: amt("TESTKUDOS:0.01"), + }; + return p; +} + +/** + * Create the parent rows a child record needs before it can be stored. + * + * The sqlite schema declares foreign keys for these relationships and the + * IndexedDB DAL cascades to match, so a fixture that invents a parent key + * without the parent is describing a state the wallet never produces. These + * helpers keep the cases realistic without repeating the setup in each one. + */ +async function seedDenomFamily( + tx: WalletDbTransaction, + exchangeBaseUrl: string, + serial: number, +): Promise<number> { + return await tx.upsertDenominationFamily({ + denominationFamilySerial: serial, + familyParams: makeFamilyParams(exchangeBaseUrl, `TESTKUDOS:${serial}`), + }); +} + +async function seedWithdrawalGroup( + tx: WalletDbTransaction, + withdrawalGroupId: string, +): Promise<void> { + await tx.upsertWithdrawalGroup(makeWithdrawalGroup(withdrawalGroupId)); +} + +async function seedRefreshGroup( + tx: WalletDbTransaction, + refreshGroupId: string, +): Promise<void> { + await tx.upsertRefreshGroup(makeRefreshGroup(refreshGroupId)); +} + +async function seedPurchase( + tx: WalletDbTransaction, + proposalId: string, +): Promise<void> { + await tx.upsertPurchase(makePurchase(proposalId)); +} + +function makeBankInfo(talerWithdrawUri: string): ReserveBankInfo { + const info: ReserveBankInfo = { + talerWithdrawUri, + confirmUrl: undefined, + timestampReserveInfoPosted: undefined, + timestampBankConfirmed: undefined, + wireTypes: undefined, + currency: undefined, + }; + return info; +} + +function makeWithdrawalGroup(withdrawalGroupId: string): WalletWithdrawalGroup { + const wg: WalletWithdrawalGroup = { + withdrawalGroupId, + wgInfo: { withdrawalType: WithdrawalRecordType.BankManual }, + secretSeed: ck(`seed-${withdrawalGroupId}`), + reservePub: ck(`rpub-${withdrawalGroupId}`), + reservePriv: ck(`rpriv-${withdrawalGroupId}`), + timestampStart: tsPrecise(1000), + status: WithdrawalGroupStatus.PendingRegisteringBank, + }; + return wg; +} + +function makePlanchet( + coinPub: string, + withdrawalGroupId: string, + coinIdx: number, +): WalletPlanchet { + const pl: WalletPlanchet = { + coinPub: ck(coinPub), + coinPriv: ck(`priv-${coinPub}`), + withdrawalGroupId, + coinIdx, + planchetStatus: PlanchetStatus.Pending, + lastError: undefined, + denomPubHash: ckh("dph-pl"), + blindingKey: ck("bk-pl"), + exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, + withdrawSig: ckh("sig-pl"), + coinEv: { + cipher: DenomKeyType.Rsa, + rsa_blinded_planchet: "blinded", + }, + coinEvHash: ckh(`evh-${coinPub}`), + }; + return pl; +} + +function makeDownloadInfo( + fulfillmentUrl: string | undefined, +): WalletProposalDownloadInfo { + const dl: WalletProposalDownloadInfo = { + contractTermsHash: ckh("cth-1"), + currency: "TESTKUDOS", + contractTermsMerchantSig: "sig-1", + ...(fulfillmentUrl !== undefined ? { fulfillmentUrl } : undefined), + }; + return dl; +} + +function makePurchase(proposalId: string): WalletPurchase { + const p: WalletPurchase = { + proposalId, + orderId: `order-${proposalId}`, + merchantBaseUrl: "https://merchant.test/", + claimToken: undefined, + downloadSessionId: undefined, + repurchaseProposalId: undefined, + purchaseStatus: PurchaseStatus.PendingDownloadingProposal, + noncePriv: ck(`npriv-${proposalId}`), + noncePub: ck(`npub-${proposalId}`), + secretSeed: undefined, + download: undefined, + payInfo: undefined, + timestampFirstSuccessfulPay: undefined, + merchantPaySig: undefined, + posConfirmation: undefined, + shared: false, + timestamp: tsPrecise(1000), + timestampAccept: undefined, + timestampLastRefundStatus: undefined, + lastSessionId: undefined, + autoRefundDeadline: undefined, + refundAmountAwaiting: undefined, + }; + return p; +} + +/** + * Strip keys whose value is undefined. + * + * The two backends disagree on how an *absent optional* field comes back: + * IndexedDB returns the key with value undefined (its structured clone + * materialises every declared field), while the sqlite mapper omits the key. + * Both read identically -- `rec.abortReason` is undefined either way, and + * both serialise the same -- so the DAL contract is that callers must not + * distinguish them, and these round-trip cases compare accordingly. + * + * This does NOT apply to fields declared as `T | undefined` rather than `T?`: + * those keys are required and must be present, which the coin and exchange + * cases assert explicitly with `in`. + */ +function withoutUndefined<T>(v: T): T { + if (v === null || typeof v !== "object" || Array.isArray(v)) { + return v; + } + const out: Record<string, unknown> = {}; + for (const [k, val] of Object.entries(v as Record<string, unknown>)) { + if (val !== undefined) { + out[k] = val; + } + } + return out as T; +} + +const tokenFamilyFields = () => ({ + slug: "fam-slug", + name: "Family", + description: "A token family", + extraData: { + class: MerchantContractTokenKind.Subscription as const, + trusted_domains: ["example.com"], + }, + tokenIssuePub: { + cipher: "RSA" as const, + rsa_pub: "rsa-pub", + signature_validity_start: { t_s: 1 }, + signature_validity_end: { t_s: 2 }, + }, + descriptionI18n: undefined, +}); + +function makeToken(tokenUsePub: string): WalletToken { + const tok: WalletToken = { + ...tokenFamilyFields(), + tokenUsePub: ck(tokenUsePub), + tokenUsePriv: ck(`priv-${tokenUsePub}`), + purchaseId: "pur-tok", + merchantBaseUrl: "https://merchant.test/", + kind: MerchantContractTokenKind.Subscription, + 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: ckh(`evh-${tokenUsePub}`), + blindingKey: ck("bk-tok"), + }; + return tok; +} + +function makeSlate( + tokenUsePub: string, + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, +): WalletSlate { + const sl: WalletSlate = { + ...tokenFamilyFields(), + tokenUsePub: ck(tokenUsePub), + tokenUsePriv: ck(`priv-${tokenUsePub}`), + purchaseId, + choiceIndex, + outputIndex, + repeatIndex, + merchantBaseUrl: "https://merchant.test/", + kind: MerchantContractTokenKind.Subscription, + tokenIssuePubHash: ckh("tiph-1"), + validAfter: ts(100), + validBefore: ts(200), + tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" }, + tokenEvHash: ckh(`evh-${tokenUsePub}`), + blindingKey: ck("bk-slate"), + }; + return sl; +} + +function makeDepositGroup(depositGroupId: string): WalletDepositGroup { + const dg: WalletDepositGroup = { + depositGroupId, + currency: "TESTKUDOS", + amount: amt("TESTKUDOS:5"), + wireTransferDeadline: ts(9999), + merchantPub: ck("mpub"), + merchantPriv: ck("mpriv"), + noncePriv: ck("npriv"), + noncePub: ck("npub"), + wire: { payto_uri: "payto://iban/DE1", salt: "salt-1" }, + contractTermsHash: ckh("cth"), + totalPayCost: amt("TESTKUDOS:5.1"), + counterpartyEffectiveDepositAmount: amt("TESTKUDOS:5"), + timestampCreated: tsPrecise(1000), + timestampFinished: undefined, + timestampLastDepositAttempt: undefined, + operationStatus: DepositOperationStatus.PendingDeposit, + }; + return dg; +} + +function makeRefreshGroup(refreshGroupId: string): WalletRefreshGroup { + const rg: WalletRefreshGroup = { + refreshGroupId, + operationStatus: RefreshOperationStatus.Pending, + currency: "TESTKUDOS", + reason: RefreshReason.Manual, + oldCoinPubs: ["c1", "c2"], + inputPerCoin: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")], + expectedOutputPerCoin: [amt("TESTKUDOS:0.9"), amt("TESTKUDOS:1.9")], + statusPerCoin: [RefreshCoinStatus.Pending, RefreshCoinStatus.Pending], + refundRequests: {}, + timestampCreated: tsPrecise(1000), + timestampFinished: undefined, + }; + return rg; +} + +function makeRefreshSession( + refreshGroupId: string, + coinIndex: number, +): WalletRefreshSession { + const rs: WalletRefreshSession = { + refreshGroupId, + coinIndex, + amountRefreshOutput: amt("TESTKUDOS:1"), + newDenoms: [{ denomPubHash: ckh("dph-1"), count: 2 }], + }; + return rs; +} + +function makeRecoupGroup( + recoupGroupId: string, + exchangeBaseUrl: string, +): WalletRecoupGroup { + const rc: WalletRecoupGroup = { + recoupGroupId, + exchangeBaseUrl, + operationStatus: RecoupOperationStatus.Pending, + timestampStarted: tsPrecise(1000), + timestampFinished: undefined, + coinPubs: ["c1"], + recoupFinishedPerCoin: [false], + scheduleRefreshCoins: [], + }; + return rc; +} + +function makePeerPushDebit(pursePub: string): WalletPeerPushDebit { + const rec: WalletPeerPushDebit = { + pursePub: ck(pursePub), + exchangeBaseUrl: "https://exchange.test/", + amount: amt("TESTKUDOS:3"), + totalCost: amt("TESTKUDOS:3.1"), + 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, + }; + return rec; +} + +function makePeerPushCredit(peerPushCreditId: string): WalletPeerPushCredit { + const rec: WalletPeerPushCredit = { + peerPushCreditId, + exchangeBaseUrl: "https://exchange.test/", + pursePub: ck(`purse-${peerPushCreditId}`), + mergePriv: ck("mpriv"), + contractPriv: ck("cpriv"), + timestamp: tsPrecise(1000), + estimatedAmountEffective: amt("TESTKUDOS:2"), + contractTermsHash: ckh("cth"), + status: PeerPushCreditStatus.PendingMerge, + withdrawalGroupId: undefined, + currency: undefined, + }; + return rec; +} + +function makePeerPullDebit(peerPullDebitId: string): WalletPeerPullDebit { + const rec: WalletPeerPullDebit = { + peerPullDebitId, + pursePub: ck(`purse-${peerPullDebitId}`), + exchangeBaseUrl: "https://exchange.test/", + amount: amt("TESTKUDOS:4"), + contractTermsHash: ckh("cth"), + timestampCreated: tsPrecise(1000), + contractPriv: ck("cpriv"), + status: PeerPullDebitRecordStatus.PendingDeposit, + totalCostEstimated: amt("TESTKUDOS:4.1"), + }; + return rec; +} + +function makePeerPullCredit(pursePub: string): WalletPeerPullCredit { + const rec: WalletPeerPullCredit = { + pursePub: ck(pursePub), + exchangeBaseUrl: "https://exchange.test/", + amount: amt("TESTKUDOS:6"), + estimatedAmountEffective: amt("TESTKUDOS:6"), + 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, + withdrawalGroupId: undefined, + }; + return rec; +} + +function makeReserve(reservePub: string): WalletReserve { + const r: WalletReserve = { + reservePub: ck(reservePub), + reservePriv: ck(`priv-of-${reservePub}`), + }; + return r; +} + +function makeRefundGroup( + refundGroupId: string, + proposalId = "prop-1", +): WalletRefundGroup { + const grp: WalletRefundGroup = { + refundGroupId, + proposalId, + status: RefundGroupStatus.Done, + timestampCreated: tsPrecise(5000), + amountRaw: amt("TESTKUDOS:1"), + amountEffective: amt("TESTKUDOS:1"), + }; + return grp; +} + +function makeRefundItem( + refundGroupId: string, + coinPub: string, + rtxid: number, +): WalletRefundItem { + return { + status: RefundItemStatus.Done, + refundGroupId, + executionTime: ts(5000), + obtainedTime: tsPrecise(5000), + refundAmount: amt("TESTKUDOS:1"), + coinPub: ck(coinPub), + rtxid, + }; +} + +export const conformanceCases: ConformanceCase[] = [ + // ---------------------------------------------------------------- basics + + { + name: "config: upsert then get round trips", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.MaterializedTransactionsVersion, + value: 7, + }); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.MaterializedTransactionsVersion), + ); + t.ok(got, "config record should exist"); + t.equal(got?.value, 7); + }, + }, + + { + name: "get on a missing key returns undefined, not a throw", + async run(t, runner) { + const got = await runner.runReadWriteTx((tx) => + tx.getCoin(ck("no-such-coin-pub")), + ); + t.equal(got, undefined); + }, + }, + + { + name: "contract terms: arbitrary JSON survives a round trip", + async run(t, runner) { + const raw = { + nested: { a: [1, 2, 3], b: null }, + unicode: "ünïcödé", + num: 1.5, + }; + await runner.runReadWriteTx((tx) => + tx.upsertContractTerms({ h: "hash-1", contractTermsRaw: raw }), + ); + const got = await runner.runReadWriteTx((tx) => + tx.getContractTerms("hash-1"), + ); + t.deepEqual(got?.contractTermsRaw, raw); + }, + }, + + // ------------------------------------------------- compound / array keys + + { + name: "denomination: compound primary key (masterPub, denomPubHash)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); + // One key set reached through two URLs is one denomination, not two: + // the URL is where the exchange answers, not what signed the coin. + const viaOneUrl = makeDenomination("https://e1/", "dph-a"); + const viaAnother = makeDenomination("https://e2/", "dph-a"); + await tx.upsertDenomination(viaOneUrl); + await tx.upsertDenomination(viaAnother); + // A different key signing the same hash *is* a second denomination. + const otherKey = makeDenomination("https://e1/", "dph-a"); + otherKey.exchangeMasterPub = ck("master-other"); + await tx.upsertDenomination(otherKey); + }); + const [shared, other] = await runner.runReadWriteTx(async (tx) => [ + await tx.getDenominationsByMasterPub(ck("master-pub")), + await tx.getDenominationsByMasterPub(ck("master-other")), + ]); + t.equal( + shared.length, + 1, + "two URLs serving one key set must collapse onto one row", + ); + t.equal(shared[0].denomPubHash, ckh("dph-a")); + t.equal( + shared[0].exchangeBaseUrl, + "https://e2/", + "upserting a denomination through a new URL must update its routing hint", + ); + t.equal( + other.length, + 1, + "the same hash under another key must be its own row", + ); + t.equal(other[0].exchangeMasterPub, ck("master-other")); + }, + }, + + { + name: "denomination: batch lookup preserves order across chunks", + async run(t, runner) { + const primary = makeDenomination("https://batch-denom/", "bd-primary"); + const other = makeDenomination("https://batch-denom/", "bd-other"); + other.exchangeMasterPub = ck("master-other"); + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://batch-denom/", 1); + await tx.upsertDenomination(primary); + await tx.upsertDenomination(other); + }); + const missing = { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("bd-missing"), + }; + const refs = Array.from({ length: 403 }, (_, i) => + i % 17 === 0 ? missing : i % 2 === 0 ? primary : other, + ); + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationsByRefs(refs), + ); + t.deepEqual( + got.map((d) => [d.exchangeMasterPub, d.denomPubHash]), + refs + .filter((ref) => ref !== missing) + .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash]), + "missing references are skipped and duplicates retain input order", + ); + t.deepEqual( + await runner.runReadWriteTx((tx) => tx.getDenominationsByRefs([])), + [], + ); + }, + }, + + { + name: "refund items by group are found (regression: array keyPath)", + // The IndexedDB index byRefundGroupId is declared with an ARRAY keyPath, + // so its keys are single-element arrays. Passing a bare string matched + // nothing, getRefundItemsByGroup silently returned [], and refunds ended + // in state "done" instead of "failed". It passed tsc and every unit test. + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); + // Items are written before their group here, deliberately: that is + // the order pay-merchant.ts uses, and a backend must tolerate it + // within a transaction. + await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-1", 1)); + await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-2", 2)); + await tx.upsertRefundItem(makeRefundItem("grp-2", "coin-3", 3)); + await tx.upsertRefundGroup(makeRefundGroup("grp-1")); + await tx.upsertRefundGroup(makeRefundGroup("grp-2")); + }); + const items = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("grp-1"), + ); + t.equal(items.length, 2, "must find both items of the group"); + const other = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("grp-2"), + ); + t.equal(other.length, 1); + const none = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("grp-missing"), + ); + t.equal(none.length, 0); + }, + }, + + { + name: "refund item lookup by (coinPub, rtxid)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); + await tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42)); + await tx.upsertRefundGroup(makeRefundGroup("grp-3")); + }); + const got = await runner.runReadWriteTx((tx) => + 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.runReadWriteTx((tx) => + tx.getRefundItemByCoinAndRtxid(ck("coin-9"), 43), + ); + t.equal(miss, undefined, "wrong rtxid must not match"); + }, + }, + + // --------------------------------------------------- generated row ids + + { + name: "auto-increment stores return a usable generated id", + // upsertReserve/upsertRefundItem/upsertExchangeDetails/ + // upsertDenominationFamily must return the generated key: callers store it + // as a foreign key. A backend returning 0 or undefined would corrupt the + // exchange entry silently. + async run(t, runner) { + const id1 = await runner.runReadWriteTx((tx) => + tx.upsertReserve({ + reservePub: ck("rp-1"), + reservePriv: ck("rv-1"), + } as any), + ); + const id2 = await runner.runReadWriteTx((tx) => + 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.runReadWriteTx((tx) => tx.getReserve(id1)); + t.equal( + back?.reservePub, + ck("rp-1"), + "the returned id must address the row", + ); + }, + }, + + // ------------------------------------------------------- ordered scans + + { + name: "findDenominationByFamilyFromExpiry returns the first match", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam77/", 77); + // Same family, ascending expiry. The first two are not offered, so + // the predicate must skip them. + await tx.upsertDenomination( + makeDenomination("https://e/", "d-1", { + familySerial: 77, + stampExpireWithdraw: 10000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "d-2", { + familySerial: 77, + stampExpireWithdraw: 20000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "d-3", { + familySerial: 77, + stampExpireWithdraw: 30000, + }), + ); + }); + const found = await runner.runReadWriteTx((tx) => + tx.findDenominationByFamilyFromExpiry(77, ts(0), (d) => d.isOffered), + ); + t.equal( + found?.denomPubHash, + ckh("d-3"), + "must return the first match in order", + ); + }, + }, + + { + name: "findDenominationByFamilyFromExpiry stops at the first match", + // Regression: this was briefly implemented as "fetch every non-expired + // denomination of the family, then filter". Families hold many + // denominations, so that turned a one-record read into a full range scan. + // The predicate is the only place we can observe how far the scan ran. + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam88/", 88); + for (let i = 0; i < 20; i++) { + await tx.upsertDenomination( + makeDenomination("https://e/", `s-${i}`, { + familySerial: 88, + stampExpireWithdraw: 10000 + i * 1000, + }), + ); + } + }); + let examined = 0; + const before = runner.getAccessStats()?.recordsRead; + const found = await runner.runReadWriteTx((tx) => + tx.findDenominationByFamilyFromExpiry(88, ts(0), () => { + examined++; + return true; + }), + ); + const after = runner.getAccessStats()?.recordsRead; + t.ok(found, "should find a denomination"); + t.equal(examined, 1, "the predicate must be consulted once"); + if (before !== undefined && after !== undefined) { + // The real regression was reading the whole family and filtering in + // JS: the predicate still ran once, so only the record count exposes + // it. Allow a small constant for cursor positioning. + t.ok( + after - before <= 3, + `must not scan the family: read ${after - before} records for one match`, + ); + } + }, + }, + + { + name: "findDenominationByFamilyFromExpiry respects the family boundary", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam101/", 101); + await seedDenomFamily(tx, "https://fam102/", 102); + await tx.upsertDenomination( + makeDenomination("https://e/", "f1-a", { + familySerial: 101, + stampExpireWithdraw: 10000, + isOffered: false, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "f2-a", { + familySerial: 102, + stampExpireWithdraw: 20000, + }), + ); + }); + const found = await runner.runReadWriteTx((tx) => + tx.findDenominationByFamilyFromExpiry(101, ts(0), (d) => d.isOffered), + ); + t.equal( + found, + undefined, + "must not spill into the next family when no match is found", + ); + }, + }, + + { + name: "findDenominationByFamilyFromExpiry honours the expiry lower bound", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam111/", 111); + await tx.upsertDenomination( + makeDenomination("https://e/", "old", { + familySerial: 111, + stampExpireWithdraw: 5000, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e/", "new", { + familySerial: 111, + stampExpireWithdraw: 50000, + }), + ); + }); + const found = await runner.runReadWriteTx((tx) => + tx.findDenominationByFamilyFromExpiry(111, ts(10000), () => true), + ); + t.equal( + found?.denomPubHash, + ckh("new"), + "must skip records before the bound", + ); + }, + }, + + // ------------------------------------------------- transaction lifecycle + + { + name: "a throwing transaction rolls back its writes", + async run(t, runner) { + let rejected = false; + try { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertContractTerms({ + h: "rollback-hash", + contractTermsRaw: { x: 1 }, + }); + throw Error("deliberate abort"); + }); + } catch (e) { + rejected = true; + } + t.ok(rejected, "the transaction must reject when its callback throws"); + const got = await runner.runReadWriteTx((tx) => + tx.getContractTerms("rollback-hash"), + ); + t.equal(got, undefined, "writes before the throw must not be visible"); + }, + }, + + { + name: "writes are visible within the same transaction", + async run(t, runner) { + const got = await runner.runReadWriteTx(async (tx) => { + await tx.upsertContractTerms({ + h: "same-tx", + contractTermsRaw: { y: 2 }, + }); + return await tx.getContractTerms("same-tx"); + }); + t.deepEqual(got?.contractTermsRaw, { y: 2 }); + }, + }, + + { + name: "scheduleOnCommit runs after the transaction, not during it", + async run(t, runner) { + const order: string[] = []; + await runner.runReadWriteTx(async (tx) => { + tx.scheduleOnCommit(() => order.push("after-commit")); + order.push("in-tx"); + }); + t.deepEqual(order, ["in-tx", "after-commit"]); + }, + }, + + { + name: "scheduleOnCommit does not run when the transaction aborts", + async run(t, runner) { + let ran = false; + let rejected = false; + try { + await runner.runReadWriteTx(async (tx) => { + tx.scheduleOnCommit(() => { + ran = true; + }); + throw Error("deliberate abort"); + }); + } catch (e) { + rejected = true; + } + t.ok(rejected, "the aborted transaction must reject"); + t.equal(ran, false, "commit hooks must not fire on a rolled-back tx"); + }, + }, + + { + name: "notify is safe to pass around unbound", + // Regression: notify was a prototype method reading this.tx. Call sites + // pass it as a bare function (applyNotifyTransition(tx.notify, ...)), which + // is well-typed but lost `this` and broke every transaction that used it. + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const notify = tx.notify; + notify({ type: "balance-change" } as any); + }); + t.ok(true, "extracting notify and calling it must not throw"); + }, + }, + + // ------------------------------------------------------- delete semantics + + { + name: "deleting a missing row is a no-op, not an error", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.deletePurchase("no-such-proposal"); + await tx.deleteDenomination({ + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("no-such-hash"), + }); + await tx.deleteRefundGroup("no-such-group"); + }); + t.ok(true, "deleting a missing row must not throw"); + }, + }, + + { + name: "getRecordCounts reflects what was written", + async run(t, runner) { + const before = await runner.runReadWriteTx((tx) => tx.getRecordCounts()); + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); + await tx.upsertDenomination(makeDenomination("https://cnt/", "c-1")); + await tx.upsertDenomination(makeDenomination("https://cnt/", "c-2")); + }); + const after = await runner.runReadWriteTx((tx) => tx.getRecordCounts()); + t.equal( + after.denominations - before.denominations, + 2, + "counts must track inserts", + ); + }, + }, + + { + name: "upsert overwrites an existing row rather than duplicating it", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); + await tx.upsertDenomination( + makeDenomination("https://e-up/", "dup", { + stampExpireWithdraw: 111, + }), + ); + await tx.upsertDenomination( + makeDenomination("https://e-up/", "dup", { + stampExpireWithdraw: 222, + }), + ); + }); + const all = await runner.runReadWriteTx((tx) => + tx.getDenominationsByMasterPub(ck("master-pub")), + ); + t.equal(all.length, 1, "the second upsert must replace, not append"); + t.equal(all[0].stampExpireWithdraw, ts(222)); + }, + }, + // ------------------------------------------- backfill: previously untested + + { + name: "reserve: upsert returns a generated row id, retrievable by pub", + async run(t, runner) { + const [id1, id2] = await runner.runReadWriteTx(async (tx) => [ + await tx.upsertReserve(makeReserve("rpub-1")), + await tx.upsertReserve(makeReserve("rpub-2")), + ]); + t.ok(typeof id1 === "number", "upsertReserve must return the row id"); + t.ok(id1 !== id2, "row ids must be distinct"); + const got = await runner.runReadWriteTx((tx) => + tx.getReserveByReservePub(ck("rpub-2")), + ); + t.equal(got?.reservePub, ck("rpub-2")); + t.equal(got?.rowId, id2, "the id returned must be the id stored"); + }, + }, + + { + name: "reserve: generated row ids are strictly increasing", + async run(t, runner) { + const first = await runner.runReadWriteTx((tx) => + tx.upsertReserve(makeReserve("rpub-r1")), + ); + const second = await runner.runReadWriteTx((tx) => + tx.upsertReserve(makeReserve("rpub-r2")), + ); + t.ok(second > first, "ids must be strictly increasing"); + }, + }, + + { + name: "reserve: batch lookup preserves requested order and skips missing", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertReserve(makeReserve("rpub-b1")); + await tx.upsertReserve(makeReserve("rpub-b2")); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getReservesByPubs([ + ck("rpub-b2"), + ck("rpub-missing"), + ck("rpub-b1"), + ]), + ); + t.deepEqual( + got.map((x) => x.reservePub), + [ck("rpub-b2"), ck("rpub-b1")], + ); + }, + }, + + { + name: "operation retry: upsert, get, delete", + async run(t, runner) { + const rec: WalletOperationRetry = { + id: "task-1", + retryInfo: { + firstTry: tsPrecise(1000), + nextRetry: tsPrecise(2000), + retryCounter: 3, + }, + }; + await runner.runReadWriteTx((tx) => tx.upsertOperationRetry(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getOperationRetry("task-1"), + ); + t.equal(got?.id, "task-1"); + t.equal(got?.retryInfo.retryCounter, 3); + await runner.runReadWriteTx((tx) => tx.deleteOperationRetry("task-1")); + const gone = await runner.runReadWriteTx((tx) => + tx.getOperationRetry("task-1"), + ); + t.equal(gone, undefined, "delete must remove the record"); + }, + }, + + { + name: "operation retry: deleting a missing task is not an error", + async run(t, runner) { + await runner.runReadWriteTx((tx) => + tx.deleteOperationRetry("never-existed"), + ); + t.ok(true, "delete of an absent key must be a no-op"); + }, + }, + + { + name: "operation retry: lastError round trips as arbitrary JSON", + async run(t, runner) { + const lastError = { + code: 7002, + hint: "unexpected", + detail: { nested: [1, null, "x"] }, + }; + await runner.runReadWriteTx((tx) => + tx.upsertOperationRetry({ + id: "task-err", + lastError, + retryInfo: { + firstTry: tsPrecise(1), + nextRetry: tsPrecise(2), + retryCounter: 0, + }, + }), + ); + const got = await runner.runReadWriteTx((tx) => + tx.getOperationRetry("task-err"), + ); + t.deepEqual(got?.lastError, lastError); + }, + }, + + { + name: "tombstone: a repeated upsert of the same id is accepted", + async run(t, runner) { + // The DAL exposes no way to read tombstones back, so this can only + // assert that the second write does not raise (a primary-key conflict + // would). See the note in the sqlite schema. + await runner.runReadWriteTx(async (tx) => { + await tx.upsertTombstone({ id: "tmb:dup" }); + await tx.upsertTombstone({ id: "tmb:dup" }); + }); + t.ok(true, "a repeated upsert must be idempotent"); + }, + }, + + { + name: "refund group: get by id and list by proposal", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-x"); + await seedPurchase(tx, "prop-y"); + await tx.upsertRefundGroup(makeRefundGroup("rg-a", "prop-x")); + await tx.upsertRefundGroup(makeRefundGroup("rg-b", "prop-x")); + await tx.upsertRefundGroup(makeRefundGroup("rg-c", "prop-y")); + }); + const one = await runner.runReadWriteTx((tx) => + tx.getRefundGroup("rg-b"), + ); + t.equal(one?.refundGroupId, "rg-b"); + const byProp = await runner.runReadWriteTx((tx) => + tx.getRefundGroupsByProposal("prop-x"), + ); + t.equal(byProp.length, 2, "must return only the groups of that proposal"); + t.deepEqual(byProp.map((g) => g.refundGroupId).sort(), ["rg-a", "rg-b"]); + }, + }, + + { + name: "refund group: unknown proposal yields an empty list, not undefined", + async run(t, runner) { + const got = await runner.runReadWriteTx((tx) => + tx.getRefundGroupsByProposal("no-such-proposal"), + ); + t.deepEqual(got, [], "list queries must return [] when nothing matches"); + }, + }, + + { + // Also pins the downloaded_at round trip: the record carries a protocol + // Timestamp while the sqlite column is an INTEGER of microseconds, so a + // conversion sits between them on one backend and not the other. + name: "mailbox message: round trips and is keyed by (mailbox, uri)", + async run(t, runner) { + const at = { t_s: 1735689600 }; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://mbox.test/", + talerUri: "taler://one", + downloadedAt: at, + }); + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://mbox.test/", + talerUri: "taler://two", + downloadedAt: { t_s: 1735689601 }, + }); + // Same URI at a different mailbox: the key is the pair. + await tx.upsertMailboxMessage({ + originMailboxBaseUrl: "https://other.test/", + talerUri: "taler://one", + downloadedAt: { t_s: 1735689602 }, + }); + }); + + const all = await runner.runReadWriteTx((tx) => tx.listMailboxMessages()); + t.equal(all.length, 3); + const one = all.find( + (m) => + m.originMailboxBaseUrl === "https://mbox.test/" && + m.talerUri === "taler://one", + ); + t.ok(one, "the message must be listed"); + t.deepEqual( + one!.downloadedAt, + at, + "downloadedAt must survive the round trip unchanged", + ); + + await runner.runReadWriteTx((tx) => + tx.deleteMailboxMessage("https://mbox.test/", "taler://one"), + ); + const left = await runner.runReadWriteTx((tx) => + tx.listMailboxMessages(), + ); + t.equal(left.length, 2, "delete must remove exactly one message"); + t.ok( + left.some((m) => m.originMailboxBaseUrl === "https://other.test/"), + "the same URI at another mailbox must survive", + ); + }, + }, + + { + name: "refresh group: delete cascades to its sessions", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedRefreshGroup(tx, "rg-casc-a"); + await seedRefreshGroup(tx, "rg-casc-b"); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 0)); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 1)); + await tx.upsertRefreshSession(makeRefreshSession("rg-casc-b", 0)); + }); + await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-casc-a")); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rg-casc-a"), + ) + ).length, + 0, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rg-casc-b"), + ) + ).length, + 1, + "sessions of another group must survive", + ); + }, + }, + + { + name: "withdrawal group: delete cascades to its planchets", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-casc-a"); + await seedWithdrawalGroup(tx, "wg-casc-b"); + await tx.upsertPlanchet(makePlanchet("pl-ca-0", "wg-casc-a", 0)); + await tx.upsertPlanchet(makePlanchet("pl-ca-1", "wg-casc-a", 1)); + await tx.upsertPlanchet(makePlanchet("pl-cb-0", "wg-casc-b", 0)); + }); + await runner.runReadWriteTx((tx) => + tx.deleteWithdrawalGroup("wg-casc-a"), + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPlanchetsByGroup("wg-casc-a"), + ) + ).length, + 0, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPlanchetsByGroup("wg-casc-b"), + ) + ).length, + 1, + "planchets of another group must survive", + ); + }, + }, + + { + // Two levels: the purchase owns the refund groups, which own the items. + name: "purchase: delete cascades through refund groups to their items", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-casc"); + await tx.upsertRefundGroup(makeRefundGroup("rg-pc", "prop-casc")); + await tx.upsertRefundItem(makeRefundItem("rg-pc", "coin-pc", 301)); + }); + await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-casc")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getRefundGroup("rg-pc")), + undefined, + "refund groups of the deleted purchase must be gone", + ); + t.equal( + (await runner.runReadWriteTx((tx) => tx.getRefundItemsByGroup("rg-pc"))) + .length, + 0, + "and their items with them", + ); + }, + }, + + { + name: "denomination family: delete cascades to its denominations", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam-a/", 41); + await seedDenomFamily(tx, "https://fam-b/", 42); + await tx.upsertDenomination( + makeDenomination("https://fam-a/", "dfa-1", { familySerial: 41 }), + ); + await tx.upsertDenomination( + makeDenomination("https://fam-b/", "dfb-1", { familySerial: 42 }), + ); + }); + await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(41)); + const left = await runner.runReadWriteTx((tx) => + tx.getDenominationsByMasterPub(ck("master-pub")), + ); + t.equal(left.length, 1, "denominations of another family must survive"); + t.equal( + left[0].denomPubHash, + ckh("dfb-1"), + "the deleted family's denomination must be the one that went", + ); + }, + }, + + { + // The database converter enumerates every store through these accessors, + // so a listAll* that misses records means rows silently absent from the + // converted database. One case per accessor would repeat the seeding; + // this seeds one record per store and checks each enumeration sees it. + name: "listAll accessors enumerate every store", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 7, + }); + await tx.upsertCurrencyInfoEntry({ + scopeInfoStr: "taler-si:global/TESTKUDOS", + currencySpec: { + name: "Test", + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2, + alt_unit_names: { "0": "TESTKUDOS" }, + }, + source: "exchange", + }); + await tx.upsertContractTerms({ + h: ckh("ct-la"), + contractTermsRaw: { summary: "listall" }, + }); + await tx.upsertTombstone({ id: "tmb:test:la" }); + await tx.upsertOperationRetry({ + id: "task-la", + retryInfo: { + firstTry: tsPrecise(1), + nextRetry: tsPrecise(2), + retryCounter: 1, + }, + }); + await tx.upsertReserve(makeReserve("res-la")); + await tx.upsertMailboxConfiguration({ + mailboxBaseUrl: "https://mb.example/", + } as MailboxConfiguration); + await tx.upsertExchangeBaseUrlFixup({ + exchangeBaseUrl: "https://old.example/", + replacement: "https://new.example/", + }); + await tx.upsertExchangeMigrationLog({ + oldExchangeBaseUrl: "https://old.example/", + newExchangeBaseUrl: "https://new.example/", + timestamp: tsPrecise(3), + reason: "auto" as ExchangeMigrationReason, + }); + await tx.upsertSlate(makeSlate("slate-la", "prop-la", 0, 0, 0)); + await tx.upsertRecoupGroup(makeRecoupGroup("rec-la", "https://e1/")); + await tx.upsertDonationPlanchet({ + donauBaseUrl: "https://donau.example/", + udiNonce: ckh("udi-la"), + donorTaxIdHash: ckh("tid-la"), + donorHashSalt: "salt-la", + donorTaxId: "tax-la", + donationYear: 2026, + proposalId: "prop-la", + udiIndex: 0, + blindedUdi: { cipher: "RSA", rsa_blinded_identifier: "blind" }, + bks: ck("bks-la"), + donationUnitPubHash: ckh("dup-la"), + value: amt("TESTKUDOS:1"), + }); + await tx.upsertDonationReceipt({ + status: DonationReceiptStatus.DoneSubmitted, + donauBaseUrl: "https://donau.example/", + udiNonce: ckh("udi-la"), + proposalId: "prop-la", + donationYear: 2026, + donationUnitPubHash: ckh("dup-la"), + donationUnitSig: { cipher: "RSA", rsa_signature: "sig" }, + donorTaxIdHash: ckh("tid-la"), + donorHashSalt: "salt-la", + donorTaxId: "tax-la", + value: amt("TESTKUDOS:1"), + udiIndex: 0, + }); + await seedDenomFamily(tx, "https://e1/", 61); + await tx.upsertDenomination( + makeDenomination("https://e1/", "den-la", { familySerial: 61 }), + ); + }); + + // Each enumeration must contain the seeded record; where the store was + // empty before, the length pins that nothing else appeared. + const r = runner; + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllConfig())).length, + 1, + "config", + ); + const ci = await r.runReadWriteTx((tx) => tx.listAllCurrencyInfo()); + t.equal(ci.length, 1, "currencyInfo"); + t.equal( + ci[0].scopeInfoStr, + "taler-si:global/TESTKUDOS", + "the storage key must round-trip opaquely", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllContractTerms())).length, + 1, + "contractTerms", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllTombstones()))[0]?.id, + "tmb:test:la", + "tombstones", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllOperationRetries()))[0]?.id, + "task-la", + "operationRetries", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllReserves())).length, + 1, + "reserves", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllMailboxConfigurations())) + .length, + 1, + "mailboxConfigurations", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllExchangeBaseUrlFixups()))[0] + ?.replacement, + "https://new.example/", + "exchangeBaseUrlFixups", + ); + t.equal( + ( + await r.runReadWriteTx((tx) => + tx.listAllExchangeMigrationLogEntries(), + ) + ).length, + 1, + "exchangeBaseUrlMigrationLog", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllSlates())).length, + 1, + "slates", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllRecoupGroups())).length, + 1, + "recoupGroups", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDonationPlanchets())).length, + 1, + "donationPlanchets", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDonationReceipts())).length, + 1, + "donationReceipts", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDenominationFamilies())) + .length, + 1, + "denominationFamilies", + ); + t.equal( + (await r.runReadWriteTx((tx) => tx.listAllDenominations())).length, + 1, + "denominations", + ); + }, + }, + + { + // Parent-delete behaviour has to be identical on both backends. sqlite + // declares ON DELETE CASCADE, IndexedDB has no constraints and must do it + // by hand; without a case here the two silently disagree, and the sqlite + // side quietly removes rows the other keeps. + name: "refund group: delete cascades to its items", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); + await tx.upsertRefundGroup(makeRefundGroup("rg-casc")); + await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c1", 201)); + await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c2", 202)); + // A second group, to pin that the cascade is scoped to one parent. + await tx.upsertRefundGroup(makeRefundGroup("rg-keep")); + await tx.upsertRefundItem(makeRefundItem("rg-keep", "coin-k1", 203)); + }); + await runner.runReadWriteTx((tx) => tx.deleteRefundGroup("rg-casc")); + const gone = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("rg-casc"), + ); + t.equal(gone.length, 0, "items of the deleted group must be gone"); + const kept = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("rg-keep"), + ); + t.equal(kept.length, 1, "items of another group must survive"); + }, + }, + + { + name: "exchange details: delete cascades to its sign keys", + async run(t, runner) { + const rowId = await runner.runReadWriteTx(async (tx) => { + const id = await tx.upsertExchangeDetails( + makeExchangeDetails("https://casc.exchange/", "mp-casc"), + ); + await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-1")); + await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-2")); + return id; + }); + const other = await runner.runReadWriteTx(async (tx) => { + const id = await tx.upsertExchangeDetails( + makeExchangeDetails("https://keep.exchange/", "mp-keep"), + ); + await tx.upsertExchangeSignKey(makeSignKey(id, "sk-keep-1")); + return id; + }); + await runner.runReadWriteTx((tx) => tx.deleteExchangeDetails(rowId)); + const gone = await runner.runReadWriteTx((tx) => + tx.getExchangeSignKeysByDetailsRowId(rowId), + ); + t.equal(gone.length, 0, "sign keys of the deleted details must be gone"); + const kept = await runner.runReadWriteTx((tx) => + tx.getExchangeSignKeysByDetailsRowId(other), + ); + t.equal(kept.length, 1, "sign keys of other details must survive"); + }, + }, + + { + name: "refund item: delete removes only the targeted item", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedPurchase(tx, "prop-1"); + await tx.upsertRefundGroup(makeRefundGroup("rg-del")); + await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d1", 101)); + await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d2", 102)); + }); + const items = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("rg-del"), + ); + t.equal(items.length, 2); + const victim = items.find((i) => i.coinPub === ck("coin-d1")); + t.ok(victim?.id !== undefined, "stored items must carry their row id"); + await runner.runReadWriteTx((tx) => tx.deleteRefundItem(victim!.id!)); + const left = await runner.runReadWriteTx((tx) => + tx.getRefundItemsByGroup("rg-del"), + ); + t.equal(left.length, 1); + t.equal(left[0].coinPub, ck("coin-d2")); + }, + }, + + { + name: "denomination: list by verification status", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); + const a = makeDenomination("https://e1/", "vs-a"); + a.verificationStatus = DenominationVerificationStatus.Unverified; + const b = makeDenomination("https://e1/", "vs-b"); + b.verificationStatus = DenominationVerificationStatus.VerifiedGood; + await tx.upsertDenomination(a); + await tx.upsertDenomination(b); + }); + const unverified = await runner.runReadWriteTx((tx) => + tx.getDenominationsByVerificationStatus( + DenominationVerificationStatus.Unverified, + ), + ); + t.equal(unverified.length, 1, "must filter on the status"); + t.equal(unverified[0].denomPubHash, ckh("vs-a")); + }, + }, + // ---------------------------------------------------------------- coins + + { + name: "coin: round trips with age commitment proof absent", + async run(t, runner) { + const coin = makeCoin("cp-1"); + await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); + const got = await runner.runReadWriteTx((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( + got !== undefined && "ageCommitmentProof" in got, + "ageCommitmentProof is a required key and must be present even when undefined", + ); + }, + }, + + { + name: "coin: nested coinSource union and denomSig survive", + async run(t, runner) { + const coin = makeCoin("cp-src"); + coin.coinSource = { + type: CoinSourceType.Withdraw, + withdrawalGroupId: "wg-1", + coinIndex: 3, + reservePub: ck("rp-1"), + }; + await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); + const got = await runner.runReadWriteTx((tx) => tx.getCoin(ck("cp-src"))); + t.deepEqual(got?.coinSource, coin.coinSource); + t.deepEqual(got?.denomSig, coin.denomSig); + }, + }, + + { + name: "coin: status is a string enum, not a number", + async run(t, runner) { + const coin = makeCoin("cp-status"); + coin.status = CoinStatus.Dormant; + await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); + const got = await runner.runReadWriteTx((tx) => + tx.getCoin(ck("cp-status")), + ); + t.equal(got?.status, CoinStatus.Dormant); + t.equal(typeof got?.status, "string", "CoinStatus must not be coerced"); + }, + }, + + { + name: "coin: upsert overwrites rather than duplicating", + async run(t, runner) { + const coin = makeCoin("cp-up"); + await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); + coin.status = CoinStatus.Dormant; + coin.visible = 1; + await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); + const all = await runner.runReadWriteTx((tx) => tx.listAllCoins()); + 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); + }, + }, + + { + name: "coin: queries by exchange, denom and source transaction", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const a = makeCoin("cq-1"); + a.exchangeBaseUrl = "https://ex-a/"; + a.denomPubHash = ckh("dq-1"); + a.sourceTransactionId = "txn-1"; + const b = makeCoin("cq-2"); + b.exchangeBaseUrl = "https://ex-a/"; + b.denomPubHash = ckh("dq-2"); + b.sourceTransactionId = "txn-2"; + const c = makeCoin("cq-3"); + c.exchangeBaseUrl = "https://ex-b/"; + c.denomPubHash = ckh("dq-1"); + await tx.upsertCoin(a); + await tx.upsertCoin(b); + await tx.upsertCoin(c); + }); + const byEx = await runner.runReadWriteTx((tx) => + tx.getCoinsByExchange("https://ex-a/"), + ); + t.equal(byEx.length, 2); + const count = await runner.runReadWriteTx((tx) => + tx.countCoinsByExchange("https://ex-a/"), + ); + t.equal(count, 2, "count must agree with the list"); + const byDenom = await runner.runReadWriteTx((tx) => + tx.getCoinsByDenomPubHash(ckh("dq-1")), + ); + t.equal(byDenom.length, 2, "denom hash spans exchanges"); + const denomHashes = [ + ckh("dq-1"), + ...Array.from({ length: 501 }, (_, i) => ckh(`dq-missing-${i}`)), + ckh("dq-2"), + ckh("dq-1"), + ]; + const byDenoms = await runner.runReadWriteTx((tx) => + tx.getCoinsByDenomPubHashes(denomHashes), + ); + t.deepEqual( + byDenoms.map((coin) => coin.coinPub).sort(), + [ck("cq-1"), ck("cq-2"), ck("cq-3")].sort(), + "batch lookup spans exchanges, chunks safely and de-duplicates hashes", + ); + const bySrc = await runner.runReadWriteTx((tx) => + tx.getCoinsBySourceTransaction("txn-1"), + ); + t.equal(bySrc.length, 1); + t.equal(bySrc[0].coinPub, ck("cq-1")); + }, + }, + + { + name: "coin: getCoinsByPubs skips missing pubs and keeps argument order", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoin(makeCoin("cb-1")); + await tx.upsertCoin(makeCoin("cb-2")); + }); + const missing = ck("cb-missing"); + const pubs = Array.from({ length: 503 }, (_, i) => + i % 19 === 0 ? missing : i % 2 === 0 ? ck("cb-2") : ck("cb-1"), + ); + const got = await runner.runReadWriteTx((tx) => tx.getCoinsByPubs(pubs)); + t.deepEqual( + got.map((c) => c.coinPub), + pubs.filter((pub) => pub !== missing), + "missing pubs are dropped while duplicates retain input order", + ); + }, + }, + + { + name: "coin: fresh-coin lookup filters on status and respects the limit", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (let i = 0; i < 4; i++) { + const c = makeCoin(`cf-${i}`); + c.exchangeBaseUrl = "https://ex-f/"; + c.denomPubHash = ckh("df-1"); + c.maxAge = 21; + c.status = i === 3 ? CoinStatus.Dormant : CoinStatus.Fresh; + await tx.upsertCoin(c); + } + }); + const all = await runner.runReadWriteTx((tx) => + tx.getFreshCoinsByDenomAndAge( + { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("df-1"), + maxAge: 21, + }, + 100, + ), + ); + t.equal(all.length, 3, "the dormant coin must be excluded"); + const limited = await runner.runReadWriteTx((tx) => + tx.getFreshCoinsByDenomAndAge( + { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("df-1"), + maxAge: 21, + }, + 2, + ), + ); + t.equal(limited.length, 2, "the limit must be applied"); + }, + }, + + { + name: "coin: delete cascades to its history", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoin(makeCoin("cd-1")); + await tx.upsertCoinHistory({ + coinPub: ck("cd-1"), + history: [{ type: "withdraw", transactionId: txnId("txn:cd-1") }], + }); + await tx.upsertCoin(makeCoin("cd-2")); + await tx.upsertCoinHistory({ + coinPub: ck("cd-2"), + history: [{ type: "withdraw", transactionId: txnId("txn:cd-2") }], + }); + }); + await runner.runReadWriteTx((tx) => tx.deleteCoin(ck("cd-1"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-1"))), + undefined, + ); + t.equal( + await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-1"))), + undefined, + "deleting a coin must delete its history: every reader looks the" + + " history up for a coin it already holds, so a history row without" + + " its coin is unreachable", + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), + "the history of another coin must survive", + ); + // deleteCoinHistory still exists on its own, for callers that want to + // drop the history while keeping the coin. + await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-2"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), + undefined, + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-2"))), + "deleteCoinHistory must not delete the coin", + ); + }, + }, + + { + name: "coin history: arbitrary history entries round trip", + async run(t, runner) { + const history: WalletCoinHistoryItem[] = [ + { type: "withdraw", transactionId: txnId("txn:ch-a") }, + { + type: "spend", + transactionId: txnId("txn:ch-b"), + amount: amt("TESTKUDOS:1.5"), + }, + { + type: "refund", + transactionId: txnId("txn:ch-c"), + amount: amt("TESTKUDOS:0.25"), + }, + ]; + await runner.runReadWriteTx(async (tx) => { + // The history belongs to a coin, and deleting that coin cascades to + // it, so the coin has to exist first. + await tx.upsertCoin(makeCoin("ch-1")); + await tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinHistory(ck("ch-1")), + ); + t.deepEqual(got?.history, history); + }, + }, + + { + name: "coin history: batch lookup preserves order across chunks", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (const label of ["chb-1", "chb-2"]) { + await tx.upsertCoin(makeCoin(label)); + await tx.upsertCoinHistory({ + coinPub: ck(label), + history: [ + { type: "withdraw", transactionId: txnId(`txn:${label}`) }, + ], + }); + } + }); + const missing = ck("chb-missing"); + const pubs = Array.from({ length: 503 }, (_, i) => + i % 23 === 0 ? missing : i % 2 === 0 ? ck("chb-2") : ck("chb-1"), + ); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinHistoriesByPubs(pubs), + ); + t.deepEqual( + got.map((h) => h.coinPub), + pubs.filter((pub) => pub !== missing), + "missing histories are skipped while duplicates retain input order", + ); + }, + }, + + // ------------------------------------------------------ coin availability + + { + name: "coin availability: compound primary key of (exchange, denom, age)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoinAvailability(makeAvail("https://ea/", "da", 0)); + await tx.upsertCoinAvailability(makeAvail("https://ea/", "da", 21)); + }); + const a = await runner.runReadWriteTx((tx) => + tx.getCoinAvailability({ + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("da"), + maxAge: 0, + }), + ); + const b = await runner.runReadWriteTx((tx) => + tx.getCoinAvailability({ + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("da"), + maxAge: 21, + }), + ); + t.ok(a && b, "differing maxAge must be distinct rows"); + t.equal(a?.maxAge, 0); + t.equal(b?.maxAge, 21); + }, + }, + + { + name: "coin availability: batch lookup preserves order across chunks", + async run(t, runner) { + const zero = makeAvail("https://batch-avail/", "ba", 0); + const adult = makeAvail("https://batch-avail/", "ba", 21); + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoinAvailability(zero); + await tx.upsertCoinAvailability(adult); + }); + const missing = { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("ba-missing"), + maxAge: 0, + }; + const refs = Array.from({ length: 303 }, (_, i) => + i % 13 === 0 ? missing : i % 2 === 0 ? zero : adult, + ); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinAvailabilitiesByRefs(refs), + ); + t.deepEqual( + got.map((a) => [a.exchangeMasterPub, a.denomPubHash, a.maxAge]), + refs + .filter((ref) => ref !== missing) + .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash, ref.maxAge]), + "missing references are skipped and duplicates retain input order", + ); + }, + }, + + { + name: "coin availability: upsert updates counts in place", + async run(t, runner) { + const rec = makeAvail("https://eu/", "du", 0); + await runner.runReadWriteTx((tx) => tx.upsertCoinAvailability(rec)); + rec.freshCoinCount = 9; + rec.visibleCoinCount = 4; + rec.pendingRefreshOutputCount = 2; + await runner.runReadWriteTx((tx) => tx.upsertCoinAvailability(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinAvailability({ + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("du"), + maxAge: 0, + }), + ); + t.equal(got?.freshCoinCount, 9); + t.equal(got?.visibleCoinCount, 4); + t.equal(got?.pendingRefreshOutputCount, 2); + const byEx = await runner.runReadWriteTx((tx) => + tx.getCoinAvailabilityByExchange("https://eu/"), + ); + t.equal(byEx.length, 1, "the upsert must not have appended a row"); + }, + }, + + { + name: "coin availability: age range excludes every zero-fresh row", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const atLowerNoFresh = makeAvail("https://er/", "d-lo", 0); + atLowerNoFresh.freshCoinCount = 0; + const atLowerFresh = makeAvail("https://er/", "d-lf", 0); + atLowerFresh.freshCoinCount = 5; + const aboveNoFresh = makeAvail("https://er/", "d-hi", 10); + aboveNoFresh.freshCoinCount = 0; + await tx.upsertCoinAvailability(atLowerNoFresh); + await tx.upsertCoinAvailability(atLowerFresh); + await tx.upsertCoinAvailability(aboveNoFresh); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getCoinAvailabilityByExchangeAndAgeRange("https://er/", 0, 21), + ); + const hashes = got.map((a) => a.denomPubHash).sort(); + t.deepEqual( + hashes, + [ckh("d-lf")], + "excludes zero-fresh rows throughout the age range", + ); + }, + }, + + { + name: "coin availability: delete targets one (exchange, denom, age)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoinAvailability(makeAvail("https://ed/", "dd", 0)); + await tx.upsertCoinAvailability(makeAvail("https://ed/", "dd", 21)); + }); + await runner.runReadWriteTx((tx) => + tx.deleteCoinAvailability({ + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("dd"), + maxAge: 0, + }), + ); + const left = await runner.runReadWriteTx((tx) => + tx.getCoinAvailabilityByExchange("https://ed/"), + ); + t.equal(left.length, 1); + t.equal(left[0].maxAge, 21, "only the age-0 row must be gone"); + }, + }, + // ------------------------------------------------------------ exchanges + + { + name: "exchange: round trips, including the flattened details pointer", + async run(t, runner) { + const ex = makeExchange("https://ex-rt/"); + ex.detailsPointer = { + masterPublicKey: ck("mpk-1"), + currency: "TESTKUDOS", + updateClock: tsPrecise(4242), + }; + await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); + const got = await runner.runReadWriteTx((tx) => + tx.getExchange("https://ex-rt/"), + ); + t.deepEqual(got, ex, "every field must survive the round trip"); + }, + }, + + { + name: "exchange: a missing details pointer stays a present, undefined key", + async run(t, runner) { + const ex = makeExchange("https://ex-np/"); + ex.detailsPointer = undefined; + await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); + const got = await runner.runReadWriteTx((tx) => + tx.getExchange("https://ex-np/"), + ); + t.equal(got?.detailsPointer, undefined); + t.ok( + got !== undefined && "detailsPointer" in got, + "detailsPointer is a required key and must be present", + ); + }, + }, + + { + name: "exchange: optional booleans keep undefined distinct from false", + async run(t, runner) { + const unset = makeExchange("https://ex-b1/"); + const explicitlyFalse = makeExchange("https://ex-b2/"); + explicitlyFalse.noFees = false; + explicitlyFalse.peerPaymentsDisabled = false; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchange(unset); + await tx.upsertExchange(explicitlyFalse); + }); + const a = await runner.runReadWriteTx((tx) => + tx.getExchange("https://ex-b1/"), + ); + const b = await runner.runReadWriteTx((tx) => + tx.getExchange("https://ex-b2/"), + ); + t.equal(a?.noFees, undefined, "an unset flag must not become false"); + t.equal(b?.noFees, false, "an explicit false must not become undefined"); + t.equal(b?.peerPaymentsDisabled, false); + }, + }, + + { + name: "exchange: list and delete", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchange(makeExchange("https://ex-l1/")); + await tx.upsertExchange(makeExchange("https://ex-l2/")); + }); + const all = await runner.runReadWriteTx((tx) => tx.getExchanges()); + const mine = all.filter((e) => e.baseUrl.startsWith("https://ex-l")); + t.equal(mine.length, 2); + await runner.runReadWriteTx((tx) => tx.deleteExchange("https://ex-l1/")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getExchange("https://ex-l1/")), + undefined, + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getExchange("https://ex-l2/")), + "deleting one exchange must not affect the other", + ); + }, + }, + + // ----------------------------------------------------- exchange details + + { + name: "exchange: a superseded key set round trips", + async run(t, runner) { + const ex = makeExchange("https://superseded/"); + ex.detailsPointer = { + masterPublicKey: ck("mpk-current"), + currency: "TESTKUDOS", + updateClock: tsPrecise(1), + }; + ex.supersededKeySet = { + masterPublicKey: ck("mpk-old"), + currency: "TESTKUDOS", + firstSeen: tsPrecise(3), + sharesDenominations: false, + }; + await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); + const got = await runner.runReadWriteTx((tx) => + tx.getExchange("https://superseded/"), + ); + t.deepEqual(got?.detailsPointer, ex.detailsPointer); + t.deepEqual(got?.supersededKeySet, ex.supersededKeySet); + t.equal( + got?.supersededKeySet?.sharesDenominations, + false, + "an explicit false must not become undefined", + ); + }, + }, + + { + name: "exchange: no superseded key set stays absent", + async run(t, runner) { + await runner.runReadWriteTx((tx) => + tx.upsertExchange(makeExchange("https://no-superseded/")), + ); + const got = await runner.runReadWriteTx((tx) => + tx.getExchange("https://no-superseded/"), + ); + t.equal(got?.supersededKeySet, undefined); + }, + }, + + { + name: "exchange: clearing a superseded key set persists", + async run(t, runner) { + const ex = makeExchange("https://confirmed/"); + ex.supersededKeySet = { + masterPublicKey: ck("mpk-gone"), + currency: "TESTKUDOS", + firstSeen: tsPrecise(5), + sharesDenominations: true, + }; + await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); + // Confirming the change clears it; the columns must go back to NULL + // rather than keeping the previous value. + delete ex.supersededKeySet; + await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); + const got = await runner.runReadWriteTx((tx) => + tx.getExchange("https://confirmed/"), + ); + t.equal(got?.supersededKeySet, undefined); + }, + }, + + { + name: "exchange details: upsert returns a row id and round trips", + async run(t, runner) { + const det = makeExchangeDetails("https://ed-1/", "mpk-a"); + const rowId = await runner.runReadWriteTx((tx) => + tx.upsertExchangeDetails(det), + ); + t.ok(typeof rowId === "number", "must return the generated row id"); + const got = await runner.runReadWriteTx((tx) => + tx.getExchangeDetailsByPointer( + "https://ed-1/", + "TESTKUDOS", + ck("mpk-a"), + ), + ); + t.equal(got?.rowId, rowId); + t.deepEqual(got?.wireInfo, det.wireInfo, "nested JSON must survive"); + t.deepEqual(got?.globalFees, det.globalFees); + t.equal(got?.protocolVersionRange, det.protocolVersionRange); + }, + }, + + { + name: "exchange details: a second upsert with the row id updates in place", + async run(t, runner) { + const det = makeExchangeDetails("https://ed-u/", "mpk-u"); + const rowId = await runner.runReadWriteTx((tx) => + tx.upsertExchangeDetails(det), + ); + det.rowId = rowId; + det.currency = "EUR"; + const again = await runner.runReadWriteTx((tx) => + tx.upsertExchangeDetails(det), + ); + t.equal(again, rowId, "the row id must be stable across updates"); + const list = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByBaseUrl("https://ed-u/"), + ); + t.equal(list.length, 1, "the update must not have inserted a row"); + t.equal(list[0].currency, "EUR"); + }, + }, + + { + name: "exchange details: resolved through the exchange's pointer", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const det = makeExchangeDetails("https://ep/", "mpk-p"); + await tx.upsertExchangeDetails(det); + const ex = makeExchange("https://ep/"); + ex.detailsPointer = { + masterPublicKey: ck("mpk-p"), + currency: "TESTKUDOS", + updateClock: tsPrecise(1), + }; + await tx.upsertExchange(ex); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getExchangeDetails("https://ep/"), + ); + t.equal(got?.masterPublicKey, ck("mpk-p")); + }, + }, + + { + name: "exchange details: no pointer means no details, not a throw", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchangeDetails( + makeExchangeDetails("https://enp/", "mpk-n"), + ); + const ex = makeExchange("https://enp/"); + ex.detailsPointer = undefined; + await tx.upsertExchange(ex); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getExchangeDetails("https://enp/"), + ); + t.equal(got, undefined, "details must not be found without a pointer"); + }, + }, + + { + name: "exchange details: unknown exchange yields undefined", + async run(t, runner) { + const got = await runner.runReadWriteTx((tx) => + tx.getExchangeDetails("https://never-added/"), + ); + t.equal(got, undefined); + }, + }, + + { + name: "exchange details: one master public key, two base URLs", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchangeDetails( + makeExchangeDetails("https://mp-a/", "mpk-shared"), + ); + await tx.upsertExchangeDetails( + makeExchangeDetails("https://mp-b/", "mpk-shared"), + ); + }); + const got = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByMasterPub(ck("mpk-shared")), + ); + t.equal(got.length, 2, "a master public key may span base URLs"); + t.deepEqual(got.map((d) => d.exchangeBaseUrl).sort(), [ + "https://mp-a/", + "https://mp-b/", + ]); + }, + }, + + { + name: "exchange details: one base URL, two master public keys", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchangeDetails( + makeExchangeDetails("https://mp-two/", "mpk-old"), + ); + await tx.upsertExchangeDetails( + makeExchangeDetails("https://mp-two/", "mpk-new"), + ); + }); + const old = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByMasterPub(ck("mpk-old")), + ); + const fresh = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByMasterPub(ck("mpk-new")), + ); + // Superseded keys keep their own row: the coins withdrawn under them + // are only interpretable through it. + t.equal(old.length, 1); + t.equal(fresh.length, 1); + t.ok( + old[0].rowId !== fresh[0].rowId, + "each key set must keep its own details row", + ); + }, + }, + + { + name: "exchange details: unknown master public key yields nothing", + async run(t, runner) { + const got = await runner.runReadWriteTx((tx) => + tx.listExchangeDetailsByMasterPub(ck("mpk-never")), + ); + t.equal(got.length, 0); + }, + }, + + // --------------------------------------------------- exchange sign keys + + { + name: "exchange sign keys: keyed by (details row id, signkey pub)", + async run(t, runner) { + const rowId = await runner.runReadWriteTx((tx) => + tx.upsertExchangeDetails(makeExchangeDetails("https://esk/", "mpk-s")), + ); + await runner.runReadWriteTx(async (tx) => { + await tx.upsertExchangeSignKey(makeSignKey(rowId, "sk-1")); + await tx.upsertExchangeSignKey(makeSignKey(rowId, "sk-2")); + }); + const keys = await runner.runReadWriteTx((tx) => + tx.getExchangeSignKeysByDetailsRowId(rowId), + ); + t.equal(keys.length, 2); + t.deepEqual( + keys.map((k) => k.signkeyPub).sort(), + [ck("sk-1"), ck("sk-2")].sort(), + ); + await runner.runReadWriteTx((tx) => + tx.deleteExchangeSignKey(rowId, ck("sk-1")), + ); + const left = await runner.runReadWriteTx((tx) => + tx.getExchangeSignKeysByDetailsRowId(rowId), + ); + t.equal(left.length, 1); + t.equal(left[0].signkeyPub, ck("sk-2")); + }, + }, + + { + name: "exchange sign keys: re-upserting the same pub updates it", + async run(t, runner) { + const rowId = await runner.runReadWriteTx((tx) => + tx.upsertExchangeDetails(makeExchangeDetails("https://esk2/", "mpk-t")), + ); + const key = makeSignKey(rowId, "sk-dup"); + await runner.runReadWriteTx((tx) => tx.upsertExchangeSignKey(key)); + key.masterSig = ckh("sig-updated"); + await runner.runReadWriteTx((tx) => tx.upsertExchangeSignKey(key)); + const keys = await runner.runReadWriteTx((tx) => + tx.getExchangeSignKeysByDetailsRowId(rowId), + ); + t.equal(keys.length, 1, "must replace, not append"); + t.equal(keys[0].masterSig, ckh("sig-updated")); + }, + }, + + // ------------------------------------------------ denomination families + + { + name: "denomination family: looked up by the full seven-part params", + async run(t, runner) { + const params = makeFamilyParams("https://df/", "TESTKUDOS:1"); + const serial = await runner.runReadWriteTx((tx) => + tx.upsertDenominationFamily({ familyParams: params }), + ); + t.ok(typeof serial === "number", "must return the generated serial"); + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationFamilyByParams(params), + ); + t.equal(got?.denominationFamilySerial, serial); + t.deepEqual(got?.familyParams, params); + }, + }, + + { + name: "denomination family: a differing fee is a different family", + async run(t, runner) { + // Five of the seven components are amounts, so a transposition between + // two of them would otherwise silently resolve to the wrong family. + const base = makeFamilyParams("https://df2/", "TESTKUDOS:1"); + await runner.runReadWriteTx((tx) => + tx.upsertDenominationFamily({ familyParams: base }), + ); + const differing = { ...base, feeRefund: amt("TESTKUDOS:0.99") }; + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationFamilyByParams(differing), + ); + t.equal(got, undefined, "a different fee must not match"); + }, + }, + + { + name: "denomination family: swapped fee components do not collide", + async run(t, runner) { + const params = makeFamilyParams("https://df3/", "TESTKUDOS:1"); + params.feeDeposit = amt("TESTKUDOS:0.01"); + params.feeRefresh = amt("TESTKUDOS:0.02"); + await runner.runReadWriteTx((tx) => + tx.upsertDenominationFamily({ familyParams: params }), + ); + const swapped = { + ...params, + feeDeposit: params.feeRefresh, + feeRefresh: params.feeDeposit, + }; + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationFamilyByParams(swapped), + ); + t.equal(got, undefined, "component order must be significant"); + }, + }, + + { + name: "denomination family: list by exchange and delete by serial", + async run(t, runner) { + const [s1] = await runner.runReadWriteTx(async (tx) => [ + await tx.upsertDenominationFamily({ + familyParams: makeFamilyParams("https://df4/", "TESTKUDOS:1"), + }), + await tx.upsertDenominationFamily({ + familyParams: makeFamilyParams("https://df4/", "TESTKUDOS:2"), + }), + ]); + const fams = await runner.runReadWriteTx((tx) => + tx.getDenominationFamiliesByExchange("https://df4/"), + ); + t.equal(fams.length, 2); + await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(s1)); + const left = await runner.runReadWriteTx((tx) => + tx.getDenominationFamiliesByExchange("https://df4/"), + ); + t.equal(left.length, 1); + }, + }, + + // ------------------------------------------------- fixups / migration log + + { + name: "exchange base URL fixup: round trips and updates", + async run(t, runner) { + await runner.runReadWriteTx((tx) => + tx.upsertExchangeBaseUrlFixup({ + exchangeBaseUrl: "https://old/", + replacement: "https://new/", + }), + ); + let got = await runner.runReadWriteTx((tx) => + tx.getExchangeBaseUrlFixup("https://old/"), + ); + t.equal(got?.replacement, "https://new/"); + await runner.runReadWriteTx((tx) => + tx.upsertExchangeBaseUrlFixup({ + exchangeBaseUrl: "https://old/", + replacement: "https://newer/", + }), + ); + got = await runner.runReadWriteTx((tx) => + tx.getExchangeBaseUrlFixup("https://old/"), + ); + t.equal(got?.replacement, "https://newer/"); + }, + }, + + { + name: "migration log: keyed by the old and new URL pair", + async run(t, runner) { + const rec: WalletExchangeMigrationLog = { + oldExchangeBaseUrl: "https://a/", + newExchangeBaseUrl: "https://b/", + timestamp: tsPrecise(9000), + reason: ExchangeMigrationReason.MismatchedBaseUrl, + }; + await runner.runReadWriteTx((tx) => tx.upsertExchangeMigrationLog(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getExchangeMigrationLog("https://a/", "https://b/"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + t.equal( + typeof got?.reason, + "string", + "ExchangeMigrationReason is a string enum and must not be coerced", + ); + const other = await runner.runReadWriteTx((tx) => + tx.getExchangeMigrationLog("https://b/", "https://a/"), + ); + t.equal(other, undefined, "the pair is ordered"); + }, + }, + + { + name: "denominations: listed by master public key", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedDenomFamily(tx, "https://fam1/", 1); + await tx.upsertDenomination(makeDenomination("https://dl1/", "d-1")); + await tx.upsertDenomination(makeDenomination("https://dl1/", "d-2")); + const other = makeDenomination("https://dl2/", "d-3"); + other.exchangeMasterPub = ck("master-other"); + await tx.upsertDenomination(other); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getDenominationsByMasterPub(ck("master-pub")), + ); + t.equal(got.length, 2, "only the denominations of that key set"); + const other = await runner.runReadWriteTx((tx) => + tx.getDenominationsByMasterPub(ck("master-other")), + ); + t.equal(other.length, 1); + }, + }, + // -------------------------------------------------- withdrawal groups + + { + name: "withdrawal group: bank-integrated wgInfo round trips whole", + async run(t, runner) { + const wg = makeWithdrawalGroup("wg-bi"); + wg.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: { + talerWithdrawUri: "taler://withdraw/example/1", + confirmUrl: "https://bank/confirm", + exchangePaytoUri: "payto://iban/DE123", + timestampReserveInfoPosted: tsPrecise(10), + timestampBankConfirmed: tsPrecise(20), + wireTypes: ["iban"], + currency: "TESTKUDOS", + externalConfirmation: true, + senderWire: "payto://iban/DE999", + }, + exchangeCreditAccounts: [], + }; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + const got = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroup("wg-bi"), + ); + t.deepEqual(got?.wgInfo, wg.wgInfo, "the whole variant must survive"); + }, + }, + + { + name: "withdrawal group: the withdraw URI has exactly one stored copy", + async run(t, runner) { + // The native schema promotes talerWithdrawUri to an indexed column and + // strips it from the JSON payload. If a second copy were kept, an + // update could change one and not the other, and the lookup by URI + // would disagree with the record. Update the URI and check that both + // the record and the index-backed lookup follow. + const wg = makeWithdrawalGroup("wg-uri"); + wg.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: makeBankInfo("taler://withdraw/example/first"), + }; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + + wg.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: makeBankInfo("taler://withdraw/example/second"), + }; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + + const byOld = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroupByTalerWithdrawUri( + "taler://withdraw/example/first", + ), + ); + t.equal(byOld, undefined, "the old URI must no longer resolve"); + const byNew = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroupByTalerWithdrawUri( + "taler://withdraw/example/second", + ), + ); + t.equal(byNew?.withdrawalGroupId, "wg-uri"); + const rec = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroup("wg-uri"), + ); + t.equal(rec?.wgInfo.withdrawalType, WithdrawalRecordType.BankIntegrated); + if (rec?.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { + t.equal( + rec.wgInfo.bankInfo.talerWithdrawUri, + "taler://withdraw/example/second", + "record and index must agree", + ); + } + }, + }, + + { + name: "withdrawal group: every wgInfo variant round trips", + async run(t, runner) { + const variants: WgInfo[] = [ + { + withdrawalType: WithdrawalRecordType.BankManual, + exchangeCreditAccounts: [], + }, + { + withdrawalType: WithdrawalRecordType.PeerPullCredit, + contractPriv: ck("cpriv-1"), + }, + { withdrawalType: WithdrawalRecordType.PeerPushCredit }, + { withdrawalType: WithdrawalRecordType.Recoup }, + ]; + for (let i = 0; i < variants.length; i++) { + const wg = makeWithdrawalGroup(`wg-var-${i}`); + wg.wgInfo = variants[i]; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + const got = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroup(`wg-var-${i}`), + ); + t.deepEqual( + got?.wgInfo, + variants[i], + `variant ${variants[i].withdrawalType} must round trip`, + ); + } + }, + }, + + { + name: "withdrawal group: switching variant clears the old variant's data", + async run(t, runner) { + // A bank-integrated group carries bankInfo and a URI; after switching + // to a manual withdrawal neither may survive as a stale column. + const wg = makeWithdrawalGroup("wg-switch"); + wg.wgInfo = { + withdrawalType: WithdrawalRecordType.BankIntegrated, + bankInfo: makeBankInfo("taler://withdraw/example/switch"), + }; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + wg.wgInfo = { withdrawalType: WithdrawalRecordType.BankManual }; + await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); + + const got = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroup("wg-switch"), + ); + t.deepEqual(got?.wgInfo, { + withdrawalType: WithdrawalRecordType.BankManual, + }); + const stale = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroupByTalerWithdrawUri( + "taler://withdraw/example/switch", + ), + ); + t.equal(stale, undefined, "the old URI must not still resolve"); + }, + }, + + { + name: "withdrawal group: active groups are the non-final ones", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const pending = makeWithdrawalGroup("wg-act"); + pending.status = WithdrawalGroupStatus.PendingRegisteringBank; + const done = makeWithdrawalGroup("wg-done"); + done.status = WithdrawalGroupStatus.Done; + await tx.upsertWithdrawalGroup(pending); + await tx.upsertWithdrawalGroup(done); + }); + const active = await runner.runReadWriteTx((tx) => + tx.getActiveWithdrawalGroups(), + ); + const ids = active.map((w) => w.withdrawalGroupId); + t.ok(ids.includes("wg-act"), "a pending group must be active"); + t.ok(!ids.includes("wg-done"), "a finished group must not be active"); + }, + }, + + { + name: "withdrawal group: query and count by exchange agree", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (let i = 0; i < 3; i++) { + const wg = makeWithdrawalGroup(`wg-ex-${i}`); + wg.exchangeBaseUrl = "https://wex/"; + await tx.upsertWithdrawalGroup(wg); + } + const other = makeWithdrawalGroup("wg-other"); + other.exchangeBaseUrl = "https://wother/"; + await tx.upsertWithdrawalGroup(other); + }); + const list = await runner.runReadWriteTx((tx) => + tx.getWithdrawalGroupsByExchange("https://wex/"), + ); + t.equal(list.length, 3); + const count = await runner.runReadWriteTx((tx) => + tx.countWithdrawalGroupsByExchange("https://wex/"), + ); + t.equal(count, 3, "count must agree with the list, not be capped at 1"); + }, + }, + + { + name: "withdrawal group: delete", + async run(t, runner) { + await runner.runReadWriteTx((tx) => + tx.upsertWithdrawalGroup(makeWithdrawalGroup("wg-del")), + ); + await runner.runReadWriteTx((tx) => tx.deleteWithdrawalGroup("wg-del")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getWithdrawalGroup("wg-del")), + undefined, + ); + }, + }, + + // ------------------------------------------------------------ planchets + + { + name: "planchet: round trips and is addressable by group and index", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-p"); + await tx.upsertPlanchet(makePlanchet("pl-1", "wg-p", 0)); + await tx.upsertPlanchet(makePlanchet("pl-2", "wg-p", 1)); + }); + const byIdx = await runner.runReadWriteTx((tx) => + tx.getPlanchetByGroupAndIndex("wg-p", 1), + ); + t.equal(byIdx?.coinPub, ck("pl-2")); + const direct = await runner.runReadWriteTx((tx) => + tx.getPlanchet(ck("pl-1")), + ); + t.deepEqual(direct, makePlanchet("pl-1", "wg-p", 0)); + }, + }, + + { + name: "planchet: lastError is a present key even when undefined", + async run(t, runner) { + const pl = makePlanchet("pl-err", "wg-e", 0); + pl.lastError = undefined; + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-e"); + await tx.upsertPlanchet(pl); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getPlanchet(ck("pl-err")), + ); + t.ok( + got !== undefined && "lastError" in got, + "lastError is declared as a required key", + ); + t.equal(got?.lastError, undefined); + }, + }, + + { + name: "planchet: list, count and bulk delete by group", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedWithdrawalGroup(tx, "wg-bulk"); + await seedWithdrawalGroup(tx, "wg-keep"); + for (let i = 0; i < 4; i++) { + await tx.upsertPlanchet(makePlanchet(`pl-g${i}`, "wg-bulk", i)); + } + await tx.upsertPlanchet(makePlanchet("pl-keep", "wg-keep", 0)); + }); + t.equal( + (await runner.runReadWriteTx((tx) => tx.getPlanchetsByGroup("wg-bulk"))) + .length, + 4, + ); + t.equal( + await runner.runReadWriteTx((tx) => + tx.countPlanchetsByGroup("wg-bulk"), + ), + 4, + "count must agree with the list", + ); + await runner.runReadWriteTx((tx) => tx.deletePlanchetsByGroup("wg-bulk")); + t.equal( + (await runner.runReadWriteTx((tx) => tx.getPlanchetsByGroup("wg-bulk"))) + .length, + 0, + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getPlanchet(ck("pl-keep"))), + "another group must be untouched", + ); + }, + }, + // -------------------------------------------------- transaction meta + + { + name: "transaction meta: round trips and updates in place", + async run(t, runner) { + const rec: WalletTransactionMeta = { + transactionId: "txn:meta:1", + timestamp: tsPrecise(500), + status: WithdrawalGroupStatus.PendingRegisteringBank, + currency: "TESTKUDOS", + exchanges: ["https://e1/", "https://e2/"], + }; + await runner.runReadWriteTx((tx) => tx.upsertTransactionMeta(rec)); + let got = await runner.runReadWriteTx((tx) => + tx.getTransactionMeta("txn:meta:1"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + rec.status = WithdrawalGroupStatus.Done; + await runner.runReadWriteTx((tx) => tx.upsertTransactionMeta(rec)); + got = await runner.runReadWriteTx((tx) => + tx.getTransactionMeta("txn:meta:1"), + ); + t.equal(got?.status, WithdrawalGroupStatus.Done); + const all = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({}), + ); + t.equal( + all.filter((m) => m.transactionId === "txn:meta:1").length, + 1, + "an update must not append a second row", + ); + }, + }, + + { + name: "transaction meta: before is inclusive, after-cursor is exclusive", + async run(t, runner) { + // These bounds come from IndexedDB key ranges: upperBound(ts, false) + // and lowerBound(ts, false) are inclusive, while the pagination cursor + // uses lowerBound(ts, true) -- exclusive. Getting one of them wrong + // either skips a transaction or loops on it forever. + await runner.runReadWriteTx(async (tx) => { + for (const [id, at] of [ + ["txn:p:10", 10], + ["txn:p:20", 20], + ["txn:p:30", 30], + ] as const) { + await tx.upsertTransactionMeta({ + transactionId: id, + timestamp: tsPrecise(at), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + const before = await runner.runReadWriteTx((tx) => + tx.getTransactionMetaBefore(tsPrecise(20)), + ); + t.equal(before?.transactionId, "txn:p:20", "before is inclusive"); + const after = await runner.runReadWriteTx((tx) => + tx.getTransactionMetaAfter(tsPrecise(20)), + ); + t.equal(after?.transactionId, "txn:p:20", "after is inclusive"); + const at = await runner.runReadWriteTx((tx) => + tx.getTransactionMetaAtTimestamp(tsPrecise(30)), + ); + t.equal(at?.transactionId, "txn:p:30"); + const page = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(20) }), + ); + t.deepEqual( + page.map((m) => m.transactionId), + ["txn:p:30"], + "the pagination cursor is exclusive", + ); + }, + }, + + { + name: "global currency: adding the same entry twice is a no-op", + async run(t, runner) { + const exch = { + currency: "TESTKUDOS", + exchangeBaseUrl: "https://exchange.example.com/", + exchangeMasterPub: ck("gc-master-1"), + }; + const auditor = { + currency: "TESTKUDOS", + auditorBaseUrl: "https://auditor.example.com/", + auditorPub: ck("gc-auditor-1"), + }; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertGlobalCurrencyExchange(exch); + await tx.upsertGlobalCurrencyAuditor(auditor); + }); + + // The duplicates go in the same transaction as a write that must + // survive them: on a store keyed by a generated id, a second row for the + // same entry violates the unique index, and that takes down everything + // else the transaction did. + await runner.runReadWriteTx(async (tx) => { + await tx.upsertGlobalCurrencyExchange(exch); + await tx.upsertGlobalCurrencyAuditor(auditor); + await tx.upsertGlobalCurrencyExchange({ + ...exch, + exchangeMasterPub: ck("gc-master-2"), + }); + }); + + const exchanges = await runner.runReadWriteTx((tx) => + tx.listGlobalCurrencyExchanges(), + ); + t.equal( + exchanges.filter((x) => x.exchangeMasterPub === ck("gc-master-1")) + .length, + 1, + "the duplicate must not have added a row", + ); + t.equal( + exchanges.filter((x) => x.exchangeMasterPub === ck("gc-master-2")) + .length, + 1, + "the write alongside the duplicates must have survived", + ); + + const auditors = await runner.runReadWriteTx((tx) => + tx.listGlobalCurrencyAuditors(), + ); + t.equal( + auditors.filter((x) => x.auditorPub === ck("gc-auditor-1")).length, + 1, + "the duplicate auditor must not have added a row", + ); + }, + }, + + { + name: "auditor scope: membership is denomination-specific", + async run(t, runner) { + const exchangeBaseUrl = "https://audited-exchange.example/"; + const auditorBaseUrl = "https://auditor.example/"; + const auditorPub = ck("scope-auditor"); + await runner.runReadWriteTx(async (tx) => { + const details = makeExchangeDetails(exchangeBaseUrl, "scope-master"); + details.auditors = [ + { + auditor_url: auditorBaseUrl, + auditor_pub: auditorPub, + auditor_name: "Scope Auditor", + denomination_keys: [ + { + denom_pub_h: ckh("scope-denom-a"), + auditor_sig: ck("scope-auditor-sig"), + }, + ], + walletAuditorSignaturesVerified: true, + }, + ]; + await tx.upsertExchangeDetails(details); + const exchange = makeExchange(exchangeBaseUrl); + exchange.detailsPointer = { + masterPublicKey: details.masterPublicKey, + currency: details.currency, + updateClock: tsPrecise(1), + }; + await tx.upsertExchange(exchange); + await tx.upsertGlobalCurrencyAuditor({ + currency: details.currency, + auditorBaseUrl, + auditorPub, + }); + }); + + const scope = { + type: ScopeType.Auditor as const, + currency: "TESTKUDOS", + url: auditorBaseUrl, + }; + const results = await runner.runReadWriteTx(async (tx) => ({ + exact: await tx.checkExchangeInScope( + exchangeBaseUrl, + scope, + ckh("scope-denom-a"), + ), + other: await tx.checkExchangeInScope( + exchangeBaseUrl, + scope, + ckh("scope-denom-b"), + ), + any: await tx.checkExchangeInScope(exchangeBaseUrl, scope), + exactScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ckh("scope-denom-a"), + ), + otherScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ckh("scope-denom-b"), + ), + exchangeScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ), + })); + t.equal(results.exact, true); + t.equal(results.other, false); + t.equal(results.any, true, "exchange prefilters use existential scope"); + t.equal(results.exactScope.type, ScopeType.Auditor); + t.equal(results.otherScope.type, ScopeType.Exchange); + t.equal( + results.exchangeScope.type, + ScopeType.Exchange, + "an exchange without a denomination context is not wholly audited", + ); + }, + }, + + { + name: "transaction meta: the timestamp cursor cannot separate a tie", + async run(t, runner) { + // Timestamps are not unique, and the pagination cursor is a bound on + // the timestamp alone. Advancing it past the last record of a page + // therefore drops every other record that shares that timestamp, which + // is why listing transactions walks the ordered list instead of paging + // through this method. + await runner.runReadWriteTx(async (tx) => { + for (const id of ["txn:tie:a", "txn:tie:b", "txn:tie:c"]) { + await tx.upsertTransactionMeta({ + transactionId: id, + timestamp: tsPrecise(700), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + const all = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({}), + ); + t.equal( + all.filter((m) => m.transactionId.startsWith("txn:tie:")).length, + 3, + "all three share one timestamp", + ); + const afterTie = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(700) }), + ); + t.equal( + afterTie.filter((m) => m.transactionId.startsWith("txn:tie:")).length, + 0, + "continuing after the timestamp skips the whole tie, not just the part already seen", + ); + }, + }, + + { + name: "transaction meta: compound cursor pages through timestamp ties", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (const id of ["txn:page:c", "txn:page:a", "txn:page:b"]) { + await tx.upsertTransactionMeta({ + transactionId: id, + timestamp: tsPrecise(710), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + const first = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaPage({ direction: "forward", limit: 2 }), + ); + const last = first[first.length - 1]; + const second = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaPage({ + direction: "forward", + limit: 2, + cursor: { + timestamp: last.timestamp, + transactionId: last.transactionId, + }, + }), + ); + const ids = [...first, ...second] + .map((x) => x.transactionId) + .filter((x) => x.startsWith("txn:page:")); + t.deepEqual(ids, ["txn:page:a", "txn:page:b", "txn:page:c"]); + const backwards = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaPage({ + direction: "backward", + limit: 3, + cursor: { timestamp: tsPrecise(710), transactionId: "\uffff" }, + }), + ); + t.deepEqual( + backwards + .map((x) => x.transactionId) + .filter((x) => x.startsWith("txn:page:")), + ["txn:page:c", "txn:page:b", "txn:page:a"], + ); + }, + }, + + { + name: "transaction meta: ordered by timestamp, and limited", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + for (const [id, at] of [ + ["txn:o:c", 300], + ["txn:o:a", 100], + ["txn:o:b", 200], + ] as const) { + await tx.upsertTransactionMeta({ + transactionId: id, + timestamp: tsPrecise(at), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + const all = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({}), + ); + const mine = all.filter((m) => m.transactionId.startsWith("txn:o:")); + t.deepEqual( + mine.map((m) => m.transactionId), + ["txn:o:a", "txn:o:b", "txn:o:c"], + "must be ordered by timestamp ascending, not insertion order", + ); + const limited = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({ limit: 1 }), + ); + t.equal(limited.length, 1); + }, + }, + + { + name: "transaction meta: onlyActive selects the non-final range", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertTransactionMeta({ + transactionId: "txn:act:1", + timestamp: tsPrecise(1), + status: WithdrawalGroupStatus.PendingRegisteringBank, + currency: "TESTKUDOS", + exchanges: [], + }); + await tx.upsertTransactionMeta({ + transactionId: "txn:act:2", + timestamp: tsPrecise(2), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + }); + const active = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByStatus({ onlyActive: true }), + ); + const ids = active.map((m) => m.transactionId); + t.ok(ids.includes("txn:act:1")); + t.ok(!ids.includes("txn:act:2"), "a final state must not be active"); + const all = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByStatus({ onlyActive: false }), + ); + t.ok(all.length >= active.length); + }, + }, + + { + name: "transaction meta: delete one and delete all", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertTransactionMeta({ + transactionId: "txn:d:1", + timestamp: tsPrecise(1), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + await tx.upsertTransactionMeta({ + transactionId: "txn:d:2", + timestamp: tsPrecise(2), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + }); + await runner.runReadWriteTx((tx) => tx.deleteTransactionMeta("txn:d:1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getTransactionMeta("txn:d:1")), + undefined, + ); + t.ok( + await runner.runReadWriteTx((tx) => tx.getTransactionMeta("txn:d:2")), + ); + await runner.runReadWriteTx((tx) => tx.deleteAllTransactionMeta()); + const left = await runner.runReadWriteTx((tx) => + tx.listTransactionMetaByTimestamp({}), + ); + t.equal(left.length, 0, "deleteAll must clear the whole view"); + }, + }, + + // ---------------------------------------------------------- purchases + + { + name: "purchase: round trips including the exchange list", + async run(t, runner) { + const p = makePurchase("prop-rt"); + p.exchanges = ["https://ex-a/", "https://ex-b/"]; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + const got = await runner.runReadWriteTx((tx) => + tx.getPurchase("prop-rt"), + ); + t.deepEqual(got, p, "every field must survive, exchange order included"); + }, + }, + + { + name: "purchase: batch lookup preserves IDs and exchange hydration", + async run(t, runner) { + const a = makePurchase("prop-batch-a"); + a.exchanges = ["https://batch-a/"]; + const b = makePurchase("prop-batch-b"); + b.exchanges = ["https://batch-b/", "https://batch-c/"]; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertPurchase(a); + await tx.upsertPurchase(b); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getPurchasesByIds(["prop-batch-b", "prop-missing", "prop-batch-a"]), + ); + t.deepEqual( + got.map((x) => x.proposalId), + ["prop-batch-b", "prop-batch-a"], + ); + t.deepEqual(got[0].exchanges, b.exchanges); + t.deepEqual(got[1].exchanges, a.exchanges); + }, + }, + + { + name: "purchase: the exchange list has exactly one stored copy", + async run(t, runner) { + // The native schema keeps this in a junction table rather than a JSON + // column plus a multiEntry index. Shrinking the list must therefore + // remove rows, not leave a stale one that byExchange still matches. + const p = makePurchase("prop-ex"); + p.exchanges = ["https://keep/", "https://drop/"]; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getPurchasesByExchange("https://drop/"), + ) + ).length, + 1, + ); + p.exchanges = ["https://keep/"]; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + const stillDropped = await runner.runReadWriteTx((tx) => + tx.getPurchasesByExchange("https://drop/"), + ); + t.equal(stillDropped.length, 0, "the removed exchange must not match"); + const kept = await runner.runReadWriteTx((tx) => + tx.getPurchasesByExchange("https://keep/"), + ); + t.equal(kept.length, 1); + t.deepEqual(kept[0].exchanges, ["https://keep/"]); + }, + }, + + { + name: "purchase: the fulfillment URL has exactly one stored copy", + async run(t, runner) { + const p = makePurchase("prop-ff"); + p.download = makeDownloadInfo("https://shop/fulfil/first"); + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + p.download = makeDownloadInfo("https://shop/fulfil/second"); + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + const byOld = await runner.runReadWriteTx((tx) => + tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/first"), + ); + t.equal(byOld.length, 0, "the old URL must no longer resolve"); + const byNew = await runner.runReadWriteTx((tx) => + tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/second"), + ); + t.equal(byNew.length, 1); + t.equal( + byNew[0].download?.fulfillmentUrl, + "https://shop/fulfil/second", + "record and index must agree", + ); + }, + }, + + { + name: "purchase: a download without a fulfillment URL round trips", + async run(t, runner) { + const p = makePurchase("prop-nf"); + const dl = makeDownloadInfo(undefined); + p.download = dl; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + const got = await runner.runReadWriteTx((tx) => + tx.getPurchase("prop-nf"), + ); + t.deepEqual(got?.download, dl); + t.equal(got?.download?.fulfillmentUrl, undefined); + }, + }, + + { + name: "purchase: lookup by merchant URL and order id", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const a = makePurchase("prop-o1"); + a.merchantBaseUrl = "https://m1/"; + a.orderId = "order-1"; + const b = makePurchase("prop-o2"); + b.merchantBaseUrl = "https://m1/"; + b.orderId = "order-2"; + await tx.upsertPurchase(a); + await tx.upsertPurchase(b); + }); + const one = await runner.runReadWriteTx((tx) => + tx.getPurchaseByUrlAndOrderId("https://m1/", "order-2"), + ); + t.equal(one?.proposalId, "prop-o2"); + const many = await runner.runReadWriteTx((tx) => + tx.getPurchasesByUrlAndOrderId("https://m1/", "order-1"), + ); + t.equal(many.length, 1); + const none = await runner.runReadWriteTx((tx) => + tx.getPurchaseByUrlAndOrderId("https://m1/", "no-such-order"), + ); + t.equal(none, undefined); + }, + }, + + { + name: "purchase: delete removes the purchase and its exchange rows", + async run(t, runner) { + const p = makePurchase("prop-del"); + p.exchanges = ["https://gone/"]; + await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); + await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-del")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getPurchase("prop-del")), + undefined, + ); + const orphaned = await runner.runReadWriteTx((tx) => + tx.getPurchasesByExchange("https://gone/"), + ); + t.equal(orphaned.length, 0, "no orphaned exchange rows may remain"); + }, + }, + + { + name: "purchase: status filters and the active range", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const pending = makePurchase("prop-s1"); + pending.purchaseStatus = PurchaseStatus.PendingDownloadingProposal; + const failed = makePurchase("prop-s2"); + failed.purchaseStatus = PurchaseStatus.Failed; + await tx.upsertPurchase(pending); + await tx.upsertPurchase(failed); + }); + const byStatus = await runner.runReadWriteTx((tx) => + tx.getPurchasesByStatus(PurchaseStatus.Failed), + ); + t.equal(byStatus.length, 1); + t.equal(byStatus[0].proposalId, "prop-s2"); + const active = await runner.runReadWriteTx((tx) => + tx.getActivePurchases(), + ); + const ids = active.map((p) => p.proposalId); + t.ok(ids.includes("prop-s1")); + t.ok(!ids.includes("prop-s2"), "a failed purchase is not active"); + const all = await runner.runReadWriteTx((tx) => tx.listAllPurchases()); + t.equal(all.length, 2); + }, + }, + // ------------------------------------- remaining domains: round trips + // + // Each of these stores a record with its optional fields unset and its + // nested JSON populated, reads it back whole, and checks that the + // active-status query agrees with the non-final range. The active queries + // are the ones worth pinning: in IndexedDB they are a key range over a + // status index, and in SQL a BETWEEN -- an off-by-one at either bound + // silently drops a transaction from the wallet's task list. + + { + name: "deposit group: round trip and active range", + async run(t, runner) { + const dg = makeDepositGroup("dg-1"); + dg.kycAuthTransferOptions = [ + { + type: "payto", + paytoUri: "payto://iban/DE2?amount=TESTKUDOS%3A0.01&message=legacy", + kycAuthAccountPaytoUri: "payto://iban/DE2", + kycAuthTransferExpiry: TalerProtocolTimestamp.fromSeconds(7777), + }, + ]; + dg.kycAuthTransferExpiry = TalerProtocolTimestamp.fromSeconds(7777); + await runner.runReadWriteTx((tx) => tx.upsertDepositGroup(dg)); + const got = await runner.runReadWriteTx((tx) => + tx.getDepositGroup("dg-1"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(dg)); + const done = makeDepositGroup("dg-2"); + done.operationStatus = DepositOperationStatus.Finished; + await runner.runReadWriteTx((tx) => tx.upsertDepositGroup(done)); + const active = await runner.runReadWriteTx((tx) => + tx.getActiveDepositGroups(), + ); + const ids = active.map((d) => d.depositGroupId); + t.ok(ids.includes("dg-1")); + t.ok(!ids.includes("dg-2"), "a finished group is not active"); + t.equal( + (await runner.runReadWriteTx((tx) => tx.listAllDepositGroups())).length, + 2, + ); + await runner.runReadWriteTx((tx) => tx.deleteDepositGroup("dg-1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getDepositGroup("dg-1")), + undefined, + ); + }, + }, + + { + name: "refresh group: round trip, active range and originating lookup", + async run(t, runner) { + const rg = makeRefreshGroup("rg-1"); + rg.originatingTransactionId = "txn:orig:1"; + await runner.runReadWriteTx((tx) => tx.upsertRefreshGroup(rg)); + const got = await runner.runReadWriteTx((tx) => + tx.getRefreshGroup("rg-1"), + ); + t.deepEqual( + withoutUndefined(got), + withoutUndefined(rg), + "nested per-coin arrays must survive", + ); + const done = makeRefreshGroup("rg-2"); + done.operationStatus = RefreshOperationStatus.Finished; + await runner.runReadWriteTx((tx) => tx.upsertRefreshGroup(done)); + const active = await runner.runReadWriteTx((tx) => + tx.getActiveRefreshGroups(), + ); + t.ok(active.map((r) => r.refreshGroupId).includes("rg-1")); + t.ok(!active.map((r) => r.refreshGroupId).includes("rg-2")); + const byOrig = await runner.runReadWriteTx((tx) => + tx.getRefreshGroupsByOriginatingTransaction("txn:orig:1"), + ); + t.equal(byOrig.length, 1); + t.equal( + (await runner.runReadWriteTx((tx) => tx.listAllRefreshGroups())).length, + 2, + ); + await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getRefreshGroup("rg-1")), + undefined, + ); + }, + }, + + { + name: "refresh session: keyed by (group, coin index)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await seedRefreshGroup(tx, "rs-g"); + await seedRefreshGroup(tx, "rs-other"); + await tx.upsertRefreshSession(makeRefreshSession("rs-g", 0)); + await tx.upsertRefreshSession(makeRefreshSession("rs-g", 1)); + await tx.upsertRefreshSession(makeRefreshSession("rs-other", 0)); + }); + const one = await runner.runReadWriteTx((tx) => + tx.getRefreshSession("rs-g", 1), + ); + t.equal(one?.coinIndex, 1); + const byGroup = await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rs-g"), + ); + t.equal(byGroup.length, 2); + t.deepEqual( + byGroup.map((r) => r.coinIndex), + [0, 1], + "sessions must come back in coin-index order", + ); + await runner.runReadWriteTx((tx) => tx.deleteRefreshSession("rs-g", 0)); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getRefreshSessionsByGroup("rs-g"), + ) + ).length, + 1, + ); + }, + }, + + { + name: "refresh session: round trip with the melt fields set and unset", + async run(t, runner) { + const fresh = makeRefreshSession("rs-melt", 0); + const melted = makeRefreshSession("rs-melt", 1); + melted.sessionPublicSeed = ckh("seed-melt"); + melted.refreshProtocolVersion = 32; + melted.norevealIndex = 2; + await runner.runReadWriteTx(async (tx) => { + await seedRefreshGroup(tx, "rs-melt"); + await tx.upsertRefreshSession(fresh); + await tx.upsertRefreshSession(melted); + }); + const gotFresh = await runner.runReadWriteTx((tx) => + tx.getRefreshSession("rs-melt", 0), + ); + t.deepEqual(withoutUndefined(gotFresh), withoutUndefined(fresh)); + // An absent protocol version means the v27 refresh protocol, so it must + // not come back as some other value. + t.equal(gotFresh?.refreshProtocolVersion, undefined); + const gotMelted = await runner.runReadWriteTx((tx) => + tx.getRefreshSession("rs-melt", 1), + ); + t.deepEqual(withoutUndefined(gotMelted), withoutUndefined(melted)); + }, + }, + + { + name: "recoup group: round trip, by exchange and active range", + async run(t, runner) { + const rc = makeRecoupGroup("rc-1", "https://rex/"); + await runner.runReadWriteTx((tx) => tx.upsertRecoupGroup(rc)); + const got = await runner.runReadWriteTx((tx) => + tx.getRecoupGroup("rc-1"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rc)); + const done = makeRecoupGroup("rc-2", "https://rex/"); + done.operationStatus = RecoupOperationStatus.Finished; + await runner.runReadWriteTx((tx) => tx.upsertRecoupGroup(done)); + const byEx = await runner.runReadWriteTx((tx) => + tx.getRecoupGroupsByExchange("https://rex/"), + ); + t.equal(byEx.length, 2); + const active = await runner.runReadWriteTx((tx) => + tx.getActiveRecoupGroups(), + ); + t.ok(active.map((r) => r.recoupGroupId).includes("rc-1")); + t.ok(!active.map((r) => r.recoupGroupId).includes("rc-2")); + await runner.runReadWriteTx((tx) => tx.deleteRecoupGroup("rc-1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getRecoupGroup("rc-1")), + undefined, + ); + }, + }, + + { + name: "peer push debit: round trip and active range", + async run(t, runner) { + const rec = makePeerPushDebit("ppd-1"); + await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getPeerPushDebit(ck("ppd-1")), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + const done = makePeerPushDebit("ppd-2"); + done.status = PeerPushDebitStatus.Done; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(done)); + // The clean-up after an expired purse still has to reclaim the coins, + // so it must be picked up like any other unfinished transaction. + const expiring = makePeerPushDebit("ppd-3"); + expiring.status = PeerPushDebitStatus.ExpiredDeletePurse; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(expiring)); + const active = await runner.runReadWriteTx((tx) => + tx.getActivePeerPushDebits(), + ); + t.ok(active.map((r) => r.pursePub).includes(ck("ppd-1"))); + t.ok(active.map((r) => r.pursePub).includes(ck("ppd-3"))); + t.ok(!active.map((r) => r.pursePub).includes(ck("ppd-2"))); + t.equal( + (await runner.runReadWriteTx((tx) => tx.listAllPeerPushDebits())) + .length, + 3, + ); + await runner.runReadWriteTx((tx) => tx.deletePeerPushDebit(ck("ppd-1"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getPeerPushDebit(ck("ppd-1"))), + undefined, + ); + }, + }, + + { + name: "peer push credit: round trip, contract-priv lookup, active range", + async run(t, runner) { + const rec = makePeerPushCredit("ppc-1"); + rec.contractPriv = ck("cpriv-find-me"); + await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getPeerPushCredit("ppc-1"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + const byPriv = await runner.runReadWriteTx((tx) => + tx.getPeerPushCreditByExchangeAndContractPriv( + rec.exchangeBaseUrl, + ck("cpriv-find-me"), + ), + ); + t.equal(byPriv?.peerPushCreditId, "ppc-1"); + const wrongExchange = await runner.runReadWriteTx((tx) => + tx.getPeerPushCreditByExchangeAndContractPriv( + "https://other/", + ck("cpriv-find-me"), + ), + ); + t.equal(wrongExchange, undefined, "both components must match"); + const done = makePeerPushCredit("ppc-2"); + done.status = PeerPushCreditStatus.Done; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(done)); + const active = await runner.runReadWriteTx((tx) => + tx.getActivePeerPushCredits(), + ); + t.ok(active.map((r) => r.peerPushCreditId).includes("ppc-1")); + t.ok(!active.map((r) => r.peerPushCreditId).includes("ppc-2")); + await runner.runReadWriteTx((tx) => tx.deletePeerPushCredit("ppc-1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getPeerPushCredit("ppc-1")), + undefined, + ); + }, + }, + + { + name: "peer push credit: exchange and contract private key are unique", + async run(t, runner) { + const first = makePeerPushCredit("ppc-unique-1"); + first.contractPriv = ck("shared-push-contract-priv"); + const duplicate = makePeerPushCredit("ppc-unique-2"); + duplicate.contractPriv = first.contractPriv; + await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(first)); + let rejected = false; + try { + await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(duplicate)); + } catch { + rejected = true; + } + t.ok(rejected, "a duplicate payment capability must be rejected"); + const records = await runner.runReadWriteTx((tx) => + tx.listAllPeerPushCredits(), + ); + t.equal(records.length, 1, "the original payment must be retained"); + t.equal(records[0].peerPushCreditId, first.peerPushCreditId); + }, + }, + + { + name: "peer pull debit: round trip, contract-priv lookup, active range", + async run(t, runner) { + const rec = makePeerPullDebit("ppld-1"); + rec.contractPriv = ck("cpriv-pull"); + rec.coinSel = { + coinPubs: [ck("pull-coin-1"), ck("pull-coin-2")], + contributions: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")], + totalCost: amt("TESTKUDOS:3.1"), + depositedCoinCount: 1, + confirmedPurseBalance: amt("TESTKUDOS:1"), + }; + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getPeerPullDebit("ppld-1"), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + const byPriv = await runner.runReadWriteTx((tx) => + tx.getPeerPullDebitByExchangeAndContractPriv( + rec.exchangeBaseUrl, + ck("cpriv-pull"), + ), + ); + t.equal(byPriv?.peerPullDebitId, "ppld-1"); + const done = makePeerPullDebit("ppld-2"); + done.status = PeerPullDebitRecordStatus.Done; + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(done)); + const active = await runner.runReadWriteTx((tx) => + tx.getActivePeerPullDebits(), + ); + t.ok(active.map((r) => r.peerPullDebitId).includes("ppld-1")); + t.ok(!active.map((r) => r.peerPullDebitId).includes("ppld-2")); + t.equal( + (await runner.runReadWriteTx((tx) => tx.listAllPeerPullDebits())) + .length, + 2, + ); + await runner.runReadWriteTx((tx) => tx.deletePeerPullDebit("ppld-1")); + t.equal( + await runner.runReadWriteTx((tx) => tx.getPeerPullDebit("ppld-1")), + undefined, + ); + }, + }, + + { + name: "peer pull debit: exchange and contract private key are unique", + async run(t, runner) { + const first = makePeerPullDebit("ppld-unique-1"); + first.contractPriv = ck("shared-pull-contract-priv"); + const duplicate = makePeerPullDebit("ppld-unique-2"); + duplicate.contractPriv = first.contractPriv; + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(first)); + let rejected = false; + try { + await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(duplicate)); + } catch { + rejected = true; + } + t.ok(rejected, "a duplicate payment capability must be rejected"); + const records = await runner.runReadWriteTx((tx) => + tx.listAllPeerPullDebits(), + ); + t.equal(records.length, 1, "the original payment must be retained"); + t.equal(records[0].peerPullDebitId, first.peerPullDebitId); + }, + }, + + { + name: "peer pull credit: round trip and active range", + async run(t, runner) { + const rec = makePeerPullCredit("pplc-1"); + await runner.runReadWriteTx((tx) => tx.upsertPeerPullCredit(rec)); + const got = await runner.runReadWriteTx((tx) => + tx.getPeerPullCredit(ck("pplc-1")), + ); + t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); + const done = makePeerPullCredit("pplc-2"); + done.status = PeerPullPaymentCreditStatus.Done; + await runner.runReadWriteTx((tx) => tx.upsertPeerPullCredit(done)); + const active = await runner.runReadWriteTx((tx) => + tx.getActivePeerPullCredits(), + ); + 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.runReadWriteTx((tx) => tx.listAllPeerPullCredits())) + .length, + 2, + ); + await runner.runReadWriteTx((tx) => + tx.deletePeerPullCredit(ck("pplc-1")), + ); + t.equal( + await runner.runReadWriteTx((tx) => tx.getPeerPullCredit(ck("pplc-1"))), + undefined, + ); + }, + }, + // ------------------------------------------------------ tokens / slates + + { + name: "token: round trip, including the inherited family fields", + async run(t, runner) { + // WalletToken extends TokenFamilyInfo, so slug/name/description/ + // extraData/tokenIssuePub are part of the record even though they are + // declared in a different interface. Reading the type through only + // its own body once cost five silently dropped columns here. + const tok = makeToken("tk-1"); + await runner.runReadWriteTx((tx) => tx.upsertToken(tok)); + const got = await runner.runReadWriteTx((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); + t.deepEqual(got?.extraData, tok.extraData); + t.deepEqual(got?.tokenEv, tok.tokenEv); + t.equal(got?.blindingKey, tok.blindingKey); + }, + }, + + { + name: "token: lookup by issue pub hash, list and delete", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const a = makeToken("tk-a"); + a.tokenIssuePubHash = ckh("tiph-shared"); + const b = makeToken("tk-b"); + b.tokenIssuePubHash = ckh("tiph-shared"); + const c = makeToken("tk-c"); + c.tokenIssuePubHash = ckh("tiph-other"); + await tx.upsertToken(a); + await tx.upsertToken(b); + await tx.upsertToken(c); + }); + const byHash = await runner.runReadWriteTx((tx) => + tx.getTokensByIssuePubHash(ckh("tiph-shared")), + ); + t.equal(byHash.length, 2); + t.equal((await runner.runReadWriteTx((tx) => tx.listTokens())).length, 3); + await runner.runReadWriteTx((tx) => tx.deleteToken(ck("tk-a"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getToken(ck("tk-a"))), + undefined, + ); + t.equal( + ( + await runner.runReadWriteTx((tx) => + tx.getTokensByIssuePubHash(ckh("tiph-shared")), + ) + ).length, + 1, + ); + }, + }, + + { + name: "token: lookup by family hash", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + const a = makeToken("tk-family-a"); + a.tokenFamilyHash = ckh("family-shared"); + const b = makeToken("tk-family-b"); + b.tokenFamilyHash = ckh("family-shared"); + const c = makeToken("tk-family-c"); + c.tokenFamilyHash = ckh("family-other"); + await tx.upsertToken(a); + await tx.upsertToken(b); + await tx.upsertToken(c); + }); + const got = await runner.runReadWriteTx((tx) => + tx.getTokensByFamilyHash(ckh("family-shared")), + ); + t.deepEqual( + got.map((x) => x.tokenUsePub).sort(), + [ck("tk-family-a"), ck("tk-family-b")].sort(), + ); + }, + }, + + { + name: "slate: addressed by the full (purchase, choice, output, repeat)", + async run(t, runner) { + await runner.runReadWriteTx(async (tx) => { + await tx.upsertSlate(makeSlate("sl-1", "pur-1", 0, 0, 0)); + await tx.upsertSlate(makeSlate("sl-2", "pur-1", 0, 0, 1)); + await tx.upsertSlate(makeSlate("sl-3", "pur-1", 0, 1, 0)); + await tx.upsertSlate(makeSlate("sl-4", "pur-1", 1, 0, 0)); + }); + const one = await runner.runReadWriteTx((tx) => + tx.getSlate("pur-1", 0, 0, 1), + ); + t.equal( + one?.tokenUsePub, + ck("sl-2"), + "all four components must select the slate", + ); + const byChoice = await runner.runReadWriteTx((tx) => + tx.getSlatesByPurchaseAndChoice("pur-1", 0), + ); + t.equal(byChoice.length, 3, "choice 1 must not be included"); + await runner.runReadWriteTx((tx) => tx.deleteSlate(ck("sl-2"))); + t.equal( + await runner.runReadWriteTx((tx) => tx.getSlate("pur-1", 0, 0, 1)), + undefined, + ); + }, + }, + + { + name: "slate: round trip with the use signature set and unset", + async run(t, runner) { + const unsigned = makeSlate("sl-u", "pur-2", 0, 0, 0); + await runner.runReadWriteTx((tx) => tx.upsertSlate(unsigned)); + const gotUnsigned = await runner.runReadWriteTx((tx) => + tx.getSlate("pur-2", 0, 0, 0), + ); + t.deepEqual(withoutUndefined(gotUnsigned), withoutUndefined(unsigned)); + t.equal(gotUnsigned?.tokenUseSig, undefined); + const signed = makeSlate("sl-s", "pur-3", 0, 0, 0); + signed.tokenUseSig = { + token_sig: "tsig", + token_pub: "tpub", + ub_sig: { cipher: DenomKeyType.Rsa, rsa_signature: "usig" }, + h_issue: "hissue", + }; + await runner.runReadWriteTx((tx) => tx.upsertSlate(signed)); + const gotSigned = await runner.runReadWriteTx((tx) => + tx.getSlate("pur-3", 0, 0, 0), + ); + t.deepEqual(gotSigned?.tokenUseSig, signed.tokenUseSig); + }, + }, + { + name: "coin availability: the master public key is part of the identity", + async run(t, runner) { + // The same denomination hash under two master public keys is two + // different denominations, so two different availability rows. Sharing + // one would pool coins the exchange settles with coins it does not. + const a = makeAvail("https://emp/", "d-emp", 0); + a.exchangeMasterPub = ck("master-a"); + a.freshCoinCount = 3; + const b = makeAvail("https://emp/", "d-emp", 0); + b.exchangeMasterPub = ck("master-b"); + b.freshCoinCount = 7; + await runner.runReadWriteTx(async (tx) => { + await tx.upsertCoinAvailability(a); + await tx.upsertCoinAvailability(b); + }); + const gotA = await runner.runReadWriteTx((tx) => + tx.getCoinAvailability({ + exchangeMasterPub: ck("master-a"), + denomPubHash: ckh("d-emp"), + maxAge: 0, + }), + ); + const gotB = await runner.runReadWriteTx((tx) => + tx.getCoinAvailability({ + exchangeMasterPub: ck("master-b"), + denomPubHash: ckh("d-emp"), + maxAge: 0, + }), + ); + t.equal(gotA?.exchangeMasterPub, ck("master-a")); + t.equal(gotB?.exchangeMasterPub, ck("master-b")); + t.equal(gotA?.freshCoinCount, 3); + t.equal(gotB?.freshCoinCount, 7); + }, + }, + { + name: "bounded queries do not scan: limit, cursor and point lookup", + // These are the other places where a correct-looking implementation can + // quietly read the whole table and filter afterwards. The row counter + // is the only way to see it: every one of these returns the right + // answer either way. + async run(t, runner) { + const N = 40; + await runner.runReadWriteTx(async (tx) => { + for (let i = 0; i < N; i++) { + const c = makeCoin(`scan-${i}`); + c.exchangeBaseUrl = "https://scan/"; + c.denomPubHash = ckh("scan-denom"); + c.maxAge = 0; + c.status = CoinStatus.Fresh; + await tx.upsertCoin(c); + await tx.upsertTransactionMeta({ + transactionId: `txn:scan:${String(i).padStart(3, "0")}`, + timestamp: tsPrecise(1000 + i), + status: WithdrawalGroupStatus.Done, + currency: "TESTKUDOS", + exchanges: [], + }); + } + }); + + const measure = async ( + label: string, + limit: number, + f: (tx: WalletDbTransaction) => Promise<unknown>, + ): Promise<void> => { + const before = runner.getAccessStats()?.recordsRead; + await runner.runReadWriteTx(f); + const after = runner.getAccessStats()?.recordsRead; + if (before === undefined || after === undefined) { + return; + } + t.ok( + after - before <= limit, + `${label}: read ${after - before} records, expected at most ${limit}`, + ); + }; + + await measure("getCoin point lookup", 3, (tx) => + tx.getCoin(ck("scan-7")), + ); + await measure("getFreshCoinsByDenomAndAge with limit 5", 8, (tx) => + tx.getFreshCoinsByDenomAndAge( + { + exchangeMasterPub: ck("master-pub"), + denomPubHash: ckh("scan-denom"), + maxAge: 0, + }, + 5, + ), + ); + await measure("listTransactionMetaByTimestamp with limit 5", 8, (tx) => + tx.listTransactionMetaByTimestamp({ limit: 5 }), + ); + await measure("getTransactionMetaAfter", 3, (tx) => + tx.getTransactionMetaAfter(tsPrecise(1010)), + ); + + // Both backends walk the index downwards and stop at the first hit: + // ORDER BY ... DESC LIMIT 1 on sqlite, a "prev" cursor on IndexedDB. + await measure("getTransactionMetaBefore", 3, (tx) => + tx.getTransactionMetaBefore(tsPrecise(1010)), + ); + }, + }, +]; diff --git a/packages/taler-wallet-core/src/db/testing/conformance.test.ts b/packages/taler-wallet-core/src/db/testing/conformance.test.ts @@ -0,0 +1,156 @@ +/* + 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/> + */ + +/** + * Runs the WalletDbTransaction conformance suite against every available + * implementation. + * + * Adding the sqlite3 implementation means adding one runner here; the cases + * themselves do not change. + */ + +import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; +import { CancellationToken } from "@gnu-taler/taler-util"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; +import assert from "node:assert"; +import { test } from "node:test"; +import { Logger } from "@gnu-taler/taler-util"; + +import { openTalerDatabase } from "../indexeddb/database.js"; +import { WalletIndexedDbStoresV1 } from "../indexeddb/schema.js"; +import { DbAccessImpl } from "../query.js"; +import { conformanceCases } from "./conformance-cases.js"; +import { ConformanceAsserts } from "./conformance.js"; +import { runnerFactories } from "./runners.js"; +import { IdbWalletTransaction } from "../indexeddb/transaction.js"; +import { initSqliteWalletDb, SqliteTxControl } from "../sqlite/database.js"; +import { SqliteWalletTransaction } from "../sqlite/transaction.js"; +import { ConfigRecordKey } from "../records.js"; + +const logger = new Logger("db/testing/conformance.test.ts"); + +const asserts: ConformanceAsserts = { + equal: (a, e, m) => assert.strictEqual(a, e, m), + deepEqual: (a, e, m) => assert.deepStrictEqual(a, e, m), + ok: (v, m) => assert.ok(v, m), + fail: (m) => assert.fail(m), +}; + +/** + * A case is reported as skipped, not failed, when the implementation under + * test has not reached that method yet. + */ +function isNotImplemented(e: unknown): boolean { + if (!(e instanceof Error)) return false; + // Either an explicit NotImplementedError, or the method simply does not + // exist on the partial implementation yet. + return ( + /is not implemented yet/.test(e.message) || + /tx\.\w+ is not a function/.test(e.message) + ); +} + +for (const makeRunner of runnerFactories) { + for (const c of conformanceCases) { + test(`dbtx conformance: ${c.name}`, async (t) => { + const runner = await makeRunner(); + try { + await c.run(asserts, runner); + } catch (e) { + if (isNotImplemented(e)) { + t.skip(`not implemented in ${runner.name}`); + return; + } + throw e; + } finally { + await runner.close(); + } + }); + } +} + +for (const makeRunner of runnerFactories) { + test(`dbtx ${makeRunner.name}: notification sink exceptions do not fail committed work`, async () => { + const runner = await makeRunner(); + try { + runner.setNotificationSink(() => { + throw Error("host notification failure"); + }); + await runner.runReadWriteTx(async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 123, + }); + tx.notify({ type: "balance-change" } as any); + }); + const record = await runner.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ); + assert.strictEqual(record?.value, 123); + } finally { + await runner.close(); + } + }); + + test(`dbtx ${makeRunner.name}: import and finalizer are atomic`, async () => { + const source = await makeRunner(); + const target = await makeRunner(); + try { + await source.runReadWriteTx((tx) => + tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 2, + }), + ); + const dump = await source.exportDatabase(); + await target.runReadWriteTx((tx) => + tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 1, + }), + ); + + await assert.rejects( + target.importDatabase(dump, async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 3, + }); + throw Error("injected finalizer failure"); + }), + /injected finalizer failure/, + ); + const afterFailure = await target.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ); + assert.strictEqual(afterFailure?.value, 1); + + await target.importDatabase(dump, async (tx) => { + await tx.upsertConfig({ + key: ConfigRecordKey.TestLoopTx, + value: 3, + }); + }); + const afterSuccess = await target.runReadWriteTx((tx) => + tx.getConfig(ConfigRecordKey.TestLoopTx), + ); + assert.strictEqual(afterSuccess?.value, 3); + } finally { + await source.close(); + await target.close(); + } + }); +} diff --git a/packages/taler-wallet-core/src/db/testing/conformance.ts b/packages/taler-wallet-core/src/db/testing/conformance.ts @@ -0,0 +1,62 @@ +/* + 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/> + */ + +/** + * Conformance suite for {@link WalletDbTransaction}. + * + * These tests describe the contract of the DAL, not the behaviour of any one + * backend. They are written against the interface and parameterised over a + * {@link DbTxRunner}, so the same suite runs against IdbWalletTransaction + * today and against a native sqlite3 implementation later. + * + * Anything backend-specific (how a transaction is opened, how the store is + * created) belongs in the runner, never in a test. + */ + +import { WalletDbTransaction } from "../transaction.js"; +import { WalletDbHandle } from "../handle.js"; + +/** + * The suite runs against a WalletDbHandle -- the same abstraction the wallet + * itself holds, not a parallel one built for tests. A backend that passes the + * suite has therefore been exercised through the interface production code + * uses, including transaction serialisation and post-commit notification. + */ +export type DbTxRunner = WalletDbHandle; + +/** + * A single conformance check. + * + * Kept as plain data so the suite can be enumerated, filtered and reported on + * per implementation, rather than being hard-wired into one test runner. + */ +export interface ConformanceCase { + name: string; + run(t: ConformanceAsserts, runner: DbTxRunner): Promise<void>; +} + +/** + * The assertions a case may use. + * + * Deliberately minimal and framework-agnostic so the suite does not depend on + * node:test, and can be driven from a harness or a browser if needed. + */ +export interface ConformanceAsserts { + equal(actual: unknown, expected: unknown, msg?: string): void; + deepEqual(actual: unknown, expected: unknown, msg?: string): void; + ok(value: unknown, msg?: string): void; + fail(msg: string): never; +} diff --git a/packages/taler-wallet-core/src/db/testing/runners.ts b/packages/taler-wallet-core/src/db/testing/runners.ts @@ -0,0 +1,79 @@ +/* + 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/> + */ + +/** + * Backend runners shared by the conformance suite and the benchmark. + * + * Both must exercise the two implementations through exactly the same setup, + * or a measured difference could be an artefact of how the database was + * opened rather than of the implementation. + */ + +import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; +import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; + +import { DbTxRunner } from "./conformance.js"; +import { IdbWalletDbHandle } from "../indexeddb/handle.js"; +import { openNativeSqliteWalletDb } from "../sqlite/database.js"; +import { SqliteWalletDbHandle } from "../sqlite/handle.js"; + +/** + * Runner for the IndexedDB implementation, on an in-memory sqlite-backed + * BridgeIDB. Each runner gets a fresh database so cases cannot leak into + * each other. + */ +export async function makeIdbRunner( + filename = ":memory:", +): Promise<DbTxRunner> { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const backend = await createSqliteBackend(sqlite3Impl, { + filename, + }); + backend.enableTracing = false; + backend.trackStats = true; + BridgeIDBFactory.enableTracing = false; + const idbFactory = new BridgeIDBFactory(backend); + const handle = new IdbWalletDbHandle( + idbFactory as any, + () => backend.accessStats, + ); + await handle.ensureOpen(); + return handle; +} + +/** + * Runner for the native sqlite3 implementation. + * + * Both runners return the handle the wallet itself uses, so the suite + * exercises transaction serialisation, the shared statement cache, + * checkpoint-on-idle and post-commit notification delivery rather than a + * reimplementation of them. + */ +export async function makeSqliteRunner( + filename = ":memory:", +): Promise<DbTxRunner> { + const sqlite3Impl = await createNodeHelperSqlite3Impl({ + enableTracing: false, + }); + const ndb = await openNativeSqliteWalletDb(await sqlite3Impl.open(filename)); + return new SqliteWalletDbHandle(ndb); +} + +export const runnerFactories: Array< + (filename?: string) => Promise<DbTxRunner> +> = [makeIdbRunner, makeSqliteRunner]; diff --git a/packages/taler-wallet-core/src/db/timestamps.test.ts b/packages/taler-wallet-core/src/db/timestamps.test.ts @@ -0,0 +1,44 @@ +/* + 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/> + */ + +import { TalerProtocolTimestamp } from "@gnu-taler/taler-util"; +import assert from "node:assert"; +import { test } from "node:test"; +import { + DbPreciseTimestamp, + timestampOptionalPreciseFromDb, + timestampPreciseFromDb, + timestampPreciseToDb, + timestampProtocolFromDb, + timestampProtocolToDb, +} from "./timestamps.js"; + +test("database timestamps preserve the never sentinel", () => { + const precise = timestampPreciseFromDb( + timestampPreciseToDb({ t_s: "never" }), + ); + const protocol = timestampProtocolFromDb( + timestampProtocolToDb(TalerProtocolTimestamp.never()), + ); + assert.strictEqual(precise.t_s, "never"); + assert.strictEqual(protocol.t_s, "never"); +}); + +test("an optional precise timestamp preserves the Unix epoch", () => { + const epoch = timestampOptionalPreciseFromDb(0 as DbPreciseTimestamp); + assert.ok(epoch); + assert.strictEqual(epoch.t_s, 0); +}); diff --git a/packages/taler-wallet-core/src/db/timestamps.ts b/packages/taler-wallet-core/src/db/timestamps.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/> + */ + +import { + AbsoluteTime, + TalerPreciseTimestamp, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +declare const symDbProtocolTimestamp: unique symbol; + +declare const symDbPreciseTimestamp: unique symbol; + +/** + * Timestamp, stored as microseconds. + * + * Always rounded to a full second. + */ +export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true }; + +/** Timestamp stored as microseconds, including sub-second precision. */ +export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true }; + +const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER; + +export function timestampPreciseFromDb( + dbTs: DbPreciseTimestamp, +): TalerPreciseTimestamp { + if (dbTs >= DB_TIMESTAMP_FOREVER) { + return { t_s: "never" }; + } + return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); +} + +export function timestampOptionalPreciseFromDb( + dbTs: DbPreciseTimestamp | undefined, +): TalerPreciseTimestamp | undefined { + if (dbTs == null) { + return undefined; + } + return timestampPreciseFromDb(dbTs); +} + +export function timestampPreciseToDb( + stamp: TalerPreciseTimestamp, +): DbPreciseTimestamp { + if (stamp.t_s === "never") { + return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp; + } + let tUs = stamp.t_s * 1000000; + if (stamp.off_us) { + tUs += stamp.off_us; + } + return tUs as DbPreciseTimestamp; +} + +export function timestampProtocolToDb( + stamp: TalerProtocolTimestamp, +): DbProtocolTimestamp { + if (stamp.t_s === "never") { + return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp; + } + return (stamp.t_s * 1000000) as DbProtocolTimestamp; +} + +export function timestampProtocolFromDb( + stamp: DbProtocolTimestamp, +): TalerProtocolTimestamp { + if (stamp >= DB_TIMESTAMP_FOREVER) { + return TalerProtocolTimestamp.never(); + } + return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000)); +} + +export function timestampAbsoluteFromDb( + stamp: DbProtocolTimestamp | DbPreciseTimestamp, +): AbsoluteTime { + if (stamp >= DB_TIMESTAMP_FOREVER) { + return AbsoluteTime.never(); + } + return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000)); +} + +export function timestampOptionalAbsoluteFromDb( + stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined, +): AbsoluteTime | undefined { + if (stamp == null) { + return undefined; + } + if (stamp >= DB_TIMESTAMP_FOREVER) { + return AbsoluteTime.never(); + } + return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000)); +} diff --git a/packages/taler-wallet-core/src/db/transaction.ts b/packages/taler-wallet-core/src/db/transaction.ts @@ -0,0 +1,1294 @@ +/* + 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/> + */ + +/** + * Backend-neutral data access layer for the wallet database. + * + * This file must only contain the {@link WalletDbTransaction} interface and the + * request/result types it uses. It must not depend on any storage backend: + * the IndexedDB implementation lives in indexeddb/transaction.ts, and a sqlite3 + * implementation will be added alongside it. + * + * Types crossing this interface belong in records.ts and are named + * Wallet<Name>. The <Name>Record types in indexeddb/schema.ts describe the + * IndexedDB object stores and must not appear here. + * + * This file imports nothing from indexeddb/schema.ts, not even as types, and + * emits an empty JS module. + */ + +import { + ContactEntry, + CurrencySpecification, + MailboxConfiguration, + MailboxMessageRecord, + ScopeInfo, + TransactionIdStr, + WalletNotification, +} from "@gnu-taler/taler-util"; +import { + ConfigRecord, + WalletPeerPullCredit, + WalletPeerPushDebit, + WalletPeerPushCredit, + WalletPeerPullDebit, + WalletToken, + WalletSlate, + WalletDenomination, + WalletTransactionMeta, + WalletTransactionMetaCursor, + DbPreciseTimestamp, + DbProtocolTimestamp, + WalletOperationRetry, + WalletContractTerms, + DenominationVerificationStatus, + WalletCoinAvailability, + WalletCoinHistory, + WalletCoin, + WalletDepositGroup, + WalletRecoupGroup, + PurchaseStatus, + WalletReserve, + WalletRefreshGroup, + WalletRefreshSession, + WalletWithdrawalGroup, + WalletPlanchet, + WalletDonationSummary, + WalletDonationReceipt, + WalletDonationPlanchet, + DonationReceiptStatus, + WalletPurchase, + WalletRefundGroup, + WalletRefundItem, + WalletTombstone, + WalletExchangeEntry, + WalletDenomLossEvent, + WalletExchangeSignkeys, + WalletDenomFamilyParams, + WalletDenominationFamily, + WalletExchangeBaseUrlFixup, + WalletExchangeMigrationLog, + WalletGlobalCurrencyExchange, + WalletGlobalCurrencyAuditor, + WalletBankAccount, + WalletExchangeDetails, +} from "./records.js"; +/** + * A currency info record with its storage key. + * + * The scope string is kept opaque: stringifyScopeInfo has no exact inverse + * (parseScopeInfoShort reads a different format), so anything that needs to + * enumerate and re-store these records -- the database converter -- must + * round-trip the key without interpreting it. + */ +export interface WalletCurrencyInfoEntry { + scopeInfoStr: string; + currencySpec: CurrencySpecification; + source: "exchange" | "user" | "preset"; +} + +export interface GetCurrencyInfoDbResult { + /** + * Currency specification. + */ + currencySpec: CurrencySpecification; + + /** + * How did the currency info get set? + */ + source: "exchange" | "user" | "preset"; +} + +export interface StoreCurrencyInfoDbRequest { + scopeInfo: ScopeInfo; + currencySpec: CurrencySpecification; + source: "exchange" | "user" | "preset"; +} + +/** + * Record counts for diagnostics. + * + * Named per entity rather than per object store, so the numbers mean the same + * thing whichever backend produced them. + */ +export interface WalletDbRecordCounts { + coins: number; + coinAvailability: number; + denominations: number; + denominationFamilies: number; + exchanges: number; + exchangeDetails: number; + exchangeSignKeys: number; +} + +/** + * What identifies one denomination to the wallet. + * + * Passed as an object rather than as positional strings on purpose. The + * exchange base URL and the denomination hash are both plain strings, so + * every signature that took them in a row accepted them in either order and + * accepted any other string besides -- a wrong argument was a runtime bug + * that looked like a lookup miss. Naming the fields makes it a compile + * error, which is what made moving the identifying field from the exchange's + * URL to the key that signed the denomination a mechanical change. + * + * `WalletCoin`, `WalletCoinAvailability` and `WalletDenomination` all satisfy + * this structurally, so a caller that holds one of those records passes it + * directly. + */ +export interface WalletDenomRef { + exchangeMasterPub: string; + denomPubHash: string; +} + +/** A denomination together with an age restriction, keying availability. */ +export interface WalletCoinAvailabilityRef extends WalletDenomRef { + maxAge: number; +} + +/** Stores participating in backend conversion, named independently of layout. */ +export type WalletDbMigrationStore = + | "config" + | "currencyInfo" + | "contacts" + | "mailboxMessages" + | "mailboxConfigurations" + | "contractTerms" + | "tombstones" + | "operationRetries" + | "bankAccounts" + | "globalCurrencyExchanges" + | "globalCurrencyAuditors" + | "exchangeBaseUrlFixups" + | "exchangeBaseUrlMigrationLog" + | "reserves" + | "exchanges" + | "exchangeDetails" + | "exchangeSignKeys" + | "denominationFamilies" + | "denominations" + | "withdrawalGroups" + | "purchases" + | "refreshGroups" + | "coins" + | "planchets" + | "refreshSessions" + | "coinHistory" + | "coinAvailability" + | "refundGroups" + | "tokens" + | "slates" + | "depositGroups" + | "recoupGroups" + | "denomLossEvents" + | "peerPushDebit" + | "peerPushCredit" + | "peerPullDebit" + | "peerPullCredit" + | "donationSummaries" + | "donationPlanchets" + | "donationReceipts" + | "transactionsMeta" + | "refundItems"; + +export interface WalletDbMigrationPage<T> { + records: T[]; + /** Backend-private continuation token. Absent after an empty page. */ + nextCursor?: unknown; +} + +export interface WalletDbTransaction { + /** + * Read a bounded page for database conversion. + * + * `read` is the ordinary DAL enumeration used to construct records on the + * native backend. IndexedDB can scan its object store directly; the store + * name tells it which physical store corresponds to the neutral entity. + */ + scanMigrationRecords<T>( + store: WalletDbMigrationStore, + read: (tx: WalletDbTransaction) => Promise<T[]>, + cursor: unknown | undefined, + limit: number, + ): Promise<WalletDbMigrationPage<T>>; + + /** Get the currency specification for a scope, if one is stored. */ + getCurrencyInfo( + scopeInfo: ScopeInfo, + ): Promise<GetCurrencyInfoDbResult | undefined>; + + /** Get a config record by key. The result type narrows to the key. */ + getConfig<T extends ConfigRecord["key"]>( + key: T, + ): Promise<Extract<ConfigRecord, { key: T }> | undefined>; + + /** Create or update a config record. */ + upsertConfig(record: ConfigRecord): Promise<void>; + + /** List every config record. */ + listAllConfig(): Promise<ConfigRecord[]>; + + /** + * Store currency info for a scope. + * + * Overrides existing currency infos. + */ + upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void>; + + /** List every currency info record with its storage key. */ + listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]>; + + /** + * Store a currency info record under an existing storage key. + * + * For the converter; regular code uses upsertCurrencyInfo, which derives + * the key from a ScopeInfo. + */ + upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void>; + + /** Store currency info for a scope, keeping any existing entry. */ + insertCurrencyInfoUnlessExists( + req: StoreCurrencyInfoDbRequest, + ): Promise<void>; + + /** Create or update a contact, keyed by (alias, aliasType). */ + addContact(contact: ContactEntry): Promise<void>; + + /** Delete a contact by alias and alias type. */ + deleteContact(alias: string, aliasType: string): Promise<void>; + + /** List all stored contacts. */ + listContacts(): Promise<ContactEntry[]>; + + /** Create or update a mailbox message. */ + upsertMailboxMessage(message: MailboxMessageRecord): Promise<void>; + + /** Delete a mailbox message by origin mailbox and taler URI. */ + deleteMailboxMessage( + originMailboxBaseUrl: string, + talerUri: string, + ): Promise<void>; + + /** List all stored mailbox messages. */ + listMailboxMessages(): Promise<MailboxMessageRecord[]>; + + /** Get the configuration for a mailbox, if one is stored. */ + getMailboxConfiguration( + mailboxBaseUrl: string, + ): Promise<MailboxConfiguration | undefined>; + + /** Create or update a mailbox configuration. */ + upsertMailboxConfiguration(mailboxConf: MailboxConfiguration): Promise<void>; + + /** List every mailbox configuration. */ + listAllMailboxConfigurations(): Promise<MailboxConfiguration[]>; + + /** Get a purchase by proposal ID. */ + getPurchase(proposalId: string): Promise<WalletPurchase | undefined>; + + /** + * Create or update the transaction metadata for a transaction. + * + * The transactionsMeta store is a materialized view over the individual + * transaction stores, used to list transactions efficiently. + */ + upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void>; + + /** + * Look up the locally assigned identifiers for transaction IDs in one + * batch. Backends without efficient local identifiers return an empty map. + */ + getLocalTransactionIdentifiers( + transactionIds: string[], + ): Promise<Map<string, string>>; + + /** Resolve one local transaction identifier, scoped by transaction type. */ + getTransactionIdByLocalIdentifier( + transactionType: string, + localIdent: string, + ): Promise<string | undefined>; + + /** + * Delete the transaction metadata for a transaction. + * + * Called when the underlying transaction record no longer exists. + */ + deleteTransactionMeta(transactionId: string): Promise<void>; + + /** + * Get the transaction metadata for a transaction. + */ + getTransactionMeta( + transactionId: string, + ): Promise<WalletTransactionMeta | undefined>; + + /** + * Get the transaction metadata at exactly the given timestamp, if any. + */ + getTransactionMetaAtTimestamp( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined>; + + /** + * Get the latest transaction metadata at or before the given timestamp. + * + * Used to resolve a pagination offset whose transaction has been deleted. + */ + getTransactionMetaBefore( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined>; + + /** + * Get the earliest transaction metadata at or after the given timestamp. + */ + getTransactionMetaAfter( + timestamp: DbPreciseTimestamp, + ): Promise<WalletTransactionMeta | undefined>; + + /** + * List transaction metadata ordered by timestamp ascending. + * + * If afterTimestamp is given, only records strictly after it are returned. + * If limit is given, at most that many records are returned. + */ + listTransactionMetaByTimestamp(req: { + afterTimestamp?: DbPreciseTimestamp; + limit?: number; + }): Promise<WalletTransactionMeta[]>; + + /** List a bounded page in stable (timestamp, transactionId) order. */ + listTransactionMetaPage(req: { + cursor?: WalletTransactionMetaCursor; + direction: "forward" | "backward"; + limit: number; + }): Promise<WalletTransactionMeta[]>; + + /** + * List all transaction metadata, optionally restricted to transactions in a + * non-final ("active") state. + */ + listTransactionMetaByStatus(req: { + onlyActive: boolean; + }): Promise<WalletTransactionMeta[]>; + + /** + * Delete all transaction metadata. + * + * Used when re-materializing the transactionsMeta view from the underlying + * transaction stores. + */ + deleteAllTransactionMeta(): Promise<void>; + + /** + * Get the retry state of a task, if the task has been retried before. + */ + getOperationRetry(taskId: string): Promise<WalletOperationRetry | undefined>; + + /** + * Create or update the retry state of a task. + */ + upsertOperationRetry(rec: WalletOperationRetry): Promise<void>; + + /** List the retry state of every task that has one. */ + listAllOperationRetries(): Promise<WalletOperationRetry[]>; + + /** + * Clear the retry state of a task. + */ + deleteOperationRetry(taskId: string): Promise<void>; + + /** + * Get downloaded contract terms by their hash. + */ + getContractTerms( + contractTermsHash: string, + ): Promise<WalletContractTerms | undefined>; + + /** + * Store downloaded contract terms. + */ + upsertContractTerms(rec: WalletContractTerms): Promise<void>; + + /** Count an exchange's withdrawal groups, to decide whether it is in use. */ + countWithdrawalGroupsByExchange(exchangeBaseUrl: string): Promise<number>; + + /** Get an exchange's withdrawal groups so their base URL can be rewritten. */ + getWithdrawalGroupsByExchangeForRekey( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]>; + + /** List every globally-trusted exchange entry. */ + listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]>; + + /** + * Add a globally-trusted exchange entry. The row id is generated. + * + * Entries are identified by (currency, exchange base URL, master public + * key), so adding one that is already stored does nothing. + */ + upsertGlobalCurrencyExchange( + rec: WalletGlobalCurrencyExchange, + ): Promise<void>; + + /** Remove a globally-trusted exchange entry by row id. */ + deleteGlobalCurrencyExchange(id: number): Promise<void>; + + /** List every globally-trusted auditor entry. */ + listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]>; + + /** + * Add a globally-trusted auditor entry. The row id is generated. + * + * Entries are identified by (currency, auditor base URL, auditor public + * key), so adding one that is already stored does nothing. + */ + upsertGlobalCurrencyAuditor(rec: WalletGlobalCurrencyAuditor): Promise<void>; + + /** Remove a globally-trusted auditor entry by row id. */ + deleteGlobalCurrencyAuditor(id: number): Promise<void>; + + /** Delete the currency info stored for a scope. */ + deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void>; + + /** Get the global-currency entry for an exchange, if it is trusted globally. */ + getGlobalCurrencyExchange( + currency: string, + exchangeBaseUrl: string, + exchangeMasterPub: string, + ): Promise<WalletGlobalCurrencyExchange | undefined>; + + /** Get the global-currency entry for an auditor, if it is trusted globally. */ + getGlobalCurrencyAuditor( + currency: string, + auditorBaseUrl: string, + auditorPub: string, + ): Promise<WalletGlobalCurrencyAuditor | undefined>; + + /** List every denomination-loss event, in any state. */ + listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]>; + + /** + * Get up to `limit` fresh coins of a given denomination and age restriction. + */ + getFreshCoinsByDenomAndAge( + ref: WalletCoinAvailabilityRef, + limit: number, + ): Promise<WalletCoin[]>; + + /** + * Get coin availability for an exchange across an age-restriction band, + * restricted to denominations that have at least one fresh coin. + */ + getCoinAvailabilityByExchangeAndAgeRange( + exchangeBaseUrl: string, + ageLower: number, + ageUpper: number, + ): Promise<WalletCoinAvailability[]>; + + /** List every known bank account. */ + listBankAccounts(): Promise<WalletBankAccount[]>; + + /** Get a known bank account by its ID. */ + getBankAccount(bankAccountId: string): Promise<WalletBankAccount | undefined>; + + /** Delete a known bank account by its ID. */ + deleteBankAccount(bankAccountId: string): Promise<void>; + + /** Get a known bank account by its payto URI. */ + getBankAccountByPaytoUri( + paytoUri: string, + ): Promise<WalletBankAccount | undefined>; + + /** Create or update a known bank account. */ + upsertBankAccount(rec: WalletBankAccount): Promise<void>; + + /** Count stored records per entity, for diagnostics. */ + getRecordCounts(): Promise<WalletDbRecordCounts>; + + /** List every coin in the wallet. */ + listAllCoins(): Promise<WalletCoin[]>; + + /** Get all coins issued by an exchange. */ + getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]>; + + /** Count the coins issued by an exchange, to decide whether it is in use. */ + countCoinsByExchange(exchangeBaseUrl: string): Promise<number>; + + /** Get the coins of a given denomination. */ + getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]>; + + /** + * Get coins issued with any of the denomination hashes. Each matching coin + * is returned once even when a hash is repeated in the input. + */ + getCoinsByDenomPubHashes(denomPubHashes: string[]): Promise<WalletCoin[]>; + + /** Delete a coin by its public key. */ + deleteCoin(coinPub: string): Promise<void>; + + /** Delete the recorded history of a coin. */ + deleteCoinHistory(coinPub: string): Promise<void>; + + /** Get the coin availability records of an exchange. */ + getCoinAvailabilityByExchange( + exchangeBaseUrl: string, + ): Promise<WalletCoinAvailability[]>; + + /** Delete a coin availability record. */ + deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void>; + + /** Get the recoup groups against an exchange. */ + getRecoupGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletRecoupGroup[]>; + + /** + * List every refresh group, in any state. + * + * Used by exchange purge and base-URL migration, which filter on + * infoPerExchange -- a field with no index. + */ + listAllRefreshGroups(): Promise<WalletRefreshGroup[]>; + + /** + * List every deposit group, in any state. As above, filtered on + * infoPerExchange by the caller. + */ + listAllDepositGroups(): Promise<WalletDepositGroup[]>; + + /** List every refund group, in any state. */ + listAllRefundGroups(): Promise<WalletRefundGroup[]>; + + /** List every withdrawal group, in any state. */ + listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]>; + + /** List every purchase, in any state. */ + listAllPurchases(): Promise<WalletPurchase[]>; + + /** List every incoming peer pull payment, in any state. */ + listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]>; + + /** List every outgoing peer pull payment, in any state. */ + listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]>; + + /** List every incoming peer push payment, in any state. */ + listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]>; + + /** List every outgoing peer push payment, in any state. */ + listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]>; + + /** Look up a denomination family by its value and fee parameters. */ + getDenominationFamilyByParams( + params: WalletDenomFamilyParams, + ): Promise<WalletDenominationFamily | undefined>; + + /** + * Create or update a denomination family, returning its serial. + * + * The denominationFamilies store is auto-incrementing on + * denominationFamilySerial. + */ + upsertDenominationFamily(rec: WalletDenominationFamily): Promise<number>; + + /** Get the denomination families of an exchange. */ + getDenominationFamiliesByExchange( + exchangeBaseUrl: string, + ): Promise<WalletDenominationFamily[]>; + + /** Delete a denomination family by serial. */ + deleteDenominationFamily(denominationFamilySerial: number): Promise<void>; + + /** Get a pending base-URL fixup for an exchange, if one is recorded. */ + getExchangeBaseUrlFixup( + exchangeBaseUrl: string, + ): Promise<WalletExchangeBaseUrlFixup | undefined>; + + /** Record that an exchange base URL should be replaced. */ + upsertExchangeBaseUrlFixup(rec: WalletExchangeBaseUrlFixup): Promise<void>; + + /** List every pending base-URL fixup. */ + listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]>; + + /** Get the log entry for a base-URL migration, if it has run. */ + getExchangeMigrationLog( + oldExchangeBaseUrl: string, + newExchangeBaseUrl: string, + ): Promise<WalletExchangeMigrationLog | undefined>; + + /** Record that a base-URL migration has run. */ + upsertExchangeMigrationLog(rec: WalletExchangeMigrationLog): Promise<void>; + + /** List every base-URL migration log entry. */ + listAllExchangeMigrationLogEntries(): Promise<WalletExchangeMigrationLog[]>; + + /** Get exchange details by the (baseUrl, currency, masterPub) pointer. */ + getExchangeDetailsByPointer( + exchangeBaseUrl: string, + currency: string, + masterPublicKey: string, + ): Promise<WalletExchangeDetails | undefined>; + + /** + * Get the exchange details record for a base URL, if there is exactly one. + */ + getExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined>; + + /** + * Get every exchange details record for a base URL. + */ + listExchangeDetailsByBaseUrl( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails[]>; + + /** + * Get every exchange details record signed by a master public key. + * + * More than one is possible: the same exchange can be known under two base + * URLs while a migration between them is still in progress. + */ + listExchangeDetailsByMasterPub( + masterPublicKey: string, + ): Promise<WalletExchangeDetails[]>; + + /** List every exchange details record, for all exchanges. */ + listAllExchangeDetails(): Promise<WalletExchangeDetails[]>; + + /** Get an exchange details record by its stable row identifier. */ + getExchangeDetailsByRowId( + rowId: number, + ): Promise<WalletExchangeDetails | undefined>; + + /** + * Create or update an exchange details record, returning its row id. + * + * The exchangeDetails store is auto-incrementing on rowId, and callers need + * the generated id to attach sign keys to it. + */ + upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number>; + + /** Delete an exchange details record by row ID. */ + deleteExchangeDetails(rowId: number): Promise<void>; + + /** Get the signing keys attached to an exchange details record. */ + getExchangeSignKeysByDetailsRowId( + exchangeDetailsRowId: number, + ): Promise<WalletExchangeSignkeys[]>; + + /** List every exchange signing key, including orphaned legacy rows. */ + listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]>; + + /** Create or update an exchange signing key. */ + upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void>; + + /** Delete an exchange signing key by details row ID and key. */ + deleteExchangeSignKey( + exchangeDetailsRowId: number, + signkeyPub: string, + ): Promise<void>; + + /** Get a denomination-loss event by ID. */ + getDenomLossEvent( + denomLossEventId: string, + ): Promise<WalletDenomLossEvent | undefined>; + + /** Create or update a denomination-loss event. */ + upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void>; + + /** Delete a denomination-loss event by ID. */ + deleteDenomLossEvent(denomLossEventId: string): Promise<void>; + + /** Get an exchange entry by base URL. */ + getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined>; + + /** Create or update an exchange entry. */ + upsertExchange(rec: WalletExchangeEntry): Promise<void>; + + /** Delete an exchange entry by base URL. Does not touch related records. */ + deleteExchange(baseUrl: string): Promise<void>; + + /** Create or update a purchase. */ + upsertPurchase(rec: WalletPurchase): Promise<void>; + + /** Delete a purchase by proposal ID. */ + deletePurchase(proposalId: string): Promise<void>; + + /** Get the purchase for a merchant order, if there is exactly one. */ + getPurchaseByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase | undefined>; + + /** Get purchases for the requested proposal IDs, skipping missing IDs. */ + getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]>; + + /** Get every purchase for a merchant order, including repurchases. */ + getPurchasesByUrlAndOrderId( + merchantBaseUrl: string, + orderId: string, + ): Promise<WalletPurchase[]>; + + /** Get purchases sharing a fulfillment URL, used to detect repurchases. */ + getPurchasesByFulfillmentUrl( + fulfillmentUrl: string, + ): Promise<WalletPurchase[]>; + + /** Get purchases that involved a given exchange. */ + getPurchasesByExchange(exchangeBaseUrl: string): Promise<WalletPurchase[]>; + + /** Get a refund group by ID. */ + getRefundGroup(refundGroupId: string): Promise<WalletRefundGroup | undefined>; + + /** Create or update a refund group. */ + upsertRefundGroup(rec: WalletRefundGroup): Promise<void>; + + /** Delete a refund group by ID. */ + deleteRefundGroup(refundGroupId: string): Promise<void>; + + /** Get the refund groups belonging to a purchase. */ + getRefundGroupsByProposal(proposalId: string): Promise<WalletRefundGroup[]>; + + /** Get the refund items belonging to a refund group. */ + getRefundItemsByGroup(refundGroupId: string): Promise<WalletRefundItem[]>; + + /** List every refund item, including orphaned legacy rows. */ + listAllRefundItems(): Promise<WalletRefundItem[]>; + + /** + * Create or update a refund item, returning its row id. + * + * The refundItems store is auto-incrementing on id. + */ + upsertRefundItem(rec: WalletRefundItem): Promise<number>; + + /** Delete a refund item by row ID. */ + deleteRefundItem(id: number): Promise<void>; + + /** Get the refund item for a coin and merchant refund transaction ID. */ + getRefundItemByCoinAndRtxid( + coinPub: string, + rtxid: number, + ): Promise<WalletRefundItem | undefined>; + + /** + * Get a slate by purchase, choice, output and repeat index. + */ + getSlate( + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, + ): Promise<WalletSlate | undefined>; + + /** Get the slates for a purchase and contract choice. */ + getSlatesByPurchaseAndChoice( + purchaseId: string, + choiceIndex: number, + ): Promise<WalletSlate[]>; + + /** Create or update a slate. */ + upsertSlate(rec: WalletSlate): Promise<void>; + + /** Delete a slate by its token use public key. */ + deleteSlate(tokenUsePub: string): Promise<void>; + + /** Record a tombstone, marking a deleted transaction as not to be revived. */ + upsertTombstone(rec: WalletTombstone): Promise<void>; + + /** List every tombstone. */ + listAllTombstones(): Promise<WalletTombstone[]>; + + /** Get the donation summary for a donau, year and currency. */ + getDonationSummary( + donauBaseUrl: string, + year: number, + currency: string, + ): Promise<WalletDonationSummary | undefined>; + + /** Create or update a donation summary. */ + upsertDonationSummary(rec: WalletDonationSummary): Promise<void>; + + /** Get a donation receipt by its unique donation identifier nonce. */ + getDonationReceipt( + udiNonce: string, + ): Promise<WalletDonationReceipt | undefined>; + + /** Create or update a donation receipt. */ + upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void>; + + /** Get donation receipts in a given status. */ + getDonationReceiptsByStatus( + status: DonationReceiptStatus, + ): Promise<WalletDonationReceipt[]>; + + /** Get donation receipts in a given status for one donau. */ + getDonationReceiptsByStatusAndDonau( + status: DonationReceiptStatus, + donauBaseUrl: string, + ): Promise<WalletDonationReceipt[]>; + + /** Create or update a donation planchet. */ + upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void>; + + /** Get the donation planchets of a purchase. */ + getDonationPlanchetsByProposal( + proposalId: string, + ): Promise<WalletDonationPlanchet[]>; + + /** Count the donation planchets of a purchase. */ + countDonationPlanchetsByProposal(proposalId: string): Promise<number>; + + /** Get a withdrawal group by ID. */ + getWithdrawalGroup( + withdrawalGroupId: string, + ): Promise<WalletWithdrawalGroup | undefined>; + + /** Create or update a withdrawal group. */ + upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void>; + + /** Delete a withdrawal group by ID. */ + deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void>; + + /** Get the withdrawal group for a taler-withdraw URI, for idempotent starts. */ + getWithdrawalGroupByTalerWithdrawUri( + talerWithdrawUri: string, + ): Promise<WalletWithdrawalGroup | undefined>; + + /** Get the withdrawal groups against an exchange. */ + getWithdrawalGroupsByExchange( + exchangeBaseUrl: string, + ): Promise<WalletWithdrawalGroup[]>; + + /** + * Get a planchet by its withdrawal group and coin index. + */ + getPlanchetByGroupAndIndex( + withdrawalGroupId: string, + coinIdx: number, + ): Promise<WalletPlanchet | undefined>; + + /** + * Get a planchet by its coin public key, which is the primary key. + */ + getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined>; + + /** Create or update a planchet. */ + upsertPlanchet(rec: WalletPlanchet): Promise<void>; + + /** Delete a planchet by coin public key. */ + deletePlanchet(coinPub: string): Promise<void>; + + /** Get the planchets of a withdrawal group. */ + getPlanchetsByGroup(withdrawalGroupId: string): Promise<WalletPlanchet[]>; + + /** List every planchet, including orphaned legacy rows. */ + listAllPlanchets(): Promise<WalletPlanchet[]>; + + /** Count the planchets of a withdrawal group. */ + countPlanchetsByGroup(withdrawalGroupId: string): Promise<number>; + + /** + * Delete every planchet belonging to a withdrawal group. + */ + deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void>; + + /** Get a refresh group by ID. */ + getRefreshGroup( + refreshGroupId: string, + ): Promise<WalletRefreshGroup | undefined>; + + /** Create or update a refresh group. */ + upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void>; + + /** Delete a refresh group by ID. */ + deleteRefreshGroup(refreshGroupId: string): Promise<void>; + + /** Get the refresh groups spawned by a transaction. */ + getRefreshGroupsByOriginatingTransaction( + transactionId: string, + ): Promise<WalletRefreshGroup[]>; + + /** Get the refresh session for a group and coin index. */ + getRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<WalletRefreshSession | undefined>; + + /** Create or update a refresh session. */ + upsertRefreshSession(rec: WalletRefreshSession): Promise<void>; + + /** Delete the refresh session for a group and coin index. */ + deleteRefreshSession( + refreshGroupId: string, + coinIndex: number, + ): Promise<void>; + + /** Get the refresh sessions of a group. */ + getRefreshSessionsByGroup( + refreshGroupId: string, + ): Promise<WalletRefreshSession[]>; + + /** List every refresh session, including orphaned legacy rows. */ + listAllRefreshSessions(): Promise<WalletRefreshSession[]>; + + /** Get a recoup group by ID. */ + getRecoupGroup(recoupGroupId: string): Promise<WalletRecoupGroup | undefined>; + + /** Create or update a recoup group. */ + upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void>; + + /** Delete a recoup group by ID. */ + deleteRecoupGroup(recoupGroupId: string): Promise<void>; + + /** + * Get a reserve by its row id. + */ + getReserve(reserveRowId: number): Promise<WalletReserve | undefined>; + + /** + * Get a reserve by its reserve public key. + */ + getReserveByReservePub( + reservePub: string, + ): Promise<WalletReserve | undefined>; + + /** Get reserves for the requested public keys, skipping missing keys. */ + getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]>; + + /** + * Create or update a reserve, returning its row id. + * + * The reserves store is auto-incrementing, and callers creating a new merge + * reserve need the generated id to reference it from the exchange entry. + */ + upsertReserve(rec: WalletReserve): Promise<number>; + + /** Get a deposit group by ID. */ + getDepositGroup( + depositGroupId: string, + ): Promise<WalletDepositGroup | undefined>; + + /** Create or update a deposit group. */ + upsertDepositGroup(rec: WalletDepositGroup): Promise<void>; + + /** Delete a deposit group by ID. */ + deleteDepositGroup(depositGroupId: string): Promise<void>; + + /** Get a coin by its public key. */ + getCoin(coinPub: string): Promise<WalletCoin | undefined>; + + /** Create or update a coin. */ + upsertCoin(coin: WalletCoin): Promise<void>; + + /** + * Get all coins whose source transaction is the given transaction. + */ + getCoinsBySourceTransaction(transactionId: string): Promise<WalletCoin[]>; + + /** Get the availability record for a denomination and age restriction. */ + getCoinAvailability( + ref: WalletCoinAvailabilityRef, + ): Promise<WalletCoinAvailability | undefined>; + + /** + * Get availability records for a list of denomination/age references. + * + * Found records follow input order, duplicates are preserved and missing + * references are skipped. + */ + getCoinAvailabilitiesByRefs( + refs: WalletCoinAvailabilityRef[], + ): Promise<WalletCoinAvailability[]>; + + /** Create or update a coin availability record. */ + upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void>; + + /** Get the recorded history of a coin. */ + getCoinHistory(coinPub: string): Promise<WalletCoinHistory | undefined>; + + /** + * Get histories for a list of coin public keys, in input order, skipping + * missing records and preserving duplicates. + */ + getCoinHistoriesByPubs(coinPubs: string[]): Promise<WalletCoinHistory[]>; + + /** List every coin history, including orphaned legacy rows. */ + listAllCoinHistories(): Promise<WalletCoinHistory[]>; + + /** Create or update the recorded history of a coin. */ + upsertCoinHistory(rec: WalletCoinHistory): Promise<void>; + + /** + * List all stored wallet tokens. + */ + listTokens(): Promise<WalletToken[]>; + + /** List every slate. */ + listAllSlates(): Promise<WalletSlate[]>; + + /** + * Get a wallet token by its token use public key. + */ + getToken(tokenUsePub: string): Promise<WalletToken | undefined>; + + /** + * Create or update a wallet token. + */ + upsertToken(token: WalletToken): Promise<void>; + + /** + * Delete a wallet token by its token use public key. + */ + deleteToken(tokenUsePub: string): Promise<void>; + + /** + * Get all tokens matching a specific token issue public key hash. + */ + getTokensByIssuePubHash(tokenIssuePubHash: string): Promise<WalletToken[]>; + + /** Get all tokens belonging to a token family. */ + getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]>; + + /** + * Get an incoming peer pull payment (credit) record by purse public key. + */ + getPeerPullCredit( + pursePub: string, + ): Promise<WalletPeerPullCredit | undefined>; + + /** + * Create or update an incoming peer pull payment (credit) record. + */ + upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void>; + + /** + * Delete an incoming peer pull payment (credit) record. + */ + deletePeerPullCredit(pursePub: string): Promise<void>; + + /** + * Get an outgoing peer push payment (debit) record by purse public key. + */ + getPeerPushDebit(pursePub: string): Promise<WalletPeerPushDebit | undefined>; + + /** + * Create or update an outgoing peer push payment (debit) record. + */ + upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void>; + + /** + * Delete an outgoing peer push payment (debit) record. + */ + deletePeerPushDebit(pursePub: string): Promise<void>; + + /** + * Get an incoming peer push payment (credit) record by peer push credit ID. + */ + getPeerPushCredit( + peerPushCreditId: string, + ): Promise<WalletPeerPushCredit | undefined>; + + /** + * Create or update an incoming peer push payment (credit) record. + */ + upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void>; + + /** + * Delete an incoming peer push payment (credit) record. + */ + deletePeerPushCredit(peerPushCreditId: string): Promise<void>; + + /** + * Get an incoming peer push payment (credit) record by exchange URL and contract private key. + */ + getPeerPushCreditByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPushCredit | undefined>; + + /** + * Get an incoming peer pull payment (debit) record by peer pull debit ID. + */ + getPeerPullDebit( + peerPullDebitId: string, + ): Promise<WalletPeerPullDebit | undefined>; + + /** + * Create or update an incoming peer pull payment (debit) record. + */ + upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void>; + + /** + * Delete an incoming peer pull payment (debit) record. + */ + deletePeerPullDebit(peerPullDebitId: string): Promise<void>; + + /** + * Get an incoming peer pull payment (debit) record by exchange URL and contract private key. + */ + getPeerPullDebitByExchangeAndContractPriv( + exchangeBaseUrl: string, + contractPriv: string, + ): Promise<WalletPeerPullDebit | undefined>; + + /** Create or update a denomination. */ + upsertDenomination(rec: WalletDenomination): Promise<void>; + + /** Get a denomination by its reference. */ + getDenomination(ref: WalletDenomRef): Promise<WalletDenomination | undefined>; + + /** + * Get denominations for a list of references. Found records follow input + * order, duplicates are preserved and missing references are skipped. + */ + getDenominationsByRefs(refs: WalletDenomRef[]): Promise<WalletDenomination[]>; + + /** + * Find the first denomination of a family, scanning in withdraw-expiry order + * from the given timestamp, that satisfies the caller's predicate. + * + * The scan stops at the first match, so a family with many denominations + * normally costs a single record read. The predicate stays with the caller; + * only the ordered, early-terminating scan lives in the implementation. + */ + findDenominationByFamilyFromExpiry( + denominationFamilySerial: number, + minStampExpireWithdraw: DbProtocolTimestamp, + match: (d: WalletDenomination) => boolean, + ): Promise<WalletDenomination | undefined>; + + /** Get every denomination signed by a master public key. */ + getDenominationsByMasterPub( + exchangeMasterPub: string, + ): Promise<WalletDenomination[]>; + + /** Delete a denomination by its reference. */ + deleteDenomination(ref: WalletDenomRef): Promise<void>; + + /** Get denominations awaiting or failing signature verification. */ + getDenominationsByVerificationStatus( + verificationStatus: DenominationVerificationStatus, + ): Promise<WalletDenomination[]>; + + /** List all donation summaries. */ + getDonationSummaries(): Promise<WalletDonationSummary[]>; + + /** List all exchange entries. */ + getExchanges(): Promise<WalletExchangeEntry[]>; + + /** List all coin availability records. */ + getCoinAvailabilities(): Promise<WalletCoinAvailability[]>; + + /** List every reserve. */ + listAllReserves(): Promise<WalletReserve[]>; + + /** List every recoup group. */ + listAllRecoupGroups(): Promise<WalletRecoupGroup[]>; + + /** List every donation planchet. */ + listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]>; + + /** List every donation receipt. */ + listAllDonationReceipts(): Promise<WalletDonationReceipt[]>; + + /** List every denomination family. */ + listAllDenominationFamilies(): Promise<WalletDenominationFamily[]>; + + /** List every denomination. */ + listAllDenominations(): Promise<WalletDenomination[]>; + + /** List every stored contract-terms record. */ + listAllContractTerms(): Promise<WalletContractTerms[]>; + + /** Get refresh groups in a non-final state. */ + getActiveRefreshGroups(): Promise<WalletRefreshGroup[]>; + + /** Get withdrawal groups in a non-final state. */ + getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]>; + + /** Get outgoing peer push payments in a non-final state. */ + getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]>; + + /** Get incoming peer push payments in a non-final state. */ + getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]>; + + /** Get incoming peer pull payments in a non-final state. */ + getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]>; + + /** Get outgoing peer pull payments in a non-final state. */ + getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]>; + + /** Get recoup groups in a non-final state. */ + getActiveRecoupGroups(): Promise<WalletRecoupGroup[]>; + + /** + * Get all purchases in a specific status. + */ + getPurchasesByStatus(status: PurchaseStatus): Promise<WalletPurchase[]>; + + /** Get purchases in a non-final state. */ + getActivePurchases(): Promise<WalletPurchase[]>; + + /** Get the coins for a list of public keys, skipping any that are missing. */ + getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]>; + + /** Get deposit groups in a non-final state. */ + getActiveDepositGroups(): Promise<WalletDepositGroup[]>; + + /** Get the details currently pointed to by an exchange entry. */ + getExchangeDetails( + exchangeBaseUrl: string, + ): Promise<WalletExchangeDetails | undefined>; + + /** + * Check whether an exchange falls within a currency scope. + * + * For auditor scopes, a denomination hash requests exact membership. When + * omitted, the check is existential and is only suitable for candidate + * exchange filtering. + */ + checkExchangeInScope( + exchangeBaseUrl: string, + scope: ScopeInfo, + denomPubHash?: string, + ): Promise<boolean>; + + /** + * Compute the scope (global, auditor or exchange) for exchange funds. + * Auditor scope is returned only with a specifically attested denomination. + */ + getExchangeScopeInfo( + exchangeBaseUrl: string, + currency: string, + denomPubHash?: string, + ): Promise<ScopeInfo>; + + /** + * Run a callback once this transaction has committed. + * + * Used to trigger work that must not run inside the transaction, such as + * starting or stopping a shepherd task. + */ + scheduleOnCommit(f: () => void): void; + + /** + * Emit a wallet notification. + * + * Bound to the instance, so it is safe to pass around unbound. + */ + notify(notif: WalletNotification): void; +} diff --git a/packages/taler-wallet-core/src/dbtx-bench.test.ts b/packages/taler-wallet-core/src/dbtx-bench.test.ts @@ -1,49 +0,0 @@ -/* - 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/> - */ - -import assert from "node:assert"; -import { test } from "node:test"; - -import { benchmarkOneBackend, DbBenchOptions } from "./dbtx-bench.js"; -import { runnerFactories } from "./dbtx-runners.js"; - -test("database benchmark smoke test returns real, comparable rows", async () => { - const options: DbBenchOptions = { - numCoins: 120, - numDenominations: 12, - numExchanges: 2, - repeats: 1, - }; - const results = []; - for (const makeRunner of runnerFactories) { - const runner = await makeRunner(); - try { - results.push(await benchmarkOneBackend(runner, options)); - } finally { - await runner.close(); - } - } - - assert.strictEqual(results.length, 2); - assert.deepStrictEqual( - results[0].queries.map((query) => [query.name, query.rows]), - results[1].queries.map((query) => [query.name, query.rows]), - ); - const freshQuery = results[0].queries.find((query) => - query.name.startsWith("getFreshCoinsByDenomAndAge"), - ); - assert.ok(freshQuery && freshQuery.rows > 0); -}); diff --git a/packages/taler-wallet-core/src/dbtx-bench.ts b/packages/taler-wallet-core/src/dbtx-bench.ts @@ -1,556 +0,0 @@ -/* - 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/> - */ - -/** - * Benchmark for {@link WalletDbTransaction} implementations. - * - * Populates a synthetic wallet of a given size and times the queries the - * wallet actually runs on its hot paths, against every backend. - * - * This exists because the integration suite cannot answer questions about the - * storage layer. Its timings are dominated by network long-polls -- the task - * shepherd sleeps 10s whenever a long-poller returns sooner than 20s, and both - * backends hit that in some runs -- so a storage change of a few per cent is - * invisible under that noise. It also never builds a wallet large enough for - * index size to matter. This benchmark touches no services and no scheduler. - * - * Every measurement is reported as a median of repeated runs, not a single - * sample: single samples of this system have repeatedly proved misleading. - */ - -import { - AmountString, - CoinStatus, - DenomKeyType, - encodeCrock, - Logger, -} from "@gnu-taler/taler-util"; -import { - CoinSourceType, - DenominationVerificationStatus, - DbProtocolTimestamp, - WalletCoin, - WalletCoinAvailability, - WalletDenomination, - WalletDenominationFamily, -} from "./db-common.js"; -import { DbTxRunner } from "./dbtx-conformance.js"; - -const logger = new Logger("dbtx-bench.ts"); - -export interface DbBenchOptions { - /** Coins to insert. The default is small enough to run in seconds. */ - numCoins: number; - /** Denominations to spread those coins over. */ - numDenominations: number; - /** Exchanges to spread the denominations over. */ - numExchanges: number; - /** How many times each query is repeated; the median is reported. */ - repeats: number; -} - -export const defaultDbBenchOptions: DbBenchOptions = { - numCoins: 20000, - numDenominations: 200, - numExchanges: 3, - repeats: 5, -}; - -export interface DbBenchQueryResult { - name: string; - /** Median wall-clock time over `repeats` runs. */ - medianMs: number; - minMs: number; - maxMs: number; - /** Rows the query returned, to catch a "fast because it found nothing". */ - rows: number; -} - -export interface DbBenchResult { - backend: string; - options: DbBenchOptions; - populateMs: number; - /** Size of the database on disk, when it is file-backed. */ - dbSizeBytes?: number; - queries: DbBenchQueryResult[]; -} - -function median(xs: number[]): number { - const s = [...xs].sort((a, b) => a - b); - const mid = Math.floor(s.length / 2); - return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; -} - -/** - * Deterministic Crockford value of the right length. - * - * 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, 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 - // keys for 500 coins -- every insert after the fourth was an upsert over an - // existing row, and the benchmark then measured a nearly empty database. - let h = 2166136261; - for (let i = 0; i < seed.length; i++) { - h = Math.imul(h ^ seed.charCodeAt(i), 16777619); - } - for (let i = 0; i < numBytes; i++) { - h = Math.imul(h ^ (h >>> 15), 2246822519); - h = (h ^ (h >>> 13)) >>> 0; - bytes[i] = h & 0xff; - } - return encodeCrock(bytes); -} - -const key = (s: string) => crockLike(s, 32); -const hash = (s: string) => crockLike(s, 64); - -function exchangeUrl(i: number): string { - return `https://exchange-${i}.test/`; -} - -async function populate( - runner: DbTxRunner, - opts: DbBenchOptions, -): Promise<void> { - // Denominations and their availability rows. - await runner.runReadWriteTx(async (tx) => { - for (let e = 0; e < opts.numExchanges; e++) { - const family: WalletDenominationFamily = { - denominationFamilySerial: e + 1, - familyParams: { - exchangeBaseUrl: exchangeUrl(e), - exchangeMasterPub: key(`master-${e}`), - value: "TESTKUDOS:1" as AmountString, - feeDeposit: "TESTKUDOS:0.01" as AmountString, - feeRefresh: "TESTKUDOS:0.01" as AmountString, - feeRefund: "TESTKUDOS:0.01" as AmountString, - feeWithdraw: "TESTKUDOS:0.01" as AmountString, - }, - }; - await tx.upsertDenominationFamily(family); - } - for (let d = 0; d < opts.numDenominations; d++) { - const ex = exchangeUrl(d % opts.numExchanges); - const dph = hash(`denom-${d}`); - const denom: WalletDenomination = { - denomPubHash: dph, - denomPub: { - cipher: DenomKeyType.Rsa, - rsa_public_key: `rsa-${d}`, - age_mask: 0, - }, - exchangeBaseUrl: ex, - exchangeMasterPub: key(`master-${d % opts.numExchanges}`), - currency: "TESTKUDOS", - value: "TESTKUDOS:1" as AmountString, - denominationFamilySerial: (d % opts.numExchanges) + 1, - stampStart: (1000 + d) as DbProtocolTimestamp, - stampExpireWithdraw: (2000 + d) as DbProtocolTimestamp, - stampExpireDeposit: (3000 + d) as DbProtocolTimestamp, - stampExpireLegal: (4000 + d) as DbProtocolTimestamp, - fees: { - feeDeposit: "TESTKUDOS:0.01" as AmountString, - feeRefresh: "TESTKUDOS:0.01" as AmountString, - feeRefund: "TESTKUDOS:0.01" as AmountString, - feeWithdraw: "TESTKUDOS:0.01" as AmountString, - }, - isOffered: true, - isRevoked: false, - masterSig: hash(`msig-${d}`), - verificationStatus: DenominationVerificationStatus.VerifiedGood, - }; - await tx.upsertDenomination(denom); - const avail: WalletCoinAvailability = { - exchangeBaseUrl: ex, - exchangeMasterPub: key(`master-${d % opts.numExchanges}`), - denomPubHash: dph, - maxAge: d % 2 === 0 ? 0 : 21, - currency: "TESTKUDOS", - value: "TESTKUDOS:1" as AmountString, - freshCoinCount: 10, - hasFreshCoins: 1, - visibleCoinCount: 10, - }; - await tx.upsertCoinAvailability(avail); - } - }); - - // Coins, in batches so a single transaction does not grow unbounded. - const batch = 2000; - for (let start = 0; start < opts.numCoins; start += batch) { - await runner.runReadWriteTx(async (tx) => { - for (let i = start; i < Math.min(start + batch, opts.numCoins); i++) { - const d = i % opts.numDenominations; - const coin: WalletCoin = { - coinPub: key(`coin-${i}`), - coinPriv: key(`coinpriv-${i}`), - exchangeBaseUrl: exchangeUrl(d % opts.numExchanges), - exchangeMasterPub: key(`master-${d % opts.numExchanges}`), - denomPubHash: hash(`denom-${d}`), - denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: `sig-${i}` }, - blindingKey: key(`bk-${i}`), - exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, - coinEvHash: hash(`evh-${i}`), - // Derived from the row *within* a denomination, not from i: with - // `i % 4` the dormant coins land on multiples of 4, which for many - // denomination counts means every coin of some denominations is - // dormant and the fresh-coin query then measures an empty result. - status: - Math.floor(i / opts.numDenominations) % 4 === 0 - ? CoinStatus.Dormant - : CoinStatus.Fresh, - maxAge: d % 2 === 0 ? 0 : 21, - ageCommitmentProof: undefined, - coinSource: { - type: CoinSourceType.Withdraw, - withdrawalGroupId: key(`wg-${i % 100}`), - coinIndex: i, - reservePub: key(`rp-${i % 100}`), - }, - }; - await tx.upsertCoin(coin); - } - }); - } -} - -/** - * Run the benchmark against one already-populated runner. - */ -async function measure( - runner: DbTxRunner, - opts: DbBenchOptions, -): Promise<DbBenchQueryResult[]> { - const results: DbBenchQueryResult[] = []; - - const time = async ( - name: string, - expectedRows: number, - f: () => Promise<number>, - ): Promise<void> => { - const samples: number[] = []; - let rows = 0; - for (let i = 0; i < opts.repeats; i++) { - const t0 = performance.now(); - rows = await f(); - samples.push(performance.now() - t0); - if (rows !== expectedRows) { - throw Error( - `benchmark query ${name} returned ${rows} rows, expected ${expectedRows}`, - ); - } - } - results.push({ - name, - medianMs: median(samples), - minMs: Math.min(...samples), - maxMs: Math.max(...samples), - rows, - }); - }; - - // A point lookup on the primary key, the single most common operation. - const someCoin = key(`coin-${Math.floor(opts.numCoins / 2)}`); - await time("getCoin (point lookup)", 1, async () => - runner.runReadWriteTx(async (tx) => ((await tx.getCoin(someCoin)) ? 1 : 0)), - ); - - // Batch lookup: this is the shape refresh uses, and was an N+1 until - // recently, so it is worth keeping an eye on. - const pubs: string[] = []; - for (let i = 0; i < Math.min(200, opts.numCoins); i++) { - pubs.push(key(`coin-${i}`)); - } - await time("getCoinsByPubs (200)", pubs.length, async () => - runner.runReadWriteTx(async (tx) => (await tx.getCoinsByPubs(pubs)).length), - ); - - const denomRefs = Array.from( - { length: Math.min(200, opts.numDenominations) }, - (_, d) => ({ - exchangeMasterPub: key(`master-${d % opts.numExchanges}`), - denomPubHash: hash(`denom-${d}`), - }), - ); - await time("getDenominationsByRefs (200)", denomRefs.length, async () => - runner.runReadWriteTx( - async (tx) => (await tx.getDenominationsByRefs(denomRefs)).length, - ), - ); - - const availabilityRefs = denomRefs.map((ref, d) => ({ - ...ref, - maxAge: d % 2 === 0 ? 0 : 21, - })); - await time( - "getCoinAvailabilitiesByRefs (200)", - availabilityRefs.length, - async () => - runner.runReadWriteTx( - async (tx) => - (await tx.getCoinAvailabilitiesByRefs(availabilityRefs)).length, - ), - ); - - const countCoinsForExchange = (exchangeIndex: number): number => { - let count = 0; - for (let i = 0; i < opts.numCoins; i++) { - if ((i % opts.numDenominations) % opts.numExchanges === exchangeIndex) { - count++; - } - } - return count; - }; - const coinsAtExchangeZero = countCoinsForExchange(0); - - await time("getCoinsByExchange", coinsAtExchangeZero, async () => - runner.runReadWriteTx( - async (tx) => (await tx.getCoinsByExchange(exchangeUrl(0))).length, - ), - ); - - await time("countCoinsByExchange", coinsAtExchangeZero, async () => - runner.runReadWriteTx(async (tx) => - tx.countCoinsByExchange(exchangeUrl(0)), - ), - ); - - const coinsForDenomZero = - Math.floor((opts.numCoins - 1) / opts.numDenominations) + 1; - await time("getCoinsByDenomPubHash", coinsForDenomZero, async () => - runner.runReadWriteTx( - async (tx) => (await tx.getCoinsByDenomPubHash(hash("denom-0"))).length, - ), - ); - - const denomHashes = Array.from({ length: opts.numDenominations }, (_, d) => - hash(`denom-${d}`), - ); - await time("getCoinsByDenomPubHashes", opts.numCoins, async () => - runner.runReadWriteTx( - async (tx) => (await tx.getCoinsByDenomPubHashes(denomHashes)).length, - ), - ); - - // Indexed multi-column lookup with a limit -- coin selection's hot path. - let freshCoinsForDenomZero = 0; - for (let i = 0; i < opts.numCoins; i += opts.numDenominations) { - if (Math.floor(i / opts.numDenominations) % 4 !== 0) { - freshCoinsForDenomZero++; - } - } - await time( - "getFreshCoinsByDenomAndAge (limit 10)", - Math.min(10, freshCoinsForDenomZero), - async () => - runner.runReadWriteTx( - async (tx) => - ( - await tx.getFreshCoinsByDenomAndAge( - { - exchangeMasterPub: key("master-0"), - denomPubHash: hash("denom-0"), - maxAge: 0, - }, - 10, - ) - ).length, - ), - ); - - const denomsAtExchangeZero = - Math.floor((opts.numDenominations - 1) / opts.numExchanges) + 1; - await time( - "getCoinAvailabilityByExchangeAndAgeRange", - denomsAtExchangeZero, - async () => - runner.runReadWriteTx( - async (tx) => - ( - await tx.getCoinAvailabilityByExchangeAndAgeRange( - exchangeUrl(0), - 0, - 21, - ) - ).length, - ), - ); - - // The early-terminating keyset scan. Deliberately matches nothing until - // late, so a backend that materialises the whole family shows up here. - await time("findDenominationByFamilyFromExpiry", 1, async () => - runner.runReadWriteTx(async (tx) => { - const found = await tx.findDenominationByFamilyFromExpiry( - 1, - 0 as DbProtocolTimestamp, - () => true, - ); - return found ? 1 : 0; - }), - ); - - await time("getDenominationsByMasterPub", denomsAtExchangeZero, async () => - runner.runReadWriteTx( - async (tx) => - (await tx.getDenominationsByMasterPub(key("master-0"))).length, - ), - ); - - // Full scans: the wallet does these on balance computation and purge. - await time("listAllCoins (full scan)", opts.numCoins, async () => - runner.runReadWriteTx(async (tx) => (await tx.listAllCoins()).length), - ); - - await time( - "getCoinAvailabilities (full scan)", - opts.numDenominations, - async () => - runner.runReadWriteTx( - async (tx) => (await tx.getCoinAvailabilities()).length, - ), - ); - - // A write-heavy transaction, to keep an eye on commit cost. - const numUpserts = Math.min(100, opts.numCoins); - await time("upsertCoin x100 (one tx)", numUpserts, async () => - runner.runReadWriteTx(async (tx) => { - let updated = 0; - for (let i = 0; i < numUpserts; i++) { - const coin = await tx.getCoin(key(`coin-${i}`)); - if (coin) { - coin.status = - coin.status === CoinStatus.Fresh - ? CoinStatus.Dormant - : CoinStatus.Fresh; - await tx.upsertCoin(coin); - updated++; - } - } - return updated; - }), - ); - - return results; -} - -/** - * Populate and measure one backend. - */ -export async function benchmarkOneBackend( - runner: DbTxRunner, - opts: DbBenchOptions, - dbSizeBytes?: () => number | undefined, -): Promise<DbBenchResult> { - if ( - !Number.isSafeInteger(opts.numCoins) || - opts.numCoins <= 0 || - !Number.isSafeInteger(opts.numDenominations) || - opts.numDenominations <= 0 || - !Number.isSafeInteger(opts.numExchanges) || - opts.numExchanges <= 0 || - !Number.isSafeInteger(opts.repeats) || - opts.repeats <= 0 - ) { - throw Error("benchmark options must be positive safe integers"); - } - logger.info(`populating ${runner.name}: ${opts.numCoins} coins`); - const t0 = performance.now(); - await populate(runner, opts); - const populateMs = performance.now() - t0; - logger.info(`populated in ${populateMs.toFixed(0)} ms, measuring`); - - // Verify the database really holds what was asked for. A benchmark on a - // database that silently failed to populate reports beautiful numbers for - // queries that match nothing, which is worse than no benchmark at all. - const actualCoins = await runner.runReadWriteTx( - async (tx) => (await tx.listAllCoins()).length, - ); - if (actualCoins !== opts.numCoins) { - throw Error( - `benchmark population is wrong: asked for ${opts.numCoins} coins, ` + - `database holds ${actualCoins}. Refusing to report timings.`, - ); - } - const queries = await measure(runner, opts); - return { - backend: runner.name, - options: opts, - populateMs, - dbSizeBytes: dbSizeBytes?.(), - queries, - }; -} - -/** - * Render results as a table, with the second and later backends shown - * relative to the first. - */ -export function formatDbBenchResults(results: DbBenchResult[]): string { - const lines: string[] = []; - const base = results[0]; - lines.push(""); - lines.push( - `wallet DB benchmark: ${base.options.numCoins} coins, ` + - `${base.options.numDenominations} denominations, ` + - `${base.options.numExchanges} exchanges, ` + - `median of ${base.options.repeats}`, - ); - lines.push(""); - for (const r of results) { - const size = - r.dbSizeBytes != null - ? `, db ${(r.dbSizeBytes / 1024 / 1024).toFixed(1)} MiB` - : ""; - lines.push( - `${r.backend}: populate ${(r.populateMs / 1000).toFixed(1)} s${size}`, - ); - } - lines.push(""); - const nameWidth = Math.max( - ...base.queries.map((q) => q.name.length), - "query".length, - ); - const head = ["query".padEnd(nameWidth), ...results.map((r) => r.backend)]; - lines.push(head.join(" | ")); - lines.push("-".repeat(head.join(" | ").length)); - for (let i = 0; i < base.queries.length; i++) { - const cells = [base.queries[i].name.padEnd(nameWidth)]; - for (let j = 0; j < results.length; j++) { - const q = results[j].queries[i]; - let cell = `${q.medianMs.toFixed(2)} ms (${q.rows})`; - if (j > 0) { - const ratio = q.medianMs / base.queries[i].medianMs; - cell += ` ${ratio.toFixed(2)}x`; - } - cells.push(cell); - } - lines.push(cells.join(" | ")); - } - lines.push(""); - lines.push( - "Row counts are shown in parentheses: a query that got faster by " + - "returning nothing is a bug, not a win.", - ); - return lines.join("\n"); -} diff --git a/packages/taler-wallet-core/src/dbtx-cache-invalidation.test.ts b/packages/taler-wallet-core/src/dbtx-cache-invalidation.test.ts @@ -1,234 +0,0 @@ -/* - 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/> - */ - -/** - * The wallet caches exchange entries, denomination info and refresh costs in - * memory. Those caches are dropped after any transaction that changed a - * record they are derived from, which the DAL detects by watching for calls - * to the methods named in CACHE_INVALIDATING_METHODS. - * - * That list is maintained by hand, and a stale entry fails silently: the - * wallet keeps serving a cached denomination that no longer exists in the - * database, with no error anywhere. This test derives the list that *should* - * be there from the IndexedDB implementation and compares. - */ - -import assert from "node:assert"; -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { test } from "node:test"; - -import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; - -import { runnerFactories } from "./dbtx-runners.js"; -import { - CACHE_INVALIDATING_METHODS, - watchForCacheInvalidation, -} from "./dbtx-shared.js"; - -/** - * The IndexedDB object stores the three caches are derived from, and the - * sqlite tables holding the same entities. A write to any of these can - * invalidate a cached value; a write to anything else cannot. - */ -const CACHE_BACKING_STORES = [ - "exchanges", - "exchangeDetails", - // The live store; "denominations" is the pre-re-key one, written only by - // the fixup that copies out of it. - "denominationsV2", - "globalCurrencyAuditors", - "globalCurrencyExchanges", -]; -const CACHE_BACKING_TABLES = [ - "exchanges", - "exchange_details", - "denominations", - "global_currency_auditors", - "global_currency_exchanges", -]; - -/** - * Locate a file under src/, from wherever this test happens to be running. - * It normally runs from the compiled lib/, so "next to me" is not the answer. - */ -function readSourceFile(name: string): string { - let dir = dirname(fileURLToPath(import.meta.url)); - for (let i = 0; i < 5; i++) { - const candidate = join(dir, "src", name); - if (existsSync(candidate)) { - return readFileSync(candidate, "utf-8"); - } - dir = dirname(dir); - } - throw Error(`could not locate src/${name}`); -} - -/** - * Split a transaction implementation into its methods, keyed by name. - * - * Whole methods rather than single lines, because an SQL statement is often - * built across several concatenated string literals, with the table name on a - * different line from the verb. - */ -function methodBodies(src: string): Map<string, string> { - // Class members sit at exactly two spaces of indentation. - const methodRe = /^ {2}(?:async )?([A-Za-z_$][\w$]*)\s*[(<]/; - const bodies = new Map<string, string>(); - let current: string | undefined; - let buf: string[] = []; - const flush = () => { - if (current !== undefined) { - bodies.set(current, (bodies.get(current) ?? "") + buf.join("\n")); - } - }; - for (const line of src.split("\n")) { - const m = methodRe.exec(line); - if (m) { - flush(); - current = m[1]; - buf = []; - } - buf.push(line); - } - flush(); - return bodies; -} - -/** - * Find the methods of one implementation that write a cache-backing entity. - */ -function findMutators(src: string, writeRe: RegExp): Set<string> { - const found = new Set<string>(); - for (const [name, body] of methodBodies(src)) { - // Collapse the string concatenation SQL is assembled from, so a statement - // split across lines reads as one. - const flat = body.replace(/"\s*\+\s*"/g, "").replace(/\s+/g, " "); - if (writeRe.test(flat)) { - found.add(name); - } - } - return found; -} - -test("every mutator of a cache-backing store invalidates the caches", () => { - const stores = CACHE_BACKING_STORES.join("|"); - const tables = CACHE_BACKING_TABLES.join("|"); - - const impls: Array<{ file: string; writeRe: RegExp }> = [ - { - // Writes look like `tx.denominations.put(rec)`, or `this.tx.exchanges - // .delete(baseUrl)` where the store handle was taken off `this`. - file: "dbtx-indexeddb.ts", - writeRe: new RegExp( - `\\b(?:this\\.)?tx\\.(?:${stores})\\.(?:put|add|delete|clear)\\(`, - ), - }, - { - // Both implementations are scanned, not just IndexedDB: a method that - // writes one of these tables only on the sqlite side would otherwise - // never be noticed, and would silently stop invalidating caches on the - // backend it applies to. - file: "dbtx-sqlite.ts", - writeRe: new RegExp( - `(?:INSERT(?:\\s+OR\\s+\\w+)?\\s+INTO|DELETE\\s+FROM|UPDATE)\\s+"?(?:${tables})"?\\b`, - ), - }, - ]; - - const mutators = new Set<string>(); - for (const impl of impls) { - const found = findMutators(readSourceFile(impl.file), impl.writeRe); - // A scan that finds nothing would make this test vacuously pass, which is - // exactly the failure mode a source-scanning test has to rule out. - assert.ok( - found.size >= 8, - `only found ${found.size} cache-backing mutators in ${impl.file}` + - ` — the scan is probably broken, not the code`, - ); - for (const name of found) { - mutators.add(name); - } - } - - assert.deepStrictEqual( - [...mutators].sort(), - [...CACHE_INVALIDATING_METHODS].sort(), - "methods that write a cache-backing entity must be listed in" + - " CACHE_INVALIDATING_METHODS (left: found in the implementations," + - " right: declared in dbtx-shared.ts)", - ); -}); - -/** - * The static test above only checks that the list is complete. This one - * checks that the wrapper acting on it actually works, against both real - * transaction implementations rather than a stub — the two differ in how - * their methods are defined (prototype methods vs. own properties), which is - * exactly what a Proxy `get` trap is sensitive to. - */ -for (const makeRunner of runnerFactories) { - test(`cache invalidation fires on writes only`, async (t) => { - const runner = await makeRunner(); - await t.test(runner.name, async () => { - // A read must not invalidate. The old store-based trigger got this - // wrong: it fired whenever a readwrite transaction so much as *looked* - // at the denominations store, dropping every cache on a pure read. - const readFlag = { dirty: false }; - await runner.runReadWriteTx(async (tx) => { - await watchForCacheInvalidation( - tx, - readFlag, - ).listGlobalCurrencyExchanges(); - }); - assert.strictEqual( - readFlag.dirty, - false, - "a read-only transaction must not invalidate the caches", - ); - - // A write to a cache-backing store must. - const writeFlag = { dirty: false }; - await runner.runReadWriteTx(async (tx) => { - await watchForCacheInvalidation( - tx, - writeFlag, - ).upsertGlobalCurrencyExchange({ - currency: "TESTKUDOS", - exchangeBaseUrl: "https://exchange.test/", - exchangeMasterPub: encodeCrock(stringToBytes("master-pub-0000")), - }); - }); - assert.strictEqual( - writeFlag.dirty, - true, - "a transaction that wrote a cache-backing store must invalidate", - ); - - // The wrapper must not otherwise change behaviour: results still come - // back, and methods still see the right `this`. - const rows = await runner.runReadWriteTx(async (tx) => { - return await watchForCacheInvalidation(tx, { - dirty: false, - }).listGlobalCurrencyExchanges(); - }); - assert.strictEqual(rows.length, 1); - assert.strictEqual(rows[0].currency, "TESTKUDOS"); - }); - await runner.close(); - }); -} diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -1,4214 +0,0 @@ -/* - 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/> - */ - -/** - * Conformance cases for {@link WalletDbTransaction}. - * - * Each case states a property of the interface that any backend must satisfy. - * Where a case exists because a real bug was shipped, the case says so: those - * are the ones most worth keeping honest, because each of them passed both - * tsc and the unit tests at the time. - */ - -import { - AmountString, - CoinStatus, - decodeCrock, - encodeCrock, - MerchantContractTokenKind, - RefreshReason, - ScopeType, - DenomKeyType, - ExchangeEntrySource, - TalerPreciseTimestamp, - TransactionIdStr, - TalerProtocolTimestamp, -} from "@gnu-taler/taler-util"; -import { MailboxConfiguration } from "@gnu-taler/taler-util"; -import { - ConfigRecordKey, - DonationReceiptStatus, - DbPreciseTimestamp, - DbProtocolTimestamp, - DenominationVerificationStatus, - RefundGroupStatus, - RefundItemStatus, - timestampPreciseToDb, - timestampProtocolToDb, - WalletDenomination, - WalletRefundGroup, - WalletRefundItem, - WalletOperationRetry, - WalletReserve, - WalletCoin, - WalletCoinAvailability, - WalletCoinHistoryItem, - CoinSourceType, - PlanchetStatus, - ReserveBankInfo, - WalletPlanchet, - WalletProposalDownloadInfo, - WalletPurchase, - WalletTransactionMeta, - PurchaseStatus, - DepositOperationStatus, - PeerPullDebitRecordStatus, - PeerPullPaymentCreditStatus, - PeerPushCreditStatus, - PeerPushDebitStatus, - RecoupOperationStatus, - RefreshCoinStatus, - RefreshOperationStatus, - WalletDepositGroup, - WalletPeerPullCredit, - WalletPeerPullDebit, - WalletPeerPushCredit, - WalletPeerPushDebit, - WalletRecoupGroup, - WalletRefreshGroup, - WalletRefreshSession, - WalletSlate, - WalletToken, - WalletWithdrawalGroup, - WgInfo, - WithdrawalGroupStatus, - WithdrawalRecordType, - ExchangeEntryDbRecordStatus, - ExchangeEntryDbUpdateStatus, - ExchangeMigrationReason, - WalletDenomFamilyParams, - WalletExchangeDetails, - WalletExchangeEntry, - WalletExchangeMigrationLog, - WalletExchangeSignkeys, -} from "./db-common.js"; -import { ConformanceCase } from "./dbtx-conformance.js"; -import { WalletDbTransaction } from "./dbtx.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)); - -const tsPrecise = (seconds: number): DbPreciseTimestamp => - timestampPreciseToDb(TalerPreciseTimestamp.fromSeconds(seconds)); - -const amt = (s: string): AmountString => s as AmountString; - -const txnId = (s: string): TransactionIdStr => s as TransactionIdStr; - -function makeDenomination( - exchangeBaseUrl: string, - denomPubHash: string, - opts: { - familySerial?: number; - stampExpireWithdraw?: number; - isOffered?: boolean; - } = {}, -): WalletDenomination { - // Deliberately not cast: an `as WalletDenomination` here once hid a field - // that does not exist on the record, and the mistake only surfaced when a - // second implementation tried to persist it. - const denom: WalletDenomination = { - exchangeBaseUrl, - denomPubHash: ckh(denomPubHash), - denomPub: { - cipher: DenomKeyType.Rsa, - rsa_public_key: "dummy", - age_mask: 0, - }, - exchangeMasterPub: ck("master-pub"), - currency: "TESTKUDOS", - value: amt("TESTKUDOS:1"), - denominationFamilySerial: opts.familySerial ?? 1, - stampStart: ts(1000), - stampExpireWithdraw: ts(opts.stampExpireWithdraw ?? 100000), - stampExpireDeposit: ts(200000), - stampExpireLegal: ts(300000), - fees: { - feeDeposit: amt("TESTKUDOS:0.1"), - feeRefresh: amt("TESTKUDOS:0.1"), - feeRefund: amt("TESTKUDOS:0.1"), - feeWithdraw: amt("TESTKUDOS:0.1"), - }, - isOffered: opts.isOffered ?? true, - isRevoked: false, - isLost: false, - masterSig: ckh("master-sig"), - verificationStatus: DenominationVerificationStatus.VerifiedGood, - }; - return denom; -} - -function makeCoin(coinPub: string): WalletCoin { - const coin: WalletCoin = { - coinPub: ck(coinPub), - coinPriv: ck(`priv-${coinPub}`), - exchangeBaseUrl: "https://exchange.test/", - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("dph-default"), - denomSig: { - cipher: DenomKeyType.Rsa, - rsa_signature: "sig-blob", - }, - blindingKey: ck("bk-1"), - exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, - coinEvHash: ckh(`evh-${coinPub}`), - status: CoinStatus.Fresh, - maxAge: 0, - ageCommitmentProof: undefined, - coinSource: { - type: CoinSourceType.Withdraw, - withdrawalGroupId: "wg-default", - coinIndex: 0, - reservePub: ck("rp-default"), - }, - }; - return coin; -} - -function makeAvail( - exchangeBaseUrl: string, - denomPubHash: string, - maxAge: number, -): WalletCoinAvailability { - const rec: WalletCoinAvailability = { - exchangeBaseUrl, - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh(denomPubHash), - maxAge, - currency: "TESTKUDOS", - value: amt("TESTKUDOS:1"), - freshCoinCount: 1, - hasFreshCoins: 1, - visibleCoinCount: 1, - }; - return rec; -} - -function makeExchange(baseUrl: string): WalletExchangeEntry { - const ex: WalletExchangeEntry = { - baseUrl, - source: ExchangeEntrySource.Builtin, - detailsPointer: undefined, - entryStatus: ExchangeEntryDbRecordStatus.Preset, - updateStatus: ExchangeEntryDbUpdateStatus.Initial, - tosCurrentEtag: undefined, - tosAcceptedEtag: undefined, - tosAcceptedTimestamp: undefined, - lastUpdate: undefined, - nextUpdateStamp: tsPrecise(1000), - lastKeysEtag: undefined, - nextRefreshCheckStamp: tsPrecise(2000), - }; - return ex; -} - -function makeExchangeDetails( - exchangeBaseUrl: string, - masterPublicKey: string, -): WalletExchangeDetails { - const det: WalletExchangeDetails = { - exchangeBaseUrl, - masterPublicKey: ck(masterPublicKey), - currency: "TESTKUDOS", - auditors: [], - protocolVersionRange: "18:0:1", - tinyAmount: amt("TESTKUDOS:0.01"), - reserveClosingDelay: { d_us: 1000 }, - globalFees: [], - wireInfo: { - accounts: [], - feesForType: {}, - }, - bankComplianceLanguage: undefined, - defaultPeerPushExpiration: undefined, - }; - return det; -} - -function makeSignKey( - exchangeDetailsRowId: number, - signkeyPub: string, -): WalletExchangeSignkeys { - const k: WalletExchangeSignkeys = { - exchangeDetailsRowId, - signkeyPub: ck(signkeyPub), - stampStart: ts(100), - stampExpire: ts(200), - stampEnd: ts(300), - masterSig: ckh("sig-1"), - }; - return k; -} - -function makeFamilyParams( - exchangeBaseUrl: string, - value: string, -): WalletDenomFamilyParams { - const p: WalletDenomFamilyParams = { - exchangeBaseUrl, - exchangeMasterPub: ck("mpk-fam"), - value: amt(value), - feeWithdraw: amt("TESTKUDOS:0.01"), - feeDeposit: amt("TESTKUDOS:0.01"), - feeRefresh: amt("TESTKUDOS:0.01"), - feeRefund: amt("TESTKUDOS:0.01"), - }; - return p; -} - -/** - * Create the parent rows a child record needs before it can be stored. - * - * The sqlite schema declares foreign keys for these relationships and the - * IndexedDB DAL cascades to match, so a fixture that invents a parent key - * without the parent is describing a state the wallet never produces. These - * helpers keep the cases realistic without repeating the setup in each one. - */ -async function seedDenomFamily( - tx: WalletDbTransaction, - exchangeBaseUrl: string, - serial: number, -): Promise<number> { - return await tx.upsertDenominationFamily({ - denominationFamilySerial: serial, - familyParams: makeFamilyParams(exchangeBaseUrl, `TESTKUDOS:${serial}`), - }); -} - -async function seedWithdrawalGroup( - tx: WalletDbTransaction, - withdrawalGroupId: string, -): Promise<void> { - await tx.upsertWithdrawalGroup(makeWithdrawalGroup(withdrawalGroupId)); -} - -async function seedRefreshGroup( - tx: WalletDbTransaction, - refreshGroupId: string, -): Promise<void> { - await tx.upsertRefreshGroup(makeRefreshGroup(refreshGroupId)); -} - -async function seedPurchase( - tx: WalletDbTransaction, - proposalId: string, -): Promise<void> { - await tx.upsertPurchase(makePurchase(proposalId)); -} - -function makeBankInfo(talerWithdrawUri: string): ReserveBankInfo { - const info: ReserveBankInfo = { - talerWithdrawUri, - confirmUrl: undefined, - timestampReserveInfoPosted: undefined, - timestampBankConfirmed: undefined, - wireTypes: undefined, - currency: undefined, - }; - return info; -} - -function makeWithdrawalGroup(withdrawalGroupId: string): WalletWithdrawalGroup { - const wg: WalletWithdrawalGroup = { - withdrawalGroupId, - wgInfo: { withdrawalType: WithdrawalRecordType.BankManual }, - secretSeed: ck(`seed-${withdrawalGroupId}`), - reservePub: ck(`rpub-${withdrawalGroupId}`), - reservePriv: ck(`rpriv-${withdrawalGroupId}`), - timestampStart: tsPrecise(1000), - status: WithdrawalGroupStatus.PendingRegisteringBank, - }; - return wg; -} - -function makePlanchet( - coinPub: string, - withdrawalGroupId: string, - coinIdx: number, -): WalletPlanchet { - const pl: WalletPlanchet = { - coinPub: ck(coinPub), - coinPriv: ck(`priv-${coinPub}`), - withdrawalGroupId, - coinIdx, - planchetStatus: PlanchetStatus.Pending, - lastError: undefined, - denomPubHash: ckh("dph-pl"), - blindingKey: ck("bk-pl"), - exchangeWithdrawValues: { cipher: DenomKeyType.Rsa }, - withdrawSig: ckh("sig-pl"), - coinEv: { - cipher: DenomKeyType.Rsa, - rsa_blinded_planchet: "blinded", - }, - coinEvHash: ckh(`evh-${coinPub}`), - }; - return pl; -} - -function makeDownloadInfo( - fulfillmentUrl: string | undefined, -): WalletProposalDownloadInfo { - const dl: WalletProposalDownloadInfo = { - contractTermsHash: ckh("cth-1"), - currency: "TESTKUDOS", - contractTermsMerchantSig: "sig-1", - ...(fulfillmentUrl !== undefined ? { fulfillmentUrl } : undefined), - }; - return dl; -} - -function makePurchase(proposalId: string): WalletPurchase { - const p: WalletPurchase = { - proposalId, - orderId: `order-${proposalId}`, - merchantBaseUrl: "https://merchant.test/", - claimToken: undefined, - downloadSessionId: undefined, - repurchaseProposalId: undefined, - purchaseStatus: PurchaseStatus.PendingDownloadingProposal, - noncePriv: ck(`npriv-${proposalId}`), - noncePub: ck(`npub-${proposalId}`), - secretSeed: undefined, - download: undefined, - payInfo: undefined, - timestampFirstSuccessfulPay: undefined, - merchantPaySig: undefined, - posConfirmation: undefined, - shared: false, - timestamp: tsPrecise(1000), - timestampAccept: undefined, - timestampLastRefundStatus: undefined, - lastSessionId: undefined, - autoRefundDeadline: undefined, - refundAmountAwaiting: undefined, - }; - return p; -} - -/** - * Strip keys whose value is undefined. - * - * The two backends disagree on how an *absent optional* field comes back: - * IndexedDB returns the key with value undefined (its structured clone - * materialises every declared field), while the sqlite mapper omits the key. - * Both read identically -- `rec.abortReason` is undefined either way, and - * both serialise the same -- so the DAL contract is that callers must not - * distinguish them, and these round-trip cases compare accordingly. - * - * This does NOT apply to fields declared as `T | undefined` rather than `T?`: - * those keys are required and must be present, which the coin and exchange - * cases assert explicitly with `in`. - */ -function withoutUndefined<T>(v: T): T { - if (v === null || typeof v !== "object" || Array.isArray(v)) { - return v; - } - const out: Record<string, unknown> = {}; - for (const [k, val] of Object.entries(v as Record<string, unknown>)) { - if (val !== undefined) { - out[k] = val; - } - } - return out as T; -} - -const tokenFamilyFields = () => ({ - slug: "fam-slug", - name: "Family", - description: "A token family", - extraData: { - class: MerchantContractTokenKind.Subscription as const, - trusted_domains: ["example.com"], - }, - tokenIssuePub: { - cipher: "RSA" as const, - rsa_pub: "rsa-pub", - signature_validity_start: { t_s: 1 }, - signature_validity_end: { t_s: 2 }, - }, - descriptionI18n: undefined, -}); - -function makeToken(tokenUsePub: string): WalletToken { - const tok: WalletToken = { - ...tokenFamilyFields(), - tokenUsePub: ck(tokenUsePub), - tokenUsePriv: ck(`priv-${tokenUsePub}`), - purchaseId: "pur-tok", - merchantBaseUrl: "https://merchant.test/", - kind: MerchantContractTokenKind.Subscription, - 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: ckh(`evh-${tokenUsePub}`), - blindingKey: ck("bk-tok"), - }; - return tok; -} - -function makeSlate( - tokenUsePub: string, - purchaseId: string, - choiceIndex: number, - outputIndex: number, - repeatIndex: number, -): WalletSlate { - const sl: WalletSlate = { - ...tokenFamilyFields(), - tokenUsePub: ck(tokenUsePub), - tokenUsePriv: ck(`priv-${tokenUsePub}`), - purchaseId, - choiceIndex, - outputIndex, - repeatIndex, - merchantBaseUrl: "https://merchant.test/", - kind: MerchantContractTokenKind.Subscription, - tokenIssuePubHash: ckh("tiph-1"), - validAfter: ts(100), - validBefore: ts(200), - tokenEv: { cipher: DenomKeyType.Rsa, rsa_blinded_planchet: "blinded" }, - tokenEvHash: ckh(`evh-${tokenUsePub}`), - blindingKey: ck("bk-slate"), - }; - return sl; -} - -function makeDepositGroup(depositGroupId: string): WalletDepositGroup { - const dg: WalletDepositGroup = { - depositGroupId, - currency: "TESTKUDOS", - amount: amt("TESTKUDOS:5"), - wireTransferDeadline: ts(9999), - merchantPub: ck("mpub"), - merchantPriv: ck("mpriv"), - noncePriv: ck("npriv"), - noncePub: ck("npub"), - wire: { payto_uri: "payto://iban/DE1", salt: "salt-1" }, - contractTermsHash: ckh("cth"), - totalPayCost: amt("TESTKUDOS:5.1"), - counterpartyEffectiveDepositAmount: amt("TESTKUDOS:5"), - timestampCreated: tsPrecise(1000), - timestampFinished: undefined, - timestampLastDepositAttempt: undefined, - operationStatus: DepositOperationStatus.PendingDeposit, - }; - return dg; -} - -function makeRefreshGroup(refreshGroupId: string): WalletRefreshGroup { - const rg: WalletRefreshGroup = { - refreshGroupId, - operationStatus: RefreshOperationStatus.Pending, - currency: "TESTKUDOS", - reason: RefreshReason.Manual, - oldCoinPubs: ["c1", "c2"], - inputPerCoin: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")], - expectedOutputPerCoin: [amt("TESTKUDOS:0.9"), amt("TESTKUDOS:1.9")], - statusPerCoin: [RefreshCoinStatus.Pending, RefreshCoinStatus.Pending], - refundRequests: {}, - timestampCreated: tsPrecise(1000), - timestampFinished: undefined, - }; - return rg; -} - -function makeRefreshSession( - refreshGroupId: string, - coinIndex: number, -): WalletRefreshSession { - const rs: WalletRefreshSession = { - refreshGroupId, - coinIndex, - amountRefreshOutput: amt("TESTKUDOS:1"), - newDenoms: [{ denomPubHash: ckh("dph-1"), count: 2 }], - }; - return rs; -} - -function makeRecoupGroup( - recoupGroupId: string, - exchangeBaseUrl: string, -): WalletRecoupGroup { - const rc: WalletRecoupGroup = { - recoupGroupId, - exchangeBaseUrl, - operationStatus: RecoupOperationStatus.Pending, - timestampStarted: tsPrecise(1000), - timestampFinished: undefined, - coinPubs: ["c1"], - recoupFinishedPerCoin: [false], - scheduleRefreshCoins: [], - }; - return rc; -} - -function makePeerPushDebit(pursePub: string): WalletPeerPushDebit { - const rec: WalletPeerPushDebit = { - pursePub: ck(pursePub), - exchangeBaseUrl: "https://exchange.test/", - amount: amt("TESTKUDOS:3"), - totalCost: amt("TESTKUDOS:3.1"), - 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, - }; - return rec; -} - -function makePeerPushCredit(peerPushCreditId: string): WalletPeerPushCredit { - const rec: WalletPeerPushCredit = { - peerPushCreditId, - exchangeBaseUrl: "https://exchange.test/", - pursePub: ck(`purse-${peerPushCreditId}`), - mergePriv: ck("mpriv"), - contractPriv: ck("cpriv"), - timestamp: tsPrecise(1000), - estimatedAmountEffective: amt("TESTKUDOS:2"), - contractTermsHash: ckh("cth"), - status: PeerPushCreditStatus.PendingMerge, - withdrawalGroupId: undefined, - currency: undefined, - }; - return rec; -} - -function makePeerPullDebit(peerPullDebitId: string): WalletPeerPullDebit { - const rec: WalletPeerPullDebit = { - peerPullDebitId, - pursePub: ck(`purse-${peerPullDebitId}`), - exchangeBaseUrl: "https://exchange.test/", - amount: amt("TESTKUDOS:4"), - contractTermsHash: ckh("cth"), - timestampCreated: tsPrecise(1000), - contractPriv: ck("cpriv"), - status: PeerPullDebitRecordStatus.PendingDeposit, - totalCostEstimated: amt("TESTKUDOS:4.1"), - }; - return rec; -} - -function makePeerPullCredit(pursePub: string): WalletPeerPullCredit { - const rec: WalletPeerPullCredit = { - pursePub: ck(pursePub), - exchangeBaseUrl: "https://exchange.test/", - amount: amt("TESTKUDOS:6"), - estimatedAmountEffective: amt("TESTKUDOS:6"), - 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, - withdrawalGroupId: undefined, - }; - return rec; -} - -function makeReserve(reservePub: string): WalletReserve { - const r: WalletReserve = { - reservePub: ck(reservePub), - reservePriv: ck(`priv-of-${reservePub}`), - }; - return r; -} - -function makeRefundGroup( - refundGroupId: string, - proposalId = "prop-1", -): WalletRefundGroup { - const grp: WalletRefundGroup = { - refundGroupId, - proposalId, - status: RefundGroupStatus.Done, - timestampCreated: tsPrecise(5000), - amountRaw: amt("TESTKUDOS:1"), - amountEffective: amt("TESTKUDOS:1"), - }; - return grp; -} - -function makeRefundItem( - refundGroupId: string, - coinPub: string, - rtxid: number, -): WalletRefundItem { - return { - status: RefundItemStatus.Done, - refundGroupId, - executionTime: ts(5000), - obtainedTime: tsPrecise(5000), - refundAmount: amt("TESTKUDOS:1"), - coinPub: ck(coinPub), - rtxid, - }; -} - -export const conformanceCases: ConformanceCase[] = [ - // ---------------------------------------------------------------- basics - - { - name: "config: upsert then get round trips", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertConfig({ - key: ConfigRecordKey.MaterializedTransactionsVersion, - value: 7, - }); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.MaterializedTransactionsVersion), - ); - t.ok(got, "config record should exist"); - t.equal(got?.value, 7); - }, - }, - - { - name: "get on a missing key returns undefined, not a throw", - async run(t, runner) { - const got = await runner.runReadWriteTx((tx) => - tx.getCoin(ck("no-such-coin-pub")), - ); - t.equal(got, undefined); - }, - }, - - { - name: "contract terms: arbitrary JSON survives a round trip", - async run(t, runner) { - const raw = { - nested: { a: [1, 2, 3], b: null }, - unicode: "ünïcödé", - num: 1.5, - }; - await runner.runReadWriteTx((tx) => - tx.upsertContractTerms({ h: "hash-1", contractTermsRaw: raw }), - ); - const got = await runner.runReadWriteTx((tx) => - tx.getContractTerms("hash-1"), - ); - t.deepEqual(got?.contractTermsRaw, raw); - }, - }, - - // ------------------------------------------------- compound / array keys - - { - name: "denomination: compound primary key (masterPub, denomPubHash)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam1/", 1); - // One key set reached through two URLs is one denomination, not two: - // the URL is where the exchange answers, not what signed the coin. - const viaOneUrl = makeDenomination("https://e1/", "dph-a"); - const viaAnother = makeDenomination("https://e2/", "dph-a"); - await tx.upsertDenomination(viaOneUrl); - await tx.upsertDenomination(viaAnother); - // A different key signing the same hash *is* a second denomination. - const otherKey = makeDenomination("https://e1/", "dph-a"); - otherKey.exchangeMasterPub = ck("master-other"); - await tx.upsertDenomination(otherKey); - }); - const [shared, other] = await runner.runReadWriteTx(async (tx) => [ - await tx.getDenominationsByMasterPub(ck("master-pub")), - await tx.getDenominationsByMasterPub(ck("master-other")), - ]); - t.equal( - shared.length, - 1, - "two URLs serving one key set must collapse onto one row", - ); - t.equal(shared[0].denomPubHash, ckh("dph-a")); - t.equal( - shared[0].exchangeBaseUrl, - "https://e2/", - "upserting a denomination through a new URL must update its routing hint", - ); - t.equal( - other.length, - 1, - "the same hash under another key must be its own row", - ); - t.equal(other[0].exchangeMasterPub, ck("master-other")); - }, - }, - - { - name: "denomination: batch lookup preserves order across chunks", - async run(t, runner) { - const primary = makeDenomination("https://batch-denom/", "bd-primary"); - const other = makeDenomination("https://batch-denom/", "bd-other"); - other.exchangeMasterPub = ck("master-other"); - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://batch-denom/", 1); - await tx.upsertDenomination(primary); - await tx.upsertDenomination(other); - }); - const missing = { - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("bd-missing"), - }; - const refs = Array.from({ length: 403 }, (_, i) => - i % 17 === 0 ? missing : i % 2 === 0 ? primary : other, - ); - const got = await runner.runReadWriteTx((tx) => - tx.getDenominationsByRefs(refs), - ); - t.deepEqual( - got.map((d) => [d.exchangeMasterPub, d.denomPubHash]), - refs - .filter((ref) => ref !== missing) - .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash]), - "missing references are skipped and duplicates retain input order", - ); - t.deepEqual( - await runner.runReadWriteTx((tx) => tx.getDenominationsByRefs([])), - [], - ); - }, - }, - - { - name: "refund items by group are found (regression: array keyPath)", - // The IndexedDB index byRefundGroupId is declared with an ARRAY keyPath, - // so its keys are single-element arrays. Passing a bare string matched - // nothing, getRefundItemsByGroup silently returned [], and refunds ended - // in state "done" instead of "failed". It passed tsc and every unit test. - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-1"); - // Items are written before their group here, deliberately: that is - // the order pay-merchant.ts uses, and a backend must tolerate it - // within a transaction. - await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-1", 1)); - await tx.upsertRefundItem(makeRefundItem("grp-1", "coin-2", 2)); - await tx.upsertRefundItem(makeRefundItem("grp-2", "coin-3", 3)); - await tx.upsertRefundGroup(makeRefundGroup("grp-1")); - await tx.upsertRefundGroup(makeRefundGroup("grp-2")); - }); - const items = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("grp-1"), - ); - t.equal(items.length, 2, "must find both items of the group"); - const other = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("grp-2"), - ); - t.equal(other.length, 1); - const none = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("grp-missing"), - ); - t.equal(none.length, 0); - }, - }, - - { - name: "refund item lookup by (coinPub, rtxid)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-1"); - await tx.upsertRefundItem(makeRefundItem("grp-3", "coin-9", 42)); - await tx.upsertRefundGroup(makeRefundGroup("grp-3")); - }); - const got = await runner.runReadWriteTx((tx) => - 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.runReadWriteTx((tx) => - tx.getRefundItemByCoinAndRtxid(ck("coin-9"), 43), - ); - t.equal(miss, undefined, "wrong rtxid must not match"); - }, - }, - - // --------------------------------------------------- generated row ids - - { - name: "auto-increment stores return a usable generated id", - // upsertReserve/upsertRefundItem/upsertExchangeDetails/ - // upsertDenominationFamily must return the generated key: callers store it - // as a foreign key. A backend returning 0 or undefined would corrupt the - // exchange entry silently. - async run(t, runner) { - const id1 = await runner.runReadWriteTx((tx) => - tx.upsertReserve({ - reservePub: ck("rp-1"), - reservePriv: ck("rv-1"), - } as any), - ); - const id2 = await runner.runReadWriteTx((tx) => - 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.runReadWriteTx((tx) => tx.getReserve(id1)); - t.equal( - back?.reservePub, - ck("rp-1"), - "the returned id must address the row", - ); - }, - }, - - // ------------------------------------------------------- ordered scans - - { - name: "findDenominationByFamilyFromExpiry returns the first match", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam77/", 77); - // Same family, ascending expiry. The first two are not offered, so - // the predicate must skip them. - await tx.upsertDenomination( - makeDenomination("https://e/", "d-1", { - familySerial: 77, - stampExpireWithdraw: 10000, - isOffered: false, - }), - ); - await tx.upsertDenomination( - makeDenomination("https://e/", "d-2", { - familySerial: 77, - stampExpireWithdraw: 20000, - isOffered: false, - }), - ); - await tx.upsertDenomination( - makeDenomination("https://e/", "d-3", { - familySerial: 77, - stampExpireWithdraw: 30000, - }), - ); - }); - const found = await runner.runReadWriteTx((tx) => - tx.findDenominationByFamilyFromExpiry(77, ts(0), (d) => d.isOffered), - ); - t.equal( - found?.denomPubHash, - ckh("d-3"), - "must return the first match in order", - ); - }, - }, - - { - name: "findDenominationByFamilyFromExpiry stops at the first match", - // Regression: this was briefly implemented as "fetch every non-expired - // denomination of the family, then filter". Families hold many - // denominations, so that turned a one-record read into a full range scan. - // The predicate is the only place we can observe how far the scan ran. - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam88/", 88); - for (let i = 0; i < 20; i++) { - await tx.upsertDenomination( - makeDenomination("https://e/", `s-${i}`, { - familySerial: 88, - stampExpireWithdraw: 10000 + i * 1000, - }), - ); - } - }); - let examined = 0; - const before = runner.getAccessStats()?.recordsRead; - const found = await runner.runReadWriteTx((tx) => - tx.findDenominationByFamilyFromExpiry(88, ts(0), () => { - examined++; - return true; - }), - ); - const after = runner.getAccessStats()?.recordsRead; - t.ok(found, "should find a denomination"); - t.equal(examined, 1, "the predicate must be consulted once"); - if (before !== undefined && after !== undefined) { - // The real regression was reading the whole family and filtering in - // JS: the predicate still ran once, so only the record count exposes - // it. Allow a small constant for cursor positioning. - t.ok( - after - before <= 3, - `must not scan the family: read ${after - before} records for one match`, - ); - } - }, - }, - - { - name: "findDenominationByFamilyFromExpiry respects the family boundary", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam101/", 101); - await seedDenomFamily(tx, "https://fam102/", 102); - await tx.upsertDenomination( - makeDenomination("https://e/", "f1-a", { - familySerial: 101, - stampExpireWithdraw: 10000, - isOffered: false, - }), - ); - await tx.upsertDenomination( - makeDenomination("https://e/", "f2-a", { - familySerial: 102, - stampExpireWithdraw: 20000, - }), - ); - }); - const found = await runner.runReadWriteTx((tx) => - tx.findDenominationByFamilyFromExpiry(101, ts(0), (d) => d.isOffered), - ); - t.equal( - found, - undefined, - "must not spill into the next family when no match is found", - ); - }, - }, - - { - name: "findDenominationByFamilyFromExpiry honours the expiry lower bound", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam111/", 111); - await tx.upsertDenomination( - makeDenomination("https://e/", "old", { - familySerial: 111, - stampExpireWithdraw: 5000, - }), - ); - await tx.upsertDenomination( - makeDenomination("https://e/", "new", { - familySerial: 111, - stampExpireWithdraw: 50000, - }), - ); - }); - const found = await runner.runReadWriteTx((tx) => - tx.findDenominationByFamilyFromExpiry(111, ts(10000), () => true), - ); - t.equal( - found?.denomPubHash, - ckh("new"), - "must skip records before the bound", - ); - }, - }, - - // ------------------------------------------------- transaction lifecycle - - { - name: "a throwing transaction rolls back its writes", - async run(t, runner) { - let rejected = false; - try { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertContractTerms({ - h: "rollback-hash", - contractTermsRaw: { x: 1 }, - }); - throw Error("deliberate abort"); - }); - } catch (e) { - rejected = true; - } - t.ok(rejected, "the transaction must reject when its callback throws"); - const got = await runner.runReadWriteTx((tx) => - tx.getContractTerms("rollback-hash"), - ); - t.equal(got, undefined, "writes before the throw must not be visible"); - }, - }, - - { - name: "writes are visible within the same transaction", - async run(t, runner) { - const got = await runner.runReadWriteTx(async (tx) => { - await tx.upsertContractTerms({ - h: "same-tx", - contractTermsRaw: { y: 2 }, - }); - return await tx.getContractTerms("same-tx"); - }); - t.deepEqual(got?.contractTermsRaw, { y: 2 }); - }, - }, - - { - name: "scheduleOnCommit runs after the transaction, not during it", - async run(t, runner) { - const order: string[] = []; - await runner.runReadWriteTx(async (tx) => { - tx.scheduleOnCommit(() => order.push("after-commit")); - order.push("in-tx"); - }); - t.deepEqual(order, ["in-tx", "after-commit"]); - }, - }, - - { - name: "scheduleOnCommit does not run when the transaction aborts", - async run(t, runner) { - let ran = false; - let rejected = false; - try { - await runner.runReadWriteTx(async (tx) => { - tx.scheduleOnCommit(() => { - ran = true; - }); - throw Error("deliberate abort"); - }); - } catch (e) { - rejected = true; - } - t.ok(rejected, "the aborted transaction must reject"); - t.equal(ran, false, "commit hooks must not fire on a rolled-back tx"); - }, - }, - - { - name: "notify is safe to pass around unbound", - // Regression: notify was a prototype method reading this.tx. Call sites - // pass it as a bare function (applyNotifyTransition(tx.notify, ...)), which - // is well-typed but lost `this` and broke every transaction that used it. - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const notify = tx.notify; - notify({ type: "balance-change" } as any); - }); - t.ok(true, "extracting notify and calling it must not throw"); - }, - }, - - // ------------------------------------------------------- delete semantics - - { - name: "deleting a missing row is a no-op, not an error", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.deletePurchase("no-such-proposal"); - await tx.deleteDenomination({ - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("no-such-hash"), - }); - await tx.deleteRefundGroup("no-such-group"); - }); - t.ok(true, "deleting a missing row must not throw"); - }, - }, - - { - name: "getRecordCounts reflects what was written", - async run(t, runner) { - const before = await runner.runReadWriteTx((tx) => tx.getRecordCounts()); - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam1/", 1); - await tx.upsertDenomination(makeDenomination("https://cnt/", "c-1")); - await tx.upsertDenomination(makeDenomination("https://cnt/", "c-2")); - }); - const after = await runner.runReadWriteTx((tx) => tx.getRecordCounts()); - t.equal( - after.denominations - before.denominations, - 2, - "counts must track inserts", - ); - }, - }, - - { - name: "upsert overwrites an existing row rather than duplicating it", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam1/", 1); - await tx.upsertDenomination( - makeDenomination("https://e-up/", "dup", { - stampExpireWithdraw: 111, - }), - ); - await tx.upsertDenomination( - makeDenomination("https://e-up/", "dup", { - stampExpireWithdraw: 222, - }), - ); - }); - const all = await runner.runReadWriteTx((tx) => - tx.getDenominationsByMasterPub(ck("master-pub")), - ); - t.equal(all.length, 1, "the second upsert must replace, not append"); - t.equal(all[0].stampExpireWithdraw, ts(222)); - }, - }, - // ------------------------------------------- backfill: previously untested - - { - name: "reserve: upsert returns a generated row id, retrievable by pub", - async run(t, runner) { - const [id1, id2] = await runner.runReadWriteTx(async (tx) => [ - await tx.upsertReserve(makeReserve("rpub-1")), - await tx.upsertReserve(makeReserve("rpub-2")), - ]); - t.ok(typeof id1 === "number", "upsertReserve must return the row id"); - t.ok(id1 !== id2, "row ids must be distinct"); - const got = await runner.runReadWriteTx((tx) => - tx.getReserveByReservePub(ck("rpub-2")), - ); - t.equal(got?.reservePub, ck("rpub-2")); - t.equal(got?.rowId, id2, "the id returned must be the id stored"); - }, - }, - - { - name: "reserve: generated row ids are strictly increasing", - async run(t, runner) { - const first = await runner.runReadWriteTx((tx) => - tx.upsertReserve(makeReserve("rpub-r1")), - ); - const second = await runner.runReadWriteTx((tx) => - tx.upsertReserve(makeReserve("rpub-r2")), - ); - t.ok(second > first, "ids must be strictly increasing"); - }, - }, - - { - name: "reserve: batch lookup preserves requested order and skips missing", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertReserve(makeReserve("rpub-b1")); - await tx.upsertReserve(makeReserve("rpub-b2")); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getReservesByPubs([ - ck("rpub-b2"), - ck("rpub-missing"), - ck("rpub-b1"), - ]), - ); - t.deepEqual( - got.map((x) => x.reservePub), - [ck("rpub-b2"), ck("rpub-b1")], - ); - }, - }, - - { - name: "operation retry: upsert, get, delete", - async run(t, runner) { - const rec: WalletOperationRetry = { - id: "task-1", - retryInfo: { - firstTry: tsPrecise(1000), - nextRetry: tsPrecise(2000), - retryCounter: 3, - }, - }; - await runner.runReadWriteTx((tx) => tx.upsertOperationRetry(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getOperationRetry("task-1"), - ); - t.equal(got?.id, "task-1"); - t.equal(got?.retryInfo.retryCounter, 3); - await runner.runReadWriteTx((tx) => tx.deleteOperationRetry("task-1")); - const gone = await runner.runReadWriteTx((tx) => - tx.getOperationRetry("task-1"), - ); - t.equal(gone, undefined, "delete must remove the record"); - }, - }, - - { - name: "operation retry: deleting a missing task is not an error", - async run(t, runner) { - await runner.runReadWriteTx((tx) => - tx.deleteOperationRetry("never-existed"), - ); - t.ok(true, "delete of an absent key must be a no-op"); - }, - }, - - { - name: "operation retry: lastError round trips as arbitrary JSON", - async run(t, runner) { - const lastError = { - code: 7002, - hint: "unexpected", - detail: { nested: [1, null, "x"] }, - }; - await runner.runReadWriteTx((tx) => - tx.upsertOperationRetry({ - id: "task-err", - lastError, - retryInfo: { - firstTry: tsPrecise(1), - nextRetry: tsPrecise(2), - retryCounter: 0, - }, - }), - ); - const got = await runner.runReadWriteTx((tx) => - tx.getOperationRetry("task-err"), - ); - t.deepEqual(got?.lastError, lastError); - }, - }, - - { - name: "tombstone: a repeated upsert of the same id is accepted", - async run(t, runner) { - // The DAL exposes no way to read tombstones back, so this can only - // assert that the second write does not raise (a primary-key conflict - // would). See the note in the sqlite schema. - await runner.runReadWriteTx(async (tx) => { - await tx.upsertTombstone({ id: "tmb:dup" }); - await tx.upsertTombstone({ id: "tmb:dup" }); - }); - t.ok(true, "a repeated upsert must be idempotent"); - }, - }, - - { - name: "refund group: get by id and list by proposal", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-x"); - await seedPurchase(tx, "prop-y"); - await tx.upsertRefundGroup(makeRefundGroup("rg-a", "prop-x")); - await tx.upsertRefundGroup(makeRefundGroup("rg-b", "prop-x")); - await tx.upsertRefundGroup(makeRefundGroup("rg-c", "prop-y")); - }); - const one = await runner.runReadWriteTx((tx) => - tx.getRefundGroup("rg-b"), - ); - t.equal(one?.refundGroupId, "rg-b"); - const byProp = await runner.runReadWriteTx((tx) => - tx.getRefundGroupsByProposal("prop-x"), - ); - t.equal(byProp.length, 2, "must return only the groups of that proposal"); - t.deepEqual(byProp.map((g) => g.refundGroupId).sort(), ["rg-a", "rg-b"]); - }, - }, - - { - name: "refund group: unknown proposal yields an empty list, not undefined", - async run(t, runner) { - const got = await runner.runReadWriteTx((tx) => - tx.getRefundGroupsByProposal("no-such-proposal"), - ); - t.deepEqual(got, [], "list queries must return [] when nothing matches"); - }, - }, - - { - // Also pins the downloaded_at round trip: the record carries a protocol - // Timestamp while the sqlite column is an INTEGER of microseconds, so a - // conversion sits between them on one backend and not the other. - name: "mailbox message: round trips and is keyed by (mailbox, uri)", - async run(t, runner) { - const at = { t_s: 1735689600 }; - await runner.runReadWriteTx(async (tx) => { - await tx.upsertMailboxMessage({ - originMailboxBaseUrl: "https://mbox.test/", - talerUri: "taler://one", - downloadedAt: at, - }); - await tx.upsertMailboxMessage({ - originMailboxBaseUrl: "https://mbox.test/", - talerUri: "taler://two", - downloadedAt: { t_s: 1735689601 }, - }); - // Same URI at a different mailbox: the key is the pair. - await tx.upsertMailboxMessage({ - originMailboxBaseUrl: "https://other.test/", - talerUri: "taler://one", - downloadedAt: { t_s: 1735689602 }, - }); - }); - - const all = await runner.runReadWriteTx((tx) => tx.listMailboxMessages()); - t.equal(all.length, 3); - const one = all.find( - (m) => - m.originMailboxBaseUrl === "https://mbox.test/" && - m.talerUri === "taler://one", - ); - t.ok(one, "the message must be listed"); - t.deepEqual( - one!.downloadedAt, - at, - "downloadedAt must survive the round trip unchanged", - ); - - await runner.runReadWriteTx((tx) => - tx.deleteMailboxMessage("https://mbox.test/", "taler://one"), - ); - const left = await runner.runReadWriteTx((tx) => - tx.listMailboxMessages(), - ); - t.equal(left.length, 2, "delete must remove exactly one message"); - t.ok( - left.some((m) => m.originMailboxBaseUrl === "https://other.test/"), - "the same URI at another mailbox must survive", - ); - }, - }, - - { - name: "refresh group: delete cascades to its sessions", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedRefreshGroup(tx, "rg-casc-a"); - await seedRefreshGroup(tx, "rg-casc-b"); - await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 0)); - await tx.upsertRefreshSession(makeRefreshSession("rg-casc-a", 1)); - await tx.upsertRefreshSession(makeRefreshSession("rg-casc-b", 0)); - }); - await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-casc-a")); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getRefreshSessionsByGroup("rg-casc-a"), - ) - ).length, - 0, - ); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getRefreshSessionsByGroup("rg-casc-b"), - ) - ).length, - 1, - "sessions of another group must survive", - ); - }, - }, - - { - name: "withdrawal group: delete cascades to its planchets", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedWithdrawalGroup(tx, "wg-casc-a"); - await seedWithdrawalGroup(tx, "wg-casc-b"); - await tx.upsertPlanchet(makePlanchet("pl-ca-0", "wg-casc-a", 0)); - await tx.upsertPlanchet(makePlanchet("pl-ca-1", "wg-casc-a", 1)); - await tx.upsertPlanchet(makePlanchet("pl-cb-0", "wg-casc-b", 0)); - }); - await runner.runReadWriteTx((tx) => - tx.deleteWithdrawalGroup("wg-casc-a"), - ); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getPlanchetsByGroup("wg-casc-a"), - ) - ).length, - 0, - ); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getPlanchetsByGroup("wg-casc-b"), - ) - ).length, - 1, - "planchets of another group must survive", - ); - }, - }, - - { - // Two levels: the purchase owns the refund groups, which own the items. - name: "purchase: delete cascades through refund groups to their items", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-casc"); - await tx.upsertRefundGroup(makeRefundGroup("rg-pc", "prop-casc")); - await tx.upsertRefundItem(makeRefundItem("rg-pc", "coin-pc", 301)); - }); - await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-casc")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getRefundGroup("rg-pc")), - undefined, - "refund groups of the deleted purchase must be gone", - ); - t.equal( - (await runner.runReadWriteTx((tx) => tx.getRefundItemsByGroup("rg-pc"))) - .length, - 0, - "and their items with them", - ); - }, - }, - - { - name: "denomination family: delete cascades to its denominations", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam-a/", 41); - await seedDenomFamily(tx, "https://fam-b/", 42); - await tx.upsertDenomination( - makeDenomination("https://fam-a/", "dfa-1", { familySerial: 41 }), - ); - await tx.upsertDenomination( - makeDenomination("https://fam-b/", "dfb-1", { familySerial: 42 }), - ); - }); - await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(41)); - const left = await runner.runReadWriteTx((tx) => - tx.getDenominationsByMasterPub(ck("master-pub")), - ); - t.equal(left.length, 1, "denominations of another family must survive"); - t.equal( - left[0].denomPubHash, - ckh("dfb-1"), - "the deleted family's denomination must be the one that went", - ); - }, - }, - - { - // The database converter enumerates every store through these accessors, - // so a listAll* that misses records means rows silently absent from the - // converted database. One case per accessor would repeat the seeding; - // this seeds one record per store and checks each enumeration sees it. - name: "listAll accessors enumerate every store", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 7, - }); - await tx.upsertCurrencyInfoEntry({ - scopeInfoStr: "taler-si:global/TESTKUDOS", - currencySpec: { - name: "Test", - num_fractional_input_digits: 2, - num_fractional_normal_digits: 2, - num_fractional_trailing_zero_digits: 2, - alt_unit_names: { "0": "TESTKUDOS" }, - }, - source: "exchange", - }); - await tx.upsertContractTerms({ - h: ckh("ct-la"), - contractTermsRaw: { summary: "listall" }, - }); - await tx.upsertTombstone({ id: "tmb:test:la" }); - await tx.upsertOperationRetry({ - id: "task-la", - retryInfo: { - firstTry: tsPrecise(1), - nextRetry: tsPrecise(2), - retryCounter: 1, - }, - }); - await tx.upsertReserve(makeReserve("res-la")); - await tx.upsertMailboxConfiguration({ - mailboxBaseUrl: "https://mb.example/", - } as MailboxConfiguration); - await tx.upsertExchangeBaseUrlFixup({ - exchangeBaseUrl: "https://old.example/", - replacement: "https://new.example/", - }); - await tx.upsertExchangeMigrationLog({ - oldExchangeBaseUrl: "https://old.example/", - newExchangeBaseUrl: "https://new.example/", - timestamp: tsPrecise(3), - reason: "auto" as ExchangeMigrationReason, - }); - await tx.upsertSlate(makeSlate("slate-la", "prop-la", 0, 0, 0)); - await tx.upsertRecoupGroup(makeRecoupGroup("rec-la", "https://e1/")); - await tx.upsertDonationPlanchet({ - donauBaseUrl: "https://donau.example/", - udiNonce: ckh("udi-la"), - donorTaxIdHash: ckh("tid-la"), - donorHashSalt: "salt-la", - donorTaxId: "tax-la", - donationYear: 2026, - proposalId: "prop-la", - udiIndex: 0, - blindedUdi: { cipher: "RSA", rsa_blinded_identifier: "blind" }, - bks: ck("bks-la"), - donationUnitPubHash: ckh("dup-la"), - value: amt("TESTKUDOS:1"), - }); - await tx.upsertDonationReceipt({ - status: DonationReceiptStatus.DoneSubmitted, - donauBaseUrl: "https://donau.example/", - udiNonce: ckh("udi-la"), - proposalId: "prop-la", - donationYear: 2026, - donationUnitPubHash: ckh("dup-la"), - donationUnitSig: { cipher: "RSA", rsa_signature: "sig" }, - donorTaxIdHash: ckh("tid-la"), - donorHashSalt: "salt-la", - donorTaxId: "tax-la", - value: amt("TESTKUDOS:1"), - udiIndex: 0, - }); - await seedDenomFamily(tx, "https://e1/", 61); - await tx.upsertDenomination( - makeDenomination("https://e1/", "den-la", { familySerial: 61 }), - ); - }); - - // Each enumeration must contain the seeded record; where the store was - // empty before, the length pins that nothing else appeared. - const r = runner; - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllConfig())).length, - 1, - "config", - ); - const ci = await r.runReadWriteTx((tx) => tx.listAllCurrencyInfo()); - t.equal(ci.length, 1, "currencyInfo"); - t.equal( - ci[0].scopeInfoStr, - "taler-si:global/TESTKUDOS", - "the storage key must round-trip opaquely", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllContractTerms())).length, - 1, - "contractTerms", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllTombstones()))[0]?.id, - "tmb:test:la", - "tombstones", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllOperationRetries()))[0]?.id, - "task-la", - "operationRetries", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllReserves())).length, - 1, - "reserves", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllMailboxConfigurations())) - .length, - 1, - "mailboxConfigurations", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllExchangeBaseUrlFixups()))[0] - ?.replacement, - "https://new.example/", - "exchangeBaseUrlFixups", - ); - t.equal( - ( - await r.runReadWriteTx((tx) => - tx.listAllExchangeMigrationLogEntries(), - ) - ).length, - 1, - "exchangeBaseUrlMigrationLog", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllSlates())).length, - 1, - "slates", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllRecoupGroups())).length, - 1, - "recoupGroups", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllDonationPlanchets())).length, - 1, - "donationPlanchets", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllDonationReceipts())).length, - 1, - "donationReceipts", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllDenominationFamilies())) - .length, - 1, - "denominationFamilies", - ); - t.equal( - (await r.runReadWriteTx((tx) => tx.listAllDenominations())).length, - 1, - "denominations", - ); - }, - }, - - { - // Parent-delete behaviour has to be identical on both backends. sqlite - // declares ON DELETE CASCADE, IndexedDB has no constraints and must do it - // by hand; without a case here the two silently disagree, and the sqlite - // side quietly removes rows the other keeps. - name: "refund group: delete cascades to its items", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-1"); - await tx.upsertRefundGroup(makeRefundGroup("rg-casc")); - await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c1", 201)); - await tx.upsertRefundItem(makeRefundItem("rg-casc", "coin-c2", 202)); - // A second group, to pin that the cascade is scoped to one parent. - await tx.upsertRefundGroup(makeRefundGroup("rg-keep")); - await tx.upsertRefundItem(makeRefundItem("rg-keep", "coin-k1", 203)); - }); - await runner.runReadWriteTx((tx) => tx.deleteRefundGroup("rg-casc")); - const gone = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("rg-casc"), - ); - t.equal(gone.length, 0, "items of the deleted group must be gone"); - const kept = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("rg-keep"), - ); - t.equal(kept.length, 1, "items of another group must survive"); - }, - }, - - { - name: "exchange details: delete cascades to its sign keys", - async run(t, runner) { - const rowId = await runner.runReadWriteTx(async (tx) => { - const id = await tx.upsertExchangeDetails( - makeExchangeDetails("https://casc.exchange/", "mp-casc"), - ); - await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-1")); - await tx.upsertExchangeSignKey(makeSignKey(id, "sk-casc-2")); - return id; - }); - const other = await runner.runReadWriteTx(async (tx) => { - const id = await tx.upsertExchangeDetails( - makeExchangeDetails("https://keep.exchange/", "mp-keep"), - ); - await tx.upsertExchangeSignKey(makeSignKey(id, "sk-keep-1")); - return id; - }); - await runner.runReadWriteTx((tx) => tx.deleteExchangeDetails(rowId)); - const gone = await runner.runReadWriteTx((tx) => - tx.getExchangeSignKeysByDetailsRowId(rowId), - ); - t.equal(gone.length, 0, "sign keys of the deleted details must be gone"); - const kept = await runner.runReadWriteTx((tx) => - tx.getExchangeSignKeysByDetailsRowId(other), - ); - t.equal(kept.length, 1, "sign keys of other details must survive"); - }, - }, - - { - name: "refund item: delete removes only the targeted item", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedPurchase(tx, "prop-1"); - await tx.upsertRefundGroup(makeRefundGroup("rg-del")); - await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d1", 101)); - await tx.upsertRefundItem(makeRefundItem("rg-del", "coin-d2", 102)); - }); - const items = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("rg-del"), - ); - t.equal(items.length, 2); - const victim = items.find((i) => i.coinPub === ck("coin-d1")); - t.ok(victim?.id !== undefined, "stored items must carry their row id"); - await runner.runReadWriteTx((tx) => tx.deleteRefundItem(victim!.id!)); - const left = await runner.runReadWriteTx((tx) => - tx.getRefundItemsByGroup("rg-del"), - ); - t.equal(left.length, 1); - t.equal(left[0].coinPub, ck("coin-d2")); - }, - }, - - { - name: "denomination: list by verification status", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam1/", 1); - const a = makeDenomination("https://e1/", "vs-a"); - a.verificationStatus = DenominationVerificationStatus.Unverified; - const b = makeDenomination("https://e1/", "vs-b"); - b.verificationStatus = DenominationVerificationStatus.VerifiedGood; - await tx.upsertDenomination(a); - await tx.upsertDenomination(b); - }); - const unverified = await runner.runReadWriteTx((tx) => - tx.getDenominationsByVerificationStatus( - DenominationVerificationStatus.Unverified, - ), - ); - t.equal(unverified.length, 1, "must filter on the status"); - t.equal(unverified[0].denomPubHash, ckh("vs-a")); - }, - }, - // ---------------------------------------------------------------- coins - - { - name: "coin: round trips with age commitment proof absent", - async run(t, runner) { - const coin = makeCoin("cp-1"); - await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runReadWriteTx((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( - got !== undefined && "ageCommitmentProof" in got, - "ageCommitmentProof is a required key and must be present even when undefined", - ); - }, - }, - - { - name: "coin: nested coinSource union and denomSig survive", - async run(t, runner) { - const coin = makeCoin("cp-src"); - coin.coinSource = { - type: CoinSourceType.Withdraw, - withdrawalGroupId: "wg-1", - coinIndex: 3, - reservePub: ck("rp-1"), - }; - await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runReadWriteTx((tx) => tx.getCoin(ck("cp-src"))); - t.deepEqual(got?.coinSource, coin.coinSource); - t.deepEqual(got?.denomSig, coin.denomSig); - }, - }, - - { - name: "coin: status is a string enum, not a number", - async run(t, runner) { - const coin = makeCoin("cp-status"); - coin.status = CoinStatus.Dormant; - await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); - const got = await runner.runReadWriteTx((tx) => - tx.getCoin(ck("cp-status")), - ); - t.equal(got?.status, CoinStatus.Dormant); - t.equal(typeof got?.status, "string", "CoinStatus must not be coerced"); - }, - }, - - { - name: "coin: upsert overwrites rather than duplicating", - async run(t, runner) { - const coin = makeCoin("cp-up"); - await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); - coin.status = CoinStatus.Dormant; - coin.visible = 1; - await runner.runReadWriteTx((tx) => tx.upsertCoin(coin)); - const all = await runner.runReadWriteTx((tx) => tx.listAllCoins()); - 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); - }, - }, - - { - name: "coin: queries by exchange, denom and source transaction", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const a = makeCoin("cq-1"); - a.exchangeBaseUrl = "https://ex-a/"; - a.denomPubHash = ckh("dq-1"); - a.sourceTransactionId = "txn-1"; - const b = makeCoin("cq-2"); - b.exchangeBaseUrl = "https://ex-a/"; - b.denomPubHash = ckh("dq-2"); - b.sourceTransactionId = "txn-2"; - const c = makeCoin("cq-3"); - c.exchangeBaseUrl = "https://ex-b/"; - c.denomPubHash = ckh("dq-1"); - await tx.upsertCoin(a); - await tx.upsertCoin(b); - await tx.upsertCoin(c); - }); - const byEx = await runner.runReadWriteTx((tx) => - tx.getCoinsByExchange("https://ex-a/"), - ); - t.equal(byEx.length, 2); - const count = await runner.runReadWriteTx((tx) => - tx.countCoinsByExchange("https://ex-a/"), - ); - t.equal(count, 2, "count must agree with the list"); - const byDenom = await runner.runReadWriteTx((tx) => - tx.getCoinsByDenomPubHash(ckh("dq-1")), - ); - t.equal(byDenom.length, 2, "denom hash spans exchanges"); - const denomHashes = [ - ckh("dq-1"), - ...Array.from({ length: 501 }, (_, i) => ckh(`dq-missing-${i}`)), - ckh("dq-2"), - ckh("dq-1"), - ]; - const byDenoms = await runner.runReadWriteTx((tx) => - tx.getCoinsByDenomPubHashes(denomHashes), - ); - t.deepEqual( - byDenoms.map((coin) => coin.coinPub).sort(), - [ck("cq-1"), ck("cq-2"), ck("cq-3")].sort(), - "batch lookup spans exchanges, chunks safely and de-duplicates hashes", - ); - const bySrc = await runner.runReadWriteTx((tx) => - tx.getCoinsBySourceTransaction("txn-1"), - ); - t.equal(bySrc.length, 1); - t.equal(bySrc[0].coinPub, ck("cq-1")); - }, - }, - - { - name: "coin: getCoinsByPubs skips missing pubs and keeps argument order", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoin(makeCoin("cb-1")); - await tx.upsertCoin(makeCoin("cb-2")); - }); - const missing = ck("cb-missing"); - const pubs = Array.from({ length: 503 }, (_, i) => - i % 19 === 0 ? missing : i % 2 === 0 ? ck("cb-2") : ck("cb-1"), - ); - const got = await runner.runReadWriteTx((tx) => tx.getCoinsByPubs(pubs)); - t.deepEqual( - got.map((c) => c.coinPub), - pubs.filter((pub) => pub !== missing), - "missing pubs are dropped while duplicates retain input order", - ); - }, - }, - - { - name: "coin: fresh-coin lookup filters on status and respects the limit", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - for (let i = 0; i < 4; i++) { - const c = makeCoin(`cf-${i}`); - c.exchangeBaseUrl = "https://ex-f/"; - c.denomPubHash = ckh("df-1"); - c.maxAge = 21; - c.status = i === 3 ? CoinStatus.Dormant : CoinStatus.Fresh; - await tx.upsertCoin(c); - } - }); - const all = await runner.runReadWriteTx((tx) => - tx.getFreshCoinsByDenomAndAge( - { - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("df-1"), - maxAge: 21, - }, - 100, - ), - ); - t.equal(all.length, 3, "the dormant coin must be excluded"); - const limited = await runner.runReadWriteTx((tx) => - tx.getFreshCoinsByDenomAndAge( - { - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("df-1"), - maxAge: 21, - }, - 2, - ), - ); - t.equal(limited.length, 2, "the limit must be applied"); - }, - }, - - { - name: "coin: delete cascades to its history", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoin(makeCoin("cd-1")); - await tx.upsertCoinHistory({ - coinPub: ck("cd-1"), - history: [{ type: "withdraw", transactionId: txnId("txn:cd-1") }], - }); - await tx.upsertCoin(makeCoin("cd-2")); - await tx.upsertCoinHistory({ - coinPub: ck("cd-2"), - history: [{ type: "withdraw", transactionId: txnId("txn:cd-2") }], - }); - }); - await runner.runReadWriteTx((tx) => tx.deleteCoin(ck("cd-1"))); - t.equal( - await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-1"))), - undefined, - ); - t.equal( - await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-1"))), - undefined, - "deleting a coin must delete its history: every reader looks the" + - " history up for a coin it already holds, so a history row without" + - " its coin is unreachable", - ); - t.ok( - await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), - "the history of another coin must survive", - ); - // deleteCoinHistory still exists on its own, for callers that want to - // drop the history while keeping the coin. - await runner.runReadWriteTx((tx) => tx.deleteCoinHistory(ck("cd-2"))); - t.equal( - await runner.runReadWriteTx((tx) => tx.getCoinHistory(ck("cd-2"))), - undefined, - ); - t.ok( - await runner.runReadWriteTx((tx) => tx.getCoin(ck("cd-2"))), - "deleteCoinHistory must not delete the coin", - ); - }, - }, - - { - name: "coin history: arbitrary history entries round trip", - async run(t, runner) { - const history: WalletCoinHistoryItem[] = [ - { type: "withdraw", transactionId: txnId("txn:ch-a") }, - { - type: "spend", - transactionId: txnId("txn:ch-b"), - amount: amt("TESTKUDOS:1.5"), - }, - { - type: "refund", - transactionId: txnId("txn:ch-c"), - amount: amt("TESTKUDOS:0.25"), - }, - ]; - await runner.runReadWriteTx(async (tx) => { - // The history belongs to a coin, and deleting that coin cascades to - // it, so the coin has to exist first. - await tx.upsertCoin(makeCoin("ch-1")); - await tx.upsertCoinHistory({ coinPub: ck("ch-1"), history }); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinHistory(ck("ch-1")), - ); - t.deepEqual(got?.history, history); - }, - }, - - { - name: "coin history: batch lookup preserves order across chunks", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - for (const label of ["chb-1", "chb-2"]) { - await tx.upsertCoin(makeCoin(label)); - await tx.upsertCoinHistory({ - coinPub: ck(label), - history: [ - { type: "withdraw", transactionId: txnId(`txn:${label}`) }, - ], - }); - } - }); - const missing = ck("chb-missing"); - const pubs = Array.from({ length: 503 }, (_, i) => - i % 23 === 0 ? missing : i % 2 === 0 ? ck("chb-2") : ck("chb-1"), - ); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinHistoriesByPubs(pubs), - ); - t.deepEqual( - got.map((h) => h.coinPub), - pubs.filter((pub) => pub !== missing), - "missing histories are skipped while duplicates retain input order", - ); - }, - }, - - // ------------------------------------------------------ coin availability - - { - name: "coin availability: compound primary key of (exchange, denom, age)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoinAvailability(makeAvail("https://ea/", "da", 0)); - await tx.upsertCoinAvailability(makeAvail("https://ea/", "da", 21)); - }); - const a = await runner.runReadWriteTx((tx) => - tx.getCoinAvailability({ - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("da"), - maxAge: 0, - }), - ); - const b = await runner.runReadWriteTx((tx) => - tx.getCoinAvailability({ - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("da"), - maxAge: 21, - }), - ); - t.ok(a && b, "differing maxAge must be distinct rows"); - t.equal(a?.maxAge, 0); - t.equal(b?.maxAge, 21); - }, - }, - - { - name: "coin availability: batch lookup preserves order across chunks", - async run(t, runner) { - const zero = makeAvail("https://batch-avail/", "ba", 0); - const adult = makeAvail("https://batch-avail/", "ba", 21); - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoinAvailability(zero); - await tx.upsertCoinAvailability(adult); - }); - const missing = { - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("ba-missing"), - maxAge: 0, - }; - const refs = Array.from({ length: 303 }, (_, i) => - i % 13 === 0 ? missing : i % 2 === 0 ? zero : adult, - ); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinAvailabilitiesByRefs(refs), - ); - t.deepEqual( - got.map((a) => [a.exchangeMasterPub, a.denomPubHash, a.maxAge]), - refs - .filter((ref) => ref !== missing) - .map((ref) => [ref.exchangeMasterPub, ref.denomPubHash, ref.maxAge]), - "missing references are skipped and duplicates retain input order", - ); - }, - }, - - { - name: "coin availability: upsert updates counts in place", - async run(t, runner) { - const rec = makeAvail("https://eu/", "du", 0); - await runner.runReadWriteTx((tx) => tx.upsertCoinAvailability(rec)); - rec.freshCoinCount = 9; - rec.visibleCoinCount = 4; - rec.pendingRefreshOutputCount = 2; - await runner.runReadWriteTx((tx) => tx.upsertCoinAvailability(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinAvailability({ - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("du"), - maxAge: 0, - }), - ); - t.equal(got?.freshCoinCount, 9); - t.equal(got?.visibleCoinCount, 4); - t.equal(got?.pendingRefreshOutputCount, 2); - const byEx = await runner.runReadWriteTx((tx) => - tx.getCoinAvailabilityByExchange("https://eu/"), - ); - t.equal(byEx.length, 1, "the upsert must not have appended a row"); - }, - }, - - { - name: "coin availability: age range excludes every zero-fresh row", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const atLowerNoFresh = makeAvail("https://er/", "d-lo", 0); - atLowerNoFresh.freshCoinCount = 0; - const atLowerFresh = makeAvail("https://er/", "d-lf", 0); - atLowerFresh.freshCoinCount = 5; - const aboveNoFresh = makeAvail("https://er/", "d-hi", 10); - aboveNoFresh.freshCoinCount = 0; - await tx.upsertCoinAvailability(atLowerNoFresh); - await tx.upsertCoinAvailability(atLowerFresh); - await tx.upsertCoinAvailability(aboveNoFresh); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getCoinAvailabilityByExchangeAndAgeRange("https://er/", 0, 21), - ); - const hashes = got.map((a) => a.denomPubHash).sort(); - t.deepEqual( - hashes, - [ckh("d-lf")], - "excludes zero-fresh rows throughout the age range", - ); - }, - }, - - { - name: "coin availability: delete targets one (exchange, denom, age)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoinAvailability(makeAvail("https://ed/", "dd", 0)); - await tx.upsertCoinAvailability(makeAvail("https://ed/", "dd", 21)); - }); - await runner.runReadWriteTx((tx) => - tx.deleteCoinAvailability({ - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("dd"), - maxAge: 0, - }), - ); - const left = await runner.runReadWriteTx((tx) => - tx.getCoinAvailabilityByExchange("https://ed/"), - ); - t.equal(left.length, 1); - t.equal(left[0].maxAge, 21, "only the age-0 row must be gone"); - }, - }, - // ------------------------------------------------------------ exchanges - - { - name: "exchange: round trips, including the flattened details pointer", - async run(t, runner) { - const ex = makeExchange("https://ex-rt/"); - ex.detailsPointer = { - masterPublicKey: ck("mpk-1"), - currency: "TESTKUDOS", - updateClock: tsPrecise(4242), - }; - await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); - const got = await runner.runReadWriteTx((tx) => - tx.getExchange("https://ex-rt/"), - ); - t.deepEqual(got, ex, "every field must survive the round trip"); - }, - }, - - { - name: "exchange: a missing details pointer stays a present, undefined key", - async run(t, runner) { - const ex = makeExchange("https://ex-np/"); - ex.detailsPointer = undefined; - await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); - const got = await runner.runReadWriteTx((tx) => - tx.getExchange("https://ex-np/"), - ); - t.equal(got?.detailsPointer, undefined); - t.ok( - got !== undefined && "detailsPointer" in got, - "detailsPointer is a required key and must be present", - ); - }, - }, - - { - name: "exchange: optional booleans keep undefined distinct from false", - async run(t, runner) { - const unset = makeExchange("https://ex-b1/"); - const explicitlyFalse = makeExchange("https://ex-b2/"); - explicitlyFalse.noFees = false; - explicitlyFalse.peerPaymentsDisabled = false; - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchange(unset); - await tx.upsertExchange(explicitlyFalse); - }); - const a = await runner.runReadWriteTx((tx) => - tx.getExchange("https://ex-b1/"), - ); - const b = await runner.runReadWriteTx((tx) => - tx.getExchange("https://ex-b2/"), - ); - t.equal(a?.noFees, undefined, "an unset flag must not become false"); - t.equal(b?.noFees, false, "an explicit false must not become undefined"); - t.equal(b?.peerPaymentsDisabled, false); - }, - }, - - { - name: "exchange: list and delete", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchange(makeExchange("https://ex-l1/")); - await tx.upsertExchange(makeExchange("https://ex-l2/")); - }); - const all = await runner.runReadWriteTx((tx) => tx.getExchanges()); - const mine = all.filter((e) => e.baseUrl.startsWith("https://ex-l")); - t.equal(mine.length, 2); - await runner.runReadWriteTx((tx) => tx.deleteExchange("https://ex-l1/")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getExchange("https://ex-l1/")), - undefined, - ); - t.ok( - await runner.runReadWriteTx((tx) => tx.getExchange("https://ex-l2/")), - "deleting one exchange must not affect the other", - ); - }, - }, - - // ----------------------------------------------------- exchange details - - { - name: "exchange: a superseded key set round trips", - async run(t, runner) { - const ex = makeExchange("https://superseded/"); - ex.detailsPointer = { - masterPublicKey: ck("mpk-current"), - currency: "TESTKUDOS", - updateClock: tsPrecise(1), - }; - ex.supersededKeySet = { - masterPublicKey: ck("mpk-old"), - currency: "TESTKUDOS", - firstSeen: tsPrecise(3), - sharesDenominations: false, - }; - await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); - const got = await runner.runReadWriteTx((tx) => - tx.getExchange("https://superseded/"), - ); - t.deepEqual(got?.detailsPointer, ex.detailsPointer); - t.deepEqual(got?.supersededKeySet, ex.supersededKeySet); - t.equal( - got?.supersededKeySet?.sharesDenominations, - false, - "an explicit false must not become undefined", - ); - }, - }, - - { - name: "exchange: no superseded key set stays absent", - async run(t, runner) { - await runner.runReadWriteTx((tx) => - tx.upsertExchange(makeExchange("https://no-superseded/")), - ); - const got = await runner.runReadWriteTx((tx) => - tx.getExchange("https://no-superseded/"), - ); - t.equal(got?.supersededKeySet, undefined); - }, - }, - - { - name: "exchange: clearing a superseded key set persists", - async run(t, runner) { - const ex = makeExchange("https://confirmed/"); - ex.supersededKeySet = { - masterPublicKey: ck("mpk-gone"), - currency: "TESTKUDOS", - firstSeen: tsPrecise(5), - sharesDenominations: true, - }; - await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); - // Confirming the change clears it; the columns must go back to NULL - // rather than keeping the previous value. - delete ex.supersededKeySet; - await runner.runReadWriteTx((tx) => tx.upsertExchange(ex)); - const got = await runner.runReadWriteTx((tx) => - tx.getExchange("https://confirmed/"), - ); - t.equal(got?.supersededKeySet, undefined); - }, - }, - - { - name: "exchange details: upsert returns a row id and round trips", - async run(t, runner) { - const det = makeExchangeDetails("https://ed-1/", "mpk-a"); - const rowId = await runner.runReadWriteTx((tx) => - tx.upsertExchangeDetails(det), - ); - t.ok(typeof rowId === "number", "must return the generated row id"); - const got = await runner.runReadWriteTx((tx) => - tx.getExchangeDetailsByPointer( - "https://ed-1/", - "TESTKUDOS", - ck("mpk-a"), - ), - ); - t.equal(got?.rowId, rowId); - t.deepEqual(got?.wireInfo, det.wireInfo, "nested JSON must survive"); - t.deepEqual(got?.globalFees, det.globalFees); - t.equal(got?.protocolVersionRange, det.protocolVersionRange); - }, - }, - - { - name: "exchange details: a second upsert with the row id updates in place", - async run(t, runner) { - const det = makeExchangeDetails("https://ed-u/", "mpk-u"); - const rowId = await runner.runReadWriteTx((tx) => - tx.upsertExchangeDetails(det), - ); - det.rowId = rowId; - det.currency = "EUR"; - const again = await runner.runReadWriteTx((tx) => - tx.upsertExchangeDetails(det), - ); - t.equal(again, rowId, "the row id must be stable across updates"); - const list = await runner.runReadWriteTx((tx) => - tx.listExchangeDetailsByBaseUrl("https://ed-u/"), - ); - t.equal(list.length, 1, "the update must not have inserted a row"); - t.equal(list[0].currency, "EUR"); - }, - }, - - { - name: "exchange details: resolved through the exchange's pointer", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const det = makeExchangeDetails("https://ep/", "mpk-p"); - await tx.upsertExchangeDetails(det); - const ex = makeExchange("https://ep/"); - ex.detailsPointer = { - masterPublicKey: ck("mpk-p"), - currency: "TESTKUDOS", - updateClock: tsPrecise(1), - }; - await tx.upsertExchange(ex); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getExchangeDetails("https://ep/"), - ); - t.equal(got?.masterPublicKey, ck("mpk-p")); - }, - }, - - { - name: "exchange details: no pointer means no details, not a throw", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchangeDetails( - makeExchangeDetails("https://enp/", "mpk-n"), - ); - const ex = makeExchange("https://enp/"); - ex.detailsPointer = undefined; - await tx.upsertExchange(ex); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getExchangeDetails("https://enp/"), - ); - t.equal(got, undefined, "details must not be found without a pointer"); - }, - }, - - { - name: "exchange details: unknown exchange yields undefined", - async run(t, runner) { - const got = await runner.runReadWriteTx((tx) => - tx.getExchangeDetails("https://never-added/"), - ); - t.equal(got, undefined); - }, - }, - - { - name: "exchange details: one master public key, two base URLs", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchangeDetails( - makeExchangeDetails("https://mp-a/", "mpk-shared"), - ); - await tx.upsertExchangeDetails( - makeExchangeDetails("https://mp-b/", "mpk-shared"), - ); - }); - const got = await runner.runReadWriteTx((tx) => - tx.listExchangeDetailsByMasterPub(ck("mpk-shared")), - ); - t.equal(got.length, 2, "a master public key may span base URLs"); - t.deepEqual(got.map((d) => d.exchangeBaseUrl).sort(), [ - "https://mp-a/", - "https://mp-b/", - ]); - }, - }, - - { - name: "exchange details: one base URL, two master public keys", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchangeDetails( - makeExchangeDetails("https://mp-two/", "mpk-old"), - ); - await tx.upsertExchangeDetails( - makeExchangeDetails("https://mp-two/", "mpk-new"), - ); - }); - const old = await runner.runReadWriteTx((tx) => - tx.listExchangeDetailsByMasterPub(ck("mpk-old")), - ); - const fresh = await runner.runReadWriteTx((tx) => - tx.listExchangeDetailsByMasterPub(ck("mpk-new")), - ); - // Superseded keys keep their own row: the coins withdrawn under them - // are only interpretable through it. - t.equal(old.length, 1); - t.equal(fresh.length, 1); - t.ok( - old[0].rowId !== fresh[0].rowId, - "each key set must keep its own details row", - ); - }, - }, - - { - name: "exchange details: unknown master public key yields nothing", - async run(t, runner) { - const got = await runner.runReadWriteTx((tx) => - tx.listExchangeDetailsByMasterPub(ck("mpk-never")), - ); - t.equal(got.length, 0); - }, - }, - - // --------------------------------------------------- exchange sign keys - - { - name: "exchange sign keys: keyed by (details row id, signkey pub)", - async run(t, runner) { - const rowId = await runner.runReadWriteTx((tx) => - tx.upsertExchangeDetails(makeExchangeDetails("https://esk/", "mpk-s")), - ); - await runner.runReadWriteTx(async (tx) => { - await tx.upsertExchangeSignKey(makeSignKey(rowId, "sk-1")); - await tx.upsertExchangeSignKey(makeSignKey(rowId, "sk-2")); - }); - const keys = await runner.runReadWriteTx((tx) => - tx.getExchangeSignKeysByDetailsRowId(rowId), - ); - t.equal(keys.length, 2); - t.deepEqual( - keys.map((k) => k.signkeyPub).sort(), - [ck("sk-1"), ck("sk-2")].sort(), - ); - await runner.runReadWriteTx((tx) => - tx.deleteExchangeSignKey(rowId, ck("sk-1")), - ); - const left = await runner.runReadWriteTx((tx) => - tx.getExchangeSignKeysByDetailsRowId(rowId), - ); - t.equal(left.length, 1); - t.equal(left[0].signkeyPub, ck("sk-2")); - }, - }, - - { - name: "exchange sign keys: re-upserting the same pub updates it", - async run(t, runner) { - const rowId = await runner.runReadWriteTx((tx) => - tx.upsertExchangeDetails(makeExchangeDetails("https://esk2/", "mpk-t")), - ); - const key = makeSignKey(rowId, "sk-dup"); - await runner.runReadWriteTx((tx) => tx.upsertExchangeSignKey(key)); - key.masterSig = ckh("sig-updated"); - await runner.runReadWriteTx((tx) => tx.upsertExchangeSignKey(key)); - const keys = await runner.runReadWriteTx((tx) => - tx.getExchangeSignKeysByDetailsRowId(rowId), - ); - t.equal(keys.length, 1, "must replace, not append"); - t.equal(keys[0].masterSig, ckh("sig-updated")); - }, - }, - - // ------------------------------------------------ denomination families - - { - name: "denomination family: looked up by the full seven-part params", - async run(t, runner) { - const params = makeFamilyParams("https://df/", "TESTKUDOS:1"); - const serial = await runner.runReadWriteTx((tx) => - tx.upsertDenominationFamily({ familyParams: params }), - ); - t.ok(typeof serial === "number", "must return the generated serial"); - const got = await runner.runReadWriteTx((tx) => - tx.getDenominationFamilyByParams(params), - ); - t.equal(got?.denominationFamilySerial, serial); - t.deepEqual(got?.familyParams, params); - }, - }, - - { - name: "denomination family: a differing fee is a different family", - async run(t, runner) { - // Five of the seven components are amounts, so a transposition between - // two of them would otherwise silently resolve to the wrong family. - const base = makeFamilyParams("https://df2/", "TESTKUDOS:1"); - await runner.runReadWriteTx((tx) => - tx.upsertDenominationFamily({ familyParams: base }), - ); - const differing = { ...base, feeRefund: amt("TESTKUDOS:0.99") }; - const got = await runner.runReadWriteTx((tx) => - tx.getDenominationFamilyByParams(differing), - ); - t.equal(got, undefined, "a different fee must not match"); - }, - }, - - { - name: "denomination family: swapped fee components do not collide", - async run(t, runner) { - const params = makeFamilyParams("https://df3/", "TESTKUDOS:1"); - params.feeDeposit = amt("TESTKUDOS:0.01"); - params.feeRefresh = amt("TESTKUDOS:0.02"); - await runner.runReadWriteTx((tx) => - tx.upsertDenominationFamily({ familyParams: params }), - ); - const swapped = { - ...params, - feeDeposit: params.feeRefresh, - feeRefresh: params.feeDeposit, - }; - const got = await runner.runReadWriteTx((tx) => - tx.getDenominationFamilyByParams(swapped), - ); - t.equal(got, undefined, "component order must be significant"); - }, - }, - - { - name: "denomination family: list by exchange and delete by serial", - async run(t, runner) { - const [s1] = await runner.runReadWriteTx(async (tx) => [ - await tx.upsertDenominationFamily({ - familyParams: makeFamilyParams("https://df4/", "TESTKUDOS:1"), - }), - await tx.upsertDenominationFamily({ - familyParams: makeFamilyParams("https://df4/", "TESTKUDOS:2"), - }), - ]); - const fams = await runner.runReadWriteTx((tx) => - tx.getDenominationFamiliesByExchange("https://df4/"), - ); - t.equal(fams.length, 2); - await runner.runReadWriteTx((tx) => tx.deleteDenominationFamily(s1)); - const left = await runner.runReadWriteTx((tx) => - tx.getDenominationFamiliesByExchange("https://df4/"), - ); - t.equal(left.length, 1); - }, - }, - - // ------------------------------------------------- fixups / migration log - - { - name: "exchange base URL fixup: round trips and updates", - async run(t, runner) { - await runner.runReadWriteTx((tx) => - tx.upsertExchangeBaseUrlFixup({ - exchangeBaseUrl: "https://old/", - replacement: "https://new/", - }), - ); - let got = await runner.runReadWriteTx((tx) => - tx.getExchangeBaseUrlFixup("https://old/"), - ); - t.equal(got?.replacement, "https://new/"); - await runner.runReadWriteTx((tx) => - tx.upsertExchangeBaseUrlFixup({ - exchangeBaseUrl: "https://old/", - replacement: "https://newer/", - }), - ); - got = await runner.runReadWriteTx((tx) => - tx.getExchangeBaseUrlFixup("https://old/"), - ); - t.equal(got?.replacement, "https://newer/"); - }, - }, - - { - name: "migration log: keyed by the old and new URL pair", - async run(t, runner) { - const rec: WalletExchangeMigrationLog = { - oldExchangeBaseUrl: "https://a/", - newExchangeBaseUrl: "https://b/", - timestamp: tsPrecise(9000), - reason: ExchangeMigrationReason.MismatchedBaseUrl, - }; - await runner.runReadWriteTx((tx) => tx.upsertExchangeMigrationLog(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getExchangeMigrationLog("https://a/", "https://b/"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - t.equal( - typeof got?.reason, - "string", - "ExchangeMigrationReason is a string enum and must not be coerced", - ); - const other = await runner.runReadWriteTx((tx) => - tx.getExchangeMigrationLog("https://b/", "https://a/"), - ); - t.equal(other, undefined, "the pair is ordered"); - }, - }, - - { - name: "denominations: listed by master public key", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedDenomFamily(tx, "https://fam1/", 1); - await tx.upsertDenomination(makeDenomination("https://dl1/", "d-1")); - await tx.upsertDenomination(makeDenomination("https://dl1/", "d-2")); - const other = makeDenomination("https://dl2/", "d-3"); - other.exchangeMasterPub = ck("master-other"); - await tx.upsertDenomination(other); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getDenominationsByMasterPub(ck("master-pub")), - ); - t.equal(got.length, 2, "only the denominations of that key set"); - const other = await runner.runReadWriteTx((tx) => - tx.getDenominationsByMasterPub(ck("master-other")), - ); - t.equal(other.length, 1); - }, - }, - // -------------------------------------------------- withdrawal groups - - { - name: "withdrawal group: bank-integrated wgInfo round trips whole", - async run(t, runner) { - const wg = makeWithdrawalGroup("wg-bi"); - wg.wgInfo = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - bankInfo: { - talerWithdrawUri: "taler://withdraw/example/1", - confirmUrl: "https://bank/confirm", - exchangePaytoUri: "payto://iban/DE123", - timestampReserveInfoPosted: tsPrecise(10), - timestampBankConfirmed: tsPrecise(20), - wireTypes: ["iban"], - currency: "TESTKUDOS", - externalConfirmation: true, - senderWire: "payto://iban/DE999", - }, - exchangeCreditAccounts: [], - }; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - const got = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroup("wg-bi"), - ); - t.deepEqual(got?.wgInfo, wg.wgInfo, "the whole variant must survive"); - }, - }, - - { - name: "withdrawal group: the withdraw URI has exactly one stored copy", - async run(t, runner) { - // The native schema promotes talerWithdrawUri to an indexed column and - // strips it from the JSON payload. If a second copy were kept, an - // update could change one and not the other, and the lookup by URI - // would disagree with the record. Update the URI and check that both - // the record and the index-backed lookup follow. - const wg = makeWithdrawalGroup("wg-uri"); - wg.wgInfo = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - bankInfo: makeBankInfo("taler://withdraw/example/first"), - }; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - - wg.wgInfo = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - bankInfo: makeBankInfo("taler://withdraw/example/second"), - }; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - - const byOld = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroupByTalerWithdrawUri( - "taler://withdraw/example/first", - ), - ); - t.equal(byOld, undefined, "the old URI must no longer resolve"); - const byNew = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroupByTalerWithdrawUri( - "taler://withdraw/example/second", - ), - ); - t.equal(byNew?.withdrawalGroupId, "wg-uri"); - const rec = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroup("wg-uri"), - ); - t.equal(rec?.wgInfo.withdrawalType, WithdrawalRecordType.BankIntegrated); - if (rec?.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) { - t.equal( - rec.wgInfo.bankInfo.talerWithdrawUri, - "taler://withdraw/example/second", - "record and index must agree", - ); - } - }, - }, - - { - name: "withdrawal group: every wgInfo variant round trips", - async run(t, runner) { - const variants: WgInfo[] = [ - { - withdrawalType: WithdrawalRecordType.BankManual, - exchangeCreditAccounts: [], - }, - { - withdrawalType: WithdrawalRecordType.PeerPullCredit, - contractPriv: ck("cpriv-1"), - }, - { withdrawalType: WithdrawalRecordType.PeerPushCredit }, - { withdrawalType: WithdrawalRecordType.Recoup }, - ]; - for (let i = 0; i < variants.length; i++) { - const wg = makeWithdrawalGroup(`wg-var-${i}`); - wg.wgInfo = variants[i]; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - const got = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroup(`wg-var-${i}`), - ); - t.deepEqual( - got?.wgInfo, - variants[i], - `variant ${variants[i].withdrawalType} must round trip`, - ); - } - }, - }, - - { - name: "withdrawal group: switching variant clears the old variant's data", - async run(t, runner) { - // A bank-integrated group carries bankInfo and a URI; after switching - // to a manual withdrawal neither may survive as a stale column. - const wg = makeWithdrawalGroup("wg-switch"); - wg.wgInfo = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - bankInfo: makeBankInfo("taler://withdraw/example/switch"), - }; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - wg.wgInfo = { withdrawalType: WithdrawalRecordType.BankManual }; - await runner.runReadWriteTx((tx) => tx.upsertWithdrawalGroup(wg)); - - const got = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroup("wg-switch"), - ); - t.deepEqual(got?.wgInfo, { - withdrawalType: WithdrawalRecordType.BankManual, - }); - const stale = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroupByTalerWithdrawUri( - "taler://withdraw/example/switch", - ), - ); - t.equal(stale, undefined, "the old URI must not still resolve"); - }, - }, - - { - name: "withdrawal group: active groups are the non-final ones", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const pending = makeWithdrawalGroup("wg-act"); - pending.status = WithdrawalGroupStatus.PendingRegisteringBank; - const done = makeWithdrawalGroup("wg-done"); - done.status = WithdrawalGroupStatus.Done; - await tx.upsertWithdrawalGroup(pending); - await tx.upsertWithdrawalGroup(done); - }); - const active = await runner.runReadWriteTx((tx) => - tx.getActiveWithdrawalGroups(), - ); - const ids = active.map((w) => w.withdrawalGroupId); - t.ok(ids.includes("wg-act"), "a pending group must be active"); - t.ok(!ids.includes("wg-done"), "a finished group must not be active"); - }, - }, - - { - name: "withdrawal group: query and count by exchange agree", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - for (let i = 0; i < 3; i++) { - const wg = makeWithdrawalGroup(`wg-ex-${i}`); - wg.exchangeBaseUrl = "https://wex/"; - await tx.upsertWithdrawalGroup(wg); - } - const other = makeWithdrawalGroup("wg-other"); - other.exchangeBaseUrl = "https://wother/"; - await tx.upsertWithdrawalGroup(other); - }); - const list = await runner.runReadWriteTx((tx) => - tx.getWithdrawalGroupsByExchange("https://wex/"), - ); - t.equal(list.length, 3); - const count = await runner.runReadWriteTx((tx) => - tx.countWithdrawalGroupsByExchange("https://wex/"), - ); - t.equal(count, 3, "count must agree with the list, not be capped at 1"); - }, - }, - - { - name: "withdrawal group: delete", - async run(t, runner) { - await runner.runReadWriteTx((tx) => - tx.upsertWithdrawalGroup(makeWithdrawalGroup("wg-del")), - ); - await runner.runReadWriteTx((tx) => tx.deleteWithdrawalGroup("wg-del")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getWithdrawalGroup("wg-del")), - undefined, - ); - }, - }, - - // ------------------------------------------------------------ planchets - - { - name: "planchet: round trips and is addressable by group and index", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedWithdrawalGroup(tx, "wg-p"); - await tx.upsertPlanchet(makePlanchet("pl-1", "wg-p", 0)); - await tx.upsertPlanchet(makePlanchet("pl-2", "wg-p", 1)); - }); - const byIdx = await runner.runReadWriteTx((tx) => - tx.getPlanchetByGroupAndIndex("wg-p", 1), - ); - t.equal(byIdx?.coinPub, ck("pl-2")); - const direct = await runner.runReadWriteTx((tx) => - tx.getPlanchet(ck("pl-1")), - ); - t.deepEqual(direct, makePlanchet("pl-1", "wg-p", 0)); - }, - }, - - { - name: "planchet: lastError is a present key even when undefined", - async run(t, runner) { - const pl = makePlanchet("pl-err", "wg-e", 0); - pl.lastError = undefined; - await runner.runReadWriteTx(async (tx) => { - await seedWithdrawalGroup(tx, "wg-e"); - await tx.upsertPlanchet(pl); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getPlanchet(ck("pl-err")), - ); - t.ok( - got !== undefined && "lastError" in got, - "lastError is declared as a required key", - ); - t.equal(got?.lastError, undefined); - }, - }, - - { - name: "planchet: list, count and bulk delete by group", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedWithdrawalGroup(tx, "wg-bulk"); - await seedWithdrawalGroup(tx, "wg-keep"); - for (let i = 0; i < 4; i++) { - await tx.upsertPlanchet(makePlanchet(`pl-g${i}`, "wg-bulk", i)); - } - await tx.upsertPlanchet(makePlanchet("pl-keep", "wg-keep", 0)); - }); - t.equal( - (await runner.runReadWriteTx((tx) => tx.getPlanchetsByGroup("wg-bulk"))) - .length, - 4, - ); - t.equal( - await runner.runReadWriteTx((tx) => - tx.countPlanchetsByGroup("wg-bulk"), - ), - 4, - "count must agree with the list", - ); - await runner.runReadWriteTx((tx) => tx.deletePlanchetsByGroup("wg-bulk")); - t.equal( - (await runner.runReadWriteTx((tx) => tx.getPlanchetsByGroup("wg-bulk"))) - .length, - 0, - ); - t.ok( - await runner.runReadWriteTx((tx) => tx.getPlanchet(ck("pl-keep"))), - "another group must be untouched", - ); - }, - }, - // -------------------------------------------------- transaction meta - - { - name: "transaction meta: round trips and updates in place", - async run(t, runner) { - const rec: WalletTransactionMeta = { - transactionId: "txn:meta:1", - timestamp: tsPrecise(500), - status: WithdrawalGroupStatus.PendingRegisteringBank, - currency: "TESTKUDOS", - exchanges: ["https://e1/", "https://e2/"], - }; - await runner.runReadWriteTx((tx) => tx.upsertTransactionMeta(rec)); - let got = await runner.runReadWriteTx((tx) => - tx.getTransactionMeta("txn:meta:1"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - rec.status = WithdrawalGroupStatus.Done; - await runner.runReadWriteTx((tx) => tx.upsertTransactionMeta(rec)); - got = await runner.runReadWriteTx((tx) => - tx.getTransactionMeta("txn:meta:1"), - ); - t.equal(got?.status, WithdrawalGroupStatus.Done); - const all = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({}), - ); - t.equal( - all.filter((m) => m.transactionId === "txn:meta:1").length, - 1, - "an update must not append a second row", - ); - }, - }, - - { - name: "transaction meta: before is inclusive, after-cursor is exclusive", - async run(t, runner) { - // These bounds come from IndexedDB key ranges: upperBound(ts, false) - // and lowerBound(ts, false) are inclusive, while the pagination cursor - // uses lowerBound(ts, true) -- exclusive. Getting one of them wrong - // either skips a transaction or loops on it forever. - await runner.runReadWriteTx(async (tx) => { - for (const [id, at] of [ - ["txn:p:10", 10], - ["txn:p:20", 20], - ["txn:p:30", 30], - ] as const) { - await tx.upsertTransactionMeta({ - transactionId: id, - timestamp: tsPrecise(at), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - } - }); - const before = await runner.runReadWriteTx((tx) => - tx.getTransactionMetaBefore(tsPrecise(20)), - ); - t.equal(before?.transactionId, "txn:p:20", "before is inclusive"); - const after = await runner.runReadWriteTx((tx) => - tx.getTransactionMetaAfter(tsPrecise(20)), - ); - t.equal(after?.transactionId, "txn:p:20", "after is inclusive"); - const at = await runner.runReadWriteTx((tx) => - tx.getTransactionMetaAtTimestamp(tsPrecise(30)), - ); - t.equal(at?.transactionId, "txn:p:30"); - const page = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(20) }), - ); - t.deepEqual( - page.map((m) => m.transactionId), - ["txn:p:30"], - "the pagination cursor is exclusive", - ); - }, - }, - - { - name: "global currency: adding the same entry twice is a no-op", - async run(t, runner) { - const exch = { - currency: "TESTKUDOS", - exchangeBaseUrl: "https://exchange.example.com/", - exchangeMasterPub: ck("gc-master-1"), - }; - const auditor = { - currency: "TESTKUDOS", - auditorBaseUrl: "https://auditor.example.com/", - auditorPub: ck("gc-auditor-1"), - }; - await runner.runReadWriteTx(async (tx) => { - await tx.upsertGlobalCurrencyExchange(exch); - await tx.upsertGlobalCurrencyAuditor(auditor); - }); - - // The duplicates go in the same transaction as a write that must - // survive them: on a store keyed by a generated id, a second row for the - // same entry violates the unique index, and that takes down everything - // else the transaction did. - await runner.runReadWriteTx(async (tx) => { - await tx.upsertGlobalCurrencyExchange(exch); - await tx.upsertGlobalCurrencyAuditor(auditor); - await tx.upsertGlobalCurrencyExchange({ - ...exch, - exchangeMasterPub: ck("gc-master-2"), - }); - }); - - const exchanges = await runner.runReadWriteTx((tx) => - tx.listGlobalCurrencyExchanges(), - ); - t.equal( - exchanges.filter((x) => x.exchangeMasterPub === ck("gc-master-1")) - .length, - 1, - "the duplicate must not have added a row", - ); - t.equal( - exchanges.filter((x) => x.exchangeMasterPub === ck("gc-master-2")) - .length, - 1, - "the write alongside the duplicates must have survived", - ); - - const auditors = await runner.runReadWriteTx((tx) => - tx.listGlobalCurrencyAuditors(), - ); - t.equal( - auditors.filter((x) => x.auditorPub === ck("gc-auditor-1")).length, - 1, - "the duplicate auditor must not have added a row", - ); - }, - }, - - { - name: "auditor scope: membership is denomination-specific", - async run(t, runner) { - const exchangeBaseUrl = "https://audited-exchange.example/"; - const auditorBaseUrl = "https://auditor.example/"; - const auditorPub = ck("scope-auditor"); - await runner.runReadWriteTx(async (tx) => { - const details = makeExchangeDetails(exchangeBaseUrl, "scope-master"); - details.auditors = [ - { - auditor_url: auditorBaseUrl, - auditor_pub: auditorPub, - auditor_name: "Scope Auditor", - denomination_keys: [ - { - denom_pub_h: ckh("scope-denom-a"), - auditor_sig: ck("scope-auditor-sig"), - }, - ], - walletAuditorSignaturesVerified: true, - }, - ]; - await tx.upsertExchangeDetails(details); - const exchange = makeExchange(exchangeBaseUrl); - exchange.detailsPointer = { - masterPublicKey: details.masterPublicKey, - currency: details.currency, - updateClock: tsPrecise(1), - }; - await tx.upsertExchange(exchange); - await tx.upsertGlobalCurrencyAuditor({ - currency: details.currency, - auditorBaseUrl, - auditorPub, - }); - }); - - const scope = { - type: ScopeType.Auditor as const, - currency: "TESTKUDOS", - url: auditorBaseUrl, - }; - const results = await runner.runReadWriteTx(async (tx) => ({ - exact: await tx.checkExchangeInScope( - exchangeBaseUrl, - scope, - ckh("scope-denom-a"), - ), - other: await tx.checkExchangeInScope( - exchangeBaseUrl, - scope, - ckh("scope-denom-b"), - ), - any: await tx.checkExchangeInScope(exchangeBaseUrl, scope), - exactScope: await tx.getExchangeScopeInfo( - exchangeBaseUrl, - "TESTKUDOS", - ckh("scope-denom-a"), - ), - otherScope: await tx.getExchangeScopeInfo( - exchangeBaseUrl, - "TESTKUDOS", - ckh("scope-denom-b"), - ), - exchangeScope: await tx.getExchangeScopeInfo( - exchangeBaseUrl, - "TESTKUDOS", - ), - })); - t.equal(results.exact, true); - t.equal(results.other, false); - t.equal(results.any, true, "exchange prefilters use existential scope"); - t.equal(results.exactScope.type, ScopeType.Auditor); - t.equal(results.otherScope.type, ScopeType.Exchange); - t.equal( - results.exchangeScope.type, - ScopeType.Exchange, - "an exchange without a denomination context is not wholly audited", - ); - }, - }, - - { - name: "transaction meta: the timestamp cursor cannot separate a tie", - async run(t, runner) { - // Timestamps are not unique, and the pagination cursor is a bound on - // the timestamp alone. Advancing it past the last record of a page - // therefore drops every other record that shares that timestamp, which - // is why listing transactions walks the ordered list instead of paging - // through this method. - await runner.runReadWriteTx(async (tx) => { - for (const id of ["txn:tie:a", "txn:tie:b", "txn:tie:c"]) { - await tx.upsertTransactionMeta({ - transactionId: id, - timestamp: tsPrecise(700), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - } - }); - const all = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({}), - ); - t.equal( - all.filter((m) => m.transactionId.startsWith("txn:tie:")).length, - 3, - "all three share one timestamp", - ); - const afterTie = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({ afterTimestamp: tsPrecise(700) }), - ); - t.equal( - afterTie.filter((m) => m.transactionId.startsWith("txn:tie:")).length, - 0, - "continuing after the timestamp skips the whole tie, not just the part already seen", - ); - }, - }, - - { - name: "transaction meta: compound cursor pages through timestamp ties", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - for (const id of ["txn:page:c", "txn:page:a", "txn:page:b"]) { - await tx.upsertTransactionMeta({ - transactionId: id, - timestamp: tsPrecise(710), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - } - }); - const first = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaPage({ direction: "forward", limit: 2 }), - ); - const last = first[first.length - 1]; - const second = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaPage({ - direction: "forward", - limit: 2, - cursor: { - timestamp: last.timestamp, - transactionId: last.transactionId, - }, - }), - ); - const ids = [...first, ...second] - .map((x) => x.transactionId) - .filter((x) => x.startsWith("txn:page:")); - t.deepEqual(ids, ["txn:page:a", "txn:page:b", "txn:page:c"]); - const backwards = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaPage({ - direction: "backward", - limit: 3, - cursor: { timestamp: tsPrecise(710), transactionId: "\uffff" }, - }), - ); - t.deepEqual( - backwards - .map((x) => x.transactionId) - .filter((x) => x.startsWith("txn:page:")), - ["txn:page:c", "txn:page:b", "txn:page:a"], - ); - }, - }, - - { - name: "transaction meta: ordered by timestamp, and limited", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - for (const [id, at] of [ - ["txn:o:c", 300], - ["txn:o:a", 100], - ["txn:o:b", 200], - ] as const) { - await tx.upsertTransactionMeta({ - transactionId: id, - timestamp: tsPrecise(at), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - } - }); - const all = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({}), - ); - const mine = all.filter((m) => m.transactionId.startsWith("txn:o:")); - t.deepEqual( - mine.map((m) => m.transactionId), - ["txn:o:a", "txn:o:b", "txn:o:c"], - "must be ordered by timestamp ascending, not insertion order", - ); - const limited = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({ limit: 1 }), - ); - t.equal(limited.length, 1); - }, - }, - - { - name: "transaction meta: onlyActive selects the non-final range", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertTransactionMeta({ - transactionId: "txn:act:1", - timestamp: tsPrecise(1), - status: WithdrawalGroupStatus.PendingRegisteringBank, - currency: "TESTKUDOS", - exchanges: [], - }); - await tx.upsertTransactionMeta({ - transactionId: "txn:act:2", - timestamp: tsPrecise(2), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - }); - const active = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByStatus({ onlyActive: true }), - ); - const ids = active.map((m) => m.transactionId); - t.ok(ids.includes("txn:act:1")); - t.ok(!ids.includes("txn:act:2"), "a final state must not be active"); - const all = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByStatus({ onlyActive: false }), - ); - t.ok(all.length >= active.length); - }, - }, - - { - name: "transaction meta: delete one and delete all", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertTransactionMeta({ - transactionId: "txn:d:1", - timestamp: tsPrecise(1), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - await tx.upsertTransactionMeta({ - transactionId: "txn:d:2", - timestamp: tsPrecise(2), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - }); - await runner.runReadWriteTx((tx) => tx.deleteTransactionMeta("txn:d:1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getTransactionMeta("txn:d:1")), - undefined, - ); - t.ok( - await runner.runReadWriteTx((tx) => tx.getTransactionMeta("txn:d:2")), - ); - await runner.runReadWriteTx((tx) => tx.deleteAllTransactionMeta()); - const left = await runner.runReadWriteTx((tx) => - tx.listTransactionMetaByTimestamp({}), - ); - t.equal(left.length, 0, "deleteAll must clear the whole view"); - }, - }, - - // ---------------------------------------------------------- purchases - - { - name: "purchase: round trips including the exchange list", - async run(t, runner) { - const p = makePurchase("prop-rt"); - p.exchanges = ["https://ex-a/", "https://ex-b/"]; - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - const got = await runner.runReadWriteTx((tx) => - tx.getPurchase("prop-rt"), - ); - t.deepEqual(got, p, "every field must survive, exchange order included"); - }, - }, - - { - name: "purchase: batch lookup preserves IDs and exchange hydration", - async run(t, runner) { - const a = makePurchase("prop-batch-a"); - a.exchanges = ["https://batch-a/"]; - const b = makePurchase("prop-batch-b"); - b.exchanges = ["https://batch-b/", "https://batch-c/"]; - await runner.runReadWriteTx(async (tx) => { - await tx.upsertPurchase(a); - await tx.upsertPurchase(b); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getPurchasesByIds(["prop-batch-b", "prop-missing", "prop-batch-a"]), - ); - t.deepEqual( - got.map((x) => x.proposalId), - ["prop-batch-b", "prop-batch-a"], - ); - t.deepEqual(got[0].exchanges, b.exchanges); - t.deepEqual(got[1].exchanges, a.exchanges); - }, - }, - - { - name: "purchase: the exchange list has exactly one stored copy", - async run(t, runner) { - // The native schema keeps this in a junction table rather than a JSON - // column plus a multiEntry index. Shrinking the list must therefore - // remove rows, not leave a stale one that byExchange still matches. - const p = makePurchase("prop-ex"); - p.exchanges = ["https://keep/", "https://drop/"]; - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getPurchasesByExchange("https://drop/"), - ) - ).length, - 1, - ); - p.exchanges = ["https://keep/"]; - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - const stillDropped = await runner.runReadWriteTx((tx) => - tx.getPurchasesByExchange("https://drop/"), - ); - t.equal(stillDropped.length, 0, "the removed exchange must not match"); - const kept = await runner.runReadWriteTx((tx) => - tx.getPurchasesByExchange("https://keep/"), - ); - t.equal(kept.length, 1); - t.deepEqual(kept[0].exchanges, ["https://keep/"]); - }, - }, - - { - name: "purchase: the fulfillment URL has exactly one stored copy", - async run(t, runner) { - const p = makePurchase("prop-ff"); - p.download = makeDownloadInfo("https://shop/fulfil/first"); - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - p.download = makeDownloadInfo("https://shop/fulfil/second"); - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - const byOld = await runner.runReadWriteTx((tx) => - tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/first"), - ); - t.equal(byOld.length, 0, "the old URL must no longer resolve"); - const byNew = await runner.runReadWriteTx((tx) => - tx.getPurchasesByFulfillmentUrl("https://shop/fulfil/second"), - ); - t.equal(byNew.length, 1); - t.equal( - byNew[0].download?.fulfillmentUrl, - "https://shop/fulfil/second", - "record and index must agree", - ); - }, - }, - - { - name: "purchase: a download without a fulfillment URL round trips", - async run(t, runner) { - const p = makePurchase("prop-nf"); - const dl = makeDownloadInfo(undefined); - p.download = dl; - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - const got = await runner.runReadWriteTx((tx) => - tx.getPurchase("prop-nf"), - ); - t.deepEqual(got?.download, dl); - t.equal(got?.download?.fulfillmentUrl, undefined); - }, - }, - - { - name: "purchase: lookup by merchant URL and order id", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const a = makePurchase("prop-o1"); - a.merchantBaseUrl = "https://m1/"; - a.orderId = "order-1"; - const b = makePurchase("prop-o2"); - b.merchantBaseUrl = "https://m1/"; - b.orderId = "order-2"; - await tx.upsertPurchase(a); - await tx.upsertPurchase(b); - }); - const one = await runner.runReadWriteTx((tx) => - tx.getPurchaseByUrlAndOrderId("https://m1/", "order-2"), - ); - t.equal(one?.proposalId, "prop-o2"); - const many = await runner.runReadWriteTx((tx) => - tx.getPurchasesByUrlAndOrderId("https://m1/", "order-1"), - ); - t.equal(many.length, 1); - const none = await runner.runReadWriteTx((tx) => - tx.getPurchaseByUrlAndOrderId("https://m1/", "no-such-order"), - ); - t.equal(none, undefined); - }, - }, - - { - name: "purchase: delete removes the purchase and its exchange rows", - async run(t, runner) { - const p = makePurchase("prop-del"); - p.exchanges = ["https://gone/"]; - await runner.runReadWriteTx((tx) => tx.upsertPurchase(p)); - await runner.runReadWriteTx((tx) => tx.deletePurchase("prop-del")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getPurchase("prop-del")), - undefined, - ); - const orphaned = await runner.runReadWriteTx((tx) => - tx.getPurchasesByExchange("https://gone/"), - ); - t.equal(orphaned.length, 0, "no orphaned exchange rows may remain"); - }, - }, - - { - name: "purchase: status filters and the active range", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const pending = makePurchase("prop-s1"); - pending.purchaseStatus = PurchaseStatus.PendingDownloadingProposal; - const failed = makePurchase("prop-s2"); - failed.purchaseStatus = PurchaseStatus.Failed; - await tx.upsertPurchase(pending); - await tx.upsertPurchase(failed); - }); - const byStatus = await runner.runReadWriteTx((tx) => - tx.getPurchasesByStatus(PurchaseStatus.Failed), - ); - t.equal(byStatus.length, 1); - t.equal(byStatus[0].proposalId, "prop-s2"); - const active = await runner.runReadWriteTx((tx) => - tx.getActivePurchases(), - ); - const ids = active.map((p) => p.proposalId); - t.ok(ids.includes("prop-s1")); - t.ok(!ids.includes("prop-s2"), "a failed purchase is not active"); - const all = await runner.runReadWriteTx((tx) => tx.listAllPurchases()); - t.equal(all.length, 2); - }, - }, - // ------------------------------------- remaining domains: round trips - // - // Each of these stores a record with its optional fields unset and its - // nested JSON populated, reads it back whole, and checks that the - // active-status query agrees with the non-final range. The active queries - // are the ones worth pinning: in IndexedDB they are a key range over a - // status index, and in SQL a BETWEEN -- an off-by-one at either bound - // silently drops a transaction from the wallet's task list. - - { - name: "deposit group: round trip and active range", - async run(t, runner) { - const dg = makeDepositGroup("dg-1"); - dg.kycAuthTransferOptions = [ - { - type: "payto", - paytoUri: "payto://iban/DE2?amount=TESTKUDOS%3A0.01&message=legacy", - kycAuthAccountPaytoUri: "payto://iban/DE2", - kycAuthTransferExpiry: TalerProtocolTimestamp.fromSeconds(7777), - }, - ]; - dg.kycAuthTransferExpiry = TalerProtocolTimestamp.fromSeconds(7777); - await runner.runReadWriteTx((tx) => tx.upsertDepositGroup(dg)); - const got = await runner.runReadWriteTx((tx) => - tx.getDepositGroup("dg-1"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(dg)); - const done = makeDepositGroup("dg-2"); - done.operationStatus = DepositOperationStatus.Finished; - await runner.runReadWriteTx((tx) => tx.upsertDepositGroup(done)); - const active = await runner.runReadWriteTx((tx) => - tx.getActiveDepositGroups(), - ); - const ids = active.map((d) => d.depositGroupId); - t.ok(ids.includes("dg-1")); - t.ok(!ids.includes("dg-2"), "a finished group is not active"); - t.equal( - (await runner.runReadWriteTx((tx) => tx.listAllDepositGroups())).length, - 2, - ); - await runner.runReadWriteTx((tx) => tx.deleteDepositGroup("dg-1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getDepositGroup("dg-1")), - undefined, - ); - }, - }, - - { - name: "refresh group: round trip, active range and originating lookup", - async run(t, runner) { - const rg = makeRefreshGroup("rg-1"); - rg.originatingTransactionId = "txn:orig:1"; - await runner.runReadWriteTx((tx) => tx.upsertRefreshGroup(rg)); - const got = await runner.runReadWriteTx((tx) => - tx.getRefreshGroup("rg-1"), - ); - t.deepEqual( - withoutUndefined(got), - withoutUndefined(rg), - "nested per-coin arrays must survive", - ); - const done = makeRefreshGroup("rg-2"); - done.operationStatus = RefreshOperationStatus.Finished; - await runner.runReadWriteTx((tx) => tx.upsertRefreshGroup(done)); - const active = await runner.runReadWriteTx((tx) => - tx.getActiveRefreshGroups(), - ); - t.ok(active.map((r) => r.refreshGroupId).includes("rg-1")); - t.ok(!active.map((r) => r.refreshGroupId).includes("rg-2")); - const byOrig = await runner.runReadWriteTx((tx) => - tx.getRefreshGroupsByOriginatingTransaction("txn:orig:1"), - ); - t.equal(byOrig.length, 1); - t.equal( - (await runner.runReadWriteTx((tx) => tx.listAllRefreshGroups())).length, - 2, - ); - await runner.runReadWriteTx((tx) => tx.deleteRefreshGroup("rg-1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getRefreshGroup("rg-1")), - undefined, - ); - }, - }, - - { - name: "refresh session: keyed by (group, coin index)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await seedRefreshGroup(tx, "rs-g"); - await seedRefreshGroup(tx, "rs-other"); - await tx.upsertRefreshSession(makeRefreshSession("rs-g", 0)); - await tx.upsertRefreshSession(makeRefreshSession("rs-g", 1)); - await tx.upsertRefreshSession(makeRefreshSession("rs-other", 0)); - }); - const one = await runner.runReadWriteTx((tx) => - tx.getRefreshSession("rs-g", 1), - ); - t.equal(one?.coinIndex, 1); - const byGroup = await runner.runReadWriteTx((tx) => - tx.getRefreshSessionsByGroup("rs-g"), - ); - t.equal(byGroup.length, 2); - t.deepEqual( - byGroup.map((r) => r.coinIndex), - [0, 1], - "sessions must come back in coin-index order", - ); - await runner.runReadWriteTx((tx) => tx.deleteRefreshSession("rs-g", 0)); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getRefreshSessionsByGroup("rs-g"), - ) - ).length, - 1, - ); - }, - }, - - { - name: "refresh session: round trip with the melt fields set and unset", - async run(t, runner) { - const fresh = makeRefreshSession("rs-melt", 0); - const melted = makeRefreshSession("rs-melt", 1); - melted.sessionPublicSeed = ckh("seed-melt"); - melted.refreshProtocolVersion = 32; - melted.norevealIndex = 2; - await runner.runReadWriteTx(async (tx) => { - await seedRefreshGroup(tx, "rs-melt"); - await tx.upsertRefreshSession(fresh); - await tx.upsertRefreshSession(melted); - }); - const gotFresh = await runner.runReadWriteTx((tx) => - tx.getRefreshSession("rs-melt", 0), - ); - t.deepEqual(withoutUndefined(gotFresh), withoutUndefined(fresh)); - // An absent protocol version means the v27 refresh protocol, so it must - // not come back as some other value. - t.equal(gotFresh?.refreshProtocolVersion, undefined); - const gotMelted = await runner.runReadWriteTx((tx) => - tx.getRefreshSession("rs-melt", 1), - ); - t.deepEqual(withoutUndefined(gotMelted), withoutUndefined(melted)); - }, - }, - - { - name: "recoup group: round trip, by exchange and active range", - async run(t, runner) { - const rc = makeRecoupGroup("rc-1", "https://rex/"); - await runner.runReadWriteTx((tx) => tx.upsertRecoupGroup(rc)); - const got = await runner.runReadWriteTx((tx) => - tx.getRecoupGroup("rc-1"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rc)); - const done = makeRecoupGroup("rc-2", "https://rex/"); - done.operationStatus = RecoupOperationStatus.Finished; - await runner.runReadWriteTx((tx) => tx.upsertRecoupGroup(done)); - const byEx = await runner.runReadWriteTx((tx) => - tx.getRecoupGroupsByExchange("https://rex/"), - ); - t.equal(byEx.length, 2); - const active = await runner.runReadWriteTx((tx) => - tx.getActiveRecoupGroups(), - ); - t.ok(active.map((r) => r.recoupGroupId).includes("rc-1")); - t.ok(!active.map((r) => r.recoupGroupId).includes("rc-2")); - await runner.runReadWriteTx((tx) => tx.deleteRecoupGroup("rc-1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getRecoupGroup("rc-1")), - undefined, - ); - }, - }, - - { - name: "peer push debit: round trip and active range", - async run(t, runner) { - const rec = makePeerPushDebit("ppd-1"); - await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getPeerPushDebit(ck("ppd-1")), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - const done = makePeerPushDebit("ppd-2"); - done.status = PeerPushDebitStatus.Done; - await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(done)); - // The clean-up after an expired purse still has to reclaim the coins, - // so it must be picked up like any other unfinished transaction. - const expiring = makePeerPushDebit("ppd-3"); - expiring.status = PeerPushDebitStatus.ExpiredDeletePurse; - await runner.runReadWriteTx((tx) => tx.upsertPeerPushDebit(expiring)); - const active = await runner.runReadWriteTx((tx) => - tx.getActivePeerPushDebits(), - ); - t.ok(active.map((r) => r.pursePub).includes(ck("ppd-1"))); - t.ok(active.map((r) => r.pursePub).includes(ck("ppd-3"))); - t.ok(!active.map((r) => r.pursePub).includes(ck("ppd-2"))); - t.equal( - (await runner.runReadWriteTx((tx) => tx.listAllPeerPushDebits())) - .length, - 3, - ); - await runner.runReadWriteTx((tx) => tx.deletePeerPushDebit(ck("ppd-1"))); - t.equal( - await runner.runReadWriteTx((tx) => tx.getPeerPushDebit(ck("ppd-1"))), - undefined, - ); - }, - }, - - { - name: "peer push credit: round trip, contract-priv lookup, active range", - async run(t, runner) { - const rec = makePeerPushCredit("ppc-1"); - rec.contractPriv = ck("cpriv-find-me"); - await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getPeerPushCredit("ppc-1"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - const byPriv = await runner.runReadWriteTx((tx) => - tx.getPeerPushCreditByExchangeAndContractPriv( - rec.exchangeBaseUrl, - ck("cpriv-find-me"), - ), - ); - t.equal(byPriv?.peerPushCreditId, "ppc-1"); - const wrongExchange = await runner.runReadWriteTx((tx) => - tx.getPeerPushCreditByExchangeAndContractPriv( - "https://other/", - ck("cpriv-find-me"), - ), - ); - t.equal(wrongExchange, undefined, "both components must match"); - const done = makePeerPushCredit("ppc-2"); - done.status = PeerPushCreditStatus.Done; - await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(done)); - const active = await runner.runReadWriteTx((tx) => - tx.getActivePeerPushCredits(), - ); - t.ok(active.map((r) => r.peerPushCreditId).includes("ppc-1")); - t.ok(!active.map((r) => r.peerPushCreditId).includes("ppc-2")); - await runner.runReadWriteTx((tx) => tx.deletePeerPushCredit("ppc-1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getPeerPushCredit("ppc-1")), - undefined, - ); - }, - }, - - { - name: "peer push credit: exchange and contract private key are unique", - async run(t, runner) { - const first = makePeerPushCredit("ppc-unique-1"); - first.contractPriv = ck("shared-push-contract-priv"); - const duplicate = makePeerPushCredit("ppc-unique-2"); - duplicate.contractPriv = first.contractPriv; - await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(first)); - let rejected = false; - try { - await runner.runReadWriteTx((tx) => tx.upsertPeerPushCredit(duplicate)); - } catch { - rejected = true; - } - t.ok(rejected, "a duplicate payment capability must be rejected"); - const records = await runner.runReadWriteTx((tx) => - tx.listAllPeerPushCredits(), - ); - t.equal(records.length, 1, "the original payment must be retained"); - t.equal(records[0].peerPushCreditId, first.peerPushCreditId); - }, - }, - - { - name: "peer pull debit: round trip, contract-priv lookup, active range", - async run(t, runner) { - const rec = makePeerPullDebit("ppld-1"); - rec.contractPriv = ck("cpriv-pull"); - rec.coinSel = { - coinPubs: [ck("pull-coin-1"), ck("pull-coin-2")], - contributions: [amt("TESTKUDOS:1"), amt("TESTKUDOS:2")], - totalCost: amt("TESTKUDOS:3.1"), - depositedCoinCount: 1, - confirmedPurseBalance: amt("TESTKUDOS:1"), - }; - await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getPeerPullDebit("ppld-1"), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - const byPriv = await runner.runReadWriteTx((tx) => - tx.getPeerPullDebitByExchangeAndContractPriv( - rec.exchangeBaseUrl, - ck("cpriv-pull"), - ), - ); - t.equal(byPriv?.peerPullDebitId, "ppld-1"); - const done = makePeerPullDebit("ppld-2"); - done.status = PeerPullDebitRecordStatus.Done; - await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(done)); - const active = await runner.runReadWriteTx((tx) => - tx.getActivePeerPullDebits(), - ); - t.ok(active.map((r) => r.peerPullDebitId).includes("ppld-1")); - t.ok(!active.map((r) => r.peerPullDebitId).includes("ppld-2")); - t.equal( - (await runner.runReadWriteTx((tx) => tx.listAllPeerPullDebits())) - .length, - 2, - ); - await runner.runReadWriteTx((tx) => tx.deletePeerPullDebit("ppld-1")); - t.equal( - await runner.runReadWriteTx((tx) => tx.getPeerPullDebit("ppld-1")), - undefined, - ); - }, - }, - - { - name: "peer pull debit: exchange and contract private key are unique", - async run(t, runner) { - const first = makePeerPullDebit("ppld-unique-1"); - first.contractPriv = ck("shared-pull-contract-priv"); - const duplicate = makePeerPullDebit("ppld-unique-2"); - duplicate.contractPriv = first.contractPriv; - await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(first)); - let rejected = false; - try { - await runner.runReadWriteTx((tx) => tx.upsertPeerPullDebit(duplicate)); - } catch { - rejected = true; - } - t.ok(rejected, "a duplicate payment capability must be rejected"); - const records = await runner.runReadWriteTx((tx) => - tx.listAllPeerPullDebits(), - ); - t.equal(records.length, 1, "the original payment must be retained"); - t.equal(records[0].peerPullDebitId, first.peerPullDebitId); - }, - }, - - { - name: "peer pull credit: round trip and active range", - async run(t, runner) { - const rec = makePeerPullCredit("pplc-1"); - await runner.runReadWriteTx((tx) => tx.upsertPeerPullCredit(rec)); - const got = await runner.runReadWriteTx((tx) => - tx.getPeerPullCredit(ck("pplc-1")), - ); - t.deepEqual(withoutUndefined(got), withoutUndefined(rec)); - const done = makePeerPullCredit("pplc-2"); - done.status = PeerPullPaymentCreditStatus.Done; - await runner.runReadWriteTx((tx) => tx.upsertPeerPullCredit(done)); - const active = await runner.runReadWriteTx((tx) => - tx.getActivePeerPullCredits(), - ); - 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.runReadWriteTx((tx) => tx.listAllPeerPullCredits())) - .length, - 2, - ); - await runner.runReadWriteTx((tx) => - tx.deletePeerPullCredit(ck("pplc-1")), - ); - t.equal( - await runner.runReadWriteTx((tx) => tx.getPeerPullCredit(ck("pplc-1"))), - undefined, - ); - }, - }, - // ------------------------------------------------------ tokens / slates - - { - name: "token: round trip, including the inherited family fields", - async run(t, runner) { - // WalletToken extends TokenFamilyInfo, so slug/name/description/ - // extraData/tokenIssuePub are part of the record even though they are - // declared in a different interface. Reading the type through only - // its own body once cost five silently dropped columns here. - const tok = makeToken("tk-1"); - await runner.runReadWriteTx((tx) => tx.upsertToken(tok)); - const got = await runner.runReadWriteTx((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); - t.deepEqual(got?.extraData, tok.extraData); - t.deepEqual(got?.tokenEv, tok.tokenEv); - t.equal(got?.blindingKey, tok.blindingKey); - }, - }, - - { - name: "token: lookup by issue pub hash, list and delete", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const a = makeToken("tk-a"); - a.tokenIssuePubHash = ckh("tiph-shared"); - const b = makeToken("tk-b"); - b.tokenIssuePubHash = ckh("tiph-shared"); - const c = makeToken("tk-c"); - c.tokenIssuePubHash = ckh("tiph-other"); - await tx.upsertToken(a); - await tx.upsertToken(b); - await tx.upsertToken(c); - }); - const byHash = await runner.runReadWriteTx((tx) => - tx.getTokensByIssuePubHash(ckh("tiph-shared")), - ); - t.equal(byHash.length, 2); - t.equal((await runner.runReadWriteTx((tx) => tx.listTokens())).length, 3); - await runner.runReadWriteTx((tx) => tx.deleteToken(ck("tk-a"))); - t.equal( - await runner.runReadWriteTx((tx) => tx.getToken(ck("tk-a"))), - undefined, - ); - t.equal( - ( - await runner.runReadWriteTx((tx) => - tx.getTokensByIssuePubHash(ckh("tiph-shared")), - ) - ).length, - 1, - ); - }, - }, - - { - name: "token: lookup by family hash", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - const a = makeToken("tk-family-a"); - a.tokenFamilyHash = ckh("family-shared"); - const b = makeToken("tk-family-b"); - b.tokenFamilyHash = ckh("family-shared"); - const c = makeToken("tk-family-c"); - c.tokenFamilyHash = ckh("family-other"); - await tx.upsertToken(a); - await tx.upsertToken(b); - await tx.upsertToken(c); - }); - const got = await runner.runReadWriteTx((tx) => - tx.getTokensByFamilyHash(ckh("family-shared")), - ); - t.deepEqual( - got.map((x) => x.tokenUsePub).sort(), - [ck("tk-family-a"), ck("tk-family-b")].sort(), - ); - }, - }, - - { - name: "slate: addressed by the full (purchase, choice, output, repeat)", - async run(t, runner) { - await runner.runReadWriteTx(async (tx) => { - await tx.upsertSlate(makeSlate("sl-1", "pur-1", 0, 0, 0)); - await tx.upsertSlate(makeSlate("sl-2", "pur-1", 0, 0, 1)); - await tx.upsertSlate(makeSlate("sl-3", "pur-1", 0, 1, 0)); - await tx.upsertSlate(makeSlate("sl-4", "pur-1", 1, 0, 0)); - }); - const one = await runner.runReadWriteTx((tx) => - tx.getSlate("pur-1", 0, 0, 1), - ); - t.equal( - one?.tokenUsePub, - ck("sl-2"), - "all four components must select the slate", - ); - const byChoice = await runner.runReadWriteTx((tx) => - tx.getSlatesByPurchaseAndChoice("pur-1", 0), - ); - t.equal(byChoice.length, 3, "choice 1 must not be included"); - await runner.runReadWriteTx((tx) => tx.deleteSlate(ck("sl-2"))); - t.equal( - await runner.runReadWriteTx((tx) => tx.getSlate("pur-1", 0, 0, 1)), - undefined, - ); - }, - }, - - { - name: "slate: round trip with the use signature set and unset", - async run(t, runner) { - const unsigned = makeSlate("sl-u", "pur-2", 0, 0, 0); - await runner.runReadWriteTx((tx) => tx.upsertSlate(unsigned)); - const gotUnsigned = await runner.runReadWriteTx((tx) => - tx.getSlate("pur-2", 0, 0, 0), - ); - t.deepEqual(withoutUndefined(gotUnsigned), withoutUndefined(unsigned)); - t.equal(gotUnsigned?.tokenUseSig, undefined); - const signed = makeSlate("sl-s", "pur-3", 0, 0, 0); - signed.tokenUseSig = { - token_sig: "tsig", - token_pub: "tpub", - ub_sig: { cipher: DenomKeyType.Rsa, rsa_signature: "usig" }, - h_issue: "hissue", - }; - await runner.runReadWriteTx((tx) => tx.upsertSlate(signed)); - const gotSigned = await runner.runReadWriteTx((tx) => - tx.getSlate("pur-3", 0, 0, 0), - ); - t.deepEqual(gotSigned?.tokenUseSig, signed.tokenUseSig); - }, - }, - { - name: "coin availability: the master public key is part of the identity", - async run(t, runner) { - // The same denomination hash under two master public keys is two - // different denominations, so two different availability rows. Sharing - // one would pool coins the exchange settles with coins it does not. - const a = makeAvail("https://emp/", "d-emp", 0); - a.exchangeMasterPub = ck("master-a"); - a.freshCoinCount = 3; - const b = makeAvail("https://emp/", "d-emp", 0); - b.exchangeMasterPub = ck("master-b"); - b.freshCoinCount = 7; - await runner.runReadWriteTx(async (tx) => { - await tx.upsertCoinAvailability(a); - await tx.upsertCoinAvailability(b); - }); - const gotA = await runner.runReadWriteTx((tx) => - tx.getCoinAvailability({ - exchangeMasterPub: ck("master-a"), - denomPubHash: ckh("d-emp"), - maxAge: 0, - }), - ); - const gotB = await runner.runReadWriteTx((tx) => - tx.getCoinAvailability({ - exchangeMasterPub: ck("master-b"), - denomPubHash: ckh("d-emp"), - maxAge: 0, - }), - ); - t.equal(gotA?.exchangeMasterPub, ck("master-a")); - t.equal(gotB?.exchangeMasterPub, ck("master-b")); - t.equal(gotA?.freshCoinCount, 3); - t.equal(gotB?.freshCoinCount, 7); - }, - }, - { - name: "bounded queries do not scan: limit, cursor and point lookup", - // These are the other places where a correct-looking implementation can - // quietly read the whole table and filter afterwards. The row counter - // is the only way to see it: every one of these returns the right - // answer either way. - async run(t, runner) { - const N = 40; - await runner.runReadWriteTx(async (tx) => { - for (let i = 0; i < N; i++) { - const c = makeCoin(`scan-${i}`); - c.exchangeBaseUrl = "https://scan/"; - c.denomPubHash = ckh("scan-denom"); - c.maxAge = 0; - c.status = CoinStatus.Fresh; - await tx.upsertCoin(c); - await tx.upsertTransactionMeta({ - transactionId: `txn:scan:${String(i).padStart(3, "0")}`, - timestamp: tsPrecise(1000 + i), - status: WithdrawalGroupStatus.Done, - currency: "TESTKUDOS", - exchanges: [], - }); - } - }); - - const measure = async ( - label: string, - limit: number, - f: (tx: WalletDbTransaction) => Promise<unknown>, - ): Promise<void> => { - const before = runner.getAccessStats()?.recordsRead; - await runner.runReadWriteTx(f); - const after = runner.getAccessStats()?.recordsRead; - if (before === undefined || after === undefined) { - return; - } - t.ok( - after - before <= limit, - `${label}: read ${after - before} records, expected at most ${limit}`, - ); - }; - - await measure("getCoin point lookup", 3, (tx) => - tx.getCoin(ck("scan-7")), - ); - await measure("getFreshCoinsByDenomAndAge with limit 5", 8, (tx) => - tx.getFreshCoinsByDenomAndAge( - { - exchangeMasterPub: ck("master-pub"), - denomPubHash: ckh("scan-denom"), - maxAge: 0, - }, - 5, - ), - ); - await measure("listTransactionMetaByTimestamp with limit 5", 8, (tx) => - tx.listTransactionMetaByTimestamp({ limit: 5 }), - ); - await measure("getTransactionMetaAfter", 3, (tx) => - tx.getTransactionMetaAfter(tsPrecise(1010)), - ); - - // Both backends walk the index downwards and stop at the first hit: - // ORDER BY ... DESC LIMIT 1 on sqlite, a "prev" cursor on IndexedDB. - await measure("getTransactionMetaBefore", 3, (tx) => - tx.getTransactionMetaBefore(tsPrecise(1010)), - ); - }, - }, -]; diff --git a/packages/taler-wallet-core/src/dbtx-conformance.ts b/packages/taler-wallet-core/src/dbtx-conformance.ts @@ -1,62 +0,0 @@ -/* - 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/> - */ - -/** - * Conformance suite for {@link WalletDbTransaction}. - * - * These tests describe the contract of the DAL, not the behaviour of any one - * backend. They are written against the interface and parameterised over a - * {@link DbTxRunner}, so the same suite runs against IdbWalletTransaction - * today and against a native sqlite3 implementation later. - * - * Anything backend-specific (how a transaction is opened, how the store is - * created) belongs in the runner, never in a test. - */ - -import { WalletDbTransaction } from "./dbtx.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; - -/** - * The suite runs against a WalletDbHandle -- the same abstraction the wallet - * itself holds, not a parallel one built for tests. A backend that passes the - * suite has therefore been exercised through the interface production code - * uses, including transaction serialisation and post-commit notification. - */ -export type DbTxRunner = WalletDbHandle; - -/** - * A single conformance check. - * - * Kept as plain data so the suite can be enumerated, filtered and reported on - * per implementation, rather than being hard-wired into one test runner. - */ -export interface ConformanceCase { - name: string; - run(t: ConformanceAsserts, runner: DbTxRunner): Promise<void>; -} - -/** - * The assertions a case may use. - * - * Deliberately minimal and framework-agnostic so the suite does not depend on - * node:test, and can be driven from a harness or a browser if needed. - */ -export interface ConformanceAsserts { - equal(actual: unknown, expected: unknown, msg?: string): void; - deepEqual(actual: unknown, expected: unknown, msg?: string): void; - ok(value: unknown, msg?: string): void; - fail(msg: string): never; -} diff --git a/packages/taler-wallet-core/src/dbtx-handle-impl.ts b/packages/taler-wallet-core/src/dbtx-handle-impl.ts @@ -1,424 +0,0 @@ -/* - 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/> - */ - -/** - * The two WalletDbHandle implementations. - * - * Everything that differs between the IndexedDB emulation and the native - * sqlite database is confined to these two classes. - */ - -import { - CancellationToken, - Logger, - WalletNotification, -} from "@gnu-taler/taler-util"; -import { - AccessStats, - BridgeIDBFactory, - IDBDatabase, -} from "@gnu-taler/idb-bridge"; - -import { - abortTalerDatabaseReplacement, - applyFixups, - beginTalerDatabaseReplacement, - clearDatabase, - exportDb, - importDb, - openTalerDatabase, - publishTalerDatabaseReplacement, - retireTalerDatabaseGeneration, - WalletIndexedDbStoresV1, -} from "./db-indexeddb.js"; -import { - WalletDbAccessStats, - WalletDbHandle, - WalletDbImportFinalizer, -} from "./dbtx-handle.js"; -import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; -import { - clearNativeSqliteWalletDb, - exportNativeSqliteDb, - importNativeSqliteDb, - NativeSqliteWalletDb, - runNativeSqliteWalletTx, -} from "./dbtx-sqlite.js"; -import { WalletDbTransaction } from "./dbtx.js"; -import { DbAccess, DbAccessImpl } from "./query.js"; - -const logger = new Logger("dbtx-handle-impl.ts"); - -function notifySafely( - sink: (notification: WalletNotification) => void, - notification: WalletNotification, -): void { - try { - sink(notification); - } catch (e) { - logger.warn( - `ignoring exception from wallet notification sink: ${ - e instanceof Error ? e.message : String(e) - }`, - ); - } -} - -/** - * WalletDbHandle over the IndexedDB emulation. - * - * Opens lazily: the wallet is constructed before it is initialised, and - * opening on construction would create a database file for a wallet that is - * never used. - */ -export class IdbWalletDbHandle implements WalletDbHandle { - readonly name = "indexeddb"; - - private idbHandle: IDBDatabase | undefined; - private dbAccess: DbAccess<typeof WalletIndexedDbStoresV1> | undefined; - private opening: Promise<{ fixupsApplied: number }> | undefined; - - private notify: (n: WalletNotification) => void = () => {}; - - /** - * Filesystem-backed capabilities, supplied by the host when it has them. - * A browser extension leaves them unset. - */ - exportToFile?: ( - directory: string, - stem: string, - forceFormat?: string, - ) => Promise<{ path: string }>; - readBackupJson?: (path: string) => Promise<any>; - getDiagnosticStats?: () => unknown; - - /** - * In-place migration to the native schema, set by the host when the - * emulation runs over a sqlite database the host can also open natively. - * See {@link WalletDbHandle.migrateToNative}. - */ - migrateToNative?: () => Promise<WalletDbHandle>; - - setNotificationSink(sink: (n: WalletNotification) => void): void { - this.notify = sink; - } - - emitNotification(notification: WalletNotification): void { - notifySafely(this.notify, notification); - } - - constructor( - private idbFactory: BridgeIDBFactory, - /** - * Raw backend counters, when the backend was asked to track them. - * Summed into a single figure by getAccessStats. - */ - private rawStats?: () => AccessStats | undefined, - private applyDbFixups: typeof applyFixups = applyFixups, - ) {} - - /** - * Open the database if it is not open yet. - * - * Returns whether fixups changed anything, which the caller needs in order - * to decide whether wallet-level views have to be rebuilt. - */ - async ensureOpen(): Promise<{ fixupsApplied: number }> { - if (this.dbAccess) { - return { fixupsApplied: 0 }; - } - if (this.opening) { - return await this.opening; - } - const opening = this.openDatabase(); - this.opening = opening; - try { - return await opening; - } finally { - if (this.opening === opening) { - this.opening = undefined; - } - } - } - - private async openDatabase(): Promise<{ fixupsApplied: number }> { - const idbHandle = await openTalerDatabase(this.idbFactory, async () => {}); - const dbAccess = this.makeAccess(idbHandle); - try { - const fixupsApplied = await this.applyDbFixups(dbAccess, (n) => - this.emitNotification(n), - ); - this.idbHandle = idbHandle; - this.dbAccess = dbAccess; - return { fixupsApplied }; - } catch (e) { - idbHandle.close(); - throw e; - } - } - - private makeAccess( - idbHandle: IDBDatabase, - notificationSink: (n: WalletNotification) => void = (n) => - this.emitNotification(n), - ): DbAccess<typeof WalletIndexedDbStoresV1> { - return new DbAccessImpl( - idbHandle, - WalletIndexedDbStoresV1, - CancellationToken.CONTINUE, - (notifs: WalletNotification[]) => { - for (const n of notifs) { - notificationSink(n); - } - }, - ); - } - - /** - * The raw DbAccess for the fixup log, which is an IndexedDB concern rather - * than a generic database concern. - * - * Reachable only through this class, so generic code cannot pick it up by - * accident the way it could when the wallet state exposed a factory. - */ - async rawAccess(): Promise<DbAccess<typeof WalletIndexedDbStoresV1>> { - await this.ensureOpen(); - if (!this.dbAccess) { - throw Error("wallet database is not open"); - } - return this.dbAccess; - } - - /** The IndexedDB factory used by hosts that install the bridge shim. */ - factory(): BridgeIDBFactory { - return this.idbFactory; - } - - async runReadWriteTx<T>( - f: (tx: WalletDbTransaction) => Promise<T>, - ): Promise<T> { - const access = await this.rawAccess(); - return await access.runAllStoresReadWriteTx({}, async (mytx) => { - return await f(new IdbWalletTransaction(mytx)); - }); - } - - async exportDatabase(): Promise<any> { - await this.ensureOpen(); - return await exportDb(this.idbFactory); - } - - async importDatabase( - dump: any, - finalize: WalletDbImportFinalizer, - ): Promise<void> { - // A native-backend dump has {schemaVersion, tables}; this backend's dumps - // have {databases}. Importing across backends is a format conversion, - // not a copy, and silently accepting the wrong shape would import - // nothing while reporting success. - if (dump != null && typeof dump === "object" && "tables" in dump) { - throw Error( - "this dump is from the native sqlite backend and cannot be" + - " imported into the IndexedDB backend; convert the database" + - " instead", - ); - } - await this.ensureOpen(); - if (!this.idbHandle) { - throw Error("wallet database is not open"); - } - const oldHandle = this.idbHandle; - const oldName = oldHandle.name; - const stagedNotifications: WalletNotification[] = []; - const staged = await beginTalerDatabaseReplacement( - this.idbFactory, - oldName, - async () => {}, - ); - const stagedAccess = this.makeAccess(staged.handle, (n) => - stagedNotifications.push(n), - ); - let published = false; - try { - await importDb(staged.handle, dump); - // The imported records may predate any of the fixups, whatever the old - // generation had applied. Clear the imported log and repair the staged - // generation before it can become authoritative. - await stagedAccess.runAllStoresReadWriteTx({}, async (tx) => { - const fixups = await tx.fixups.getAll(); - for (const fx of fixups) { - await tx.fixups.delete(fx.fixupName); - } - }); - await this.applyDbFixups(stagedAccess, (n) => - stagedNotifications.push(n), - ); - await stagedAccess.runAllStoresReadWriteTx({}, async (tx) => { - await finalize(new IdbWalletTransaction(tx)); - }); - - // This metadata transaction is the commit point. A crash before it - // keeps oldName authoritative; a crash afterwards opens staged.name. - await publishTalerDatabaseReplacement( - this.idbFactory, - oldName, - staged.name, - ); - published = true; - this.idbHandle = staged.handle; - this.dbAccess = stagedAccess; - oldHandle.close(); - for (const n of stagedNotifications) this.emitNotification(n); - } catch (e) { - if (!published) { - staged.handle.close(); - await abortTalerDatabaseReplacement(this.idbFactory, staged.name); - } - throw e; - } - - // Deletion is deliberately outside the commit semantics: failure or a - // second client blocking it only retains an unreachable old generation. - await retireTalerDatabaseGeneration(this.idbFactory, oldName, staged.name); - } - - async clearDatabase(): Promise<void> { - await this.ensureOpen(); - if (!this.idbHandle) { - throw Error("wallet database is not open"); - } - await clearDatabase(this.idbHandle); - } - - getAccessStats(): WalletDbAccessStats | undefined { - const st = this.rawStats?.(); - if (!st) { - return undefined; - } - // Index reads and store reads both count: a query served entirely from an - // index still read those records, and leaving them out would make a - // full index scan look bounded. - let recordsRead = 0; - for (const k of Object.keys(st.readItemsPerIndex)) { - recordsRead += st.readItemsPerIndex[k]; - } - for (const k of Object.keys(st.readItemsPerStore)) { - recordsRead += st.readItemsPerStore[k]; - } - return { recordsRead }; - } - - async close(): Promise<void> { - this.idbHandle?.close(); - this.idbHandle = undefined; - this.dbAccess = undefined; - } -} - -/** - * WalletDbHandle over the native sqlite database. - * - * No fixups: the schema is clean-slate and evolves through schemaMigrations, - * so there is no legacy record shape for a fixup to repair. - */ -export class SqliteWalletDbHandle implements WalletDbHandle { - readonly name = "sqlite"; - - private notify: (n: WalletNotification) => void = () => {}; - - setNotificationSink(sink: (n: WalletNotification) => void): void { - this.notify = sink; - } - - emitNotification(notification: WalletNotification): void { - notifySafely(this.notify, notification); - } - - constructor(private ndb: NativeSqliteWalletDb) {} - - /** - * VACUUM INTO, which writes a consistent copy without holding the whole - * database in memory. Goes through the transaction queue: it cannot run - * inside a transaction, and the queue is what serialises them. - */ - async exportToFile( - directory: string, - stem: string, - forceFormat?: string, - ): Promise<{ path: string }> { - if (forceFormat != null && forceFormat !== "sqlite3") { - throw Error( - `the native backend can only export sqlite3, not ${forceFormat}`, - ); - } - const path = `${directory}/${stem}.sqlite3`; - await this.ndb.lock.run(async () => { - await ( - await this.ndb.db.prepare("VACUUM INTO $filename") - ).run({ - filename: path, - }); - }); - return { path }; - } - - getDiagnosticStats(): unknown { - return { rowsRead: this.ndb.stats.rowsRead }; - } - - async runReadWriteTx<T>( - f: (tx: WalletDbTransaction) => Promise<T>, - ): Promise<T> { - return await runNativeSqliteWalletTx( - this.ndb, - (n) => this.emitNotification(n), - async (tx) => await f(tx), - ); - } - - async exportDatabase(): Promise<any> { - return await exportNativeSqliteDb(this.ndb); - } - - async importDatabase( - dump: any, - finalize: WalletDbImportFinalizer, - ): Promise<void> { - // See the IndexedDB counterpart: a dump from the other backend is a - // conversion job, and must not be half-imported here. - if (dump != null && typeof dump === "object" && "databases" in dump) { - throw Error( - "this dump is from the IndexedDB backend and cannot be imported" + - " into the native sqlite backend; convert the database instead", - ); - } - await importNativeSqliteDb(this.ndb, dump, finalize, (n) => - this.emitNotification(n), - ); - } - - async clearDatabase(): Promise<void> { - await clearNativeSqliteWalletDb(this.ndb); - } - - getAccessStats(): WalletDbAccessStats | undefined { - return { recordsRead: this.ndb.stats.rowsRead }; - } - - async close(): Promise<void> { - await this.ndb.db.close(); - } -} diff --git a/packages/taler-wallet-core/src/dbtx-handle.ts b/packages/taler-wallet-core/src/dbtx-handle.ts @@ -1,145 +0,0 @@ -/* - 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/> - */ - -/** - * Backend-neutral handle for the wallet database as a whole. - * - * WalletDbTransaction abstracts a running transaction; this abstracts the - * database that hands them out, together with the operations that act on the - * store as a unit rather than on records: export, import, clear. - * - * The wallet holds exactly one of these. It used to hold an IndexedDB factory - * and an optional native sqlite handle side by side, and every operation that - * worked on the whole database had to pick one -- with the wrong choice - * producing a valid, empty database rather than an error, so a mistake looked - * like a freshly initialised wallet instead of a failure. With a single - * handle, that choice does not exist to get wrong. - */ - -import { WalletNotification } from "@gnu-taler/taler-util"; - -import { WalletDbTransaction } from "./dbtx.js"; - -/** - * Number of records a backend has read, for tests that assert a query is - * bounded rather than scanning. - */ -export interface WalletDbAccessStats { - recordsRead: number; -} - -/** - * Wallet-level work that must become visible in the same atomic import as the - * restored records (currently rebuilding the materialized transaction view). - */ -export type WalletDbImportFinalizer = ( - tx: WalletDbTransaction, -) => Promise<void>; - -export interface WalletDbHandle { - /** - * Which backend this is, for logs and test names. - * - * Deliberately not something to branch on: code that needs to know whether a - * capability is present should test for the capability. - */ - readonly name: string; - - /** - * Run f in a read-write transaction over all stores and return its result. - */ - runReadWriteTx<T>(f: (tx: WalletDbTransaction) => Promise<T>): Promise<T>; - - /** - * Serialise the whole database into a backend-specific dump. - * - * The dump is opaque to callers and is only meaningful to importDatabase on - * the same backend. - */ - exportDatabase(): Promise<any>; - - /** - * Replace the contents of the database with a dump. - * - * Returns with the database consistent: a backend whose stored records need - * repairing after an import that may predate its current schema does that - * repair here. The supplied finalizer rebuilds wallet-level derived state - * before the replacement becomes visible. A failure in import, repair, or - * finalization leaves the previous database authoritative. - * - * Throws if the dump did not come from this backend. - */ - importDatabase(dump: any, finalize: WalletDbImportFinalizer): Promise<void>; - - /** Remove all records, leaving an empty database of the current schema. */ - clearDatabase(): Promise<void>; - - /** Access statistics, if the backend tracks them. */ - getAccessStats(): WalletDbAccessStats | undefined; - - /** - * Where post-commit notifications go. - * - * The host opens the database before the wallet that will consume its - * notifications exists, so the sink starts as a no-op and the wallet - * installs its own during construction. - */ - setNotificationSink(sink: (n: WalletNotification) => void): void; - - /** Emit non-transactional maintenance progress to the installed sink. */ - emitNotification(notification: WalletNotification): void; - - /** - * Copy the database to a file, in whatever format the backend supports. - * - * Absent when the host cannot do this -- a browser extension has no - * filesystem -- so callers must say what they do without it rather than - * receive an error from a method that looked available. - */ - exportToFile?( - directory: string, - stem: string, - forceFormat?: string, - ): Promise<{ path: string }>; - - /** Read a dump previously written by exportToFile. Absent if unsupported. */ - readBackupJson?(path: string): Promise<any>; - - /** - * Migrate this database in place to the native schema and return the handle - * to use from here on. - * - * Present only where the host keeps the wallet in a sqlite file it can open - * with either schema, which is every host except a browser extension: there - * IndexedDB is the real thing rather than an emulation over sqlite, and - * there is nothing to migrate to. - * - * This handle is unusable afterwards. On failure it is untouched and still - * the authoritative database. - */ - migrateToNative?(): Promise<WalletDbHandle>; - - /** - * Backend-specific counters for the testing API. - * - * Deliberately untyped: this is diagnostic output whose shape follows - * whichever backend produced it, unlike getAccessStats which is the one - * figure both backends agree on. - */ - getDiagnosticStats?(): unknown; - - close(): Promise<void>; -} diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -1,2165 +0,0 @@ -/* - 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/> - */ - -/** - * IndexedDB implementation of the backend-neutral {@link WalletDbTransaction} - * data access layer. - * - * All IndexedDB specifics (key ranges, store/index names, the - * <Name>Record storage types) belong here. The interface itself lives in - * dbtx.ts and must stay free of them. - */ - -import { - ContactEntry, - CurrencySpecification, - MailboxConfiguration, - MailboxMessageRecord, - ScopeInfo, - TransactionIdStr, - stringifyScopeInfo, - assertUnreachable, - ScopeType, - WalletNotification, - checkDbInvariant, - CoinStatus, -} from "@gnu-taler/taler-util"; -import { GlobalIDB } from "@gnu-taler/idb-bridge"; -import { - ConfigRecord, - WalletPeerPullCredit, - WalletPeerPushDebit, - WalletPeerPushCredit, - WalletPeerPullDebit, - WalletToken, - WalletSlate, - WalletDenomination, - WalletTransactionMeta, - WalletTransactionMetaCursor, - DbPreciseTimestamp, - DbProtocolTimestamp, - WalletOperationRetry, - WalletContractTerms, - DenominationVerificationStatus, - OPERATION_STATUS_NONFINAL_FIRST, - OPERATION_STATUS_NONFINAL_LAST, - WalletCoinAvailability, - WalletCoinHistory, - WalletCoin, - WalletDepositGroup, - WalletRecoupGroup, - PurchaseStatus, - WalletReserve, - WalletRefreshGroup, - WalletRefreshSession, - WalletWithdrawalGroup, - WalletPlanchet, - WalletDonationSummary, - WalletDonationReceipt, - WalletDonationPlanchet, - DonationReceiptStatus, - WalletPurchase, - WalletRefundGroup, - WalletRefundItem, - WalletTombstone, - WalletExchangeEntry, - WalletDenomLossEvent, - WalletExchangeSignkeys, - WalletDenomFamilyParams, - WalletDenominationFamily, - WalletExchangeBaseUrlFixup, - WalletExchangeMigrationLog, - WalletGlobalCurrencyExchange, - WalletGlobalCurrencyAuditor, - WalletBankAccount, - WalletExchangeDetails, -} from "./db-common.js"; -import type {} from "./db-indexeddb.js"; -import { WalletIndexedDbTransaction } from "./db-indexeddb.js"; -import type { - WalletCurrencyInfoEntry, - WalletDbRecordCounts, - GetCurrencyInfoDbResult, - StoreCurrencyInfoDbRequest, - WalletCoinAvailabilityRef, - WalletDbTransaction, - WalletDbMigrationStore, - WalletDbMigrationPage, - WalletDenomRef, -} from "./dbtx.js"; -import { auditorProvidesVerifiedTrust } from "./auditorTrust.js"; - -function getActiveKeyRange() { - return GlobalIDB.KeyRange.bound( - OPERATION_STATUS_NONFINAL_FIRST, - OPERATION_STATUS_NONFINAL_LAST, - ); -} - -export class IdbWalletTransaction implements WalletDbTransaction { - tx: WalletIndexedDbTransaction; - constructor(tx: WalletIndexedDbTransaction) { - this.tx = tx; - } - - async scanMigrationRecords<T>( - store: WalletDbMigrationStore, - _read: (tx: WalletDbTransaction) => Promise<T[]>, - cursor: unknown | undefined, - limit: number, - ): Promise<WalletDbMigrationPage<T>> { - const physicalStore: Record<WalletDbMigrationStore, string> = { - config: "config", - currencyInfo: "currencyInfo", - contacts: "contacts", - mailboxMessages: "mailboxMessages", - mailboxConfigurations: "mailboxConfigurations", - contractTerms: "contractTerms", - tombstones: "tombstones", - operationRetries: "operationRetries", - bankAccounts: "bankAccountsV2", - globalCurrencyExchanges: "globalCurrencyExchanges", - globalCurrencyAuditors: "globalCurrencyAuditors", - exchangeBaseUrlFixups: "exchangeBaseUrlFixups", - exchangeBaseUrlMigrationLog: "exchangeBaseUrlMigrationLog", - reserves: "reserves", - exchanges: "exchanges", - exchangeDetails: "exchangeDetails", - exchangeSignKeys: "exchangeSignKeys", - denominationFamilies: "denominationFamilies", - denominations: "denominationsV2", - withdrawalGroups: "withdrawalGroups", - purchases: "purchases", - refreshGroups: "refreshGroups", - coins: "coins", - planchets: "planchets", - refreshSessions: "refreshSessions", - coinHistory: "coinHistory", - coinAvailability: "coinAvailabilityV2", - refundGroups: "refundGroups", - tokens: "tokens", - slates: "slates", - depositGroups: "depositGroups", - recoupGroups: "recoupGroups", - denomLossEvents: "denomLossEvents", - peerPushDebit: "peerPushDebit", - peerPushCredit: "peerPushCredit", - peerPullDebit: "peerPullDebit", - peerPullCredit: "peerPullCredit", - donationSummaries: "donationSummaries", - donationPlanchets: "donationPlanchets", - donationReceipts: "donationReceipts", - transactionsMeta: "transactionsMeta", - refundItems: "refundItems", - }; - const accessor = (this.tx as any)[physicalStore[store]]; - if (!accessor) { - throw Error(`migration store ${store} is not available`); - } - const page = await accessor.scan(cursor, limit); - return { - records: page.records as T[], - ...(page.records.length > 0 ? { nextCursor: page.lastKey } : {}), - }; - } - - scheduleOnCommit(f: () => void): void { - this.tx._util.scheduleOnCommit(f); - } - - /** - * Bound as an instance property, not a prototype method: call sites pass - * this around unbound (e.g. applyNotifyTransition(tx.notify, ...)), which - * would otherwise lose "this" and fail on this.tx. - */ - notify = (notif: WalletNotification): void => { - this.tx.notify(notif); - }; - async getCurrencyInfo( - scopeInfo: ScopeInfo, - ): Promise<GetCurrencyInfoDbResult | undefined> { - const tx = this.tx; - const s = stringifyScopeInfo(scopeInfo); - const res = await tx.currencyInfo.get(s); - if (!res) { - return undefined; - } - return { - currencySpec: res.currencySpec, - source: res.source, - }; - } - - async getConfig<T extends ConfigRecord["key"]>( - key: T, - ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { - const tx = this.tx; - return (await tx.config.get(key)) as any; - } - - async upsertConfig(record: ConfigRecord): Promise<void> { - const tx = this.tx; - await tx.config.put(record); - } - - async listAllConfig(): Promise<ConfigRecord[]> { - return await this.tx.config.getAll(); - } - - async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { - return await this.tx.currencyInfo.getAll(); - } - - async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { - await this.tx.currencyInfo.put(entry); - } - - async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { - const tx = this.tx; - await tx.currencyInfo.put({ - scopeInfoStr: stringifyScopeInfo(req.scopeInfo), - currencySpec: req.currencySpec, - source: req.source, - }); - } - - async insertCurrencyInfoUnlessExists( - req: StoreCurrencyInfoDbRequest, - ): Promise<void> { - const tx = this.tx; - const scopeInfoStr = stringifyScopeInfo(req.scopeInfo); - const oldRec = await tx.currencyInfo.get(scopeInfoStr); - if (oldRec) { - return; - } - await tx.currencyInfo.put({ - scopeInfoStr: stringifyScopeInfo(req.scopeInfo), - currencySpec: req.currencySpec, - source: req.source, - }); - } - - async addContact(contact: ContactEntry): Promise<void> { - const tx = this.tx; - await tx.contacts.put({ - alias: contact.alias, - aliasType: contact.aliasType, - mailboxBaseUri: contact.mailboxBaseUri, - mailboxAddress: contact.mailboxAddress, - source: contact.source, - petname: contact.petname, - }); - } - - async deleteContact(alias: string, aliasType: string): Promise<void> { - const tx = this.tx; - await tx.contacts.delete([alias, aliasType]); - } - - async listContacts(): Promise<ContactEntry[]> { - const tx = this.tx; - const records = await tx.contacts.getAll(); - return records.map((r) => ({ - alias: r.alias, - aliasType: r.aliasType, - mailboxBaseUri: r.mailboxBaseUri, - mailboxAddress: r.mailboxAddress, - source: r.source, - petname: r.petname, - })); - } - - async upsertMailboxMessage(message: MailboxMessageRecord): Promise<void> { - const tx = this.tx; - await tx.mailboxMessages.put(message); - } - - async deleteMailboxMessage( - originMailboxBaseUrl: string, - talerUri: string, - ): Promise<void> { - const tx = this.tx; - await tx.mailboxMessages.delete([originMailboxBaseUrl, talerUri]); - } - - async listMailboxMessages(): Promise<MailboxMessageRecord[]> { - const tx = this.tx; - return await tx.mailboxMessages.getAll(); - } - - async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { - return await this.tx.mailboxConfigurations.getAll(); - } - - async getMailboxConfiguration( - mailboxBaseUrl: string, - ): Promise<MailboxConfiguration | undefined> { - const tx = this.tx; - return await tx.mailboxConfigurations.get(mailboxBaseUrl); - } - - async upsertMailboxConfiguration( - mailboxConf: MailboxConfiguration, - ): Promise<void> { - const tx = this.tx; - await tx.mailboxConfigurations.put(mailboxConf); - } - - async getPurchase(proposalId: string): Promise<WalletPurchase | undefined> { - const tx = this.tx; - return await tx.purchases.get(proposalId); - } - - async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> { - const tx = this.tx; - await tx.transactionsMeta.put({ - transactionId: rec.transactionId, - timestamp: rec.timestamp, - status: rec.status, - exchanges: rec.exchanges, - currency: rec.currency, - }); - } - - async getLocalTransactionIdentifiers( - _transactionIds: string[], - ): Promise<Map<string, string>> { - // Do not add a store just for this feature: assigning a counter safely - // across IndexedDB transactions would require serialising every metadata - // update. Native SQLite can do this cheaply and atomically. - return new Map(); - } - - async getTransactionIdByLocalIdentifier( - _transactionType: string, - _localIdent: string, - ): Promise<string | undefined> { - return undefined; - } - - async deleteTransactionMeta(transactionId: string): Promise<void> { - const tx = this.tx; - await tx.transactionsMeta.delete(transactionId); - } - - async getTransactionMeta( - transactionId: string, - ): Promise<WalletTransactionMeta | undefined> { - const tx = this.tx; - return await tx.transactionsMeta.get(transactionId); - } - - async getTransactionMetaAtTimestamp( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - const tx = this.tx; - return await tx.transactionsMeta.indexes.byTimestamp.get(timestamp); - } - - async getTransactionMetaBefore( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - const tx = this.tx; - // Walk the index downwards from the bound and stop at the first hit. - // Reading the whole range and keeping the last entry gave the same answer - // but touched every record below the bound, which on a wallet with a long - // history is the entire table. - const cursor = tx.transactionsMeta.indexes.byTimestamp.iterPrev( - GlobalIDB.KeyRange.upperBound(timestamp, false), - ); - const first = await cursor.next(); - return first.hasValue ? first.value : undefined; - } - - async getTransactionMetaAfter( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - const tx = this.tx; - const recs = await tx.transactionsMeta.indexes.byTimestamp.getAll( - GlobalIDB.KeyRange.lowerBound(timestamp, false), - 1, - ); - return recs[0]; - } - - async listTransactionMetaByTimestamp(req: { - afterTimestamp?: DbPreciseTimestamp; - limit?: number; - }): Promise<WalletTransactionMeta[]> { - const tx = this.tx; - const range = - req.afterTimestamp != null - ? GlobalIDB.KeyRange.lowerBound(req.afterTimestamp, true) - : undefined; - return await tx.transactionsMeta.indexes.byTimestamp.getAll( - range, - req.limit, - ); - } - - async listTransactionMetaPage(req: { - cursor?: WalletTransactionMetaCursor; - direction: "forward" | "backward"; - limit: number; - }): Promise<WalletTransactionMeta[]> { - const index = this.tx.transactionsMeta.indexes.byTimestampAndId; - const key = req.cursor - ? [req.cursor.timestamp, req.cursor.transactionId] - : undefined; - if (req.direction === "forward") { - const range = key ? GlobalIDB.KeyRange.lowerBound(key, true) : undefined; - return await index.getAll(range, req.limit); - } - const range = key ? GlobalIDB.KeyRange.upperBound(key, true) : undefined; - const cursor = index.iterPrev(range); - const records: WalletTransactionMeta[] = []; - while (records.length < req.limit) { - const next = await cursor.next(); - if (!next.hasValue) { - break; - } - records.push(next.value); - } - return records; - } - - async listTransactionMetaByStatus(req: { - onlyActive: boolean; - }): Promise<WalletTransactionMeta[]> { - const tx = this.tx; - const range = req.onlyActive ? getActiveKeyRange() : undefined; - return await tx.transactionsMeta.indexes.byStatus.getAll(range); - } - - async deleteAllTransactionMeta(): Promise<void> { - const tx = this.tx; - const all = await tx.transactionsMeta.getAll(); - for (const rec of all) { - await tx.transactionsMeta.delete(rec.transactionId); - } - } - - async getOperationRetry( - taskId: string, - ): Promise<WalletOperationRetry | undefined> { - const tx = this.tx; - return await tx.operationRetries.get(taskId); - } - - async upsertOperationRetry(rec: WalletOperationRetry): Promise<void> { - const tx = this.tx; - await tx.operationRetries.put({ - id: rec.id, - lastError: rec.lastError, - retryInfo: rec.retryInfo, - }); - } - async listAllOperationRetries(): Promise<WalletOperationRetry[]> { - return await this.tx.operationRetries.getAll(); - } - - async deleteOperationRetry(taskId: string): Promise<void> { - const tx = this.tx; - await tx.operationRetries.delete(taskId); - } - - async getContractTerms( - contractTermsHash: string, - ): Promise<WalletContractTerms | undefined> { - const tx = this.tx; - return await tx.contractTerms.get(contractTermsHash); - } - - async upsertContractTerms(rec: WalletContractTerms): Promise<void> { - const tx = this.tx; - await tx.contractTerms.put({ - h: rec.h, - contractTermsRaw: rec.contractTermsRaw, - }); - } - - async countWithdrawalGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<number> { - const tx = this.tx; - return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.count( - exchangeBaseUrl, - ); - } - - async getWithdrawalGroupsByExchangeForRekey( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]> { - const tx = this.tx; - return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]> { - return await this.tx.globalCurrencyExchanges.getAll(); - } - - async upsertGlobalCurrencyExchange( - rec: WalletGlobalCurrencyExchange, - ): Promise<void> { - // The row id is generated, so putting a record that is already stored - // would insert a second row and violate the unique index over the three - // identifying fields, which aborts the whole transaction. - const existing = await this.getGlobalCurrencyExchange( - rec.currency, - rec.exchangeBaseUrl, - rec.exchangeMasterPub, - ); - if (existing) { - return; - } - await this.tx.globalCurrencyExchanges.put(rec); - } - - async deleteGlobalCurrencyExchange(id: number): Promise<void> { - await this.tx.globalCurrencyExchanges.delete(id); - } - - async listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]> { - return await this.tx.globalCurrencyAuditors.getAll(); - } - - async upsertGlobalCurrencyAuditor( - rec: WalletGlobalCurrencyAuditor, - ): Promise<void> { - // See upsertGlobalCurrencyExchange. - const existing = await this.getGlobalCurrencyAuditor( - rec.currency, - rec.auditorBaseUrl, - rec.auditorPub, - ); - if (existing) { - return; - } - await this.tx.globalCurrencyAuditors.put(rec); - } - - async deleteGlobalCurrencyAuditor(id: number): Promise<void> { - await this.tx.globalCurrencyAuditors.delete(id); - } - - async deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void> { - await this.tx.currencyInfo.delete(stringifyScopeInfo(scopeInfo)); - } - - async getGlobalCurrencyExchange( - currency: string, - exchangeBaseUrl: string, - exchangeMasterPub: string, - ): Promise<WalletGlobalCurrencyExchange | undefined> { - const tx = this.tx; - return await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ - currency, - exchangeBaseUrl, - exchangeMasterPub, - ]); - } - - async getGlobalCurrencyAuditor( - currency: string, - auditorBaseUrl: string, - auditorPub: string, - ): Promise<WalletGlobalCurrencyAuditor | undefined> { - const tx = this.tx; - return await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([ - currency, - auditorBaseUrl, - auditorPub, - ]); - } - - async listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]> { - const tx = this.tx; - return await tx.denomLossEvents.getAll(); - } - - async getFreshCoinsByDenomAndAge( - ref: WalletCoinAvailabilityRef, - limit: number, - ): Promise<WalletCoin[]> { - const tx = this.tx; - return await tx.coins.indexes.byMasterPubDenomPubHashAndAgeAndStatus.getAll( - [ref.exchangeMasterPub, ref.denomPubHash, ref.maxAge, CoinStatus.Fresh], - limit, - ); - } - - async getCoinAvailabilityByExchangeAndAgeRange( - exchangeBaseUrl: string, - ageLower: number, - ageUpper: number, - ): Promise<WalletCoinAvailability[]> { - const tx = this.tx; - // Lower bound of 1 on freshCoinCount: only denominations that actually - // have a fresh coin available. - return await tx.coinAvailabilityV2.indexes.byExchangeFreshAndAge.getAll( - GlobalIDB.KeyRange.bound( - [exchangeBaseUrl, 1, ageLower], - [exchangeBaseUrl, 1, ageUpper], - ), - ); - } - - async listBankAccounts(): Promise<WalletBankAccount[]> { - return await this.tx.bankAccountsV2.getAll(); - } - - async getBankAccount( - bankAccountId: string, - ): Promise<WalletBankAccount | undefined> { - return await this.tx.bankAccountsV2.get(bankAccountId); - } - - async deleteBankAccount(bankAccountId: string): Promise<void> { - await this.tx.bankAccountsV2.delete(bankAccountId); - } - - async getBankAccountByPaytoUri( - paytoUri: string, - ): Promise<WalletBankAccount | undefined> { - return await this.tx.bankAccountsV2.indexes.byPaytoUri.get(paytoUri); - } - - async upsertBankAccount(rec: WalletBankAccount): Promise<void> { - await this.tx.bankAccountsV2.put(rec); - } - - async getRecordCounts(): Promise<WalletDbRecordCounts> { - const tx = this.tx; - return { - coins: await tx.coins.count(), - coinAvailability: await tx.coinAvailabilityV2.count(), - denominations: await tx.denominationsV2.count(), - denominationFamilies: await tx.denominationFamilies.count(), - exchanges: await tx.exchanges.count(), - exchangeDetails: await tx.exchangeDetails.count(), - exchangeSignKeys: await tx.exchangeSignKeys.count(), - }; - } - - async listAllCoins(): Promise<WalletCoin[]> { - return await this.tx.coins.getAll(); - } - - async getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]> { - const tx = this.tx; - return await tx.coins.indexes.byBaseUrl.getAll(exchangeBaseUrl); - } - - async countCoinsByExchange(exchangeBaseUrl: string): Promise<number> { - const tx = this.tx; - return await tx.coins.indexes.byBaseUrl.count(exchangeBaseUrl); - } - - async getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]> { - const tx = this.tx; - return await tx.coins.indexes.byDenomPubHash.getAll(denomPubHash); - } - - async getCoinsByDenomPubHashes( - denomPubHashes: string[], - ): Promise<WalletCoin[]> { - const uniqueHashes = [...new Set(denomPubHashes)]; - const groups = await Promise.all( - uniqueHashes.map((hash) => - this.tx.coins.indexes.byDenomPubHash.getAll(hash), - ), - ); - return groups.flat(); - } - - async deleteCoin(coinPub: string): Promise<void> { - const tx = this.tx; - // Cascade to the history, which describes this coin and nothing else. - // Every reader looks it up for a coin it already holds, so a history row - // without its coin is unreachable. Matches the sqlite constraint. - await tx.coinHistory.delete(coinPub); - await tx.coins.delete(coinPub); - } - - async deleteCoinHistory(coinPub: string): Promise<void> { - const tx = this.tx; - await tx.coinHistory.delete(coinPub); - } - - async getCoinAvailabilityByExchange( - exchangeBaseUrl: string, - ): Promise<WalletCoinAvailability[]> { - const tx = this.tx; - return await tx.coinAvailabilityV2.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void> { - const tx = this.tx; - await tx.coinAvailabilityV2.delete([ - ref.exchangeMasterPub, - ref.denomPubHash, - ref.maxAge, - ]); - } - - async getRecoupGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletRecoupGroup[]> { - const tx = this.tx; - return await tx.recoupGroups.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async listAllRefreshGroups(): Promise<WalletRefreshGroup[]> { - const tx = this.tx; - return await tx.refreshGroups.getAll(); - } - - async listAllDepositGroups(): Promise<WalletDepositGroup[]> { - const tx = this.tx; - return await tx.depositGroups.getAll(); - } - - async listAllRefundGroups(): Promise<WalletRefundGroup[]> { - return await this.tx.refundGroups.getAll(); - } - - async listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { - return await this.tx.withdrawalGroups.getAll(); - } - - async listAllPurchases(): Promise<WalletPurchase[]> { - return await this.tx.purchases.getAll(); - } - - async listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]> { - const tx = this.tx; - return await tx.peerPullCredit.getAll(); - } - - async listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]> { - const tx = this.tx; - return await tx.peerPullDebit.getAll(); - } - - async listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]> { - const tx = this.tx; - return await tx.peerPushCredit.getAll(); - } - - async listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]> { - const tx = this.tx; - return await tx.peerPushDebit.getAll(); - } - - async getDenominationFamilyByParams( - params: WalletDenomFamilyParams, - ): Promise<WalletDenominationFamily | undefined> { - const tx = this.tx; - // The byFamilyParms index key is a 7-component array whose component - // order is part of the schema; it is built here so exactly one place - // knows it. - return await tx.denominationFamilies.indexes.byFamilyParms.get([ - params.exchangeBaseUrl, - params.exchangeMasterPub, - params.value, - params.feeWithdraw, - params.feeDeposit, - params.feeRefresh, - params.feeRefund, - ]); - } - - async upsertDenominationFamily( - rec: WalletDenominationFamily, - ): Promise<number> { - const tx = this.tx; - const res = await tx.denominationFamilies.put(rec); - checkDbInvariant( - typeof res.key === "number", - "denomination family serial must be a number", - ); - return res.key; - } - - async getDenominationFamiliesByExchange( - exchangeBaseUrl: string, - ): Promise<WalletDenominationFamily[]> { - const tx = this.tx; - return await tx.denominationFamilies.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async deleteDenominationFamily( - denominationFamilySerial: number, - ): Promise<void> { - const tx = this.tx; - // Cascade to the denominations of that family. There is no accessor for - // "denominations by family" on its own, so this walks the index whose - // first component is the family serial. - const doomed = - await tx.denominationsV2.indexes.byDenominationFamilySerialAndStampExpireWithdraw.getAll( - GlobalIDB.KeyRange.bound( - [denominationFamilySerial, Number.MIN_SAFE_INTEGER], - [denominationFamilySerial, Number.MAX_SAFE_INTEGER], - ), - ); - for (const d of doomed) { - await tx.denominationsV2.delete([d.exchangeMasterPub, d.denomPubHash]); - } - await tx.denominationFamilies.delete(denominationFamilySerial); - } - - async getExchangeBaseUrlFixup( - exchangeBaseUrl: string, - ): Promise<WalletExchangeBaseUrlFixup | undefined> { - const tx = this.tx; - return await tx.exchangeBaseUrlFixups.get(exchangeBaseUrl); - } - - async upsertExchangeBaseUrlFixup( - rec: WalletExchangeBaseUrlFixup, - ): Promise<void> { - const tx = this.tx; - await tx.exchangeBaseUrlFixups.put(rec); - } - - async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { - return await this.tx.exchangeBaseUrlFixups.getAll(); - } - - async listAllExchangeMigrationLogEntries(): Promise< - WalletExchangeMigrationLog[] - > { - return await this.tx.exchangeBaseUrlMigrationLog.getAll(); - } - - async getExchangeMigrationLog( - oldExchangeBaseUrl: string, - newExchangeBaseUrl: string, - ): Promise<WalletExchangeMigrationLog | undefined> { - const tx = this.tx; - return await tx.exchangeBaseUrlMigrationLog.get([ - oldExchangeBaseUrl, - newExchangeBaseUrl, - ]); - } - - async upsertExchangeMigrationLog( - rec: WalletExchangeMigrationLog, - ): Promise<void> { - const tx = this.tx; - await tx.exchangeBaseUrlMigrationLog.put(rec); - } - - async getExchangeDetailsByPointer( - exchangeBaseUrl: string, - currency: string, - masterPublicKey: string, - ): Promise<WalletExchangeDetails | undefined> { - const tx = this.tx; - return await tx.exchangeDetails.indexes.byPointer.get([ - exchangeBaseUrl, - currency, - masterPublicKey, - ]); - } - - async getExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined> { - const tx = this.tx; - return await tx.exchangeDetails.indexes.byExchangeBaseUrl.get( - exchangeBaseUrl, - ); - } - - async listExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails[]> { - const tx = this.tx; - return await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async listExchangeDetailsByMasterPub( - masterPublicKey: string, - ): Promise<WalletExchangeDetails[]> { - const tx = this.tx; - return await tx.exchangeDetails.indexes.byMasterPublicKey.getAll( - masterPublicKey, - ); - } - - async listAllExchangeDetails(): Promise<WalletExchangeDetails[]> { - const tx = this.tx; - return await tx.exchangeDetails.indexes.byExchangeBaseUrl.getAll(); - } - - async getExchangeDetailsByRowId( - rowId: number, - ): Promise<WalletExchangeDetails | undefined> { - return await this.tx.exchangeDetails.get(rowId); - } - - async upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number> { - const tx = this.tx; - const res = await tx.exchangeDetails.put(rec); - checkDbInvariant( - typeof res.key === "number", - "exchange details row id must be a number", - ); - return res.key; - } - - async deleteExchangeDetails(rowId: number): Promise<void> { - const tx = this.tx; - // Cascade to the sign keys, which describe this details row and nothing - // else. Matches ON DELETE CASCADE in the sqlite schema. - for (const sk of await this.getExchangeSignKeysByDetailsRowId(rowId)) { - await tx.exchangeSignKeys.delete([rowId, sk.signkeyPub]); - } - await tx.exchangeDetails.delete(rowId); - } - - async getExchangeSignKeysByDetailsRowId( - exchangeDetailsRowId: number, - ): Promise<WalletExchangeSignkeys[]> { - const tx = this.tx; - // byExchangeDetailsRowId has an array keyPath (["exchangeDetailsRowId"]), - // so its keys are single-element arrays and a bare number matches nothing. - return await tx.exchangeSignKeys.indexes.byExchangeDetailsRowId.getAll([ - exchangeDetailsRowId, - ]); - } - - async listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]> { - return await this.tx.exchangeSignKeys.getAll(); - } - - async upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void> { - const tx = this.tx; - await tx.exchangeSignKeys.put(rec); - } - - async deleteExchangeSignKey( - exchangeDetailsRowId: number, - signkeyPub: string, - ): Promise<void> { - const tx = this.tx; - await tx.exchangeSignKeys.delete([exchangeDetailsRowId, signkeyPub]); - } - - async getDenomLossEvent( - denomLossEventId: string, - ): Promise<WalletDenomLossEvent | undefined> { - const tx = this.tx; - return await tx.denomLossEvents.get(denomLossEventId); - } - - async upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void> { - const tx = this.tx; - await tx.denomLossEvents.put(rec); - } - - async deleteDenomLossEvent(denomLossEventId: string): Promise<void> { - const tx = this.tx; - await tx.denomLossEvents.delete(denomLossEventId); - } - - async getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined> { - const tx = this.tx; - return await tx.exchanges.get(baseUrl); - } - - async upsertExchange(rec: WalletExchangeEntry): Promise<void> { - const tx = this.tx; - await tx.exchanges.put(rec); - } - - async deleteExchange(baseUrl: string): Promise<void> { - const tx = this.tx; - await tx.exchanges.delete(baseUrl); - } - - async upsertPurchase(rec: WalletPurchase): Promise<void> { - const tx = this.tx; - await tx.purchases.put(rec); - } - - async deletePurchase(proposalId: string): Promise<void> { - const tx = this.tx; - // Cascade to the refund groups, and through deleteRefundGroup to their - // items -- two levels, matching what the sqlite constraints do. - for (const rg of await this.getRefundGroupsByProposal(proposalId)) { - await this.deleteRefundGroup(rg.refundGroupId); - } - await tx.purchases.delete(proposalId); - } - - async getPurchaseByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase | undefined> { - const tx = this.tx; - return await tx.purchases.indexes.byUrlAndOrderId.get([ - merchantBaseUrl, - orderId, - ]); - } - - async getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]> { - const purchases = await Promise.all( - proposalIds.map((proposalId) => this.tx.purchases.get(proposalId)), - ); - return purchases.filter((x): x is WalletPurchase => x !== undefined); - } - - async getPurchasesByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase[]> { - const tx = this.tx; - return await tx.purchases.indexes.byUrlAndOrderId.getAll([ - merchantBaseUrl, - orderId, - ]); - } - - async getPurchasesByFulfillmentUrl( - fulfillmentUrl: string, - ): Promise<WalletPurchase[]> { - const tx = this.tx; - return await tx.purchases.indexes.byFulfillmentUrl.getAll(fulfillmentUrl); - } - - async getPurchasesByExchange( - exchangeBaseUrl: string, - ): Promise<WalletPurchase[]> { - const tx = this.tx; - return await tx.purchases.indexes.byExchange.getAll(exchangeBaseUrl); - } - - async getRefundGroup( - refundGroupId: string, - ): Promise<WalletRefundGroup | undefined> { - const tx = this.tx; - return await tx.refundGroups.get(refundGroupId); - } - - async upsertRefundGroup(rec: WalletRefundGroup): Promise<void> { - const tx = this.tx; - await tx.refundGroups.put(rec); - } - - async deleteRefundGroup(refundGroupId: string): Promise<void> { - const tx = this.tx; - // Cascade to the items. A refund item exists only as part of its group, - // and the sqlite schema enforces that with ON DELETE CASCADE; deleting - // only the group here would leave rows behind on this backend that the - // other one removes. - for (const item of await this.getRefundItemsByGroup(refundGroupId)) { - checkDbInvariant( - typeof item.id === "number", - "stored refund item must have a row id", - ); - await tx.refundItems.delete(item.id); - } - await tx.refundGroups.delete(refundGroupId); - } - - async getRefundGroupsByProposal( - proposalId: string, - ): Promise<WalletRefundGroup[]> { - const tx = this.tx; - return await tx.refundGroups.indexes.byProposalId.getAll(proposalId); - } - - async getRefundItemsByGroup( - refundGroupId: string, - ): Promise<WalletRefundItem[]> { - const tx = this.tx; - // byRefundGroupId has an array keyPath (["refundGroupId"]), so its keys - // are single-element arrays and a bare string matches nothing. - return await tx.refundItems.indexes.byRefundGroupId.getAll([refundGroupId]); - } - - async listAllRefundItems(): Promise<WalletRefundItem[]> { - return await this.tx.refundItems.getAll(); - } - - async upsertRefundItem(rec: WalletRefundItem): Promise<number> { - const tx = this.tx; - const res = await tx.refundItems.put(rec); - checkDbInvariant( - typeof res.key === "number", - "refund item row id must be a number", - ); - return res.key; - } - - async deleteRefundItem(id: number): Promise<void> { - const tx = this.tx; - await tx.refundItems.delete(id); - } - - async getRefundItemByCoinAndRtxid( - coinPub: string, - rtxid: number, - ): Promise<WalletRefundItem | undefined> { - const tx = this.tx; - return await tx.refundItems.indexes.byCoinPubAndRtxid.get([coinPub, rtxid]); - } - - async getSlate( - purchaseId: string, - choiceIndex: number, - outputIndex: number, - repeatIndex: number, - ): Promise<WalletSlate | undefined> { - const tx = this.tx; - return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get( - [purchaseId, choiceIndex, outputIndex, repeatIndex], - ); - } - - async getSlatesByPurchaseAndChoice( - purchaseId: string, - choiceIndex: number, - ): Promise<WalletSlate[]> { - const tx = this.tx; - return await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([ - purchaseId, - choiceIndex, - ]); - } - - async upsertSlate(rec: WalletSlate): Promise<void> { - const tx = this.tx; - await tx.slates.put(rec); - } - - async deleteSlate(tokenUsePub: string): Promise<void> { - const tx = this.tx; - await tx.slates.delete(tokenUsePub); - } - - async listAllSlates(): Promise<WalletSlate[]> { - return await this.tx.slates.getAll(); - } - - async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { - return await this.tx.recoupGroups.getAll(); - } - - async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { - return await this.tx.donationPlanchets.getAll(); - } - - async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { - return await this.tx.donationReceipts.getAll(); - } - - async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { - return await this.tx.denominationFamilies.getAll(); - } - - async listAllDenominations(): Promise<WalletDenomination[]> { - return await this.tx.denominationsV2.getAll(); - } - - async listAllContractTerms(): Promise<WalletContractTerms[]> { - return await this.tx.contractTerms.getAll(); - } - - async upsertTombstone(rec: WalletTombstone): Promise<void> { - const tx = this.tx; - await tx.tombstones.put(rec); - } - - async listAllTombstones(): Promise<WalletTombstone[]> { - return await this.tx.tombstones.getAll(); - } - - async getDonationSummary( - donauBaseUrl: string, - year: number, - currency: string, - ): Promise<WalletDonationSummary | undefined> { - const tx = this.tx; - return await tx.donationSummaries.get([donauBaseUrl, year, currency]); - } - - async upsertDonationSummary(rec: WalletDonationSummary): Promise<void> { - const tx = this.tx; - await tx.donationSummaries.put(rec); - } - - async getDonationReceipt( - udiNonce: string, - ): Promise<WalletDonationReceipt | undefined> { - const tx = this.tx; - return await tx.donationReceipts.get(udiNonce); - } - - async upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void> { - const tx = this.tx; - await tx.donationReceipts.put(rec); - } - - async getDonationReceiptsByStatus( - status: DonationReceiptStatus, - ): Promise<WalletDonationReceipt[]> { - const tx = this.tx; - return await tx.donationReceipts.indexes.byStatus.getAll(status); - } - - async getDonationReceiptsByStatusAndDonau( - status: DonationReceiptStatus, - donauBaseUrl: string, - ): Promise<WalletDonationReceipt[]> { - const tx = this.tx; - return await tx.donationReceipts.indexes.byStatusAndDonauBaseUrl.getAll([ - status, - donauBaseUrl, - ]); - } - - async upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void> { - const tx = this.tx; - await tx.donationPlanchets.put(rec); - } - - async getDonationPlanchetsByProposal( - proposalId: string, - ): Promise<WalletDonationPlanchet[]> { - const tx = this.tx; - return await tx.donationPlanchets.indexes.byProposalId.getAll(proposalId); - } - - async countDonationPlanchetsByProposal(proposalId: string): Promise<number> { - return await this.tx.donationPlanchets.indexes.byProposalId.count( - proposalId, - ); - } - - async getWithdrawalGroup( - withdrawalGroupId: string, - ): Promise<WalletWithdrawalGroup | undefined> { - const tx = this.tx; - return await tx.withdrawalGroups.get(withdrawalGroupId); - } - - async upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void> { - const tx = this.tx; - await tx.withdrawalGroups.put(rec); - } - - async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> { - const tx = this.tx; - // Cascade to the planchets, which exist only as part of the group. - await this.deletePlanchetsByGroup(withdrawalGroupId); - await tx.withdrawalGroups.delete(withdrawalGroupId); - } - - async getWithdrawalGroupByTalerWithdrawUri( - talerWithdrawUri: string, - ): Promise<WalletWithdrawalGroup | undefined> { - const tx = this.tx; - return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( - talerWithdrawUri, - ); - } - - async getWithdrawalGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]> { - const tx = this.tx; - return await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll( - exchangeBaseUrl, - ); - } - - async getPlanchetByGroupAndIndex( - withdrawalGroupId: string, - coinIdx: number, - ): Promise<WalletPlanchet | undefined> { - const tx = this.tx; - return await tx.planchets.indexes.byGroupAndIndex.get([ - withdrawalGroupId, - coinIdx, - ]); - } - - async getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined> { - const tx = this.tx; - return await tx.planchets.get(coinPub); - } - - async upsertPlanchet(rec: WalletPlanchet): Promise<void> { - const tx = this.tx; - await tx.planchets.put(rec); - } - - async deletePlanchet(coinPub: string): Promise<void> { - const tx = this.tx; - await tx.planchets.delete(coinPub); - } - - async getPlanchetsByGroup( - withdrawalGroupId: string, - ): Promise<WalletPlanchet[]> { - const tx = this.tx; - return await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); - } - - async listAllPlanchets(): Promise<WalletPlanchet[]> { - return await this.tx.planchets.getAll(); - } - - async countPlanchetsByGroup(withdrawalGroupId: string): Promise<number> { - return await this.tx.planchets.indexes.byGroup.count(withdrawalGroupId); - } - - async deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void> { - const tx = this.tx; - const planchets = - await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId); - for (const p of planchets) { - await tx.planchets.delete(p.coinPub); - } - } - - async getRefreshGroup( - refreshGroupId: string, - ): Promise<WalletRefreshGroup | undefined> { - const tx = this.tx; - return await tx.refreshGroups.get(refreshGroupId); - } - - async upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void> { - const tx = this.tx; - await tx.refreshGroups.put(rec); - } - - async deleteRefreshGroup(refreshGroupId: string): Promise<void> { - const tx = this.tx; - // Cascade to the sessions, which exist only as part of the group. - for (const sess of await this.getRefreshSessionsByGroup(refreshGroupId)) { - await tx.refreshSessions.delete([refreshGroupId, sess.coinIndex]); - } - await tx.refreshGroups.delete(refreshGroupId); - } - - async getRefreshGroupsByOriginatingTransaction( - transactionId: string, - ): Promise<WalletRefreshGroup[]> { - const tx = this.tx; - return await tx.refreshGroups.indexes.byOriginatingTransactionId.getAll( - transactionId, - ); - } - - async getRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<WalletRefreshSession | undefined> { - const tx = this.tx; - return await tx.refreshSessions.get([refreshGroupId, coinIndex]); - } - - async upsertRefreshSession(rec: WalletRefreshSession): Promise<void> { - const tx = this.tx; - await tx.refreshSessions.put(rec); - } - - async deleteRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<void> { - const tx = this.tx; - await tx.refreshSessions.delete([refreshGroupId, coinIndex]); - } - - async getRefreshSessionsByGroup( - refreshGroupId: string, - ): Promise<WalletRefreshSession[]> { - const tx = this.tx; - return await tx.refreshSessions.indexes.byRefreshGroupId.getAll( - refreshGroupId, - ); - } - - async listAllRefreshSessions(): Promise<WalletRefreshSession[]> { - return await this.tx.refreshSessions.getAll(); - } - - async getRecoupGroup( - recoupGroupId: string, - ): Promise<WalletRecoupGroup | undefined> { - const tx = this.tx; - return await tx.recoupGroups.get(recoupGroupId); - } - - async upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void> { - const tx = this.tx; - await tx.recoupGroups.put(rec); - } - - async deleteRecoupGroup(recoupGroupId: string): Promise<void> { - const tx = this.tx; - await tx.recoupGroups.delete(recoupGroupId); - } - - async getReserve(reserveRowId: number): Promise<WalletReserve | undefined> { - const tx = this.tx; - return await tx.reserves.get(reserveRowId); - } - - async getReserveByReservePub( - reservePub: string, - ): Promise<WalletReserve | undefined> { - const tx = this.tx; - return await tx.reserves.indexes.byReservePub.get(reservePub); - } - - async getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]> { - const reserves = await Promise.all( - reservePubs.map((reservePub) => - this.tx.reserves.indexes.byReservePub.get(reservePub), - ), - ); - return reserves.filter((x): x is WalletReserve => x !== undefined); - } - - async listAllReserves(): Promise<WalletReserve[]> { - return await this.tx.reserves.getAll(); - } - - async upsertReserve(rec: WalletReserve): Promise<number> { - const tx = this.tx; - const res = await tx.reserves.put(rec); - checkDbInvariant( - typeof res.key === "number", - "reserve row id must be a number", - ); - return res.key; - } - - async getDepositGroup( - depositGroupId: string, - ): Promise<WalletDepositGroup | undefined> { - const tx = this.tx; - return await tx.depositGroups.get(depositGroupId); - } - - async upsertDepositGroup(rec: WalletDepositGroup): Promise<void> { - const tx = this.tx; - await tx.depositGroups.put(rec); - } - - async deleteDepositGroup(depositGroupId: string): Promise<void> { - const tx = this.tx; - await tx.depositGroups.delete(depositGroupId); - } - - async getCoin(coinPub: string): Promise<WalletCoin | undefined> { - const tx = this.tx; - return await tx.coins.get(coinPub); - } - - async upsertCoin(coin: WalletCoin): Promise<void> { - const tx = this.tx; - await tx.coins.put(coin); - } - - async getCoinsBySourceTransaction( - transactionId: string, - ): Promise<WalletCoin[]> { - const tx = this.tx; - return await tx.coins.indexes.bySourceTransactionId.getAll(transactionId); - } - - async getCoinAvailability( - ref: WalletCoinAvailabilityRef, - ): Promise<WalletCoinAvailability | undefined> { - const tx = this.tx; - return await tx.coinAvailabilityV2.get([ - ref.exchangeMasterPub, - ref.denomPubHash, - ref.maxAge, - ]); - } - - async getCoinAvailabilitiesByRefs( - refs: WalletCoinAvailabilityRef[], - ): Promise<WalletCoinAvailability[]> { - const records = await Promise.all( - refs.map((ref) => - this.tx.coinAvailabilityV2.get([ - ref.exchangeMasterPub, - ref.denomPubHash, - ref.maxAge, - ]), - ), - ); - return records.filter( - (record): record is WalletCoinAvailability => record !== undefined, - ); - } - - async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { - const tx = this.tx; - await tx.coinAvailabilityV2.put({ - ...rec, - hasFreshCoins: rec.freshCoinCount > 0 ? 1 : 0, - }); - } - - async getCoinHistory( - coinPub: string, - ): Promise<WalletCoinHistory | undefined> { - const tx = this.tx; - return await tx.coinHistory.get(coinPub); - } - - async getCoinHistoriesByPubs( - coinPubs: string[], - ): Promise<WalletCoinHistory[]> { - const records = await Promise.all( - coinPubs.map((coinPub) => this.tx.coinHistory.get(coinPub)), - ); - return records.filter( - (record): record is WalletCoinHistory => record !== undefined, - ); - } - - async listAllCoinHistories(): Promise<WalletCoinHistory[]> { - return await this.tx.coinHistory.getAll(); - } - - async upsertCoinHistory(rec: WalletCoinHistory): Promise<void> { - const tx = this.tx; - await tx.coinHistory.put(rec); - } - - async listTokens(): Promise<WalletToken[]> { - const tx = this.tx; - return await tx.tokens.getAll(); - } - - async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { - const tx = this.tx; - return await tx.tokens.get(tokenUsePub); - } - - async upsertToken(token: WalletToken): Promise<void> { - const tx = this.tx; - await tx.tokens.put(token); - } - - async deleteToken(tokenUsePub: string): Promise<void> { - const tx = this.tx; - await tx.tokens.delete(tokenUsePub); - } - - async getTokensByIssuePubHash( - tokenIssuePubHash: string, - ): Promise<WalletToken[]> { - const tx = this.tx; - return await tx.tokens.indexes.byTokenIssuePubHash.getAll( - tokenIssuePubHash, - ); - } - - async getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]> { - return await this.tx.tokens.indexes.byTokenFamilyHash.getAll( - tokenFamilyHash, - ); - } - - async getPeerPullCredit( - pursePub: string, - ): Promise<WalletPeerPullCredit | undefined> { - const tx = this.tx; - const r = await tx.peerPullCredit.get(pursePub); - if (!r) { - return undefined; - } - return { - exchangeBaseUrl: r.exchangeBaseUrl, - amount: r.amount, - estimatedAmountEffective: r.estimatedAmountEffective, - pursePub: r.pursePub, - pursePriv: r.pursePriv, - contractTermsHash: r.contractTermsHash, - mergePub: r.mergePub, - mergePriv: r.mergePriv, - contractPub: r.contractPub, - contractPriv: r.contractPriv, - contractEncNonce: r.contractEncNonce, - mergeTimestamp: r.mergeTimestamp, - mergeReserveRowId: r.mergeReserveRowId, - status: r.status, - kycPaytoHash: r.kycPaytoHash, - kycAccessToken: r.kycAccessToken, - kycLastCheckStatus: r.kycLastCheckStatus, - kycLastCheckCode: r.kycLastCheckCode, - kycLastRuleGen: r.kycLastRuleGen, - kycLastAmlReview: r.kycLastAmlReview, - kycLastDeny: r.kycLastDeny, - abortReason: r.abortReason, - failReason: r.failReason, - withdrawalGroupId: r.withdrawalGroupId, - }; - } - - async upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void> { - const tx = this.tx; - await tx.peerPullCredit.put({ - exchangeBaseUrl: rec.exchangeBaseUrl, - amount: rec.amount, - estimatedAmountEffective: rec.estimatedAmountEffective, - pursePub: rec.pursePub, - pursePriv: rec.pursePriv, - contractTermsHash: rec.contractTermsHash, - mergePub: rec.mergePub, - mergePriv: rec.mergePriv, - contractPub: rec.contractPub, - contractPriv: rec.contractPriv, - contractEncNonce: rec.contractEncNonce, - mergeTimestamp: rec.mergeTimestamp, - mergeReserveRowId: rec.mergeReserveRowId, - status: rec.status, - kycPaytoHash: rec.kycPaytoHash, - kycAccessToken: rec.kycAccessToken, - kycLastCheckStatus: rec.kycLastCheckStatus, - kycLastCheckCode: rec.kycLastCheckCode, - kycLastRuleGen: rec.kycLastRuleGen, - kycLastAmlReview: rec.kycLastAmlReview, - kycLastDeny: rec.kycLastDeny, - abortReason: rec.abortReason, - failReason: rec.failReason, - withdrawalGroupId: rec.withdrawalGroupId, - }); - } - - async deletePeerPullCredit(pursePub: string): Promise<void> { - const tx = this.tx; - await tx.peerPullCredit.delete(pursePub); - } - - async getPeerPushDebit( - pursePub: string, - ): Promise<WalletPeerPushDebit | undefined> { - const tx = this.tx; - const r = await tx.peerPushDebit.get(pursePub); - if (!r) { - return undefined; - } - return { - exchangeBaseUrl: r.exchangeBaseUrl, - restrictScope: r.restrictScope, - amount: r.amount, - totalCost: r.totalCost, - coinSel: r.coinSel, - contractTermsHash: r.contractTermsHash, - pursePub: r.pursePub, - pursePriv: r.pursePriv, - mergePub: r.mergePub, - mergePriv: r.mergePriv, - contractPriv: r.contractPriv, - contractPub: r.contractPub, - contractEncNonce: r.contractEncNonce, - purseExpiration: r.purseExpiration, - timestampCreated: r.timestampCreated, - abortRefreshGroupId: r.abortRefreshGroupId, - abortReason: r.abortReason, - failReason: r.failReason, - status: r.status, - }; - } - - async upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void> { - const tx = this.tx; - await tx.peerPushDebit.put({ - exchangeBaseUrl: rec.exchangeBaseUrl, - restrictScope: rec.restrictScope, - amount: rec.amount, - totalCost: rec.totalCost, - coinSel: rec.coinSel, - contractTermsHash: rec.contractTermsHash, - pursePub: rec.pursePub, - pursePriv: rec.pursePriv, - mergePub: rec.mergePub, - mergePriv: rec.mergePriv, - contractPriv: rec.contractPriv, - contractPub: rec.contractPub, - contractEncNonce: rec.contractEncNonce, - purseExpiration: rec.purseExpiration, - timestampCreated: rec.timestampCreated, - abortRefreshGroupId: rec.abortRefreshGroupId, - abortReason: rec.abortReason, - failReason: rec.failReason, - status: rec.status, - }); - } - - async deletePeerPushDebit(pursePub: string): Promise<void> { - const tx = this.tx; - await tx.peerPushDebit.delete(pursePub); - } - - async getPeerPushCredit( - peerPushCreditId: string, - ): Promise<WalletPeerPushCredit | undefined> { - const tx = this.tx; - const r = await tx.peerPushCredit.get(peerPushCreditId); - if (!r) { - return undefined; - } - return { - peerPushCreditId: r.peerPushCreditId, - exchangeBaseUrl: r.exchangeBaseUrl, - pursePub: r.pursePub, - mergePriv: r.mergePriv, - contractPriv: r.contractPriv, - timestamp: r.timestamp, - estimatedAmountEffective: r.estimatedAmountEffective, - contractTermsHash: r.contractTermsHash, - status: r.status, - abortReason: r.abortReason, - failReason: r.failReason, - withdrawalGroupId: r.withdrawalGroupId, - currency: r.currency, - kycPaytoHash: r.kycPaytoHash, - kycAccessToken: r.kycAccessToken, - kycLastCheckStatus: r.kycLastCheckStatus, - kycLastCheckCode: r.kycLastCheckCode, - kycLastRuleGen: r.kycLastRuleGen, - kycLastAmlReview: r.kycLastAmlReview, - kycLastDeny: r.kycLastDeny, - }; - } - - async upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void> { - const tx = this.tx; - await tx.peerPushCredit.put({ - peerPushCreditId: rec.peerPushCreditId, - exchangeBaseUrl: rec.exchangeBaseUrl, - pursePub: rec.pursePub, - mergePriv: rec.mergePriv, - contractPriv: rec.contractPriv, - timestamp: rec.timestamp, - estimatedAmountEffective: rec.estimatedAmountEffective, - contractTermsHash: rec.contractTermsHash, - status: rec.status, - abortReason: rec.abortReason, - failReason: rec.failReason, - withdrawalGroupId: rec.withdrawalGroupId, - currency: rec.currency, - kycPaytoHash: rec.kycPaytoHash, - kycAccessToken: rec.kycAccessToken, - kycLastCheckStatus: rec.kycLastCheckStatus, - kycLastCheckCode: rec.kycLastCheckCode, - kycLastRuleGen: rec.kycLastRuleGen, - kycLastAmlReview: rec.kycLastAmlReview, - kycLastDeny: rec.kycLastDeny, - }); - } - - async deletePeerPushCredit(peerPushCreditId: string): Promise<void> { - const tx = this.tx; - await tx.peerPushCredit.delete(peerPushCreditId); - } - - async getPeerPushCreditByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPushCredit | undefined> { - const tx = this.tx; - const r = await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([ - exchangeBaseUrl, - contractPriv, - ]); - if (!r) { - return undefined; - } - return this.getPeerPushCredit(r.peerPushCreditId); - } - - async getPeerPullDebit( - peerPullDebitId: string, - ): Promise<WalletPeerPullDebit | undefined> { - const tx = this.tx; - const r = await tx.peerPullDebit.get(peerPullDebitId); - if (!r) { - return undefined; - } - return { - peerPullDebitId: r.peerPullDebitId, - pursePub: r.pursePub, - exchangeBaseUrl: r.exchangeBaseUrl, - amount: r.amount, - contractTermsHash: r.contractTermsHash, - timestampCreated: r.timestampCreated, - contractPriv: r.contractPriv, - status: r.status, - totalCostEstimated: r.totalCostEstimated, - abortRefreshGroupId: r.abortRefreshGroupId, - abortReason: r.abortReason, - failReason: r.failReason, - coinSel: r.coinSel, - }; - } - - async upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void> { - const tx = this.tx; - await tx.peerPullDebit.put({ - peerPullDebitId: rec.peerPullDebitId, - pursePub: rec.pursePub, - exchangeBaseUrl: rec.exchangeBaseUrl, - amount: rec.amount, - contractTermsHash: rec.contractTermsHash, - timestampCreated: rec.timestampCreated, - contractPriv: rec.contractPriv, - status: rec.status, - totalCostEstimated: rec.totalCostEstimated, - abortRefreshGroupId: rec.abortRefreshGroupId, - abortReason: rec.abortReason, - failReason: rec.failReason, - coinSel: rec.coinSel, - }); - } - - async deletePeerPullDebit(peerPullDebitId: string): Promise<void> { - const tx = this.tx; - await tx.peerPullDebit.delete(peerPullDebitId); - } - - async getPeerPullDebitByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPullDebit | undefined> { - const tx = this.tx; - const r = await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([ - exchangeBaseUrl, - contractPriv, - ]); - if (!r) { - return undefined; - } - return this.getPeerPullDebit(r.peerPullDebitId); - } - - async upsertDenomination(rec: WalletDenomination): Promise<void> { - const tx = this.tx; - await tx.denominationsV2.put(rec); - } - - async getDenomination( - ref: WalletDenomRef, - ): Promise<WalletDenomination | undefined> { - const tx = this.tx; - return await tx.denominationsV2.get([ - ref.exchangeMasterPub, - ref.denomPubHash, - ]); - } - - async getDenominationsByRefs( - refs: WalletDenomRef[], - ): Promise<WalletDenomination[]> { - const records = await Promise.all( - refs.map((ref) => - this.tx.denominationsV2.get([ref.exchangeMasterPub, ref.denomPubHash]), - ), - ); - return records.filter( - (record): record is WalletDenomination => record !== undefined, - ); - } - - async findDenominationByFamilyFromExpiry( - denominationFamilySerial: number, - minStampExpireWithdraw: DbProtocolTimestamp, - match: (d: WalletDenomination) => boolean, - ): Promise<WalletDenomination | undefined> { - const tx = this.tx; - const cursor = - tx.denominationsV2.indexes.byDenominationFamilySerialAndStampExpireWithdraw.iter(); - // The cursor has to be positioned before it can be moved. - const first = await cursor.current(); - if (!first.hasValue) { - return undefined; - } - // Denominations without a family are not part of the index. - const firstSerial = first.value.denominationFamilySerial; - if ( - firstSerial == null || - firstSerial < denominationFamilySerial || - (firstSerial === denominationFamilySerial && - first.value.stampExpireWithdraw < minStampExpireWithdraw) - ) { - cursor.continue([denominationFamilySerial, minStampExpireWithdraw]); - } - while (true) { - const cur = await cursor.current(); - if (!cur.hasValue) { - return undefined; - } - if (cur.value.denominationFamilySerial != denominationFamilySerial) { - // Moved past this family. - return undefined; - } - if (match(cur.value)) { - return cur.value; - } - cursor.continue(); - } - } - - async getDenominationsByMasterPub( - exchangeMasterPub: string, - ): Promise<WalletDenomination[]> { - const tx = this.tx; - return await tx.denominationsV2.indexes.byExchangeMasterPub.getAll( - exchangeMasterPub, - ); - } - - async deleteDenomination(ref: WalletDenomRef): Promise<void> { - const tx = this.tx; - await tx.denominationsV2.delete([ref.exchangeMasterPub, ref.denomPubHash]); - } - - async getDenominationsByVerificationStatus( - verificationStatus: DenominationVerificationStatus, - ): Promise<WalletDenomination[]> { - const tx = this.tx; - return await tx.denominationsV2.indexes.byVerificationStatus.getAll( - verificationStatus, - ); - } - - async getDonationSummaries(): Promise<WalletDonationSummary[]> { - return await this.tx.donationSummaries.getAll(); - } - - async getExchanges(): Promise<WalletExchangeEntry[]> { - return await this.tx.exchanges.getAll(); - } - - async getCoinAvailabilities(): Promise<WalletCoinAvailability[]> { - return await this.tx.coinAvailabilityV2.getAll(); - } - - async getActiveRefreshGroups(): Promise<WalletRefreshGroup[]> { - return await this.tx.refreshGroups.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { - return await this.tx.withdrawalGroups.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]> { - return await this.tx.peerPushDebit.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]> { - return await this.tx.peerPushCredit.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]> { - return await this.tx.peerPullCredit.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]> { - return await this.tx.peerPullDebit.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getActiveRecoupGroups(): Promise<WalletRecoupGroup[]> { - return await this.tx.recoupGroups.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getPurchasesByStatus( - status: PurchaseStatus, - ): Promise<WalletPurchase[]> { - return await this.tx.purchases.indexes.byStatus.getAll(status); - } - - async getActivePurchases(): Promise<WalletPurchase[]> { - return await this.tx.purchases.indexes.byStatus.getAll(getActiveKeyRange()); - } - - async getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]> { - const coins = await Promise.all( - coinPubs.map((pub) => this.tx.coins.get(pub)), - ); - return coins.filter((coin): coin is WalletCoin => coin !== undefined); - } - - async getActiveDepositGroups(): Promise<WalletDepositGroup[]> { - return await this.tx.depositGroups.indexes.byStatus.getAll( - getActiveKeyRange(), - ); - } - - async getExchangeDetails( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined> { - const r = await this.tx.exchanges.get(exchangeBaseUrl); - if (!r || !r.detailsPointer) { - return undefined; - } - return await this.tx.exchangeDetails.indexes.byPointer.get([ - r.baseUrl, - r.detailsPointer.currency, - r.detailsPointer.masterPublicKey, - ]); - } - - async checkExchangeInScope( - exchangeBaseUrl: string, - scope: ScopeInfo, - denomPubHash?: string, - ): Promise<boolean> { - switch (scope.type) { - case ScopeType.Exchange: { - return scope.url === exchangeBaseUrl; - } - case ScopeType.Global: { - const exchangeDetails = await this.getExchangeDetails(exchangeBaseUrl); - if (!exchangeDetails) { - return false; - } - const gr = - await this.tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get( - [ - exchangeDetails.currency, - exchangeBaseUrl, - exchangeDetails.masterPublicKey, - ], - ); - return gr != null; - } - case ScopeType.Auditor: { - const exchangeDetails = await this.getExchangeDetails(exchangeBaseUrl); - if (!exchangeDetails || exchangeDetails.currency !== scope.currency) { - return false; - } - for (const auditor of exchangeDetails.auditors) { - if ( - !auditorProvidesVerifiedTrust(auditor, { - auditorBaseUrl: scope.url, - denomPubHash, - }) - ) { - continue; - } - const configured = - await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( - [ - exchangeDetails.currency, - auditor.auditor_url, - auditor.auditor_pub, - ], - ); - if (configured) { - return true; - } - } - return false; - } - case ScopeType.ExchangeLegacyKeys: - // See checkExchangeInScopeGeneric: an entry stands for its current - // key set, which is never a superseded one. - return false; - default: - assertUnreachable(scope); - } - } - - async getExchangeScopeInfo( - exchangeBaseUrl: string, - currency: string, - denomPubHash?: string, - ): Promise<ScopeInfo> { - const det = await this.getExchangeDetails(exchangeBaseUrl); - if (!det) { - return { - type: ScopeType.Exchange, - currency: currency, - url: exchangeBaseUrl, - }; - } - const globalExchangeRec = - await this.tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ - det.currency, - det.exchangeBaseUrl, - det.masterPublicKey, - ]); - if (globalExchangeRec) { - return { - currency: det.currency, - type: ScopeType.Global, - }; - } else { - for (const aud of denomPubHash == null ? [] : det.auditors) { - if (!auditorProvidesVerifiedTrust(aud, { denomPubHash })) { - continue; - } - const globalAuditorRec = - await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( - [det.currency, aud.auditor_url, aud.auditor_pub], - ); - if (globalAuditorRec) { - return { - currency: det.currency, - type: ScopeType.Auditor, - url: aud.auditor_url, - }; - } - } - } - return { - currency: det.currency, - type: ScopeType.Exchange, - url: det.exchangeBaseUrl, - }; - } -} diff --git a/packages/taler-wallet-core/src/dbtx-runners.ts b/packages/taler-wallet-core/src/dbtx-runners.ts @@ -1,78 +0,0 @@ -/* - 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/> - */ - -/** - * Backend runners shared by the conformance suite and the benchmark. - * - * Both must exercise the two implementations through exactly the same setup, - * or a measured difference could be an artefact of how the database was - * opened rather than of the implementation. - */ - -import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; -import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; - -import { DbTxRunner } from "./dbtx-conformance.js"; -import { IdbWalletDbHandle, SqliteWalletDbHandle } from "./dbtx-handle-impl.js"; -import { openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; - -/** - * Runner for the IndexedDB implementation, on an in-memory sqlite-backed - * BridgeIDB. Each runner gets a fresh database so cases cannot leak into - * each other. - */ -export async function makeIdbRunner( - filename = ":memory:", -): Promise<DbTxRunner> { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const backend = await createSqliteBackend(sqlite3Impl, { - filename, - }); - backend.enableTracing = false; - backend.trackStats = true; - BridgeIDBFactory.enableTracing = false; - const idbFactory = new BridgeIDBFactory(backend); - const handle = new IdbWalletDbHandle( - idbFactory as any, - () => backend.accessStats, - ); - await handle.ensureOpen(); - return handle; -} - -/** - * Runner for the native sqlite3 implementation. - * - * Both runners return the handle the wallet itself uses, so the suite - * exercises transaction serialisation, the shared statement cache, - * checkpoint-on-idle and post-commit notification delivery rather than a - * reimplementation of them. - */ -export async function makeSqliteRunner( - filename = ":memory:", -): Promise<DbTxRunner> { - const sqlite3Impl = await createNodeHelperSqlite3Impl({ - enableTracing: false, - }); - const ndb = await openNativeSqliteWalletDb(await sqlite3Impl.open(filename)); - return new SqliteWalletDbHandle(ndb); -} - -export const runnerFactories: Array< - (filename?: string) => Promise<DbTxRunner> -> = [makeIdbRunner, makeSqliteRunner]; diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts @@ -1,217 +0,0 @@ -/* - 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/> - */ - -/** - * Operations built on top of {@link WalletDbTransaction} rather than on any - * one backend. - * - * Scope resolution is business logic that happens to read from the database: - * it is identical for every backend, so it lives here once instead of being - * reimplemented (and drifting) in each. - */ - -import { assertUnreachable, ScopeInfo, ScopeType } from "@gnu-taler/taler-util"; -import { WalletDbTransaction } from "./dbtx.js"; -import { PurchaseStatus, WalletPurchase } from "./db-common.js"; -import { auditorProvidesVerifiedTrust } from "./auditorTrust.js"; - -/** - * Does the exchange fall within the given scope? - */ -export async function checkExchangeInScopeGeneric( - tx: WalletDbTransaction, - exchangeBaseUrl: string, - scope: ScopeInfo, - denomPubHash?: string, -): Promise<boolean> { - switch (scope.type) { - case ScopeType.Exchange: - return scope.url === exchangeBaseUrl; - case ScopeType.Global: { - const details = await tx.getExchangeDetails(exchangeBaseUrl); - if (!details) { - return false; - } - const gr = await tx.getGlobalCurrencyExchange( - details.currency, - exchangeBaseUrl, - details.masterPublicKey, - ); - return gr != null; - } - case ScopeType.Auditor: { - const details = await tx.getExchangeDetails(exchangeBaseUrl); - if (!details || details.currency !== scope.currency) { - return false; - } - for (const auditor of details.auditors) { - if ( - !auditorProvidesVerifiedTrust(auditor, { - auditorBaseUrl: scope.url, - denomPubHash, - }) - ) { - continue; - } - if ( - await tx.getGlobalCurrencyAuditor( - details.currency, - auditor.auditor_url, - auditor.auditor_pub, - ) - ) { - return true; - } - } - return false; - } - case ScopeType.ExchangeLegacyKeys: - // Asked of an exchange entry, which always stands for the key set it - // currently uses. That is by definition not a superseded one, so the - // answer is no even when the URLs agree. - return false; - default: - assertUnreachable(scope); - } -} - -/** - * Compute the scope (global, auditor or exchange) an exchange belongs to. - */ -export async function getExchangeScopeInfoGeneric( - tx: WalletDbTransaction, - exchangeBaseUrl: string, - currency: string, - denomPubHash?: string, -): Promise<ScopeInfo> { - const det = await tx.getExchangeDetails(exchangeBaseUrl); - if (!det) { - return { - type: ScopeType.Exchange, - currency, - url: exchangeBaseUrl, - }; - } - const globalExchangeRec = await tx.getGlobalCurrencyExchange( - det.currency, - det.exchangeBaseUrl, - det.masterPublicKey, - ); - if (globalExchangeRec) { - return { - currency: det.currency, - type: ScopeType.Global, - }; - } - for (const aud of denomPubHash == null ? [] : det.auditors) { - if ( - !auditorProvidesVerifiedTrust(aud, { - denomPubHash, - }) - ) { - continue; - } - const globalAuditorRec = await tx.getGlobalCurrencyAuditor( - det.currency, - aud.auditor_url, - aud.auditor_pub, - ); - if (globalAuditorRec) { - return { - currency: det.currency, - type: ScopeType.Auditor, - url: aud.auditor_url, - }; - } - } - return { - type: ScopeType.Exchange, - currency: det.currency, - url: det.exchangeBaseUrl, - }; -} - -/** - * DAL methods after which the wallet's in-memory caches are stale. - * - * The caches hold exchange summaries, denomination info and refresh costs, - * all derived from these entities. Listing the methods here rather than - * having each backend decide keeps the two implementations from drifting: a - * cache that is dropped on one backend and not the other is a bug that only - * shows up as stale data much later. - * - * Mutations only. Reading a denomination cannot invalidate anything derived - * from denominations. - */ -export const CACHE_INVALIDATING_METHODS: ReadonlySet<string> = new Set([ - "upsertExchange", - "deleteExchange", - "upsertExchangeDetails", - "deleteExchangeDetails", - "upsertDenomination", - "deleteDenomination", - // Cascades to denominations, so it invalidates the same caches. - "deleteDenominationFamily", - "upsertGlobalCurrencyExchange", - "deleteGlobalCurrencyExchange", - "upsertGlobalCurrencyAuditor", - "deleteGlobalCurrencyAuditor", -]); - -/** - * Wrap a transaction so that calls to cache-invalidating methods are noticed. - * - * `flag.dirty` is set as a side effect; the caller drops the caches after the - * transaction commits, never before, so a rolled-back transaction does not - * invalidate anything. - */ -export function watchForCacheInvalidation<T extends WalletDbTransaction>( - tx: T, - flag: { dirty: boolean; terminalPaymentIds?: Set<string> }, -): T { - return new Proxy(tx, { - get(target, prop, receiver) { - const value = Reflect.get(target, prop, receiver); - if (prop === "upsertPurchase" && typeof value === "function") { - return (...args: unknown[]) => { - const purchase = args[0] as WalletPurchase; - if (purchase.purchaseStatus >= PurchaseStatus.Done) { - flag.terminalPaymentIds?.add(purchase.proposalId); - } - return value.apply(target, args); - }; - } - if (prop === "deletePurchase" && typeof value === "function") { - return (...args: unknown[]) => { - flag.terminalPaymentIds?.add(args[0] as string); - return value.apply(target, args); - }; - } - if (typeof prop === "string" && CACHE_INVALIDATING_METHODS.has(prop)) { - if (typeof value === "function") { - return (...args: unknown[]) => { - flag.dirty = true; - return value.apply(target, args); - }; - } - } - if (typeof value === "function") { - return value.bind(target); - } - return value; - }, - }); -} diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.test.ts b/packages/taler-wallet-core/src/dbtx-sqlite.test.ts @@ -1,180 +0,0 @@ -/* - 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"; -import { initSqliteWalletDb } from "./dbtx-sqlite.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.runReadWriteTx(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(", ")}`); - } -}); - -/** - * The schema's CHECK and UNIQUE constraints, exercised directly. - * - * A constraint that is silently dropped -- a typo in the DDL, a column - * rewritten during a migration -- looks exactly like one that is holding, - * because correct code never trips it. These probes write the bad values on - * purpose. - */ -test("sqlite: schema constraints reject invalid rows", async () => { - const impl = await createNodeHelperSqlite3Impl({ enableTracing: false }); - const db = await impl.open(":memory:"); - await initSqliteWalletDb(db); - - const run = async (sql: string): Promise<string> => { - try { - await (await db.prepare(sql)).run({}); - return "accepted"; - } catch (e) { - return `rejected: ${e instanceof Error ? e.message : String(e)}`; - } - }; - - // Booleans are 0/1/NULL; sqlite would otherwise store any integer, and a - // stray 2 reads back as a truthy value that is not `true`. - assert.match( - await run( - "INSERT INTO bank_accounts (bank_account_id, payto_uri, kyc_completed)" + - " VALUES ('bad', 'payto://x', 7)", - ), - /CHECK constraint failed/, - "a boolean column must reject a value outside 0/1", - ); - assert.strictEqual( - await run( - "INSERT INTO bank_accounts (bank_account_id, payto_uri, kyc_completed)" + - " VALUES ('good', 'payto://x', 1)", - ), - "accepted", - "a boolean column must still accept 1", - ); - - // Counts drive coin selection, and are decremented in places without a - // floor, so a negative value is a bug rather than a state to store. - assert.match( - await run( - "INSERT INTO coin_availability (exchange_base_url, exchange_master_pub," + - " denom_pub_hash, max_age, currency, value, fresh_coin_count," + - " visible_coin_count)" + - " VALUES ('https://e/', x'01', x'00', 0, 'C', 'C:1', -1, 0)", - ), - /CHECK constraint failed/, - "a negative coin count must be rejected", - ); - - // getReserveByPub is a single-row lookup. - assert.strictEqual( - await run( - "INSERT INTO reserves (reserve_pub, reserve_priv) VALUES (x'11', x'22')", - ), - "accepted", - ); - assert.match( - await run( - "INSERT INTO reserves (reserve_pub, reserve_priv) VALUES (x'11', x'33')", - ), - /UNIQUE constraint failed/, - "two reserves must not share a public key", - ); - - await db.close(); -}); diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -1,5945 +0,0 @@ -/* - 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. - */ - -/** - * Native sqlite3 implementation of {@link WalletDbTransaction}. - * - * Talks to sqlite directly through {@link Sqlite3Interface} — no IndexedDB - * emulation. The interface was shaped during the DAL migration to make this - * possible: no cursors, no key ranges, compound keys as separate parameters, - * generated ids returned from upserts. - * - * Work in progress. Methods that are not implemented yet throw - * {@link NotImplementedError} rather than being silently wrong; the - * conformance suite is the checklist. - */ - -import { - ResultRow, - Sqlite3Database, - Sqlite3Statement, - Sqlite3Value, -} from "@gnu-taler/idb-bridge"; -import { - AmountString, - CoinStatus, - decodeCrock, - encodeCrock, - MerchantContractTokenKind, - stringifyScopeInfo, - DenomLossEventType, - RefreshReason, - Logger, - WalletNotification, - ContactEntry, - CurrencySpecification, - ExchangeEntrySource, - MailboxConfiguration, - MailboxMessageRecord, - ScopeInfo, - TransactionIdStr, -} from "@gnu-taler/taler-util"; -import { - checkExchangeInScopeGeneric, - getExchangeScopeInfoGeneric, -} from "./dbtx-shared.js"; -import { - GetCurrencyInfoDbResult, - StoreCurrencyInfoDbRequest, - WalletCoinAvailabilityRef, - WalletDbRecordCounts, - WalletDbTransaction, - WalletDbMigrationPage, - WalletDbMigrationStore, - WalletDenomRef, - WalletCurrencyInfoEntry, -} from "./dbtx.js"; -import { - ConfigRecord, - DbPreciseTimestamp, - DbProtocolTimestamp, - DenominationVerificationStatus, - WalletCoin, - WalletCoinAvailability, - WalletCoinHistory, - OPERATION_STATUS_NONFINAL_FIRST, - OPERATION_STATUS_NONFINAL_LAST, - ReserveBankInfo, - WalletContractTerms, - WalletPlanchet, - WalletProposalDownloadInfo, - WalletWithdrawalGroup, - WgInfo, - WgInfoBankIntegrated, - WgInfoBankManual, - WgInfoBankPeerPull, - WgInfoBankPeerPush, - WgInfoBankRecoup, - WithdrawalRecordType, - WalletDenomFamilyParams, - WalletDenominationFamily, - WalletExchangeBaseUrlFixup, - WalletExchangeDetails, - WalletExchangeEntry, - WalletExchangeMigrationLog, - WalletExchangeSignkeys, - ExchangeMigrationReason, - WalletDenomination, - WalletOperationRetry, - WalletTransactionMetaCursor, - WalletRefundGroup, - WalletRefundItem, - WalletReserve, - WalletTombstone, - DonationReceiptStatus, - PurchaseStatus, - WalletBankAccount, - WalletDenomLossEvent, - WalletDepositGroup, - WalletDonationPlanchet, - WalletDonationReceipt, - WalletDonationSummary, - WalletGlobalCurrencyAuditor, - WalletGlobalCurrencyExchange, - WalletPeerPullCredit, - WalletPeerPullDebit, - WalletPeerPushCredit, - WalletPeerPushDebit, - WalletPurchase, - WalletRecoupGroup, - WalletRefreshGroup, - WalletRefreshSession, - WalletSlate, - WalletToken, - WalletTransactionMeta, - timestampProtocolFromDb, - timestampProtocolToDb, -} from "./db-common.js"; -import { - DATA_TABLES_CONDITION, - SQLITE_BASELINE_SCHEMA, - SchemaMigration, - SQLITE_SCHEMA_VERSION, - schemaMigrations, -} from "./db-sqlite-schema.js"; - -const logger = new Logger("dbtx-sqlite.ts"); - -export class NotImplementedError extends Error { - constructor(method: string) { - super(`sqlite DAL: ${method} is not implemented yet`); - } -} - -/** - * Transaction control for the helper protocol. - * - * These must go through prepared statements. `exec` commits implicitly, which - * has two consequences worth stating plainly: - * - * 1. A BEGIN issued with `exec` reports success and then does nothing. The - * following COMMIT fails with "no transaction is active" and the writes - * have already been committed individually — indistinguishable from a - * working transaction until something needs to roll back, which is exactly - * when a wallet can least afford it. - * 2. An `exec` *inside* an explicit transaction ends that transaction. So - * `exec` must not be used for anything that has to be atomic with - * surrounding work; use prepared statements throughout instead. - */ -export class SqliteTxControl { - private constructor( - private beginStmt: Sqlite3Statement, - private commitStmt: Sqlite3Statement, - private rollbackStmt: Sqlite3Statement, - ) {} - - static async create(db: Sqlite3Database): Promise<SqliteTxControl> { - return new SqliteTxControl( - await db.prepare("BEGIN"), - await db.prepare("COMMIT"), - await db.prepare("ROLLBACK"), - ); - } - - async begin(): Promise<void> { - await this.beginStmt.run({}); - } - async commit(): Promise<void> { - await this.commitStmt.run({}); - } - async rollback(): Promise<void> { - await this.rollbackStmt.run({}); - } -} - -/** - * How long to wait for a lock held by another connection before giving up. - * - * Without this a concurrent writer surfaces as an immediate SQLITE_BUSY. - * Within one process TxQueue serialises transactions so it cannot happen, but - * nothing stops a second process -- a CLI command run against a wallet the - * shepherd has open, say -- from touching the same file. Five seconds is long - * enough to outlast any transaction this code issues and short enough that a - * genuine deadlock still surfaces as an error rather than a hang. - */ -const SQLITE_BUSY_TIMEOUT_MS = 5000; - -/** Current time in the microseconds the schema's INTEGER timestamps use. */ -function nowMicros(): number { - return Date.now() * 1000; -} - -/** - * Check the migration list before running any of it. - * - * A duplicate or out-of-order version does not fail on its own: migrations are - * skipped by looking up the version in schema_migrations, so a reused version - * silently never runs, and the database ends up missing a change while - * claiming to have applied it. Better to refuse to open. - */ -function validateSchemaMigrations(migrations: SchemaMigration[]): void { - let prev = 1; // the baseline occupies version 1 - for (const mig of migrations) { - if (mig.version <= prev) { - throw Error( - `schema migration ${mig.version} (${mig.name}) is not greater than` + - ` the preceding version ${prev}: versions must strictly increase` + - ` and may not be reused`, - ); - } - prev = mig.version; - } -} - -function validateAppliedSchemaMigrations( - applied: ResultRow[], - extraMigrations: SchemaMigration[], -): void { - const expected = new Map<number, string>([[1, "baseline"]]); - for (const migration of [...schemaMigrations, ...extraMigrations]) { - const previous = expected.get(migration.version); - if (previous !== undefined && previous !== migration.name) { - throw Error( - `schema migration ${migration.version} has conflicting names` + - ` (${previous} and ${migration.name})`, - ); - } - expected.set(migration.version, migration.name); - } - for (const row of applied) { - const version = Number(row.version); - const name = String(row.name); - const expectedName = expected.get(version); - if (expectedName === undefined) { - throw Error(`database records unknown schema migration ${version}`); - } - if (name !== expectedName) { - throw Error( - `database schema migration ${version} is named ${name},` + - ` expected ${expectedName}`, - ); - } - } -} - -/** - * Open the database and bring its schema up to date. - * - * Each migration runs in its own transaction with its schema_migrations row - * written inside that transaction, so a crash part-way through cannot leave a - * half-applied migration recorded as done. - * - * The migration list is a parameter so that tests can exercise the path with - * a synthetic migration. Until a real one exists this is the only thing that - * runs it at all. - */ -export async function initSqliteWalletDb( - db: Sqlite3Database, - migrations: SchemaMigration[] = schemaMigrations, -): Promise<void> { - validateSchemaMigrations(migrations); - await db.exec("PRAGMA foreign_keys = ON"); - await db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`); - const versionRows = await (await db.prepare("PRAGMA user_version")).getAll(); - const databaseVersion = Number(versionRows[0]?.user_version ?? 0); - if (databaseVersion > SQLITE_SCHEMA_VERSION) { - throw Error( - `database schema version ${databaseVersion} is newer than this wallet` + - ` (version ${SQLITE_SCHEMA_VERSION})`, - ); - } - const migrationTable = await ( - await db.prepare( - "SELECT 1 AS present FROM sqlite_master" + - " WHERE type = 'table' AND name = 'schema_migrations'", - ) - ).getAll(); - if (migrationTable.length !== 0) { - const recorded = await ( - await db.prepare("SELECT version, name FROM schema_migrations") - ).getAll(); - validateAppliedSchemaMigrations(recorded, migrations); - } - // WAL: readers do not block the writer, and a commit appends to the log - // instead of fsyncing the whole database. - // - // The cost is that a WAL database is three files (db, -wal, -shm), so - // copying just the database file is not a snapshot. That matters because - // callers do treat the wallet DB as one file they can copy. Rather than - // give up WAL, {@link runNativeSqliteWalletTx} checkpoints with TRUNCATE - // whenever the transaction queue drains: under load the WAL accumulates - // normally, and the moment the wallet goes idle the main file is complete - // again and the -wal is empty. Idle is also the only moment at which an - // external copy could be coherent at all, so this makes single-file copies - // valid exactly when they can be. - // - // It also means a killed process leaves an empty -wal behind, so restoring - // a database file over it cannot replay stale frames from the old one. - await db.exec("PRAGMA journal_mode = WAL"); - // NORMAL is safe against process crashes in WAL mode; only a power loss - // can cost the most recent commits, and never corruption. - await db.exec("PRAGMA synchronous = NORMAL"); - - const txc = await SqliteTxControl.create(db); - - // The baseline is exec'd outside a transaction: every statement is - // CREATE ... IF NOT EXISTS, so re-running it is a no-op, and exec would end - // an enclosing transaction anyway. - await db.exec(SQLITE_BASELINE_SCHEMA); - const baselineStmt = await db.prepare( - "INSERT OR IGNORE INTO schema_migrations (version, name, applied_at)" + - " VALUES ($version, $name, $applied_at)", - ); - await baselineStmt.run({ - version: 1, - name: "baseline", - applied_at: nowMicros(), - }); - - const applied = await ( - await db.prepare("SELECT version, name FROM schema_migrations") - ).getAll(); - validateAppliedSchemaMigrations(applied, migrations); - const have = new Set(applied.map((r) => Number(r.version))); - - for (const mig of migrations) { - if (have.has(mig.version)) { - continue; - } - logger.info(`applying schema migration ${mig.version} (${mig.name})`); - await txc.begin(); - try { - for (const sql of mig.statements) { - await (await db.prepare(sql)).run({}); - } - const stmt = await db.prepare( - "INSERT INTO schema_migrations (version, name, applied_at)" + - " VALUES ($version, $name, $applied_at)", - ); - await stmt.run({ - version: mig.version, - name: mig.name, - applied_at: nowMicros(), - }); - await txc.commit(); - } catch (e) { - await txc.rollback(); - throw e; - } - } - await db.exec(`PRAGMA user_version = ${SQLITE_SCHEMA_VERSION}`); -} - -/** Encode an optional boolean the way sqlite wants it. */ -function boolToDb(b: boolean | undefined): number | null { - if (b === undefined) return null; - return b ? 1 : 0; -} - -function dbToBool(v: Sqlite3Value | undefined): boolean { - return v === 1 || v === 1n; -} - -function dbToOptBool(v: Sqlite3Value | undefined): boolean | undefined { - if (v == null) return undefined; - return dbToBool(v); -} - -function num(v: Sqlite3Value | undefined): number { - return Number(v); -} - -/** - * Restore a branded timestamp read back from the database. - * - * DbProtocolTimestamp and DbPreciseTimestamp are compile-time brands over a - * microsecond number: there is nothing to convert at runtime. The brand - * exists to stop the two being mixed up in business logic, not to prevent - * construction, so re-applying it at the deserialisation boundary is the one - * place an assertion is legitimate. Keeping it in a single named helper means - * it cannot spread into the mapping code. - */ -function dbTimestamp<T extends DbProtocolTimestamp | DbPreciseTimestamp>( - v: Sqlite3Value | undefined, -): T { - return Number(v) as T; -} - -/** - * Restore a branded AmountString read back from the database. - * - * Same reasoning as {@link dbTimestamp}: the brand is a compile-time marker - * over a string, and the deserialisation boundary is where it is reapplied. - */ -function dbAmount(v: Sqlite3Value | undefined): AmountString { - return v as AmountString; -} - -function optNum(v: Sqlite3Value | undefined): number | undefined { - return v == null ? undefined : Number(v); -} - -function str(v: Sqlite3Value | undefined): string { - return v as string; -} - -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. - */ -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); -} - -function dbToJson<T>(v: Sqlite3Value | undefined): T { - return JSON.parse(v as string) as T; -} - -function dbToOptJson<T>(v: Sqlite3Value | undefined): T | undefined { - return v == null ? undefined : (JSON.parse(v as string) as T); -} - -/** Native table enumerated for each backend-conversion store. */ -const SQLITE_MIGRATION_TABLES: Record<WalletDbMigrationStore, string> = { - config: "config", - currencyInfo: "currency_info", - contacts: "contacts", - mailboxMessages: "mailbox_messages", - mailboxConfigurations: "mailbox_configurations", - contractTerms: "contract_terms", - tombstones: "tombstones", - operationRetries: "operation_retries", - bankAccounts: "bank_accounts", - globalCurrencyExchanges: "global_currency_exchanges", - globalCurrencyAuditors: "global_currency_auditors", - exchangeBaseUrlFixups: "exchange_base_url_fixups", - exchangeBaseUrlMigrationLog: "exchange_base_url_migration_log", - reserves: "reserves", - exchanges: "exchanges", - exchangeDetails: "exchange_details", - exchangeSignKeys: "exchange_sign_keys", - denominationFamilies: "denomination_families", - denominations: "denominations", - withdrawalGroups: "withdrawal_groups", - purchases: "purchases", - refreshGroups: "refresh_groups", - coins: "coins", - planchets: "planchets", - refreshSessions: "refresh_sessions", - coinHistory: "coin_history", - coinAvailability: "coin_availability", - refundGroups: "refund_groups", - tokens: "tokens", - slates: "slates", - depositGroups: "deposit_groups", - recoupGroups: "recoup_groups", - denomLossEvents: "denom_loss_events", - peerPushDebit: "peer_push_debit", - peerPushCredit: "peer_push_credit", - peerPullDebit: "peer_pull_debit", - peerPullCredit: "peer_pull_credit", - donationSummaries: "donation_summaries", - donationPlanchets: "donation_planchets", - donationReceipts: "donation_receipts", - transactionsMeta: "transactions_meta", - refundItems: "refund_items", -}; - -/** - * One sqlite transaction. - * - * Notifications and commit hooks are buffered and only released by the - * runner after COMMIT succeeds, matching the IndexedDB implementation: a - * transaction that rolls back must not have told anyone it happened. - */ -export class SqliteWalletTransaction implements WalletDbTransaction { - readonly pendingNotifications: WalletNotification[] = []; - readonly afterCommitHandlers: (() => void)[] = []; - - /** - * Prepared-statement cache. - * - * Owned by the connection, not by this object: a transaction is a new - * SqliteWalletTransaction every time, so a per-instance cache re-prepared - * every statement on every transaction — one wasted round-trip to the - * sqlite helper per distinct statement per transaction. - */ - private stmtCache: Map<string, Sqlite3Statement>; - - /** - * Row counter shared with the connection, when one was supplied. - */ - private stats: SqliteAccessStats; - - /** Keyset page applied to the root SELECT of one migration enumeration. */ - private migrationPage: - | { - table: string; - afterRowId: number; - limit: number; - consumed: boolean; - nextRowId?: number; - } - | undefined; - - constructor( - private db: Sqlite3Database, - stmtCache?: Map<string, Sqlite3Statement>, - stats?: SqliteAccessStats, - ) { - this.stmtCache = stmtCache ?? new Map(); - this.stats = stats ?? { rowsRead: 0 }; - } - - private async prep(sql: string): Promise<Sqlite3Statement> { - let stmt = this.stmtCache.get(sql); - if (!stmt) { - stmt = await this.db.prepare(sql); - this.stmtCache.set(sql, stmt); - } - return stmt; - } - - private async run(sql: string, params: Record<string, any> = {}) { - return await (await this.prep(sql)).run(params); - } - - private async first( - sql: string, - params: Record<string, any> = {}, - ): Promise<ResultRow | undefined> { - const row = await (await this.prep(sql)).getFirst(params); - if (row !== undefined) { - this.stats.rowsRead++; - } - return row; - } - - private async all( - sql: string, - params: Record<string, any> = {}, - ): Promise<ResultRow[]> { - let isMigrationRoot = false; - if (this.migrationPage && !this.migrationPage.consumed) { - isMigrationRoot = true; - this.migrationPage.consumed = true; - sql = - `SELECT rowid AS __migration_rowid, * FROM ${this.migrationPage.table}` + - " WHERE rowid > $migration_after" + - " ORDER BY rowid LIMIT $migration_limit"; - params = { - migration_after: this.migrationPage.afterRowId, - migration_limit: this.migrationPage.limit, - }; - } - const rows = await (await this.prep(sql)).getAll(params); - if (isMigrationRoot && this.migrationPage && rows.length > 0) { - this.migrationPage.nextRowId = num( - rows[rows.length - 1].__migration_rowid, - ); - } - this.stats.rowsRead += rows.length; - return rows; - } - - async scanMigrationRecords<T>( - store: WalletDbMigrationStore, - read: (tx: WalletDbTransaction) => Promise<T[]>, - cursor: unknown | undefined, - limit: number, - ): Promise<WalletDbMigrationPage<T>> { - const afterRowId = cursor === undefined ? 0 : Number(cursor); - if (!Number.isSafeInteger(afterRowId) || afterRowId < 0) { - throw Error("invalid sqlite migration cursor"); - } - if (!Number.isSafeInteger(limit) || limit <= 0) { - throw Error("migration page size must be a positive integer"); - } - if (this.migrationPage) { - throw Error("nested migration scan is not supported"); - } - this.migrationPage = { - table: SQLITE_MIGRATION_TABLES[store], - afterRowId, - limit, - consumed: false, - }; - try { - const records = await read(this); - if (!this.migrationPage.consumed) { - throw Error("migration enumeration did not issue a SELECT"); - } - return { - records, - ...(records.length === limit && this.migrationPage.nextRowId != null - ? { nextCursor: this.migrationPage.nextRowId } - : {}), - }; - } finally { - this.migrationPage = undefined; - } - } - - // Bound as an instance property for the same reason as the IndexedDB - // implementation: call sites pass it around unbound. - notify = (notif: WalletNotification): void => { - this.pendingNotifications.push(notif); - }; - - scheduleOnCommit(f: () => void): void { - this.afterCommitHandlers.push(f); - } - - // ------------------------------------------------------------- config - - async getConfig<T extends ConfigRecord["key"]>( - key: T, - ): Promise<Extract<ConfigRecord, { key: T }> | undefined> { - const row = await this.first("SELECT value FROM config WHERE key = $key", { - key, - }); - if (!row) return undefined; - return dbToJson(row.value); - } - - async listAllConfig(): Promise<ConfigRecord[]> { - const rows = await this.all("SELECT value FROM config"); - return rows.map((r) => dbToJson<ConfigRecord>(r.value)); - } - - async listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]> { - const rows = await this.all("SELECT * FROM currency_info"); - return rows.map((r) => ({ - scopeInfoStr: str(r.scope_info_str), - currencySpec: dbToJson(r.currency_spec), - source: str(r.source) as WalletCurrencyInfoEntry["source"], - })); - } - - async upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void> { - await this.run( - "INSERT INTO currency_info (scope_info_str, currency_spec, source)" + - " VALUES ($s, $spec, $src)" + - " ON CONFLICT(scope_info_str) DO UPDATE SET" + - " currency_spec = excluded.currency_spec," + - " source = excluded.source", - { - s: entry.scopeInfoStr, - spec: jsonToDb(entry.currencySpec), - src: entry.source, - }, - ); - } - - async upsertConfig(record: ConfigRecord): Promise<void> { - await this.run( - "INSERT INTO config (key, value) VALUES ($key, $value)" + - " ON CONFLICT(key) DO UPDATE SET value = excluded.value", - { key: record.key, value: jsonToDb(record) }, - ); - } - - // ------------------------------------------------------ contract terms - - async getContractTerms( - contractTermsHash: string, - ): Promise<WalletContractTerms | undefined> { - const row = await this.first( - "SELECT h, contract_terms_raw FROM contract_terms WHERE h = $h", - { h: contractTermsHash }, - ); - if (!row) return undefined; - return { - h: str(row.h), - contractTermsRaw: dbToJson(row.contract_terms_raw), - }; - } - - async upsertContractTerms(rec: WalletContractTerms): Promise<void> { - await this.run( - "INSERT INTO contract_terms (h, contract_terms_raw)" + - " VALUES ($h, $raw)" + - " ON CONFLICT(h) DO UPDATE SET contract_terms_raw = excluded.contract_terms_raw", - { h: rec.h, raw: jsonToDb(rec.contractTermsRaw) }, - ); - } - - // --------------------------------------------------------- tombstones - - async upsertTombstone(rec: WalletTombstone): Promise<void> { - await this.run("INSERT OR REPLACE INTO tombstones (id) VALUES ($id)", { - id: rec.id, - }); - } - - async listAllTombstones(): Promise<WalletTombstone[]> { - const rows = await this.all("SELECT id FROM tombstones"); - return rows.map((r) => ({ id: str(r.id) })); - } - - // --------------------------------------------------- operation retries - - async getOperationRetry( - taskId: string, - ): Promise<WalletOperationRetry | undefined> { - const row = await this.first( - "SELECT id, last_error, retry_info FROM operation_retries WHERE id = $id", - { id: taskId }, - ); - if (!row) return undefined; - return { - id: str(row.id), - lastError: dbToOptJson(row.last_error), - retryInfo: dbToJson(row.retry_info), - }; - } - - async upsertOperationRetry(rec: WalletOperationRetry): Promise<void> { - await this.run( - "INSERT INTO operation_retries (id, last_error, retry_info)" + - " VALUES ($id, $last_error, $retry_info)" + - " ON CONFLICT(id) DO UPDATE SET" + - " last_error = excluded.last_error," + - " retry_info = excluded.retry_info", - { - id: rec.id, - last_error: rec.lastError == null ? null : jsonToDb(rec.lastError), - retry_info: jsonToDb(rec.retryInfo), - }, - ); - } - - async listAllOperationRetries(): Promise<WalletOperationRetry[]> { - const rows = await this.all( - "SELECT id, last_error, retry_info FROM operation_retries", - ); - return rows.map((r) => ({ - id: str(r.id), - lastError: dbToOptJson(r.last_error), - retryInfo: dbToJson(r.retry_info), - })); - } - - async deleteOperationRetry(taskId: string): Promise<void> { - await this.run("DELETE FROM operation_retries WHERE id = $id", { - id: taskId, - }); - } - - // ------------------------------------------------------------ reserves - - async upsertReserve(rec: WalletReserve): Promise<number> { - const cols = { - pub: crockToDb(rec.reservePub), - priv: crockToDb(rec.reservePriv), - status: rec.status ?? null, - requirement_row: rec.requirementRow ?? null, - threshold_requested: rec.thresholdRequested ?? null, - threshold_granted: rec.thresholdGranted ?? null, - threshold_next: rec.thresholdNext ?? null, - kyc_access_token: rec.kycAccessToken ?? null, - aml_review: boolToDb(rec.amlReview), - }; - const names = - "reserve_pub, reserve_priv, status, requirement_row," + - " threshold_requested, threshold_granted, threshold_next," + - " kyc_access_token, aml_review"; - const values = - "$pub, $priv, $status, $requirement_row," + - " $threshold_requested, $threshold_granted, $threshold_next," + - " $kyc_access_token, $aml_review"; - if (rec.rowId != null) { - await this.run( - `INSERT INTO reserves (row_id, ${names}) VALUES ($row_id, ${values})` + - " ON CONFLICT(row_id) DO UPDATE SET" + - " reserve_pub = excluded.reserve_pub," + - " reserve_priv = excluded.reserve_priv," + - " status = excluded.status," + - " requirement_row = excluded.requirement_row," + - " threshold_requested = excluded.threshold_requested," + - " threshold_granted = excluded.threshold_granted," + - " threshold_next = excluded.threshold_next," + - " kyc_access_token = excluded.kyc_access_token," + - " aml_review = excluded.aml_review", - { row_id: rec.rowId, ...cols }, - ); - return rec.rowId; - } - const res = await this.run( - `INSERT INTO reserves (${names}) VALUES (${values})`, - cols, - ); - return Number(res.lastInsertRowid); - } - - async getReserve(reserveRowId: number): Promise<WalletReserve | undefined> { - const row = await this.first( - "SELECT * FROM reserves" + " WHERE row_id = $row_id", - { row_id: reserveRowId }, - ); - return row ? this.rowToReserve(row) : undefined; - } - - async getReserveByReservePub( - reservePub: string, - ): Promise<WalletReserve | undefined> { - const row = await this.first( - "SELECT * FROM reserves" + " WHERE reserve_pub = $pub", - { pub: crockToDb(reservePub) }, - ); - return row ? this.rowToReserve(row) : undefined; - } - - async getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]> { - if (reservePubs.length === 0) { - return []; - } - const params: Record<string, Uint8Array> = {}; - const blobs = reservePubs.map((pub) => crockToDb(pub)); - const placeholders = blobs.map((blob, i) => { - params[`p${i}`] = blob; - return `$p${i}`; - }); - const rows = await this.all( - `SELECT * FROM reserves WHERE reserve_pub IN (${placeholders.join(", ")})`, - params, - ); - const byPub = new Map( - rows.map((r) => { - const reservePub = r.reserve_pub; - if (!(reservePub instanceof Uint8Array)) { - throw Error("reserves.reserve_pub must be a BLOB column"); - } - return [blobKey(reservePub), r] as const; - }), - ); - return blobs.flatMap((blob) => { - const row = byPub.get(blobKey(blob)); - return row ? [this.rowToReserve(row)] : []; - }); - } - - async listAllReserves(): Promise<WalletReserve[]> { - const rows = await this.all("SELECT * FROM reserves"); - return rows.map((r) => this.rowToReserve(r)); - } - - private rowToReserve(row: ResultRow): WalletReserve { - return { - rowId: num(row.row_id), - 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) } - : undefined), - ...(row.threshold_requested != null - ? { thresholdRequested: dbAmount(row.threshold_requested) } - : undefined), - ...(row.threshold_granted != null - ? { thresholdGranted: dbAmount(row.threshold_granted) } - : undefined), - ...(row.threshold_next != null - ? { thresholdNext: dbAmount(row.threshold_next) } - : undefined), - ...(row.kyc_access_token != null - ? { kycAccessToken: str(row.kyc_access_token) } - : undefined), - ...(row.aml_review != null - ? { amlReview: dbToBool(row.aml_review) } - : undefined), - }; - } - - // ------------------------------------------------------- denominations - - async upsertDenomination(rec: WalletDenomination): Promise<void> { - await this.run( - `INSERT INTO denominations ( - exchange_base_url, denom_pub_hash, denom_pub, exchange_master_pub, - currency, value, denomination_family_serial, - stamp_start, stamp_expire_withdraw, stamp_expire_deposit, - stamp_expire_legal, - fee_deposit, fee_refresh, fee_refund, fee_withdraw, - is_offered, is_revoked, is_lost, master_sig, - verification_status - ) VALUES ( - $exchange_base_url, $denom_pub_hash, $denom_pub, $exchange_master_pub, - $currency, $value, $family_serial, - $stamp_start, $stamp_expire_withdraw, $stamp_expire_deposit, - $stamp_expire_legal, - $fee_deposit, $fee_refresh, $fee_refund, $fee_withdraw, - $is_offered, $is_revoked, $is_lost, $master_sig, - $verification_status - ) - ON CONFLICT(exchange_master_pub, denom_pub_hash) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - denom_pub = excluded.denom_pub, - exchange_master_pub = excluded.exchange_master_pub, - currency = excluded.currency, - value = excluded.value, - denomination_family_serial = excluded.denomination_family_serial, - stamp_start = excluded.stamp_start, - stamp_expire_withdraw = excluded.stamp_expire_withdraw, - stamp_expire_deposit = excluded.stamp_expire_deposit, - stamp_expire_legal = excluded.stamp_expire_legal, - fee_deposit = excluded.fee_deposit, - fee_refresh = excluded.fee_refresh, - fee_refund = excluded.fee_refund, - fee_withdraw = excluded.fee_withdraw, - is_offered = excluded.is_offered, - is_revoked = excluded.is_revoked, - is_lost = excluded.is_lost, - master_sig = excluded.master_sig, - verification_status = excluded.verification_status`, - { - exchange_base_url: rec.exchangeBaseUrl, - denom_pub_hash: crockToDb(rec.denomPubHash), - denom_pub: jsonToDb(rec.denomPub), - exchange_master_pub: crockToDb(rec.exchangeMasterPub), - currency: rec.currency, - value: rec.value, - family_serial: rec.denominationFamilySerial ?? null, - stamp_start: rec.stampStart, - stamp_expire_withdraw: rec.stampExpireWithdraw, - stamp_expire_deposit: rec.stampExpireDeposit, - stamp_expire_legal: rec.stampExpireLegal, - fee_deposit: rec.fees.feeDeposit, - fee_refresh: rec.fees.feeRefresh, - fee_refund: rec.fees.feeRefund, - fee_withdraw: rec.fees.feeWithdraw, - is_offered: boolToDb(rec.isOffered), - is_revoked: boolToDb(rec.isRevoked), - is_lost: boolToDb(rec.isLost), - master_sig: crockToDb(rec.masterSig), - verification_status: rec.verificationStatus, - }, - ); - } - - async getDenomination( - ref: WalletDenomRef, - ): Promise<WalletDenomination | undefined> { - const row = await this.first( - "SELECT * FROM denominations" + - " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $hash", - { - mpk: crockToDb(ref.exchangeMasterPub), - hash: crockToDb(ref.denomPubHash), - }, - ); - return row ? this.rowToDenomination(row) : undefined; - } - - async getDenominationsByRefs( - refs: WalletDenomRef[], - ): Promise<WalletDenomination[]> { - if (refs.length === 0) { - return []; - } - const encoded = refs.map((ref) => { - const masterPub = crockToDb(ref.exchangeMasterPub); - const denomPubHash = crockToDb(ref.denomPubHash); - return { - masterPub, - denomPubHash, - key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, - }; - }); - const byKey = new Map<string, WalletDenomination>(); - // Two parameters per reference. Staying below 999 keeps this compatible - // with sqlite builds that use the traditional bind-parameter limit. - for (let offset = 0; offset < encoded.length; offset += 400) { - const chunk = encoded.slice(offset, offset + 400); - const params: Record<string, Sqlite3Value> = {}; - const values = chunk.map((ref, i) => { - params[`mpk${i}`] = ref.masterPub; - params[`dph${i}`] = ref.denomPubHash; - return `($mpk${i}, $dph${i})`; - }); - const rows = await this.all( - "SELECT * FROM denominations" + - ` WHERE (exchange_master_pub, denom_pub_hash) IN (${values.join(", ")})`, - params, - ); - for (const row of rows) { - const masterPub = row.exchange_master_pub; - const denomPubHash = row.denom_pub_hash; - if ( - !(masterPub instanceof Uint8Array) || - !(denomPubHash instanceof Uint8Array) - ) { - throw Error("denomination identity columns must be BLOBs"); - } - byKey.set( - `${blobKey(masterPub)}/${blobKey(denomPubHash)}`, - this.rowToDenomination(row), - ); - } - } - return encoded.flatMap((ref) => { - const record = byKey.get(ref.key); - return record ? [record] : []; - }); - } - - async getDenominationsByMasterPub( - exchangeMasterPub: string, - ): Promise<WalletDenomination[]> { - const rows = await this.all( - "SELECT * FROM denominations WHERE exchange_master_pub = $mpk", - { mpk: crockToDb(exchangeMasterPub) }, - ); - return rows.map((r) => this.rowToDenomination(r)); - } - - async getDenominationsByVerificationStatus( - verificationStatus: DenominationVerificationStatus, - ): Promise<WalletDenomination[]> { - const rows = await this.all( - "SELECT * FROM denominations WHERE verification_status = $st", - { st: verificationStatus }, - ); - return rows.map((r) => this.rowToDenomination(r)); - } - - async deleteDenomination(ref: WalletDenomRef): Promise<void> { - await this.run( - "DELETE FROM denominations" + - " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $hash", - { - mpk: crockToDb(ref.exchangeMasterPub), - hash: crockToDb(ref.denomPubHash), - }, - ); - } - - /** - * Scan a family in expiry order and stop at the first record the caller - * accepts. - * - * The helper protocol has no cursor, so this pages with LIMIT and a keyset - * continuation. denom_pub_hash is part of the ordering and of the - * continuation predicate: rows can share a stamp_expire_withdraw, and a - * strictly-greater continuation on expiry alone would skip the siblings. - * - * The batch size is the read amplification for a single lookup, so it is - * deliberately small. The conformance suite asserts the record count stays - * bounded. - */ - async findDenominationByFamilyFromExpiry( - denominationFamilySerial: number, - minStampExpireWithdraw: DbProtocolTimestamp, - match: (d: WalletDenomination) => boolean, - ): Promise<WalletDenomination | undefined> { - const batchSize = 1; - let afterExpiry: number = minStampExpireWithdraw; - // 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 - ? await this.all( - "SELECT * FROM denominations" + - " WHERE denomination_family_serial = $serial" + - " AND stamp_expire_withdraw >= $expiry" + - " ORDER BY stamp_expire_withdraw, denom_pub_hash" + - " LIMIT $limit", - { - serial: denominationFamilySerial, - expiry: afterExpiry, - limit: batchSize, - }, - ) - : await this.all( - "SELECT * FROM denominations" + - " WHERE denomination_family_serial = $serial" + - " AND (stamp_expire_withdraw, denom_pub_hash) > ($expiry, $hash)" + - " ORDER BY stamp_expire_withdraw, denom_pub_hash" + - " LIMIT $limit", - { - serial: denominationFamilySerial, - expiry: afterExpiry, - hash: afterHash, - limit: batchSize, - }, - ); - if (rows.length === 0) { - return undefined; - } - for (const row of rows) { - const d = this.rowToDenomination(row); - if (match(d)) { - return d; - } - } - const last = rows[rows.length - 1]; - afterExpiry = num(last.stamp_expire_withdraw); - const lastHash = last.denom_pub_hash; - if (!(lastHash instanceof Uint8Array)) { - throw Error("denominations.denom_pub_hash must be a BLOB column"); - } - afterHash = lastHash; - } - } - - async listAllDenominations(): Promise<WalletDenomination[]> { - const rows = await this.all("SELECT * FROM denominations"); - return rows.map((r) => this.rowToDenomination(r)); - } - - async listAllContractTerms(): Promise<WalletContractTerms[]> { - const rows = await this.all( - "SELECT h, contract_terms_raw FROM contract_terms", - ); - return rows.map((r) => ({ - h: str(r.h), - contractTermsRaw: dbToJson(r.contract_terms_raw), - })); - } - - private rowToDenomination(row: ResultRow): WalletDenomination { - const denom: WalletDenomination = { - exchangeBaseUrl: str(row.exchange_base_url), - denomPubHash: dbToCrock(row.denom_pub_hash), - denomPub: dbToJson(row.denom_pub), - exchangeMasterPub: dbToCrock(row.exchange_master_pub), - currency: str(row.currency), - value: dbAmount(row.value), - ...(row.denomination_family_serial != null - ? { - denominationFamilySerial: num(row.denomination_family_serial), - } - : undefined), - stampStart: dbTimestamp(row.stamp_start), - stampExpireWithdraw: dbTimestamp(row.stamp_expire_withdraw), - stampExpireDeposit: dbTimestamp(row.stamp_expire_deposit), - stampExpireLegal: dbTimestamp(row.stamp_expire_legal), - fees: { - feeDeposit: dbAmount(row.fee_deposit), - feeRefresh: dbAmount(row.fee_refresh), - feeRefund: dbAmount(row.fee_refund), - feeWithdraw: dbAmount(row.fee_withdraw), - }, - isOffered: dbToBool(row.is_offered), - isRevoked: dbToBool(row.is_revoked), - isLost: dbToOptBool(row.is_lost), - masterSig: dbToCrock(row.master_sig), - verificationStatus: num(row.verification_status), - }; - return denom; - } - - // ------------------------------------------------------------- refunds - - async getRefundGroup( - refundGroupId: string, - ): Promise<WalletRefundGroup | undefined> { - const row = await this.first( - "SELECT * FROM refund_groups WHERE refund_group_id = $id", - { id: refundGroupId }, - ); - return row ? this.rowToRefundGroup(row) : undefined; - } - - async upsertRefundGroup(rec: WalletRefundGroup): Promise<void> { - await this.run( - `INSERT INTO refund_groups ( - refund_group_id, proposal_id, status, timestamp_created, - amount_raw, amount_effective, refresh_group_id - ) VALUES ($id, $proposal_id, $status, $ts, $raw, $eff, $refresh_group_id) - ON CONFLICT(refund_group_id) DO UPDATE SET - proposal_id = excluded.proposal_id, - status = excluded.status, - timestamp_created = excluded.timestamp_created, - amount_raw = excluded.amount_raw, - amount_effective = excluded.amount_effective, - refresh_group_id = excluded.refresh_group_id`, - { - id: rec.refundGroupId, - proposal_id: rec.proposalId, - status: rec.status, - ts: rec.timestampCreated, - raw: rec.amountRaw, - eff: rec.amountEffective, - refresh_group_id: rec.refreshGroupId ?? null, - }, - ); - } - - async deleteRefundGroup(refundGroupId: string): Promise<void> { - await this.run("DELETE FROM refund_groups WHERE refund_group_id = $id", { - id: refundGroupId, - }); - } - - async getRefundGroupsByProposal( - proposalId: string, - ): Promise<WalletRefundGroup[]> { - const rows = await this.all( - "SELECT * FROM refund_groups WHERE proposal_id = $pid", - { pid: proposalId }, - ); - return rows.map((r) => this.rowToRefundGroup(r)); - } - - // Returns the record type, not `unknown`: an earlier version returned - // `unknown` and every call site cast it, which let a `reason` field that - // does not exist on WalletRefundGroup survive review. - private rowToRefundGroup(row: ResultRow): WalletRefundGroup { - return { - refundGroupId: str(row.refund_group_id), - proposalId: str(row.proposal_id), - status: num(row.status), - timestampCreated: dbTimestamp(row.timestamp_created), - amountRaw: dbAmount(row.amount_raw), - amountEffective: dbAmount(row.amount_effective), - ...(row.refresh_group_id != null - ? { refreshGroupId: str(row.refresh_group_id) } - : undefined), - }; - } - - async getRefundItemsByGroup( - refundGroupId: string, - ): Promise<WalletRefundItem[]> { - const rows = await this.all( - "SELECT * FROM refund_items WHERE refund_group_id = $id", - { id: refundGroupId }, - ); - return rows.map((r) => this.rowToRefundItem(r)); - } - - async listAllRefundItems(): Promise<WalletRefundItem[]> { - const rows = await this.all("SELECT * FROM refund_items"); - return rows.map((r) => this.rowToRefundItem(r)); - } - - async upsertRefundItem(rec: WalletRefundItem): Promise<number> { - if (rec.id != null) { - await this.run( - `INSERT INTO refund_items ( - id, refund_group_id, status, proposal_id, execution_time, - obtained_time, refund_amount, coin_pub, rtxid - ) VALUES ($id, $gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid) - ON CONFLICT(id) DO UPDATE SET - refund_group_id = excluded.refund_group_id, - status = excluded.status, - proposal_id = excluded.proposal_id, - execution_time = excluded.execution_time, - obtained_time = excluded.obtained_time, - refund_amount = excluded.refund_amount, - coin_pub = excluded.coin_pub, - rtxid = excluded.rtxid`, - this.refundItemParams(rec, rec.id), - ); - return rec.id; - } - const res = await this.run( - `INSERT INTO refund_items ( - refund_group_id, status, proposal_id, execution_time, - obtained_time, refund_amount, coin_pub, rtxid - ) VALUES ($gid, $status, $pid, $exec, $obt, $amt, $coin, $rtxid)`, - this.refundItemParams(rec, undefined), - ); - return Number(res.lastInsertRowid); - } - - private refundItemParams( - rec: WalletRefundItem, - id: number | undefined, - ): Record<string, any> { - const p: Record<string, any> = { - gid: rec.refundGroupId, - status: rec.status, - pid: rec.proposalId ?? null, - exec: rec.executionTime, - obt: rec.obtainedTime, - amt: rec.refundAmount, - coin: crockToDb(rec.coinPub), - rtxid: rec.rtxid, - }; - if (id !== undefined) { - p.id = id; - } - return p; - } - - async deleteRefundItem(id: number): Promise<void> { - await this.run("DELETE FROM refund_items WHERE id = $id", { id }); - } - - async getRefundItemByCoinAndRtxid( - coinPub: string, - rtxid: number, - ): Promise<WalletRefundItem | undefined> { - const row = await this.first( - "SELECT * FROM refund_items WHERE coin_pub = $coin AND rtxid = $rtxid", - { coin: crockToDb(coinPub), rtxid }, - ); - return row ? this.rowToRefundItem(row) : undefined; - } - - private rowToRefundItem(row: ResultRow): WalletRefundItem { - return { - id: num(row.id), - refundGroupId: str(row.refund_group_id), - status: num(row.status), - proposalId: optStr(row.proposal_id), - executionTime: dbTimestamp(row.execution_time), - obtainedTime: dbTimestamp(row.obtained_time), - refundAmount: dbAmount(row.refund_amount), - coinPub: dbToCrock(row.coin_pub), - rtxid: num(row.rtxid), - }; - } - - // --------------------------------------------------------------- coins - - private rowToCoin(row: ResultRow): WalletCoin { - return { - coinPub: dbToCrock(row.coin_pub), - coinPriv: dbToCrock(row.coin_priv), - exchangeBaseUrl: str(row.exchange_base_url), - // Coins written before the column existed and whose denomination had - // already been deleted have no key recorded. Empty rather than absent: - // the field is required on the record. - exchangeMasterPub: - row.exchange_master_pub == null - ? "" - : dbToCrock(row.exchange_master_pub), - denomPubHash: dbToCrock(row.denom_pub_hash), - denomSig: dbToJson(row.denom_sig), - blindingKey: dbToCrock(row.blinding_key), - exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), - 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 - // `AgeCommitmentProof | undefined`, i.e. the key is required. - ageCommitmentProof: dbToOptJson(row.age_commitment_proof), - coinSource: dbToJson(row.coin_source), - ...(row.visible != null ? { visible: num(row.visible) } : undefined), - ...(row.source_transaction_id != null - ? { sourceTransactionId: str(row.source_transaction_id) } - : undefined), - }; - } - - async getCoin(coinPub: string): Promise<WalletCoin | undefined> { - const row = await this.first("SELECT * FROM coins WHERE coin_pub = $pub", { - pub: crockToDb(coinPub), - }); - return row ? this.rowToCoin(row) : undefined; - } - - async upsertCoin(coin: WalletCoin): Promise<void> { - await this.run( - `INSERT INTO coins ( - coin_pub, coin_priv, exchange_base_url, exchange_master_pub, - denom_pub_hash, denom_sig, - blinding_key, exchange_withdraw_values, coin_ev_hash, status, visible, max_age, - age_commitment_proof, coin_source, source_transaction_id - ) VALUES ( - $pub, $priv, $url, $emp, $dph, $sig, $bk, $ewv, $ceh, $status, $visible, - $age, $acp, $source, $stid - ) - ON CONFLICT(coin_pub) DO UPDATE SET - coin_priv = excluded.coin_priv, - exchange_base_url = excluded.exchange_base_url, - exchange_master_pub = excluded.exchange_master_pub, - denom_pub_hash = excluded.denom_pub_hash, - denom_sig = excluded.denom_sig, - blinding_key = excluded.blinding_key, - exchange_withdraw_values = excluded.exchange_withdraw_values, - coin_ev_hash = excluded.coin_ev_hash, - status = excluded.status, - visible = excluded.visible, - max_age = excluded.max_age, - age_commitment_proof = excluded.age_commitment_proof, - coin_source = excluded.coin_source, - source_transaction_id = excluded.source_transaction_id`, - { - pub: crockToDb(coin.coinPub), - priv: crockToDb(coin.coinPriv), - url: coin.exchangeBaseUrl, - emp: optCrockToDb(coin.exchangeMasterPub) ?? null, - dph: crockToDb(coin.denomPubHash), - sig: jsonToDb(coin.denomSig), - bk: crockToDb(coin.blindingKey), - ewv: jsonToDb(coin.exchangeWithdrawValues), - ceh: crockToDb(coin.coinEvHash), - status: coin.status, - visible: coin.visible ?? null, - age: coin.maxAge, - acp: - coin.ageCommitmentProof === undefined - ? null - : jsonToDb(coin.ageCommitmentProof), - source: jsonToDb(coin.coinSource), - stid: coin.sourceTransactionId ?? null, - }, - ); - } - - async listAllCoins(): Promise<WalletCoin[]> { - const rows = await this.all("SELECT * FROM coins"); - return rows.map((r) => this.rowToCoin(r)); - } - - async getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]> { - const rows = await this.all( - "SELECT * FROM coins WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToCoin(r)); - } - - async countCoinsByExchange(exchangeBaseUrl: string): Promise<number> { - const row = await this.first( - "SELECT COUNT(*) AS n FROM coins WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return num(row?.n); - } - - async getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]> { - const rows = await this.all( - "SELECT * FROM coins WHERE denom_pub_hash = $dph", - { dph: crockToDb(denomPubHash) }, - ); - return rows.map((r) => this.rowToCoin(r)); - } - - async getCoinsByDenomPubHashes( - denomPubHashes: string[], - ): Promise<WalletCoin[]> { - const unique = new Map<string, Uint8Array>(); - for (const hash of denomPubHashes) { - const blob = crockToDb(hash); - unique.set(blobKey(blob), blob); - } - const blobs = [...unique.values()]; - const coins: WalletCoin[] = []; - for (let offset = 0; offset < blobs.length; offset += 500) { - const chunk = blobs.slice(offset, offset + 500); - const params: Record<string, Uint8Array> = {}; - const placeholders = chunk.map((blob, i) => { - params[`p${i}`] = blob; - return `$p${i}`; - }); - const rows = await this.all( - `SELECT * FROM coins WHERE denom_pub_hash IN (${placeholders.join(", ")})`, - params, - ); - coins.push(...rows.map((row) => this.rowToCoin(row))); - } - return coins; - } - - async getCoinsBySourceTransaction( - transactionId: string, - ): Promise<WalletCoin[]> { - const rows = await this.all( - "SELECT * FROM coins WHERE source_transaction_id = $tid", - { tid: transactionId }, - ); - return rows.map((r) => this.rowToCoin(r)); - } - - async getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]> { - // One statement for the whole batch, then reordered in memory. The - // IndexedDB version loops per pub, which is fine there but is a network - // round-trip each on this backend -- refresh passes every coin of a - // group through here. Contract is unchanged: missing pubs are skipped - // rather than yielding holes, and the result follows argument order. - if (coinPubs.length === 0) { - return []; - } - const blobs = coinPubs.map((pub) => crockToDb(pub)); - // 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 (let offset = 0; offset < blobs.length; offset += 500) { - const chunk = blobs.slice(offset, offset + 500); - const params: Record<string, Uint8Array> = {}; - const placeholders = chunk.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, - ); - for (const row of rows) { - 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 blob of blobs) { - const coin = byKey.get(blobKey(blob)); - if (coin) { - coins.push(coin); - } - } - return coins; - } - - async getFreshCoinsByDenomAndAge( - ref: WalletCoinAvailabilityRef, - limit: number, - ): Promise<WalletCoin[]> { - const rows = await this.all( - "SELECT * FROM coins" + - " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + - " AND max_age = $age AND status = $status" + - " ORDER BY coin_pub" + - " LIMIT $limit", - { - mpk: crockToDb(ref.exchangeMasterPub), - dph: crockToDb(ref.denomPubHash), - age: ref.maxAge, - status: CoinStatus.Fresh, - limit, - }, - ); - return rows.map((r) => this.rowToCoin(r)); - } - - async deleteCoin(coinPub: string): Promise<void> { - await this.run("DELETE FROM coins WHERE coin_pub = $pub", { - pub: crockToDb(coinPub), - }); - } - - // ------------------------------------------------------ coin history - - async getCoinHistory( - coinPub: string, - ): Promise<WalletCoinHistory | undefined> { - const row = await this.first( - "SELECT * FROM coin_history WHERE coin_pub = $pub", - { pub: crockToDb(coinPub) }, - ); - if (!row) { - return undefined; - } - return this.rowToCoinHistory(row); - } - - private rowToCoinHistory(row: ResultRow): WalletCoinHistory { - return { - coinPub: dbToCrock(row.coin_pub), - history: dbToJson(row.history), - }; - } - - async getCoinHistoriesByPubs( - coinPubs: string[], - ): Promise<WalletCoinHistory[]> { - if (coinPubs.length === 0) { - return []; - } - const blobs = coinPubs.map((coinPub) => crockToDb(coinPub)); - const byKey = new Map<string, WalletCoinHistory>(); - for (let offset = 0; offset < blobs.length; offset += 500) { - const chunk = blobs.slice(offset, offset + 500); - const params: Record<string, Uint8Array> = {}; - const placeholders = chunk.map((blob, i) => { - params[`p${i}`] = blob; - return `$p${i}`; - }); - const rows = await this.all( - `SELECT * FROM coin_history WHERE coin_pub IN (${placeholders.join(", ")})`, - params, - ); - for (const row of rows) { - const raw = row.coin_pub; - if (!(raw instanceof Uint8Array)) { - throw Error("coin_history.coin_pub must be a BLOB column"); - } - byKey.set(blobKey(raw), this.rowToCoinHistory(row)); - } - } - return blobs.flatMap((blob) => { - const record = byKey.get(blobKey(blob)); - return record ? [record] : []; - }); - } - - async listAllCoinHistories(): Promise<WalletCoinHistory[]> { - const rows = await this.all("SELECT * FROM coin_history"); - return rows.map((row) => this.rowToCoinHistory(row)); - } - - async upsertCoinHistory(rec: WalletCoinHistory): Promise<void> { - await this.run( - "INSERT INTO coin_history (coin_pub, history) VALUES ($pub, $h)" + - " ON CONFLICT(coin_pub) DO UPDATE SET history = excluded.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: crockToDb(coinPub), - }); - } - - // -------------------------------------------------- coin availability - - private rowToCoinAvailability(row: ResultRow): WalletCoinAvailability { - return { - exchangeBaseUrl: str(row.exchange_base_url), - 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), - hasFreshCoins: num(row.has_fresh_coins) === 1 ? 1 : 0, - visibleCoinCount: num(row.visible_coin_count), - exchangeMasterPub: dbToCrock(row.exchange_master_pub), - ...(row.pending_refresh_output_count != null - ? { pendingRefreshOutputCount: num(row.pending_refresh_output_count) } - : undefined), - }; - } - - async getCoinAvailability( - ref: WalletCoinAvailabilityRef, - ): Promise<WalletCoinAvailability | undefined> { - const row = await this.first( - "SELECT * FROM coin_availability" + - " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + - " AND max_age = $age", - { - mpk: crockToDb(ref.exchangeMasterPub), - dph: crockToDb(ref.denomPubHash), - age: ref.maxAge, - }, - ); - return row ? this.rowToCoinAvailability(row) : undefined; - } - - async getCoinAvailabilitiesByRefs( - refs: WalletCoinAvailabilityRef[], - ): Promise<WalletCoinAvailability[]> { - if (refs.length === 0) { - return []; - } - const encoded = refs.map((ref) => { - const masterPub = crockToDb(ref.exchangeMasterPub); - const denomPubHash = crockToDb(ref.denomPubHash); - return { - masterPub, - denomPubHash, - maxAge: ref.maxAge, - key: `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${ref.maxAge}`, - }; - }); - const byKey = new Map<string, WalletCoinAvailability>(); - // Three parameters per reference, again below sqlite's traditional limit. - for (let offset = 0; offset < encoded.length; offset += 300) { - const chunk = encoded.slice(offset, offset + 300); - const params: Record<string, Sqlite3Value> = {}; - const values = chunk.map((ref, i) => { - params[`mpk${i}`] = ref.masterPub; - params[`dph${i}`] = ref.denomPubHash; - params[`age${i}`] = ref.maxAge; - return `($mpk${i}, $dph${i}, $age${i})`; - }); - const rows = await this.all( - "SELECT * FROM coin_availability" + - ` WHERE (exchange_master_pub, denom_pub_hash, max_age) IN (${values.join(", ")})`, - params, - ); - for (const row of rows) { - const masterPub = row.exchange_master_pub; - const denomPubHash = row.denom_pub_hash; - if ( - !(masterPub instanceof Uint8Array) || - !(denomPubHash instanceof Uint8Array) - ) { - throw Error("coin availability identity columns must be BLOBs"); - } - byKey.set( - `${blobKey(masterPub)}/${blobKey(denomPubHash)}/${num(row.max_age)}`, - this.rowToCoinAvailability(row), - ); - } - } - return encoded.flatMap((ref) => { - const record = byKey.get(ref.key); - return record ? [record] : []; - }); - } - - async upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void> { - await this.run( - `INSERT INTO coin_availability ( - exchange_base_url, denom_pub_hash, max_age, currency, value, - exchange_master_pub, fresh_coin_count, has_fresh_coins, - visible_coin_count, - pending_refresh_output_count - ) VALUES ( - $url, $dph, $age, $cur, $val, $emp, $fresh, $hasFresh, $vis, $pend - ) - ON CONFLICT(exchange_master_pub, denom_pub_hash, max_age) DO UPDATE SET - currency = excluded.currency, - value = excluded.value, - exchange_master_pub = excluded.exchange_master_pub, - fresh_coin_count = excluded.fresh_coin_count, - has_fresh_coins = excluded.has_fresh_coins, - visible_coin_count = excluded.visible_coin_count, - pending_refresh_output_count = - excluded.pending_refresh_output_count`, - { - url: rec.exchangeBaseUrl, - dph: crockToDb(rec.denomPubHash), - age: rec.maxAge, - cur: rec.currency, - val: rec.value, - emp: optCrockToDb(rec.exchangeMasterPub), - fresh: rec.freshCoinCount, - hasFresh: rec.freshCoinCount > 0 ? 1 : 0, - vis: rec.visibleCoinCount, - pend: rec.pendingRefreshOutputCount ?? null, - }, - ); - } - - async getCoinAvailabilities(): Promise<WalletCoinAvailability[]> { - const rows = await this.all("SELECT * FROM coin_availability"); - return rows.map((r) => this.rowToCoinAvailability(r)); - } - - async getCoinAvailabilityByExchange( - exchangeBaseUrl: string, - ): Promise<WalletCoinAvailability[]> { - const rows = await this.all( - "SELECT * FROM coin_availability WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToCoinAvailability(r)); - } - - async getCoinAvailabilityByExchangeAndAgeRange( - exchangeBaseUrl: string, - ageLower: number, - ageUpper: number, - ): Promise<WalletCoinAvailability[]> { - const rows = await this.all( - "SELECT * FROM coin_availability" + - " WHERE exchange_base_url = $url" + - " AND has_fresh_coins = 1" + - " AND max_age BETWEEN $lower AND $upper", - { - url: exchangeBaseUrl, - lower: ageLower, - upper: ageUpper, - }, - ); - return rows.map((r) => this.rowToCoinAvailability(r)); - } - - async deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void> { - await this.run( - "DELETE FROM coin_availability" + - " WHERE exchange_master_pub = $mpk AND denom_pub_hash = $dph" + - " AND max_age = $age", - { - mpk: crockToDb(ref.exchangeMasterPub), - dph: crockToDb(ref.denomPubHash), - age: ref.maxAge, - }, - ); - } - - // ----------------------------------------------------------- exchanges - - private rowToExchange(row: ResultRow): WalletExchangeEntry { - return { - baseUrl: str(row.base_url), - // Required key: undefined when there is no pointer. - detailsPointer: - row.details_pointer_master_pub == null - ? undefined - : { - masterPublicKey: dbToCrock(row.details_pointer_master_pub), - currency: str(row.details_pointer_currency), - updateClock: dbTimestamp(row.details_pointer_update_clock), - }, - entryStatus: num(row.entry_status), - updateStatus: num(row.update_status), - tosCurrentEtag: optStr(row.tos_current_etag), - tosAcceptedEtag: optStr(row.tos_accepted_etag), - tosAcceptedTimestamp: - row.tos_accepted_timestamp == null - ? undefined - : dbTimestamp(row.tos_accepted_timestamp), - lastUpdate: - row.last_update == null ? undefined : dbTimestamp(row.last_update), - nextUpdateStamp: dbTimestamp(row.next_update_stamp), - lastKeysEtag: optStr(row.last_keys_etag), - nextRefreshCheckStamp: dbTimestamp(row.next_refresh_check_stamp), - ...(row.preset_currency_hint != null - ? { presetCurrencyHint: str(row.preset_currency_hint) } - : undefined), - ...(row.preset_currency_spec != null - ? { presetCurrencySpec: dbToJson(row.preset_currency_spec) } - : undefined), - ...(row.preset_type != null - ? { presetType: str(row.preset_type) } - : undefined), - ...(row.source != null - ? { source: str(row.source) as ExchangeEntrySource } - : undefined), - ...(row.last_withdrawal != null - ? { lastWithdrawal: dbTimestamp(row.last_withdrawal) } - : undefined), - ...(row.unavailable_reason != null - ? { unavailableReason: dbToJson(row.unavailable_reason) } - : undefined), - ...(row.cachebreak_next_update != null - ? { cachebreakNextUpdate: dbToBool(row.cachebreak_next_update) } - : undefined), - ...(row.current_merge_reserve_row_id != null - ? { currentMergeReserveRowId: num(row.current_merge_reserve_row_id) } - : undefined), - ...(row.current_account_priv != null - ? { currentAccountPriv: dbToCrock(row.current_account_priv) } - : undefined), - ...(row.current_account_pub != null - ? { currentAccountPub: dbToCrock(row.current_account_pub) } - : undefined), - ...(row.peer_payments_disabled != null - ? { peerPaymentsDisabled: dbToBool(row.peer_payments_disabled) } - : undefined), - ...(row.direct_deposit_disabled != null - ? { directDepositDisabled: dbToBool(row.direct_deposit_disabled) } - : undefined), - ...(row.no_fees != null ? { noFees: dbToBool(row.no_fees) } : undefined), - ...(row.superseded_master_pub != null - ? { - supersededKeySet: { - masterPublicKey: dbToCrock(row.superseded_master_pub), - currency: str(row.superseded_currency), - firstSeen: dbTimestamp(row.superseded_first_seen), - sharesDenominations: dbToBool(row.superseded_shares_denoms), - }, - } - : undefined), - }; - } - - async getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined> { - const row = await this.first( - "SELECT * FROM exchanges WHERE base_url = $url", - { url: baseUrl }, - ); - return row ? this.rowToExchange(row) : undefined; - } - - async getExchanges(): Promise<WalletExchangeEntry[]> { - const rows = await this.all("SELECT * FROM exchanges"); - return rows.map((r) => this.rowToExchange(r)); - } - - async upsertExchange(rec: WalletExchangeEntry): Promise<void> { - await this.run( - `INSERT INTO exchanges ( - base_url, preset_currency_hint, preset_currency_spec, preset_type, - source, - last_withdrawal, details_pointer_master_pub, - details_pointer_currency, details_pointer_update_clock, - entry_status, update_status, unavailable_reason, - cachebreak_next_update, tos_current_etag, tos_accepted_etag, - tos_accepted_timestamp, last_update, next_update_stamp, - last_keys_etag, next_refresh_check_stamp, - current_merge_reserve_row_id, current_account_priv, - current_account_pub, peer_payments_disabled, - direct_deposit_disabled, no_fees, - superseded_master_pub, superseded_currency, - superseded_first_seen, superseded_shares_denoms - ) VALUES ( - $url, $pch, $pcs, $pt, $src, $lw, $dpmp, $dpc, $dpuc, $es, $us, $ur, - $cnu, $tce, $tae, $tat, $lu, $nus, $lke, $nrcs, $cmrri, $cap, - $capub, $ppd, $ddd, $nf, $smp, $sc, $sfs, $ssd - ) - ON CONFLICT(base_url) DO UPDATE SET - preset_currency_hint = excluded.preset_currency_hint, - preset_currency_spec = excluded.preset_currency_spec, - preset_type = excluded.preset_type, - source = excluded.source, - last_withdrawal = excluded.last_withdrawal, - details_pointer_master_pub = excluded.details_pointer_master_pub, - details_pointer_currency = excluded.details_pointer_currency, - details_pointer_update_clock = - excluded.details_pointer_update_clock, - entry_status = excluded.entry_status, - update_status = excluded.update_status, - unavailable_reason = excluded.unavailable_reason, - cachebreak_next_update = excluded.cachebreak_next_update, - tos_current_etag = excluded.tos_current_etag, - tos_accepted_etag = excluded.tos_accepted_etag, - tos_accepted_timestamp = excluded.tos_accepted_timestamp, - last_update = excluded.last_update, - next_update_stamp = excluded.next_update_stamp, - last_keys_etag = excluded.last_keys_etag, - next_refresh_check_stamp = excluded.next_refresh_check_stamp, - current_merge_reserve_row_id = - excluded.current_merge_reserve_row_id, - current_account_priv = excluded.current_account_priv, - current_account_pub = excluded.current_account_pub, - peer_payments_disabled = excluded.peer_payments_disabled, - direct_deposit_disabled = excluded.direct_deposit_disabled, - no_fees = excluded.no_fees, - superseded_master_pub = excluded.superseded_master_pub, - superseded_currency = excluded.superseded_currency, - superseded_first_seen = excluded.superseded_first_seen, - superseded_shares_denoms = excluded.superseded_shares_denoms`, - { - url: rec.baseUrl, - pch: rec.presetCurrencyHint ?? null, - pcs: - rec.presetCurrencySpec === undefined - ? null - : jsonToDb(rec.presetCurrencySpec), - pt: rec.presetType ?? null, - src: rec.source ?? null, - lw: rec.lastWithdrawal ?? null, - dpmp: optCrockToDb(rec.detailsPointer?.masterPublicKey), - dpc: rec.detailsPointer?.currency ?? null, - dpuc: rec.detailsPointer?.updateClock ?? null, - smp: optCrockToDb(rec.supersededKeySet?.masterPublicKey), - sc: rec.supersededKeySet?.currency ?? null, - sfs: rec.supersededKeySet?.firstSeen ?? null, - ssd: - rec.supersededKeySet === undefined - ? null - : boolToDb(rec.supersededKeySet.sharesDenominations), - es: rec.entryStatus, - us: rec.updateStatus, - ur: - rec.unavailableReason === undefined - ? null - : jsonToDb(rec.unavailableReason), - cnu: boolToDb(rec.cachebreakNextUpdate), - tce: rec.tosCurrentEtag ?? null, - tae: rec.tosAcceptedEtag ?? null, - tat: rec.tosAcceptedTimestamp ?? null, - lu: rec.lastUpdate ?? null, - nus: rec.nextUpdateStamp, - lke: rec.lastKeysEtag ?? null, - nrcs: rec.nextRefreshCheckStamp, - cmrri: rec.currentMergeReserveRowId ?? null, - cap: optCrockToDb(rec.currentAccountPriv), - capub: optCrockToDb(rec.currentAccountPub), - ppd: boolToDb(rec.peerPaymentsDisabled), - ddd: boolToDb(rec.directDepositDisabled), - nf: boolToDb(rec.noFees), - }, - ); - } - - async deleteExchange(baseUrl: string): Promise<void> { - await this.run("DELETE FROM exchanges WHERE base_url = $url", { - url: baseUrl, - }); - } - - // ---------------------------------------------------- exchange details - - private rowToExchangeDetails(row: ResultRow): WalletExchangeDetails { - return { - rowId: num(row.row_id), - exchangeBaseUrl: str(row.exchange_base_url), - masterPublicKey: dbToCrock(row.master_public_key), - currency: str(row.currency), - auditors: dbToJson(row.auditors), - protocolVersionRange: str(row.protocol_version_range), - tinyAmount: dbAmount(row.tiny_amount), - reserveClosingDelay: dbToJson(row.reserve_closing_delay), - globalFees: dbToJson(row.global_fees), - wireInfo: dbToJson(row.wire_info), - bankComplianceLanguage: optStr(row.bank_compliance_language), - defaultPeerPushExpiration: dbToOptJson(row.default_peer_push_expiration), - ...(row.shopping_url != null - ? { shoppingUrl: str(row.shopping_url) } - : undefined), - ...(row.age_mask != null ? { ageMask: num(row.age_mask) } : undefined), - ...(row.wallet_balance_limits != null - ? { walletBalanceLimits: dbToJson(row.wallet_balance_limits) } - : undefined), - ...(row.hard_limits != null - ? { hardLimits: dbToJson(row.hard_limits) } - : undefined), - ...(row.zero_limits != null - ? { zeroLimits: dbToJson(row.zero_limits) } - : undefined), - }; - } - - private exchangeDetailsCols(rec: WalletExchangeDetails) { - return { - url: rec.exchangeBaseUrl, - mpk: crockToDb(rec.masterPublicKey), - cur: rec.currency, - aud: jsonToDb(rec.auditors), - pvr: rec.protocolVersionRange, - tiny: rec.tinyAmount, - rcd: jsonToDb(rec.reserveClosingDelay), - surl: rec.shoppingUrl ?? null, - gf: jsonToDb(rec.globalFees), - wi: jsonToDb(rec.wireInfo), - am: rec.ageMask ?? null, - wbl: - rec.walletBalanceLimits === undefined - ? null - : jsonToDb(rec.walletBalanceLimits), - hl: rec.hardLimits === undefined ? null : jsonToDb(rec.hardLimits), - zl: rec.zeroLimits === undefined ? null : jsonToDb(rec.zeroLimits), - bcl: rec.bankComplianceLanguage ?? null, - dppe: - rec.defaultPeerPushExpiration === undefined - ? null - : jsonToDb(rec.defaultPeerPushExpiration), - }; - } - - async upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number> { - const names = - "exchange_base_url, master_public_key, currency, auditors," + - " protocol_version_range, tiny_amount, reserve_closing_delay," + - " shopping_url, global_fees, wire_info, age_mask," + - " wallet_balance_limits, hard_limits, zero_limits," + - " bank_compliance_language, default_peer_push_expiration"; - const values = - "$url, $mpk, $cur, $aud, $pvr, $tiny, $rcd, $surl, $gf, $wi, $am," + - " $wbl, $hl, $zl, $bcl, $dppe"; - const cols = this.exchangeDetailsCols(rec); - if (rec.rowId != null) { - await this.run( - `INSERT INTO exchange_details (row_id, ${names})` + - ` VALUES ($row_id, ${values})` + - " ON CONFLICT(row_id) DO UPDATE SET" + - " exchange_base_url = excluded.exchange_base_url," + - " master_public_key = excluded.master_public_key," + - " currency = excluded.currency," + - " auditors = excluded.auditors," + - " protocol_version_range = excluded.protocol_version_range," + - " tiny_amount = excluded.tiny_amount," + - " reserve_closing_delay = excluded.reserve_closing_delay," + - " shopping_url = excluded.shopping_url," + - " global_fees = excluded.global_fees," + - " wire_info = excluded.wire_info," + - " age_mask = excluded.age_mask," + - " wallet_balance_limits = excluded.wallet_balance_limits," + - " hard_limits = excluded.hard_limits," + - " zero_limits = excluded.zero_limits," + - " bank_compliance_language = excluded.bank_compliance_language," + - " default_peer_push_expiration =" + - " excluded.default_peer_push_expiration", - { row_id: rec.rowId, ...cols }, - ); - return rec.rowId; - } - const res = await this.run( - `INSERT INTO exchange_details (${names}) VALUES (${values})`, - cols, - ); - return Number(res.lastInsertRowid); - } - - async getExchangeDetailsByPointer( - exchangeBaseUrl: string, - currency: string, - masterPublicKey: string, - ): Promise<WalletExchangeDetails | undefined> { - const row = await this.first( - "SELECT * FROM exchange_details" + - " WHERE exchange_base_url = $url AND currency = $cur" + - " AND master_public_key = $mpk", - { url: exchangeBaseUrl, cur: currency, mpk: crockToDb(masterPublicKey) }, - ); - return row ? this.rowToExchangeDetails(row) : undefined; - } - - async getExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined> { - const row = await this.first( - "SELECT * FROM exchange_details WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return row ? this.rowToExchangeDetails(row) : undefined; - } - - async listExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails[]> { - const rows = await this.all( - "SELECT * FROM exchange_details WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToExchangeDetails(r)); - } - - async listExchangeDetailsByMasterPub( - masterPublicKey: string, - ): Promise<WalletExchangeDetails[]> { - const rows = await this.all( - "SELECT * FROM exchange_details WHERE master_public_key = $pub", - { pub: crockToDb(masterPublicKey) }, - ); - return rows.map((r) => this.rowToExchangeDetails(r)); - } - - async listAllExchangeDetails(): Promise<WalletExchangeDetails[]> { - const rows = await this.all("SELECT * FROM exchange_details"); - return rows.map((r) => this.rowToExchangeDetails(r)); - } - - async getExchangeDetailsByRowId( - rowId: number, - ): Promise<WalletExchangeDetails | undefined> { - const row = await this.first( - "SELECT * FROM exchange_details WHERE row_id = $id", - { id: rowId }, - ); - return row ? this.rowToExchangeDetails(row) : undefined; - } - - async deleteExchangeDetails(rowId: number): Promise<void> { - await this.run("DELETE FROM exchange_details WHERE row_id = $id", { - id: rowId, - }); - } - - async getExchangeDetails( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined> { - const exchange = await this.getExchange(exchangeBaseUrl); - if (!exchange || !exchange.detailsPointer) { - return undefined; - } - return await this.getExchangeDetailsByPointer( - exchange.baseUrl, - exchange.detailsPointer.currency, - exchange.detailsPointer.masterPublicKey, - ); - } - - // -------------------------------------------------- exchange sign keys - - async getExchangeSignKeysByDetailsRowId( - exchangeDetailsRowId: number, - ): Promise<WalletExchangeSignkeys[]> { - const rows = await this.all( - "SELECT * FROM exchange_sign_keys WHERE exchange_details_row_id = $id", - { id: exchangeDetailsRowId }, - ); - return rows.map((row) => ({ - exchangeDetailsRowId: num(row.exchange_details_row_id), - signkeyPub: dbToCrock(row.signkey_pub), - stampStart: dbTimestamp(row.stamp_start), - stampExpire: dbTimestamp(row.stamp_expire), - stampEnd: dbTimestamp(row.stamp_end), - masterSig: dbToCrock(row.master_sig), - })); - } - - async listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]> { - const rows = await this.all("SELECT * FROM exchange_sign_keys"); - return rows.map((row) => ({ - exchangeDetailsRowId: num(row.exchange_details_row_id), - signkeyPub: dbToCrock(row.signkey_pub), - stampStart: dbTimestamp(row.stamp_start), - stampExpire: dbTimestamp(row.stamp_expire), - stampEnd: dbTimestamp(row.stamp_end), - masterSig: dbToCrock(row.master_sig), - })); - } - - async upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void> { - await this.run( - `INSERT INTO exchange_sign_keys ( - exchange_details_row_id, signkey_pub, stamp_start, stamp_expire, - stamp_end, master_sig - ) VALUES ($id, $pub, $start, $expire, $end, $sig) - ON CONFLICT(exchange_details_row_id, signkey_pub) DO UPDATE SET - stamp_start = excluded.stamp_start, - stamp_expire = excluded.stamp_expire, - stamp_end = excluded.stamp_end, - master_sig = excluded.master_sig`, - { - id: rec.exchangeDetailsRowId, - pub: crockToDb(rec.signkeyPub), - start: rec.stampStart, - expire: rec.stampExpire, - end: rec.stampEnd, - sig: crockToDb(rec.masterSig), - }, - ); - } - - async deleteExchangeSignKey( - exchangeDetailsRowId: number, - signkeyPub: string, - ): Promise<void> { - await this.run( - "DELETE FROM exchange_sign_keys" + - " WHERE exchange_details_row_id = $id AND signkey_pub = $pub", - { id: exchangeDetailsRowId, pub: crockToDb(signkeyPub) }, - ); - } - - // ----------------------------------------------- denomination families - - async listAllDenominationFamilies(): Promise<WalletDenominationFamily[]> { - const rows = await this.all("SELECT * FROM denomination_families"); - return rows.map((r) => this.rowToDenominationFamily(r)); - } - - private rowToDenominationFamily(row: ResultRow): WalletDenominationFamily { - return { - denominationFamilySerial: num(row.denomination_family_serial), - familyParams: { - exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: dbToCrock(row.exchange_master_pub), - value: dbAmount(row.value), - feeWithdraw: dbAmount(row.fee_withdraw), - feeDeposit: dbAmount(row.fee_deposit), - feeRefresh: dbAmount(row.fee_refresh), - feeRefund: dbAmount(row.fee_refund), - }, - }; - } - - async upsertDenominationFamily( - rec: WalletDenominationFamily, - ): Promise<number> { - const p = rec.familyParams; - const cols = { - url: p.exchangeBaseUrl, - mpub: crockToDb(p.exchangeMasterPub), - val: p.value, - fw: p.feeWithdraw, - fd: p.feeDeposit, - frs: p.feeRefresh, - frf: p.feeRefund, - }; - const names = - "exchange_base_url, exchange_master_pub, value," + - " fee_withdraw, fee_deposit, fee_refresh, fee_refund"; - const values = "$url, $mpub, $val, $fw, $fd, $frs, $frf"; - if (rec.denominationFamilySerial != null) { - await this.run( - `INSERT INTO denomination_families - (denomination_family_serial, ${names}) - VALUES ($serial, ${values}) - ON CONFLICT(denomination_family_serial) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - exchange_master_pub = excluded.exchange_master_pub, - value = excluded.value, - fee_withdraw = excluded.fee_withdraw, - fee_deposit = excluded.fee_deposit, - fee_refresh = excluded.fee_refresh, - fee_refund = excluded.fee_refund`, - { serial: rec.denominationFamilySerial, ...cols }, - ); - return rec.denominationFamilySerial; - } - const res = await this.run( - `INSERT INTO denomination_families (${names}) VALUES (${values})`, - cols, - ); - return Number(res.lastInsertRowid); - } - - async getDenominationFamilyByParams( - params: WalletDenomFamilyParams, - ): Promise<WalletDenominationFamily | undefined> { - const row = await this.first( - "SELECT * FROM denomination_families" + - " WHERE exchange_base_url = $url AND exchange_master_pub = $mpub" + - " AND value = $val AND fee_withdraw = $fw AND fee_deposit = $fd" + - " AND fee_refresh = $frs AND fee_refund = $frf", - { - url: params.exchangeBaseUrl, - mpub: crockToDb(params.exchangeMasterPub), - val: params.value, - fw: params.feeWithdraw, - fd: params.feeDeposit, - frs: params.feeRefresh, - frf: params.feeRefund, - }, - ); - return row ? this.rowToDenominationFamily(row) : undefined; - } - - async getDenominationFamiliesByExchange( - exchangeBaseUrl: string, - ): Promise<WalletDenominationFamily[]> { - const rows = await this.all( - "SELECT * FROM denomination_families WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToDenominationFamily(r)); - } - - async deleteDenominationFamily( - denominationFamilySerial: number, - ): Promise<void> { - await this.run( - "DELETE FROM denomination_families" + - " WHERE denomination_family_serial = $serial", - { serial: denominationFamilySerial }, - ); - } - - // ------------------------------------------- base URL fixups / mig log - - async getExchangeBaseUrlFixup( - exchangeBaseUrl: string, - ): Promise<WalletExchangeBaseUrlFixup | undefined> { - const row = await this.first( - "SELECT * FROM exchange_base_url_fixups WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - if (!row) { - return undefined; - } - return { - exchangeBaseUrl: str(row.exchange_base_url), - replacement: str(row.replacement), - }; - } - - async upsertExchangeBaseUrlFixup( - rec: WalletExchangeBaseUrlFixup, - ): Promise<void> { - await this.run( - "INSERT INTO exchange_base_url_fixups (exchange_base_url, replacement)" + - " VALUES ($url, $repl)" + - " ON CONFLICT(exchange_base_url) DO UPDATE SET" + - " replacement = excluded.replacement", - { url: rec.exchangeBaseUrl, repl: rec.replacement }, - ); - } - - async listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]> { - const rows = await this.all("SELECT * FROM exchange_base_url_fixups"); - return rows.map((r) => ({ - exchangeBaseUrl: str(r.exchange_base_url), - replacement: str(r.replacement), - })); - } - - async getExchangeMigrationLog( - oldExchangeBaseUrl: string, - newExchangeBaseUrl: string, - ): Promise<WalletExchangeMigrationLog | undefined> { - const row = await this.first( - "SELECT * FROM exchange_base_url_migration_log" + - " WHERE old_exchange_base_url = $old AND new_exchange_base_url = $new", - { old: oldExchangeBaseUrl, new: newExchangeBaseUrl }, - ); - if (!row) { - return undefined; - } - return { - oldExchangeBaseUrl: str(row.old_exchange_base_url), - newExchangeBaseUrl: str(row.new_exchange_base_url), - timestamp: dbTimestamp(row.timestamp), - reason: str(row.reason) as ExchangeMigrationReason, - }; - } - - async listAllExchangeMigrationLogEntries(): Promise< - WalletExchangeMigrationLog[] - > { - const rows = await this.all( - "SELECT * FROM exchange_base_url_migration_log", - ); - return rows.map((r) => ({ - oldExchangeBaseUrl: str(r.old_exchange_base_url), - newExchangeBaseUrl: str(r.new_exchange_base_url), - timestamp: dbTimestamp(r.timestamp), - reason: str(r.reason) as ExchangeMigrationReason, - })); - } - - async upsertExchangeMigrationLog( - rec: WalletExchangeMigrationLog, - ): Promise<void> { - await this.run( - `INSERT INTO exchange_base_url_migration_log ( - old_exchange_base_url, new_exchange_base_url, timestamp, reason - ) VALUES ($old, $new, $ts, $reason) - ON CONFLICT(old_exchange_base_url, new_exchange_base_url) DO UPDATE SET - timestamp = excluded.timestamp, - reason = excluded.reason`, - { - old: rec.oldExchangeBaseUrl, - new: rec.newExchangeBaseUrl, - ts: rec.timestamp, - reason: rec.reason, - }, - ); - } - - // -------------------------------------------------- withdrawal groups - - /** - * Split wgInfo into its stored columns. - * - * talerWithdrawUri is deliberately removed from the JSON payload: the - * column is the only copy, so the indexed value and the payload cannot - * drift apart. {@link rowToWithdrawalGroup} puts it back. - */ - private wgInfoToCols(wgInfo: WgInfo) { - const cols = { - wtype: wgInfo.withdrawalType, - uri: null as string | null, - cpriv: null as Uint8Array | null, - binfo: null as string | null, - eca: null as string | null, - }; - switch (wgInfo.withdrawalType) { - case WithdrawalRecordType.BankIntegrated: { - const { talerWithdrawUri, ...rest } = wgInfo.bankInfo; - cols.uri = talerWithdrawUri; - cols.binfo = jsonToDb(rest); - cols.eca = - wgInfo.exchangeCreditAccounts === undefined - ? null - : jsonToDb(wgInfo.exchangeCreditAccounts); - break; - } - case WithdrawalRecordType.BankManual: - cols.eca = - wgInfo.exchangeCreditAccounts === undefined - ? null - : jsonToDb(wgInfo.exchangeCreditAccounts); - break; - case WithdrawalRecordType.PeerPullCredit: - cols.cpriv = crockToDb(wgInfo.contractPriv); - break; - case WithdrawalRecordType.PeerPushCredit: - case WithdrawalRecordType.Recoup: - break; - } - return cols; - } - - private rowToWgInfo(row: ResultRow): WgInfo { - const wtype = str(row.withdrawal_type) as WithdrawalRecordType; - switch (wtype) { - case WithdrawalRecordType.BankIntegrated: { - const rest = dbToJson<Omit<ReserveBankInfo, "talerWithdrawUri">>( - row.bank_info, - ); - const wg: WgInfoBankIntegrated = { - withdrawalType: WithdrawalRecordType.BankIntegrated, - bankInfo: { - ...rest, - // Re-inserted from the column, which is the only copy. - talerWithdrawUri: str(row.taler_withdraw_uri), - }, - ...(row.exchange_credit_accounts != null - ? { - exchangeCreditAccounts: dbToJson(row.exchange_credit_accounts), - } - : undefined), - }; - return wg; - } - case WithdrawalRecordType.BankManual: { - const wg: WgInfoBankManual = { - withdrawalType: WithdrawalRecordType.BankManual, - ...(row.exchange_credit_accounts != null - ? { - exchangeCreditAccounts: dbToJson(row.exchange_credit_accounts), - } - : undefined), - }; - return wg; - } - case WithdrawalRecordType.PeerPullCredit: { - const wg: WgInfoBankPeerPull = { - withdrawalType: WithdrawalRecordType.PeerPullCredit, - contractPriv: dbToCrock(row.contract_priv), - }; - return wg; - } - case WithdrawalRecordType.PeerPushCredit: { - const wg: WgInfoBankPeerPush = { - withdrawalType: WithdrawalRecordType.PeerPushCredit, - }; - return wg; - } - case WithdrawalRecordType.Recoup: { - const wg: WgInfoBankRecoup = { - withdrawalType: WithdrawalRecordType.Recoup, - }; - return wg; - } - } - } - - private rowToWithdrawalGroup(row: ResultRow): WalletWithdrawalGroup { - return { - withdrawalGroupId: str(row.withdrawal_group_id), - wgInfo: this.rowToWgInfo(row), - 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: dbToCrock(row.kyc_payto_hash) } - : undefined), - ...(row.kyc_access_token != null - ? { kycAccessToken: str(row.kyc_access_token) } - : undefined), - ...(row.kyc_last_check_status != null - ? { kycLastCheckStatus: num(row.kyc_last_check_status) } - : undefined), - ...(row.kyc_last_check_code != null - ? { kycLastCheckCode: num(row.kyc_last_check_code) } - : undefined), - ...(row.kyc_last_rule_gen != null - ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } - : undefined), - ...(row.kyc_last_aml_review != null - ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } - : undefined), - ...(row.kyc_last_deny != null - ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } - : undefined), - ...(row.kyc_withdrawal_delay != null - ? { kycWithdrawalDelay: dbToJson(row.kyc_withdrawal_delay) } - : undefined), - ...(row.exchange_base_url != null - ? { exchangeBaseUrl: str(row.exchange_base_url) } - : undefined), - ...(row.timestamp_finish != null - ? { timestampFinish: dbTimestamp(row.timestamp_finish) } - : undefined), - ...(row.restrict_age != null - ? { restrictAge: num(row.restrict_age) } - : undefined), - ...(row.instructed_amount != null - ? { instructedAmount: dbAmount(row.instructed_amount) } - : undefined), - ...(row.reserve_balance_amount != null - ? { reserveBalanceAmount: dbAmount(row.reserve_balance_amount) } - : undefined), - ...(row.raw_withdrawal_amount != null - ? { rawWithdrawalAmount: dbAmount(row.raw_withdrawal_amount) } - : undefined), - ...(row.effective_withdrawal_amount != null - ? { - effectiveWithdrawalAmount: dbAmount( - row.effective_withdrawal_amount, - ), - } - : undefined), - ...(row.denoms_sel != null - ? { denomsSel: dbToJson(row.denoms_sel) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - }; - } - - async getWithdrawalGroup( - withdrawalGroupId: string, - ): Promise<WalletWithdrawalGroup | undefined> { - const row = await this.first( - "SELECT * FROM withdrawal_groups WHERE withdrawal_group_id = $id", - { id: withdrawalGroupId }, - ); - return row ? this.rowToWithdrawalGroup(row) : undefined; - } - - async upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void> { - const wg = this.wgInfoToCols(rec.wgInfo); - await this.run( - `INSERT INTO withdrawal_groups ( - withdrawal_group_id, withdrawal_type, taler_withdraw_uri, - contract_priv, bank_info, exchange_credit_accounts, - is_foreign_account, kyc_payto_hash, kyc_access_token, - kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, - kyc_last_aml_review, kyc_last_deny, kyc_withdrawal_delay, - secret_seed, reserve_pub, reserve_priv, exchange_base_url, - timestamp_start, timestamp_finish, status, restrict_age, - instructed_amount, reserve_balance_amount, raw_withdrawal_amount, - effective_withdrawal_amount, denoms_sel, abort_reason, fail_reason - ) VALUES ( - $id, $wtype, $uri, $cpriv, $binfo, $eca, $ifa, $kph, $kat, $klcs, - $klcc, $klrg, $klar, $kld, $kwd, $seed, $rpub, $rpriv, $url, - $tstart, $tfinish, $status, $age, $ia, $rba, $rwa, $ewa, $ds, - $abort, $fail - ) - ON CONFLICT(withdrawal_group_id) DO UPDATE SET - withdrawal_type = excluded.withdrawal_type, - taler_withdraw_uri = excluded.taler_withdraw_uri, - contract_priv = excluded.contract_priv, - bank_info = excluded.bank_info, - exchange_credit_accounts = excluded.exchange_credit_accounts, - is_foreign_account = excluded.is_foreign_account, - kyc_payto_hash = excluded.kyc_payto_hash, - kyc_access_token = excluded.kyc_access_token, - kyc_last_check_status = excluded.kyc_last_check_status, - kyc_last_check_code = excluded.kyc_last_check_code, - kyc_last_rule_gen = excluded.kyc_last_rule_gen, - kyc_last_aml_review = excluded.kyc_last_aml_review, - kyc_last_deny = excluded.kyc_last_deny, - kyc_withdrawal_delay = excluded.kyc_withdrawal_delay, - secret_seed = excluded.secret_seed, - reserve_pub = excluded.reserve_pub, - reserve_priv = excluded.reserve_priv, - exchange_base_url = excluded.exchange_base_url, - timestamp_start = excluded.timestamp_start, - timestamp_finish = excluded.timestamp_finish, - status = excluded.status, - restrict_age = excluded.restrict_age, - instructed_amount = excluded.instructed_amount, - reserve_balance_amount = excluded.reserve_balance_amount, - raw_withdrawal_amount = excluded.raw_withdrawal_amount, - effective_withdrawal_amount = excluded.effective_withdrawal_amount, - denoms_sel = excluded.denoms_sel, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason`, - { - id: rec.withdrawalGroupId, - ...wg, - ifa: boolToDb(rec.isForeignAccount), - kph: optCrockToDb(rec.kycPaytoHash), - kat: rec.kycAccessToken ?? null, - klcs: rec.kycLastCheckStatus ?? null, - klcc: rec.kycLastCheckCode ?? null, - klrg: rec.kycLastRuleGen ?? null, - klar: boolToDb(rec.kycLastAmlReview), - kld: rec.kycLastDeny ?? null, - kwd: - rec.kycWithdrawalDelay === undefined - ? null - : jsonToDb(rec.kycWithdrawalDelay), - seed: crockToDb(rec.secretSeed), - rpub: crockToDb(rec.reservePub), - rpriv: crockToDb(rec.reservePriv), - url: rec.exchangeBaseUrl ?? null, - tstart: rec.timestampStart, - tfinish: rec.timestampFinish ?? null, - status: rec.status, - age: rec.restrictAge ?? null, - ia: rec.instructedAmount ?? null, - rba: rec.reserveBalanceAmount ?? null, - rwa: rec.rawWithdrawalAmount ?? null, - ewa: rec.effectiveWithdrawalAmount ?? null, - ds: rec.denomsSel === undefined ? null : jsonToDb(rec.denomsSel), - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - }, - ); - } - - async deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void> { - await this.run( - "DELETE FROM withdrawal_groups WHERE withdrawal_group_id = $id", - { id: withdrawalGroupId }, - ); - } - - async listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { - const rows = await this.all("SELECT * FROM withdrawal_groups"); - return rows.map((r) => this.rowToWithdrawalGroup(r)); - } - - async getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]> { - const rows = await this.all( - "SELECT * FROM withdrawal_groups WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, withdrawal_group_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToWithdrawalGroup(r)); - } - - async getWithdrawalGroupByTalerWithdrawUri( - talerWithdrawUri: string, - ): Promise<WalletWithdrawalGroup | undefined> { - const row = await this.first( - "SELECT * FROM withdrawal_groups WHERE taler_withdraw_uri = $uri", - { uri: talerWithdrawUri }, - ); - return row ? this.rowToWithdrawalGroup(row) : undefined; - } - - async getWithdrawalGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]> { - const rows = await this.all( - "SELECT * FROM withdrawal_groups WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToWithdrawalGroup(r)); - } - - async getWithdrawalGroupsByExchangeForRekey( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]> { - return await this.getWithdrawalGroupsByExchange(exchangeBaseUrl); - } - - async countWithdrawalGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<number> { - const row = await this.first( - "SELECT COUNT(*) AS n FROM withdrawal_groups" + - " WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return num(row?.n); - } - - // ------------------------------------------------------------ planchets - - private rowToPlanchet(row: ResultRow): WalletPlanchet { - return { - 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: dbToCrock(row.denom_pub_hash), - blindingKey: dbToCrock(row.blinding_key), - exchangeWithdrawValues: dbToJson(row.exchange_withdraw_values), - withdrawSig: dbToCrock(row.withdraw_sig), - coinEv: dbToJson(row.coin_ev), - coinEvHash: dbToCrock(row.coin_ev_hash), - ...(row.age_commitment_proof != null - ? { ageCommitmentProof: dbToJson(row.age_commitment_proof) } - : undefined), - }; - } - - async getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined> { - const row = await this.first( - "SELECT * FROM planchets WHERE coin_pub = $pub", - { pub: crockToDb(coinPub) }, - ); - return row ? this.rowToPlanchet(row) : undefined; - } - - async upsertPlanchet(rec: WalletPlanchet): Promise<void> { - await this.run( - `INSERT INTO planchets ( - coin_pub, coin_priv, withdrawal_group_id, coin_idx, planchet_status, - last_error, denom_pub_hash, blinding_key, exchange_withdraw_values, withdraw_sig, coin_ev, - coin_ev_hash, age_commitment_proof - ) VALUES ( - $pub, $priv, $wgid, $idx, $status, $err, $dph, $bk, $ewv, $sig, $ev, - $evh, $acp - ) - ON CONFLICT(coin_pub) DO UPDATE SET - coin_priv = excluded.coin_priv, - withdrawal_group_id = excluded.withdrawal_group_id, - coin_idx = excluded.coin_idx, - planchet_status = excluded.planchet_status, - last_error = excluded.last_error, - denom_pub_hash = excluded.denom_pub_hash, - blinding_key = excluded.blinding_key, - exchange_withdraw_values = excluded.exchange_withdraw_values, - withdraw_sig = excluded.withdraw_sig, - coin_ev = excluded.coin_ev, - coin_ev_hash = excluded.coin_ev_hash, - age_commitment_proof = excluded.age_commitment_proof`, - { - 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: crockToDb(rec.denomPubHash), - bk: crockToDb(rec.blindingKey), - ewv: jsonToDb(rec.exchangeWithdrawValues), - sig: crockToDb(rec.withdrawSig), - ev: jsonToDb(rec.coinEv), - evh: crockToDb(rec.coinEvHash), - acp: - rec.ageCommitmentProof === undefined - ? null - : jsonToDb(rec.ageCommitmentProof), - }, - ); - } - - async getPlanchetByGroupAndIndex( - withdrawalGroupId: string, - coinIdx: number, - ): Promise<WalletPlanchet | undefined> { - const row = await this.first( - "SELECT * FROM planchets" + - " WHERE withdrawal_group_id = $wgid AND coin_idx = $idx", - { wgid: withdrawalGroupId, idx: coinIdx }, - ); - return row ? this.rowToPlanchet(row) : undefined; - } - - async getPlanchetsByGroup( - withdrawalGroupId: string, - ): Promise<WalletPlanchet[]> { - const rows = await this.all( - "SELECT * FROM planchets WHERE withdrawal_group_id = $wgid", - { wgid: withdrawalGroupId }, - ); - return rows.map((r) => this.rowToPlanchet(r)); - } - - async listAllPlanchets(): Promise<WalletPlanchet[]> { - const rows = await this.all("SELECT * FROM planchets"); - return rows.map((r) => this.rowToPlanchet(r)); - } - - async countPlanchetsByGroup(withdrawalGroupId: string): Promise<number> { - const row = await this.first( - "SELECT COUNT(*) AS n FROM planchets WHERE withdrawal_group_id = $wgid", - { wgid: withdrawalGroupId }, - ); - return num(row?.n); - } - - async deletePlanchet(coinPub: string): Promise<void> { - await this.run("DELETE FROM planchets WHERE coin_pub = $pub", { - pub: crockToDb(coinPub), - }); - } - - async deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void> { - await this.run("DELETE FROM planchets WHERE withdrawal_group_id = $wgid", { - wgid: withdrawalGroupId, - }); - } - - // -------------------------------------------------- transaction meta - - private rowToTransactionMeta(row: ResultRow): WalletTransactionMeta { - return { - transactionId: str(row.transaction_id), - timestamp: dbTimestamp(row.timestamp), - status: num(row.status), - currency: str(row.currency), - exchanges: dbToJson(row.exchanges), - }; - } - - /** - * Allocate a stable, per-type local number once. The mapping is not tied - * to transactions_meta because that view is periodically rebuilt. - */ - private async ensureLocalTransactionIdentifier( - transactionId: string, - ): Promise<void> { - const existing = await this.first( - "SELECT 1 FROM transaction_local_ids WHERE transaction_id = $id", - { id: transactionId }, - ); - if (existing != null) { - return; - } - const [prefix, transactionType] = transactionId.split(":", 3); - if (prefix !== "txn" || transactionType == null || transactionType === "") { - throw Error(`invalid transaction identifier '${transactionId}'`); - } - await this.run( - "INSERT OR IGNORE INTO transaction_local_id_counters" + - " (transaction_type, next_ident) VALUES ($type, 1)", - { type: transactionType }, - ); - const counter = await this.first( - "SELECT next_ident FROM transaction_local_id_counters" + - " WHERE transaction_type = $type", - { type: transactionType }, - ); - const localIdent = num(counter?.next_ident); - await this.run( - "INSERT INTO transaction_local_ids" + - " (transaction_id, transaction_type, local_ident)" + - " VALUES ($id, $type, $localIdent)", - { id: transactionId, type: transactionType, localIdent }, - ); - await this.run( - "UPDATE transaction_local_id_counters SET next_ident = next_ident + 1" + - " WHERE transaction_type = $type", - { type: transactionType }, - ); - } - - async upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void> { - await this.ensureLocalTransactionIdentifier(rec.transactionId); - await this.run( - `INSERT INTO transactions_meta ( - transaction_id, timestamp, status, currency, exchanges - ) VALUES ($id, $ts, $status, $cur, $ex) - ON CONFLICT(transaction_id) DO UPDATE SET - timestamp = excluded.timestamp, - status = excluded.status, - currency = excluded.currency, - exchanges = excluded.exchanges`, - { - id: rec.transactionId, - ts: rec.timestamp, - status: rec.status, - cur: rec.currency, - ex: jsonToDb(rec.exchanges), - }, - ); - } - - async getLocalTransactionIdentifiers( - transactionIds: string[], - ): Promise<Map<string, string>> { - const result = new Map<string, string>(); - // SQLite commonly limits a statement to 999 bind parameters. Chunks keep - // a large transaction history to a handful of indexed lookups. - for (let start = 0; start < transactionIds.length; start += 500) { - const ids = transactionIds.slice(start, start + 500); - const params: Record<string, string> = {}; - const placeholders = ids.map((id, i) => { - const name = `id${i}`; - params[name] = id; - return `$${name}`; - }); - const rows = await this.all( - "SELECT transaction_id, local_ident FROM transaction_local_ids" + - ` WHERE transaction_id IN (${placeholders.join(", ")})`, - params, - ); - for (const row of rows) { - result.set(str(row.transaction_id), String(row.local_ident)); - } - } - return result; - } - - async getTransactionIdByLocalIdentifier( - transactionType: string, - localIdent: string, - ): Promise<string | undefined> { - // Integer comparison deliberately accepts the canonical decimal strings - // emitted by the wallet. Future local-ID schemes can use another - // backend without exposing that storage detail in the API. - const row = await this.first( - "SELECT transaction_id FROM transaction_local_ids" + - " WHERE transaction_type = $type AND local_ident = $localIdent", - { type: transactionType, localIdent }, - ); - return row == null ? undefined : str(row.transaction_id); - } - - async deleteTransactionMeta(transactionId: string): Promise<void> { - await this.run("DELETE FROM transactions_meta WHERE transaction_id = $id", { - id: transactionId, - }); - } - - async deleteAllTransactionMeta(): Promise<void> { - await this.run("DELETE FROM transactions_meta"); - } - - async getTransactionMeta( - transactionId: string, - ): Promise<WalletTransactionMeta | undefined> { - const row = await this.first( - "SELECT * FROM transactions_meta WHERE transaction_id = $id", - { id: transactionId }, - ); - return row ? this.rowToTransactionMeta(row) : undefined; - } - - async getTransactionMetaAtTimestamp( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - // Ties broken by transaction_id, so "the record at this timestamp" is - // deterministic rather than whatever the storage engine returns first. - const row = await this.first( - "SELECT * FROM transactions_meta WHERE timestamp = $ts" + - " ORDER BY transaction_id LIMIT 1", - { ts: timestamp }, - ); - return row ? this.rowToTransactionMeta(row) : undefined; - } - - async getTransactionMetaBefore( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - // The IndexedDB version reads the whole range and takes the last entry; - // this asks for that entry directly. Inclusive upper bound, matching - // KeyRange.upperBound(timestamp, false). - const row = await this.first( - "SELECT * FROM transactions_meta WHERE timestamp <= $ts" + - " ORDER BY timestamp DESC, transaction_id DESC LIMIT 1", - { ts: timestamp }, - ); - return row ? this.rowToTransactionMeta(row) : undefined; - } - - async getTransactionMetaAfter( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined> { - const row = await this.first( - "SELECT * FROM transactions_meta WHERE timestamp >= $ts" + - " ORDER BY timestamp, transaction_id LIMIT 1", - { ts: timestamp }, - ); - return row ? this.rowToTransactionMeta(row) : undefined; - } - - async listTransactionMetaByTimestamp(req: { - afterTimestamp?: DbPreciseTimestamp; - limit?: number; - }): Promise<WalletTransactionMeta[]> { - // afterTimestamp is exclusive, matching KeyRange.lowerBound(ts, true). - const where = req.afterTimestamp != null ? " WHERE timestamp > $after" : ""; - const limit = req.limit != null ? " LIMIT $limit" : ""; - const rows = await this.all( - `SELECT * FROM transactions_meta${where}` + - ` ORDER BY timestamp, transaction_id${limit}`, - { - ...(req.afterTimestamp != null ? { after: req.afterTimestamp } : {}), - ...(req.limit != null ? { limit: req.limit } : {}), - }, - ); - return rows.map((r) => this.rowToTransactionMeta(r)); - } - - async listTransactionMetaPage(req: { - cursor?: WalletTransactionMetaCursor; - direction: "forward" | "backward"; - limit: number; - }): Promise<WalletTransactionMeta[]> { - const backwards = req.direction === "backward"; - const comparison = backwards ? "<" : ">"; - const ordering = backwards ? " DESC" : ""; - const where = req.cursor - ? ` WHERE (timestamp, transaction_id) ${comparison} ($ts, $id)` - : ""; - const rows = await this.all( - `SELECT * FROM transactions_meta${where}` + - ` ORDER BY timestamp${ordering}, transaction_id${ordering}` + - " LIMIT $limit", - { - ...(req.cursor - ? { ts: req.cursor.timestamp, id: req.cursor.transactionId } - : {}), - limit: req.limit, - }, - ); - return rows.map((r) => this.rowToTransactionMeta(r)); - } - - async listTransactionMetaByStatus(req: { - onlyActive: boolean; - }): Promise<WalletTransactionMeta[]> { - const rows = req.onlyActive - ? await this.all( - "SELECT * FROM transactions_meta WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, transaction_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ) - : await this.all( - "SELECT * FROM transactions_meta ORDER BY status, transaction_id", - ); - return rows.map((r) => this.rowToTransactionMeta(r)); - } - - // -------------------------------------------------- peer push debit - - private rowToPeerPushDebit(row: ResultRow): WalletPeerPushDebit { - return { - pursePub: dbToCrock(row.purse_pub), - exchangeBaseUrl: str(row.exchange_base_url), - amount: dbAmount(row.amount), - totalCost: dbAmount(row.total_cost), - 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), - ...(row.restrict_scope != null - ? { restrictScope: dbToJson(row.restrict_scope) } - : undefined), - ...(row.coin_sel != null - ? { coinSel: dbToJson(row.coin_sel) } - : undefined), - ...(row.abort_refresh_group_id != null - ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - }; - } - - async getPeerPushDebit( - pursePub: string, - ): Promise<WalletPeerPushDebit | undefined> { - const row = await this.first( - "SELECT * FROM peer_push_debit WHERE purse_pub = $pub", - { pub: crockToDb(pursePub) }, - ); - return row ? this.rowToPeerPushDebit(row) : undefined; - } - - async upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void> { - await this.run( - `INSERT INTO peer_push_debit ( - purse_pub, exchange_base_url, restrict_scope, amount, total_cost, - coin_sel, contract_terms_hash, purse_priv, merge_pub, merge_priv, - contract_priv, contract_pub, contract_enc_nonce, purse_expiration, - timestamp_created, abort_refresh_group_id, abort_reason, - fail_reason, status - ) VALUES ( - $pub, $url, $scope, $amt, $cost, $csel, $cth, $ppriv, $mpub, - $mpriv, $cpriv, $cpub, $nonce, $exp, $created, $argi, $abort, - $fail, $status - ) - ON CONFLICT(purse_pub) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - restrict_scope = excluded.restrict_scope, - amount = excluded.amount, - total_cost = excluded.total_cost, - coin_sel = excluded.coin_sel, - contract_terms_hash = excluded.contract_terms_hash, - purse_priv = excluded.purse_priv, - merge_pub = excluded.merge_pub, - merge_priv = excluded.merge_priv, - contract_priv = excluded.contract_priv, - contract_pub = excluded.contract_pub, - contract_enc_nonce = excluded.contract_enc_nonce, - purse_expiration = excluded.purse_expiration, - timestamp_created = excluded.timestamp_created, - abort_refresh_group_id = excluded.abort_refresh_group_id, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - status = excluded.status`, - { - 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: 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, - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - status: rec.status, - }, - ); - } - - async deletePeerPushDebit(pursePub: string): Promise<void> { - await this.run("DELETE FROM peer_push_debit WHERE purse_pub = $pub", { - pub: crockToDb(pursePub), - }); - } - - async listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]> { - const rows = await this.all("SELECT * FROM peer_push_debit"); - return rows.map((r) => this.rowToPeerPushDebit(r)); - } - - async getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]> { - const rows = await this.all( - "SELECT * FROM peer_push_debit WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, purse_pub", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToPeerPushDebit(r)); - } - - // ------------------------------------------------- peer push credit - - private rowToPeerPushCredit(row: ResultRow): WalletPeerPushCredit { - return { - peerPushCreditId: str(row.peer_push_credit_id), - exchangeBaseUrl: str(row.exchange_base_url), - 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: dbToCrock(row.contract_terms_hash), - status: num(row.status), - withdrawalGroupId: optStr(row.withdrawal_group_id), - currency: optStr(row.currency), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - ...(row.kyc_payto_hash != null - ? { kycPaytoHash: dbToCrock(row.kyc_payto_hash) } - : undefined), - ...(row.kyc_access_token != null - ? { kycAccessToken: str(row.kyc_access_token) } - : undefined), - ...(row.kyc_last_check_status != null - ? { kycLastCheckStatus: num(row.kyc_last_check_status) } - : undefined), - ...(row.kyc_last_check_code != null - ? { kycLastCheckCode: num(row.kyc_last_check_code) } - : undefined), - ...(row.kyc_last_rule_gen != null - ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } - : undefined), - ...(row.kyc_last_aml_review != null - ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } - : undefined), - ...(row.kyc_last_deny != null - ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } - : undefined), - }; - } - - async getPeerPushCredit( - peerPushCreditId: string, - ): Promise<WalletPeerPushCredit | undefined> { - const row = await this.first( - "SELECT * FROM peer_push_credit WHERE peer_push_credit_id = $id", - { id: peerPushCreditId }, - ); - return row ? this.rowToPeerPushCredit(row) : undefined; - } - - async upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void> { - await this.run( - `INSERT INTO peer_push_credit ( - peer_push_credit_id, exchange_base_url, purse_pub, merge_priv, - contract_priv, timestamp, estimated_amount_effective, - contract_terms_hash, status, abort_reason, fail_reason, - withdrawal_group_id, currency, kyc_payto_hash, kyc_access_token, - kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, - kyc_last_aml_review, kyc_last_deny - ) VALUES ( - $id, $url, $ppub, $mpriv, $cpriv, $ts, $eae, $cth, $status, - $abort, $fail, $wgid, $cur, $kph, $kat, $klcs, $klcc, $klrg, - $klar, $kld - ) - ON CONFLICT(peer_push_credit_id) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - purse_pub = excluded.purse_pub, - merge_priv = excluded.merge_priv, - contract_priv = excluded.contract_priv, - timestamp = excluded.timestamp, - estimated_amount_effective = excluded.estimated_amount_effective, - contract_terms_hash = excluded.contract_terms_hash, - status = excluded.status, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - withdrawal_group_id = excluded.withdrawal_group_id, - currency = excluded.currency, - kyc_payto_hash = excluded.kyc_payto_hash, - kyc_access_token = excluded.kyc_access_token, - kyc_last_check_status = excluded.kyc_last_check_status, - kyc_last_check_code = excluded.kyc_last_check_code, - kyc_last_rule_gen = excluded.kyc_last_rule_gen, - kyc_last_aml_review = excluded.kyc_last_aml_review, - kyc_last_deny = excluded.kyc_last_deny`, - { - id: rec.peerPushCreditId, - url: rec.exchangeBaseUrl, - ppub: crockToDb(rec.pursePub), - mpriv: crockToDb(rec.mergePriv), - cpriv: crockToDb(rec.contractPriv), - ts: rec.timestamp, - eae: rec.estimatedAmountEffective, - 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: optCrockToDb(rec.kycPaytoHash), - kat: rec.kycAccessToken ?? null, - klcs: rec.kycLastCheckStatus ?? null, - klcc: rec.kycLastCheckCode ?? null, - klrg: rec.kycLastRuleGen ?? null, - klar: boolToDb(rec.kycLastAmlReview), - kld: rec.kycLastDeny ?? null, - }, - ); - } - - async deletePeerPushCredit(peerPushCreditId: string): Promise<void> { - await this.run( - "DELETE FROM peer_push_credit WHERE peer_push_credit_id = $id", - { id: peerPushCreditId }, - ); - } - - async listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]> { - const rows = await this.all("SELECT * FROM peer_push_credit"); - return rows.map((r) => this.rowToPeerPushCredit(r)); - } - - async getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]> { - const rows = await this.all( - "SELECT * FROM peer_push_credit WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, peer_push_credit_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToPeerPushCredit(r)); - } - - async getPeerPushCreditByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPushCredit | undefined> { - const row = await this.first( - "SELECT * FROM peer_push_credit" + - " WHERE exchange_base_url = $url AND contract_priv = $priv", - { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, - ); - return row ? this.rowToPeerPushCredit(row) : undefined; - } - - // -------------------------------------------------- peer pull debit - - private rowToPeerPullDebit(row: ResultRow): WalletPeerPullDebit { - return { - peerPullDebitId: str(row.peer_pull_debit_id), - pursePub: dbToCrock(row.purse_pub), - exchangeBaseUrl: str(row.exchange_base_url), - amount: dbAmount(row.amount), - contractTermsHash: dbToCrock(row.contract_terms_hash), - timestampCreated: dbTimestamp(row.timestamp_created), - contractPriv: dbToCrock(row.contract_priv), - status: num(row.status), - totalCostEstimated: dbAmount(row.total_cost_estimated), - ...(row.abort_refresh_group_id != null - ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - ...(row.coin_sel != null - ? { coinSel: dbToJson(row.coin_sel) } - : undefined), - }; - } - - async getPeerPullDebit( - peerPullDebitId: string, - ): Promise<WalletPeerPullDebit | undefined> { - const row = await this.first( - "SELECT * FROM peer_pull_debit WHERE peer_pull_debit_id = $id", - { id: peerPullDebitId }, - ); - return row ? this.rowToPeerPullDebit(row) : undefined; - } - - async upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void> { - await this.run( - `INSERT INTO peer_pull_debit ( - peer_pull_debit_id, purse_pub, exchange_base_url, amount, - contract_terms_hash, timestamp_created, contract_priv, status, - total_cost_estimated, abort_refresh_group_id, abort_reason, - fail_reason, coin_sel - ) VALUES ( - $id, $ppub, $url, $amt, $cth, $created, $cpriv, $status, $tce, - $argi, $abort, $fail, $csel - ) - ON CONFLICT(peer_pull_debit_id) DO UPDATE SET - purse_pub = excluded.purse_pub, - exchange_base_url = excluded.exchange_base_url, - amount = excluded.amount, - contract_terms_hash = excluded.contract_terms_hash, - timestamp_created = excluded.timestamp_created, - contract_priv = excluded.contract_priv, - status = excluded.status, - total_cost_estimated = excluded.total_cost_estimated, - abort_refresh_group_id = excluded.abort_refresh_group_id, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - coin_sel = excluded.coin_sel`, - { - id: rec.peerPullDebitId, - ppub: crockToDb(rec.pursePub), - url: rec.exchangeBaseUrl, - amt: rec.amount, - cth: crockToDb(rec.contractTermsHash), - created: rec.timestampCreated, - cpriv: crockToDb(rec.contractPriv), - status: rec.status, - tce: rec.totalCostEstimated, - argi: rec.abortRefreshGroupId ?? null, - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - csel: rec.coinSel === undefined ? null : jsonToDb(rec.coinSel), - }, - ); - } - - async deletePeerPullDebit(peerPullDebitId: string): Promise<void> { - await this.run( - "DELETE FROM peer_pull_debit WHERE peer_pull_debit_id = $id", - { id: peerPullDebitId }, - ); - } - - async listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]> { - const rows = await this.all("SELECT * FROM peer_pull_debit"); - return rows.map((r) => this.rowToPeerPullDebit(r)); - } - - async getPeerPullDebitByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPullDebit | undefined> { - const row = await this.first( - "SELECT * FROM peer_pull_debit" + - " WHERE exchange_base_url = $url AND contract_priv = $priv", - { url: exchangeBaseUrl, priv: crockToDb(contractPriv) }, - ); - return row ? this.rowToPeerPullDebit(row) : undefined; - } - - // ------------------------------------------------- peer pull credit - - private rowToPeerPullCredit(row: ResultRow): WalletPeerPullCredit { - return { - pursePub: dbToCrock(row.purse_pub), - exchangeBaseUrl: str(row.exchange_base_url), - amount: dbAmount(row.amount), - estimatedAmountEffective: dbAmount(row.estimated_amount_effective), - 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: dbToCrock(row.kyc_payto_hash) } - : undefined), - ...(row.kyc_access_token != null - ? { kycAccessToken: str(row.kyc_access_token) } - : undefined), - ...(row.kyc_last_check_status != null - ? { kycLastCheckStatus: num(row.kyc_last_check_status) } - : undefined), - ...(row.kyc_last_check_code != null - ? { kycLastCheckCode: num(row.kyc_last_check_code) } - : undefined), - ...(row.kyc_last_rule_gen != null - ? { kycLastRuleGen: num(row.kyc_last_rule_gen) } - : undefined), - ...(row.kyc_last_aml_review != null - ? { kycLastAmlReview: dbToBool(row.kyc_last_aml_review) } - : undefined), - ...(row.kyc_last_deny != null - ? { kycLastDeny: dbTimestamp(row.kyc_last_deny) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - }; - } - - async getPeerPullCredit( - pursePub: string, - ): Promise<WalletPeerPullCredit | undefined> { - const row = await this.first( - "SELECT * FROM peer_pull_credit WHERE purse_pub = $pub", - { pub: crockToDb(pursePub) }, - ); - return row ? this.rowToPeerPullCredit(row) : undefined; - } - - async upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void> { - await this.run( - `INSERT INTO peer_pull_credit ( - purse_pub, exchange_base_url, amount, estimated_amount_effective, - purse_priv, contract_terms_hash, merge_pub, merge_priv, - contract_pub, contract_priv, contract_enc_nonce, merge_timestamp, - merge_reserve_row_id, status, kyc_payto_hash, kyc_access_token, - kyc_last_check_status, kyc_last_check_code, kyc_last_rule_gen, - kyc_last_aml_review, kyc_last_deny, abort_reason, fail_reason, - withdrawal_group_id - ) VALUES ( - $pub, $url, $amt, $eae, $ppriv, $cth, $mpub, $mpriv, $cpub, - $cpriv, $nonce, $mts, $mrri, $status, $kph, $kat, $klcs, $klcc, - $klrg, $klar, $kld, $abort, $fail, $wgid - ) - ON CONFLICT(purse_pub) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - amount = excluded.amount, - estimated_amount_effective = excluded.estimated_amount_effective, - purse_priv = excluded.purse_priv, - contract_terms_hash = excluded.contract_terms_hash, - merge_pub = excluded.merge_pub, - merge_priv = excluded.merge_priv, - contract_pub = excluded.contract_pub, - contract_priv = excluded.contract_priv, - contract_enc_nonce = excluded.contract_enc_nonce, - merge_timestamp = excluded.merge_timestamp, - merge_reserve_row_id = excluded.merge_reserve_row_id, - status = excluded.status, - kyc_payto_hash = excluded.kyc_payto_hash, - kyc_access_token = excluded.kyc_access_token, - kyc_last_check_status = excluded.kyc_last_check_status, - kyc_last_check_code = excluded.kyc_last_check_code, - kyc_last_rule_gen = excluded.kyc_last_rule_gen, - kyc_last_aml_review = excluded.kyc_last_aml_review, - kyc_last_deny = excluded.kyc_last_deny, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - withdrawal_group_id = excluded.withdrawal_group_id`, - { - pub: crockToDb(rec.pursePub), - url: rec.exchangeBaseUrl, - amt: rec.amount, - eae: rec.estimatedAmountEffective, - 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: optCrockToDb(rec.kycPaytoHash), - kat: rec.kycAccessToken ?? null, - klcs: rec.kycLastCheckStatus ?? null, - klcc: rec.kycLastCheckCode ?? null, - klrg: rec.kycLastRuleGen ?? null, - klar: boolToDb(rec.kycLastAmlReview), - kld: rec.kycLastDeny ?? null, - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - wgid: rec.withdrawalGroupId ?? null, - }, - ); - } - - async deletePeerPullCredit(pursePub: string): Promise<void> { - await this.run("DELETE FROM peer_pull_credit WHERE purse_pub = $pub", { - pub: crockToDb(pursePub), - }); - } - - async listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]> { - const rows = await this.all("SELECT * FROM peer_pull_credit"); - return rows.map((r) => this.rowToPeerPullCredit(r)); - } - - // ----------------------------------------------------- deposit groups - - private rowToDepositGroup(row: ResultRow): WalletDepositGroup { - return { - depositGroupId: str(row.deposit_group_id), - currency: str(row.currency), - amount: dbAmount(row.amount), - wireTransferDeadline: dbTimestamp(row.wire_transfer_deadline), - 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: dbToCrock(row.contract_terms_hash), - totalPayCost: dbAmount(row.total_pay_cost), - counterpartyEffectiveDepositAmount: dbAmount( - row.counterparty_effective_deposit_amount, - ), - timestampCreated: dbTimestamp(row.timestamp_created), - timestampFinished: - row.timestamp_finished == null - ? undefined - : dbTimestamp(row.timestamp_finished), - timestampLastDepositAttempt: - row.timestamp_last_deposit_attempt == null - ? undefined - : dbTimestamp(row.timestamp_last_deposit_attempt), - operationStatus: num(row.operation_status), - ...(row.pay_coin_selection != null - ? { payCoinSelection: dbToJson(row.pay_coin_selection) } - : undefined), - ...(row.pay_coin_selection_uid != null - ? { payCoinSelectionUid: str(row.pay_coin_selection_uid) } - : undefined), - ...(row.status_per_coin != null - ? { statusPerCoin: dbToJson(row.status_per_coin) } - : undefined), - ...(row.info_per_exchange != null - ? { infoPerExchange: dbToJson(row.info_per_exchange) } - : undefined), - ...(row.abort_refresh_group_id != null - ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - ...(row.kyc_info != null - ? { kycInfo: dbToJson(row.kyc_info) } - : undefined), - ...(row.kyc_auth_transfer_options != null - ? { kycAuthTransferOptions: dbToJson(row.kyc_auth_transfer_options) } - : undefined), - ...(row.kyc_auth_transfer_expiry != null - ? { kycAuthTransferExpiry: dbToJson(row.kyc_auth_transfer_expiry) } - : undefined), - ...(row.tracking_state != null - ? { trackingState: dbToJson(row.tracking_state) } - : undefined), - }; - } - - async getDepositGroup( - depositGroupId: string, - ): Promise<WalletDepositGroup | undefined> { - const row = await this.first( - "SELECT * FROM deposit_groups WHERE deposit_group_id = $id", - { id: depositGroupId }, - ); - return row ? this.rowToDepositGroup(row) : undefined; - } - - async upsertDepositGroup(rec: WalletDepositGroup): Promise<void> { - await this.run( - `INSERT INTO deposit_groups ( - deposit_group_id, currency, amount, wire_transfer_deadline, - merchant_pub, merchant_priv, nonce_priv, nonce_pub, wire, - contract_terms_hash, pay_coin_selection, pay_coin_selection_uid, - total_pay_cost, counterparty_effective_deposit_amount, - timestamp_created, timestamp_finished, - timestamp_last_deposit_attempt, operation_status, status_per_coin, - info_per_exchange, abort_refresh_group_id, abort_reason, - fail_reason, kyc_info, kyc_auth_transfer_options, - kyc_auth_transfer_expiry, tracking_state - ) VALUES ( - $id, $cur, $amt, $wtd, $mpub, $mpriv, $npriv, $npub, $wire, $cth, - $pcs, $pcsu, $tpc, $ceda, $created, $finished, $lastAttempt, - $status, $spc, $ipe, $argi, $abort, $fail, $kyc, $kato, $kate, - $tracking - ) - ON CONFLICT(deposit_group_id) DO UPDATE SET - currency = excluded.currency, - amount = excluded.amount, - wire_transfer_deadline = excluded.wire_transfer_deadline, - merchant_pub = excluded.merchant_pub, - merchant_priv = excluded.merchant_priv, - nonce_priv = excluded.nonce_priv, - nonce_pub = excluded.nonce_pub, - wire = excluded.wire, - contract_terms_hash = excluded.contract_terms_hash, - pay_coin_selection = excluded.pay_coin_selection, - pay_coin_selection_uid = excluded.pay_coin_selection_uid, - total_pay_cost = excluded.total_pay_cost, - counterparty_effective_deposit_amount = - excluded.counterparty_effective_deposit_amount, - timestamp_created = excluded.timestamp_created, - timestamp_finished = excluded.timestamp_finished, - timestamp_last_deposit_attempt = - excluded.timestamp_last_deposit_attempt, - operation_status = excluded.operation_status, - status_per_coin = excluded.status_per_coin, - info_per_exchange = excluded.info_per_exchange, - abort_refresh_group_id = excluded.abort_refresh_group_id, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - kyc_info = excluded.kyc_info, - kyc_auth_transfer_options = excluded.kyc_auth_transfer_options, - kyc_auth_transfer_expiry = excluded.kyc_auth_transfer_expiry, - tracking_state = excluded.tracking_state`, - { - id: rec.depositGroupId, - cur: rec.currency, - amt: rec.amount, - wtd: rec.wireTransferDeadline, - mpub: crockToDb(rec.merchantPub), - mpriv: crockToDb(rec.merchantPriv), - npriv: crockToDb(rec.noncePriv), - npub: crockToDb(rec.noncePub), - wire: jsonToDb(rec.wire), - cth: crockToDb(rec.contractTermsHash), - pcs: - rec.payCoinSelection === undefined - ? null - : jsonToDb(rec.payCoinSelection), - pcsu: rec.payCoinSelectionUid ?? null, - tpc: rec.totalPayCost, - ceda: rec.counterpartyEffectiveDepositAmount, - created: rec.timestampCreated, - finished: rec.timestampFinished ?? null, - lastAttempt: rec.timestampLastDepositAttempt ?? null, - status: rec.operationStatus, - spc: - rec.statusPerCoin === undefined ? null : jsonToDb(rec.statusPerCoin), - ipe: - rec.infoPerExchange === undefined - ? null - : jsonToDb(rec.infoPerExchange), - argi: rec.abortRefreshGroupId ?? null, - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - kyc: rec.kycInfo === undefined ? null : jsonToDb(rec.kycInfo), - kato: - rec.kycAuthTransferOptions === undefined - ? null - : jsonToDb(rec.kycAuthTransferOptions), - kate: - rec.kycAuthTransferExpiry === undefined - ? null - : jsonToDb(rec.kycAuthTransferExpiry), - tracking: - rec.trackingState === undefined ? null : jsonToDb(rec.trackingState), - }, - ); - } - - async deleteDepositGroup(depositGroupId: string): Promise<void> { - await this.run("DELETE FROM deposit_groups WHERE deposit_group_id = $id", { - id: depositGroupId, - }); - } - - async listAllDepositGroups(): Promise<WalletDepositGroup[]> { - const rows = await this.all("SELECT * FROM deposit_groups"); - return rows.map((r) => this.rowToDepositGroup(r)); - } - - async getActiveDepositGroups(): Promise<WalletDepositGroup[]> { - const rows = await this.all( - "SELECT * FROM deposit_groups" + - " WHERE operation_status BETWEEN $lo AND $hi" + - " ORDER BY operation_status, deposit_group_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToDepositGroup(r)); - } - - // ----------------------------------------------------- refresh groups - - private rowToRefreshGroup(row: ResultRow): WalletRefreshGroup { - return { - refreshGroupId: str(row.refresh_group_id), - operationStatus: num(row.operation_status), - currency: str(row.currency), - reason: str(row.reason) as RefreshReason, - oldCoinPubs: dbToJson(row.old_coin_pubs), - inputPerCoin: dbToJson(row.input_per_coin), - expectedOutputPerCoin: dbToJson(row.expected_output_per_coin), - statusPerCoin: dbToJson(row.status_per_coin), - refundRequests: dbToJson(row.refund_requests), - timestampCreated: dbTimestamp(row.timestamp_created), - timestampFinished: - row.timestamp_finished == null - ? undefined - : dbTimestamp(row.timestamp_finished), - ...(row.originating_transaction_id != null - ? { originatingTransactionId: str(row.originating_transaction_id) } - : undefined), - ...(row.info_per_exchange != null - ? { infoPerExchange: dbToJson(row.info_per_exchange) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - }; - } - - async getRefreshGroup( - refreshGroupId: string, - ): Promise<WalletRefreshGroup | undefined> { - const row = await this.first( - "SELECT * FROM refresh_groups WHERE refresh_group_id = $id", - { id: refreshGroupId }, - ); - return row ? this.rowToRefreshGroup(row) : undefined; - } - - async upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void> { - await this.run( - `INSERT INTO refresh_groups ( - refresh_group_id, operation_status, currency, reason, - originating_transaction_id, old_coin_pubs, input_per_coin, - expected_output_per_coin, info_per_exchange, status_per_coin, - refund_requests, timestamp_created, fail_reason, timestamp_finished - ) VALUES ( - $id, $status, $cur, $reason, $otid, $ocp, $ipc, $eopc, $ipe, - $spc, $rr, $created, $fail, $finished - ) - ON CONFLICT(refresh_group_id) DO UPDATE SET - operation_status = excluded.operation_status, - currency = excluded.currency, - reason = excluded.reason, - originating_transaction_id = excluded.originating_transaction_id, - old_coin_pubs = excluded.old_coin_pubs, - input_per_coin = excluded.input_per_coin, - expected_output_per_coin = excluded.expected_output_per_coin, - info_per_exchange = excluded.info_per_exchange, - status_per_coin = excluded.status_per_coin, - refund_requests = excluded.refund_requests, - timestamp_created = excluded.timestamp_created, - fail_reason = excluded.fail_reason, - timestamp_finished = excluded.timestamp_finished`, - { - id: rec.refreshGroupId, - status: rec.operationStatus, - cur: rec.currency, - reason: rec.reason, - otid: rec.originatingTransactionId ?? null, - ocp: jsonToDb(rec.oldCoinPubs), - ipc: jsonToDb(rec.inputPerCoin), - eopc: jsonToDb(rec.expectedOutputPerCoin), - ipe: - rec.infoPerExchange === undefined - ? null - : jsonToDb(rec.infoPerExchange), - spc: jsonToDb(rec.statusPerCoin), - rr: jsonToDb(rec.refundRequests), - created: rec.timestampCreated, - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - finished: rec.timestampFinished ?? null, - }, - ); - } - - async deleteRefreshGroup(refreshGroupId: string): Promise<void> { - await this.run("DELETE FROM refresh_groups WHERE refresh_group_id = $id", { - id: refreshGroupId, - }); - } - - async listAllRefreshGroups(): Promise<WalletRefreshGroup[]> { - const rows = await this.all("SELECT * FROM refresh_groups"); - return rows.map((r) => this.rowToRefreshGroup(r)); - } - - async getActiveRefreshGroups(): Promise<WalletRefreshGroup[]> { - const rows = await this.all( - "SELECT * FROM refresh_groups" + - " WHERE operation_status BETWEEN $lo AND $hi" + - " ORDER BY operation_status, refresh_group_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToRefreshGroup(r)); - } - - async getRefreshGroupsByOriginatingTransaction( - transactionId: string, - ): Promise<WalletRefreshGroup[]> { - const rows = await this.all( - "SELECT * FROM refresh_groups WHERE originating_transaction_id = $tid", - { tid: transactionId }, - ); - return rows.map((r) => this.rowToRefreshGroup(r)); - } - - // -------------------------------------------------- denom loss events - - private rowToDenomLossEvent(row: ResultRow): WalletDenomLossEvent { - return { - denomLossEventId: str(row.denom_loss_event_id), - currency: str(row.currency), - denomPubHashes: dbToJson(row.denom_pub_hashes), - status: num(row.status), - timestampCreated: dbTimestamp(row.timestamp_created), - amount: str(row.amount), - eventType: str(row.event_type) as DenomLossEventType, - exchangeBaseUrl: str(row.exchange_base_url), - }; - } - - async getDenomLossEvent( - denomLossEventId: string, - ): Promise<WalletDenomLossEvent | undefined> { - const row = await this.first( - "SELECT * FROM denom_loss_events WHERE denom_loss_event_id = $id", - { id: denomLossEventId }, - ); - return row ? this.rowToDenomLossEvent(row) : undefined; - } - - async upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void> { - await this.run( - `INSERT INTO denom_loss_events ( - denom_loss_event_id, currency, denom_pub_hashes, status, - timestamp_created, amount, event_type, exchange_base_url - ) VALUES ($id, $cur, $dph, $status, $created, $amt, $et, $url) - ON CONFLICT(denom_loss_event_id) DO UPDATE SET - currency = excluded.currency, - denom_pub_hashes = excluded.denom_pub_hashes, - status = excluded.status, - timestamp_created = excluded.timestamp_created, - amount = excluded.amount, - event_type = excluded.event_type, - exchange_base_url = excluded.exchange_base_url`, - { - id: rec.denomLossEventId, - cur: rec.currency, - dph: jsonToDb(rec.denomPubHashes), - status: rec.status, - created: rec.timestampCreated, - amt: rec.amount, - et: rec.eventType, - url: rec.exchangeBaseUrl, - }, - ); - } - - async deleteDenomLossEvent(denomLossEventId: string): Promise<void> { - await this.run( - "DELETE FROM denom_loss_events WHERE denom_loss_event_id = $id", - { id: denomLossEventId }, - ); - } - - async listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]> { - const rows = await this.all("SELECT * FROM denom_loss_events"); - return rows.map((r) => this.rowToDenomLossEvent(r)); - } - - async listAllRefundGroups(): Promise<WalletRefundGroup[]> { - const rows = await this.all("SELECT * FROM refund_groups"); - return rows.map((r) => this.rowToRefundGroup(r)); - } - - // --------------------------------------------------------- purchases - - /** - * Rebuild a purchase from its row plus its exchange rows. - * - * Takes the exchange list separately because it lives in a junction table: - * that table is the only copy, so it has to be read to reconstruct the - * record. - */ - private rowToPurchase( - row: ResultRow, - exchanges: string[] | undefined, - ): WalletPurchase { - const download = dbToOptJson<WalletProposalDownloadInfo>(row.download); - if (download && row.download_fulfillment_url != null) { - // Re-inserted from the column, which is the only copy. - download.fulfillmentUrl = str(row.download_fulfillment_url); - } - return { - proposalId: str(row.proposal_id), - orderId: str(row.order_id), - merchantBaseUrl: str(row.merchant_base_url), - claimToken: optStr(row.claim_token), - downloadSessionId: optStr(row.download_session_id), - repurchaseProposalId: optStr(row.repurchase_proposal_id), - purchaseStatus: num(row.purchase_status), - 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: dbToOptCrock(row.merchant_pay_sig), - posConfirmation: optStr(row.pos_confirmation), - shared: dbToBool(row.shared), - timestamp: dbTimestamp(row.timestamp), - timestampAccept: - row.timestamp_accept == null - ? undefined - : dbTimestamp(row.timestamp_accept), - timestampLastRefundStatus: - row.timestamp_last_refund_status == null - ? undefined - : dbTimestamp(row.timestamp_last_refund_status), - lastSessionId: optStr(row.last_session_id), - autoRefundDeadline: - row.auto_refund_deadline == null - ? undefined - : dbTimestamp(row.auto_refund_deadline), - refundAmountAwaiting: - row.refund_amount_awaiting == null - ? undefined - : dbAmount(row.refund_amount_awaiting), - ...(exchanges !== undefined ? { exchanges } : undefined), - ...(row.abort_refresh_group_id != null - ? { abortRefreshGroupId: str(row.abort_refresh_group_id) } - : undefined), - ...(row.abort_reason != null - ? { abortReason: dbToJson(row.abort_reason) } - : undefined), - ...(row.fail_reason != null - ? { failReason: dbToJson(row.fail_reason) } - : undefined), - ...(row.choice_index != null - ? { choiceIndex: num(row.choice_index) } - : undefined), - ...(row.pending_removed_coin_pubs != null - ? { pendingRemovedCoinPubs: dbToJson(row.pending_removed_coin_pubs) } - : undefined), - ...(row.donau_output_index != null - ? { donauOutputIndex: num(row.donau_output_index) } - : undefined), - ...(row.donau_base_url != null - ? { donauBaseUrl: str(row.donau_base_url) } - : undefined), - ...(row.donau_amount != null - ? { donauAmount: dbAmount(row.donau_amount) } - : undefined), - ...(row.donau_tax_id_hash != null - ? { donauTaxIdHash: dbToCrock(row.donau_tax_id_hash) } - : undefined), - ...(row.donau_tax_id_salt != null - ? { donauTaxIdSalt: str(row.donau_tax_id_salt) } - : undefined), - ...(row.donau_tax_id != null - ? { donauTaxId: str(row.donau_tax_id) } - : undefined), - ...(row.donau_year != null - ? { donauYear: num(row.donau_year) } - : undefined), - ...(row.created_from_shared != null - ? { createdFromShared: dbToBool(row.created_from_shared) } - : undefined), - ...(row.timestamp_expired != null - ? { timestampExpired: dbTimestamp(row.timestamp_expired) } - : undefined), - ...(row.taler_uri != null ? { talerUri: str(row.taler_uri) } : undefined), - }; - } - - private async loadPurchaseExchanges( - proposalId: string, - ): Promise<string[] | undefined> { - const rows = await this.all( - "SELECT exchange_base_url FROM purchase_exchanges" + - " WHERE proposal_id = $id ORDER BY idx", - { id: proposalId }, - ); - // No rows means the field was absent, not an empty array: an empty array - // would have produced no rows either, but the record type makes the - // field optional and the wallet never stores an empty list. - return rows.length === 0 - ? undefined - : rows.map((r) => str(r.exchange_base_url)); - } - - private async hydratePurchases(rows: ResultRow[]): Promise<WalletPurchase[]> { - if (rows.length === 0) { - return []; - } - const exchangesByProposal = new Map<string, string[]>(); - const proposalIds = rows.map((r) => str(r.proposal_id)); - for (let offset = 0; offset < proposalIds.length; offset += 400) { - const chunk = proposalIds.slice(offset, offset + 400); - const params: Record<string, string> = {}; - const placeholders = chunk.map((id, i) => { - params[`id${i}`] = id; - return `$id${i}`; - }); - const exchangeRows = await this.all( - "SELECT proposal_id, exchange_base_url FROM purchase_exchanges" + - ` WHERE proposal_id IN (${placeholders.join(", ")})` + - " ORDER BY proposal_id, idx", - params, - ); - for (const exchangeRow of exchangeRows) { - const proposalId = str(exchangeRow.proposal_id); - const exchanges = exchangesByProposal.get(proposalId) ?? []; - exchanges.push(str(exchangeRow.exchange_base_url)); - exchangesByProposal.set(proposalId, exchanges); - } - } - return rows.map((row) => - this.rowToPurchase(row, exchangesByProposal.get(str(row.proposal_id))), - ); - } - - async getPurchase(proposalId: string): Promise<WalletPurchase | undefined> { - const row = await this.first( - "SELECT * FROM purchases WHERE proposal_id = $id", - { id: proposalId }, - ); - if (!row) { - return undefined; - } - return this.rowToPurchase( - row, - await this.loadPurchaseExchanges(proposalId), - ); - } - - async upsertPurchase(rec: WalletPurchase): Promise<void> { - // download is stored without its fulfillmentUrl; the column holds it. - let downloadJson: string | null = null; - let fulfillmentUrl: string | null = null; - if (rec.download) { - const { fulfillmentUrl: fu, ...rest } = rec.download; - fulfillmentUrl = fu ?? null; - downloadJson = jsonToDb(rest); - } - await this.run( - `INSERT INTO purchases ( - proposal_id, order_id, merchant_base_url, claim_token, - download_session_id, repurchase_proposal_id, purchase_status, - abort_refresh_group_id, abort_reason, fail_reason, nonce_priv, - nonce_pub, choice_index, secret_seed, download, - download_fulfillment_url, pay_info, pending_removed_coin_pubs, - timestamp_first_successful_pay, merchant_pay_sig, pos_confirmation, - donau_output_index, donau_base_url, donau_amount, - donau_tax_id_hash, donau_tax_id_salt, donau_tax_id, donau_year, - shared, created_from_shared, timestamp, timestamp_accept, - timestamp_last_refund_status, timestamp_expired, last_session_id, - auto_refund_deadline, refund_amount_awaiting, taler_uri - ) VALUES ( - $id, $oid, $url, $ct, $dsid, $rpid, $status, $argi, $abort, $fail, - $npriv, $npub, $ci, $seed, $dl, $ffu, $pi, $prcp, $tfsp, $mps, - $posc, $doi, $dbu, $damt, $dtih, $dtis, $dti, $dy, $shared, - $cfs, $ts, $tsa, $tslrs, $tse, $lsid, $ard, $raa, $turi - ) - ON CONFLICT(proposal_id) DO UPDATE SET - order_id = excluded.order_id, - merchant_base_url = excluded.merchant_base_url, - claim_token = excluded.claim_token, - download_session_id = excluded.download_session_id, - repurchase_proposal_id = excluded.repurchase_proposal_id, - purchase_status = excluded.purchase_status, - abort_refresh_group_id = excluded.abort_refresh_group_id, - abort_reason = excluded.abort_reason, - fail_reason = excluded.fail_reason, - nonce_priv = excluded.nonce_priv, - nonce_pub = excluded.nonce_pub, - choice_index = excluded.choice_index, - secret_seed = excluded.secret_seed, - download = excluded.download, - download_fulfillment_url = excluded.download_fulfillment_url, - pay_info = excluded.pay_info, - pending_removed_coin_pubs = excluded.pending_removed_coin_pubs, - timestamp_first_successful_pay = - excluded.timestamp_first_successful_pay, - merchant_pay_sig = excluded.merchant_pay_sig, - pos_confirmation = excluded.pos_confirmation, - donau_output_index = excluded.donau_output_index, - donau_base_url = excluded.donau_base_url, - donau_amount = excluded.donau_amount, - donau_tax_id_hash = excluded.donau_tax_id_hash, - donau_tax_id_salt = excluded.donau_tax_id_salt, - donau_tax_id = excluded.donau_tax_id, - donau_year = excluded.donau_year, - shared = excluded.shared, - created_from_shared = excluded.created_from_shared, - timestamp = excluded.timestamp, - timestamp_accept = excluded.timestamp_accept, - timestamp_last_refund_status = - excluded.timestamp_last_refund_status, - timestamp_expired = excluded.timestamp_expired, - last_session_id = excluded.last_session_id, - auto_refund_deadline = excluded.auto_refund_deadline, - refund_amount_awaiting = excluded.refund_amount_awaiting, - taler_uri = excluded.taler_uri`, - { - id: rec.proposalId, - oid: rec.orderId, - url: rec.merchantBaseUrl, - ct: rec.claimToken ?? null, - dsid: rec.downloadSessionId ?? null, - rpid: rec.repurchaseProposalId ?? null, - status: rec.purchaseStatus, - argi: rec.abortRefreshGroupId ?? null, - abort: rec.abortReason === undefined ? null : jsonToDb(rec.abortReason), - fail: rec.failReason === undefined ? null : jsonToDb(rec.failReason), - npriv: crockToDb(rec.noncePriv), - npub: crockToDb(rec.noncePub), - ci: rec.choiceIndex ?? null, - seed: optCrockToDb(rec.secretSeed), - dl: downloadJson, - ffu: fulfillmentUrl, - pi: rec.payInfo === undefined ? null : jsonToDb(rec.payInfo), - prcp: - rec.pendingRemovedCoinPubs === undefined - ? null - : jsonToDb(rec.pendingRemovedCoinPubs), - tfsp: rec.timestampFirstSuccessfulPay ?? null, - mps: optCrockToDb(rec.merchantPaySig), - posc: rec.posConfirmation ?? null, - doi: rec.donauOutputIndex ?? null, - dbu: rec.donauBaseUrl ?? null, - damt: rec.donauAmount ?? null, - dtih: optCrockToDb(rec.donauTaxIdHash), - dtis: rec.donauTaxIdSalt ?? null, - dti: rec.donauTaxId ?? null, - dy: rec.donauYear ?? null, - shared: boolToDb(rec.shared), - cfs: boolToDb(rec.createdFromShared), - ts: rec.timestamp, - tsa: rec.timestampAccept ?? null, - tslrs: rec.timestampLastRefundStatus ?? null, - tse: rec.timestampExpired ?? null, - lsid: rec.lastSessionId ?? null, - ard: rec.autoRefundDeadline ?? null, - raa: rec.refundAmountAwaiting ?? null, - turi: rec.talerUri ?? null, - }, - ); - const oldExchanges = await this.loadPurchaseExchanges(rec.proposalId); - const newExchanges = rec.exchanges; - const oldList = oldExchanges ?? []; - const newList = newExchanges ?? []; - if ( - oldList.length === newList.length && - oldList.every((url, i) => url === newList[i]) - ) { - return; - } - await this.run("DELETE FROM purchase_exchanges WHERE proposal_id = $id", { - id: rec.proposalId, - }); - if (newExchanges?.length) { - const params: Record<string, string | number> = { id: rec.proposalId }; - const values = newExchanges.map((url, i) => { - params[`idx${i}`] = i; - params[`url${i}`] = url; - return `($id, $idx${i}, $url${i})`; - }); - await this.run( - "INSERT INTO purchase_exchanges (proposal_id, idx, exchange_base_url)" + - ` VALUES ${values.join(", ")}`, - params, - ); - } - } - - async deletePurchase(proposalId: string): Promise<void> { - await this.run("DELETE FROM purchase_exchanges WHERE proposal_id = $id", { - id: proposalId, - }); - await this.run("DELETE FROM purchases WHERE proposal_id = $id", { - id: proposalId, - }); - } - - async listAllPurchases(): Promise<WalletPurchase[]> { - return await this.hydratePurchases( - await this.all("SELECT * FROM purchases"), - ); - } - - async getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]> { - if (proposalIds.length === 0) { - return []; - } - const rows: ResultRow[] = []; - for (let offset = 0; offset < proposalIds.length; offset += 400) { - const chunk = proposalIds.slice(offset, offset + 400); - const params: Record<string, string> = {}; - const placeholders = chunk.map((id, i) => { - params[`id${i}`] = id; - return `$id${i}`; - }); - rows.push( - ...(await this.all( - `SELECT * FROM purchases WHERE proposal_id IN (${placeholders.join(", ")})`, - params, - )), - ); - } - const purchases = await this.hydratePurchases(rows); - const byId = new Map(purchases.map((p) => [p.proposalId, p])); - return proposalIds.flatMap((id) => { - const purchase = byId.get(id); - return purchase ? [purchase] : []; - }); - } - - async getPurchasesByStatus( - status: PurchaseStatus, - ): Promise<WalletPurchase[]> { - return await this.hydratePurchases( - await this.all( - "SELECT * FROM purchases WHERE purchase_status = $s" + - " ORDER BY purchase_status, proposal_id", - { - s: status, - }, - ), - ); - } - - async getActivePurchases(): Promise<WalletPurchase[]> { - return await this.hydratePurchases( - await this.all( - "SELECT * FROM purchases WHERE purchase_status BETWEEN $lo AND $hi" + - " ORDER BY purchase_status, proposal_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ), - ); - } - - async getPurchaseByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase | undefined> { - const row = await this.first( - "SELECT * FROM purchases" + - " WHERE merchant_base_url = $url AND order_id = $oid", - { url: merchantBaseUrl, oid: orderId }, - ); - if (!row) { - return undefined; - } - return this.rowToPurchase( - row, - await this.loadPurchaseExchanges(str(row.proposal_id)), - ); - } - - async getPurchasesByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase[]> { - return await this.hydratePurchases( - await this.all( - "SELECT * FROM purchases" + - " WHERE merchant_base_url = $url AND order_id = $oid", - { url: merchantBaseUrl, oid: orderId }, - ), - ); - } - - async getPurchasesByFulfillmentUrl( - fulfillmentUrl: string, - ): Promise<WalletPurchase[]> { - return await this.hydratePurchases( - await this.all( - "SELECT * FROM purchases WHERE download_fulfillment_url = $url", - { url: fulfillmentUrl }, - ), - ); - } - - async getPurchasesByExchange( - exchangeBaseUrl: string, - ): Promise<WalletPurchase[]> { - // Via the junction table, which replaces the multiEntry index. - return await this.hydratePurchases( - await this.all( - "SELECT p.* FROM purchases p" + - " JOIN purchase_exchanges pe ON pe.proposal_id = p.proposal_id" + - " WHERE pe.exchange_base_url = $url", - { url: exchangeBaseUrl }, - ), - ); - } - - // ---------------------------------------------------------- donations - - private rowToDonationSummary(row: ResultRow): WalletDonationSummary { - return { - donauBaseUrl: str(row.donau_base_url), - year: num(row.year), - currency: str(row.currency), - amountReceiptsAvailable: dbAmount(row.amount_receipts_available), - amountReceiptsSubmitted: dbAmount(row.amount_receipts_submitted), - ...(row.legal_domain != null - ? { legalDomain: str(row.legal_domain) } - : undefined), - }; - } - - async getDonationSummary( - donauBaseUrl: string, - year: number, - currency: string, - ): Promise<WalletDonationSummary | undefined> { - const row = await this.first( - "SELECT * FROM donation_summaries" + - " WHERE donau_base_url = $url AND year = $year AND currency = $cur", - { url: donauBaseUrl, year, cur: currency }, - ); - return row ? this.rowToDonationSummary(row) : undefined; - } - - async getDonationSummaries(): Promise<WalletDonationSummary[]> { - const rows = await this.all("SELECT * FROM donation_summaries"); - return rows.map((r) => this.rowToDonationSummary(r)); - } - - async upsertDonationSummary(rec: WalletDonationSummary): Promise<void> { - await this.run( - `INSERT INTO donation_summaries ( - donau_base_url, year, currency, legal_domain, - amount_receipts_available, amount_receipts_submitted - ) VALUES ($url, $year, $cur, $ld, $avail, $sub) - ON CONFLICT(donau_base_url, year, currency) DO UPDATE SET - legal_domain = excluded.legal_domain, - amount_receipts_available = excluded.amount_receipts_available, - amount_receipts_submitted = excluded.amount_receipts_submitted`, - { - url: rec.donauBaseUrl, - year: rec.year, - cur: rec.currency, - ld: rec.legalDomain ?? null, - avail: rec.amountReceiptsAvailable, - sub: rec.amountReceiptsSubmitted, - }, - ); - } - - private rowToDonationPlanchet(row: ResultRow): WalletDonationPlanchet { - return { - udiNonce: dbToCrock(row.udi_nonce), - donauBaseUrl: str(row.donau_base_url), - 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: dbToCrock(row.bks), - donationUnitPubHash: dbToCrock(row.donation_unit_pub_hash), - value: dbAmount(row.value), - }; - } - - async upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void> { - await this.run( - `INSERT INTO donation_planchets ( - udi_nonce, donau_base_url, donor_tax_id_hash, donor_hash_salt, - donor_tax_id, donation_year, proposal_id, udi_index, blinded_udi, - bks, donation_unit_pub_hash, value - ) VALUES ( - $nonce, $url, $dtih, $dhs, $dti, $year, $pid, $idx, $budi, $bks, - $duph, $val - ) - ON CONFLICT(udi_nonce) DO UPDATE SET - donau_base_url = excluded.donau_base_url, - donor_tax_id_hash = excluded.donor_tax_id_hash, - donor_hash_salt = excluded.donor_hash_salt, - donor_tax_id = excluded.donor_tax_id, - donation_year = excluded.donation_year, - proposal_id = excluded.proposal_id, - udi_index = excluded.udi_index, - blinded_udi = excluded.blinded_udi, - bks = excluded.bks, - donation_unit_pub_hash = excluded.donation_unit_pub_hash, - value = excluded.value`, - { - nonce: crockToDb(rec.udiNonce), - url: rec.donauBaseUrl, - dtih: crockToDb(rec.donorTaxIdHash), - dhs: rec.donorHashSalt, - dti: rec.donorTaxId, - year: rec.donationYear, - pid: rec.proposalId, - idx: rec.udiIndex, - budi: jsonToDb(rec.blindedUdi), - bks: crockToDb(rec.bks), - duph: crockToDb(rec.donationUnitPubHash), - val: rec.value, - }, - ); - } - - async getDonationPlanchetsByProposal( - proposalId: string, - ): Promise<WalletDonationPlanchet[]> { - const rows = await this.all( - "SELECT * FROM donation_planchets WHERE proposal_id = $pid", - { pid: proposalId }, - ); - return rows.map((r) => this.rowToDonationPlanchet(r)); - } - - async countDonationPlanchetsByProposal(proposalId: string): Promise<number> { - const row = await this.first( - "SELECT COUNT(*) AS n FROM donation_planchets WHERE proposal_id = $pid", - { pid: proposalId }, - ); - return num(row?.n); - } - - async listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]> { - const rows = await this.all("SELECT * FROM donation_planchets"); - return rows.map((r) => this.rowToDonationPlanchet(r)); - } - - async listAllDonationReceipts(): Promise<WalletDonationReceipt[]> { - const rows = await this.all("SELECT * FROM donation_receipts"); - return rows.map((r) => this.rowToDonationReceipt(r)); - } - - private rowToDonationReceipt(row: ResultRow): WalletDonationReceipt { - return { - 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: dbToCrock(row.donation_unit_pub_hash), - donationUnitSig: dbToJson(row.donation_unit_sig), - donorTaxIdHash: dbToCrock(row.donor_tax_id_hash), - donorHashSalt: str(row.donor_hash_salt), - donorTaxId: str(row.donor_tax_id), - value: dbAmount(row.value), - udiIndex: num(row.udi_index), - }; - } - - async getDonationReceipt( - udiNonce: string, - ): Promise<WalletDonationReceipt | undefined> { - const row = await this.first( - "SELECT * FROM donation_receipts WHERE udi_nonce = $nonce", - { nonce: crockToDb(udiNonce) }, - ); - return row ? this.rowToDonationReceipt(row) : undefined; - } - - async upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void> { - await this.run( - `INSERT INTO donation_receipts ( - udi_nonce, status, donau_base_url, proposal_id, donation_year, - donation_unit_pub_hash, donation_unit_sig, donor_tax_id_hash, - donor_hash_salt, donor_tax_id, value, udi_index - ) VALUES ( - $nonce, $status, $url, $pid, $year, $duph, $dus, $dtih, $dhs, - $dti, $val, $idx - ) - ON CONFLICT(udi_nonce) DO UPDATE SET - status = excluded.status, - donau_base_url = excluded.donau_base_url, - proposal_id = excluded.proposal_id, - donation_year = excluded.donation_year, - donation_unit_pub_hash = excluded.donation_unit_pub_hash, - donation_unit_sig = excluded.donation_unit_sig, - donor_tax_id_hash = excluded.donor_tax_id_hash, - donor_hash_salt = excluded.donor_hash_salt, - donor_tax_id = excluded.donor_tax_id, - value = excluded.value, - udi_index = excluded.udi_index`, - { - nonce: crockToDb(rec.udiNonce), - status: rec.status, - url: rec.donauBaseUrl, - pid: rec.proposalId, - year: rec.donationYear, - duph: crockToDb(rec.donationUnitPubHash), - dus: jsonToDb(rec.donationUnitSig), - dtih: crockToDb(rec.donorTaxIdHash), - dhs: rec.donorHashSalt, - dti: rec.donorTaxId, - val: rec.value, - idx: rec.udiIndex, - }, - ); - } - - async getDonationReceiptsByStatus( - status: DonationReceiptStatus, - ): Promise<WalletDonationReceipt[]> { - const rows = await this.all( - "SELECT * FROM donation_receipts WHERE status = $s" + - " ORDER BY status, udi_nonce", - { s: status }, - ); - return rows.map((r) => this.rowToDonationReceipt(r)); - } - - async getDonationReceiptsByStatusAndDonau( - status: DonationReceiptStatus, - donauBaseUrl: string, - ): Promise<WalletDonationReceipt[]> { - const rows = await this.all( - "SELECT * FROM donation_receipts" + - " WHERE status = $s AND donau_base_url = $url", - { s: status, url: donauBaseUrl }, - ); - return rows.map((r) => this.rowToDonationReceipt(r)); - } - - // ----------------------------------------------------- currency info - - async getCurrencyInfo( - scopeInfo: ScopeInfo, - ): Promise<GetCurrencyInfoDbResult | undefined> { - const row = await this.first( - "SELECT * FROM currency_info WHERE scope_info_str = $s", - { s: stringifyScopeInfo(scopeInfo) }, - ); - if (!row) { - return undefined; - } - return { - currencySpec: dbToJson(row.currency_spec), - source: str(row.source) as GetCurrencyInfoDbResult["source"], - }; - } - - async upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void> { - await this.run( - "INSERT INTO currency_info (scope_info_str, currency_spec, source)" + - " VALUES ($s, $spec, $src)" + - " ON CONFLICT(scope_info_str) DO UPDATE SET" + - " currency_spec = excluded.currency_spec," + - " source = excluded.source", - { - s: stringifyScopeInfo(req.scopeInfo), - spec: jsonToDb(req.currencySpec), - src: req.source, - }, - ); - } - - async insertCurrencyInfoUnlessExists( - req: StoreCurrencyInfoDbRequest, - ): Promise<void> { - // OR IGNORE rather than read-then-write: the effect is the same and it - // cannot race with itself. - await this.run( - "INSERT OR IGNORE INTO currency_info" + - " (scope_info_str, currency_spec, source) VALUES ($s, $spec, $src)", - { - s: stringifyScopeInfo(req.scopeInfo), - spec: jsonToDb(req.currencySpec), - src: req.source, - }, - ); - } - - async deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void> { - await this.run("DELETE FROM currency_info WHERE scope_info_str = $s", { - s: stringifyScopeInfo(scopeInfo), - }); - } - - // --------------------------------------------------------- contacts - - async addContact(contact: ContactEntry): Promise<void> { - await this.run( - `INSERT INTO contacts ( - alias, alias_type, mailbox_base_uri, mailbox_address, source, petname - ) VALUES ($alias, $type, $uri, $addr, $src, $pet) - ON CONFLICT(alias, alias_type) DO UPDATE SET - mailbox_base_uri = excluded.mailbox_base_uri, - mailbox_address = excluded.mailbox_address, - source = excluded.source, - petname = excluded.petname`, - { - alias: contact.alias, - type: contact.aliasType, - uri: contact.mailboxBaseUri, - addr: contact.mailboxAddress, - src: contact.source, - pet: contact.petname, - }, - ); - } - - async deleteContact(alias: string, aliasType: string): Promise<void> { - await this.run( - "DELETE FROM contacts WHERE alias = $alias AND alias_type = $type", - { alias, type: aliasType }, - ); - } - - async listContacts(): Promise<ContactEntry[]> { - const rows = await this.all("SELECT * FROM contacts"); - return rows.map((row) => ({ - alias: str(row.alias), - aliasType: str(row.alias_type), - mailboxBaseUri: str(row.mailbox_base_uri), - mailboxAddress: str(row.mailbox_address), - source: str(row.source), - petname: str(row.petname), - })); - } - - // ---------------------------------------------------------- mailbox - - async upsertMailboxMessage(message: MailboxMessageRecord): Promise<void> { - await this.run( - "INSERT INTO mailbox_messages" + - " (origin_mailbox_base_url, taler_uri, downloaded_at)" + - " VALUES ($url, $uri, $at)" + - " ON CONFLICT(origin_mailbox_base_url, taler_uri) DO UPDATE SET" + - " downloaded_at = excluded.downloaded_at", - { - url: message.originMailboxBaseUrl, - uri: message.talerUri, - at: timestampProtocolToDb(message.downloadedAt), - }, - ); - } - - async deleteMailboxMessage( - originMailboxBaseUrl: string, - talerUri: string, - ): Promise<void> { - await this.run( - "DELETE FROM mailbox_messages" + - " WHERE origin_mailbox_base_url = $url AND taler_uri = $uri", - { url: originMailboxBaseUrl, uri: talerUri }, - ); - } - - async listMailboxMessages(): Promise<MailboxMessageRecord[]> { - const rows = await this.all("SELECT * FROM mailbox_messages"); - return rows.map((row) => ({ - originMailboxBaseUrl: str(row.origin_mailbox_base_url), - talerUri: str(row.taler_uri), - downloadedAt: timestampProtocolFromDb(dbTimestamp(row.downloaded_at)), - })); - } - - async listAllMailboxConfigurations(): Promise<MailboxConfiguration[]> { - const rows = await this.all("SELECT payload FROM mailbox_configurations"); - return rows.map((r) => dbToJson<MailboxConfiguration>(r.payload)); - } - - async getMailboxConfiguration( - mailboxBaseUrl: string, - ): Promise<MailboxConfiguration | undefined> { - const row = await this.first( - "SELECT * FROM mailbox_configurations WHERE mailbox_base_url = $url", - { url: mailboxBaseUrl }, - ); - return row ? dbToJson<MailboxConfiguration>(row.payload) : undefined; - } - - async upsertMailboxConfiguration( - mailboxConf: MailboxConfiguration, - ): Promise<void> { - await this.run( - "INSERT INTO mailbox_configurations (mailbox_base_url, payload)" + - " VALUES ($url, $p)" + - " ON CONFLICT(mailbox_base_url) DO UPDATE SET payload = excluded.payload", - { url: mailboxConf.mailboxBaseUrl, p: jsonToDb(mailboxConf) }, - ); - } - - // ------------------------------------------------- global currency - - async listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]> { - const rows = await this.all("SELECT * FROM global_currency_exchanges"); - return rows.map((row) => ({ - id: num(row.id), - currency: str(row.currency), - exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: dbToCrock(row.exchange_master_pub), - })); - } - - async upsertGlobalCurrencyExchange( - rec: WalletGlobalCurrencyExchange, - ): Promise<void> { - await this.run( - "INSERT OR IGNORE INTO global_currency_exchanges" + - " (currency, exchange_base_url, exchange_master_pub)" + - " VALUES ($cur, $url, $pub)", - { - cur: rec.currency, - url: rec.exchangeBaseUrl, - pub: crockToDb(rec.exchangeMasterPub), - }, - ); - } - - async deleteGlobalCurrencyExchange(id: number): Promise<void> { - await this.run("DELETE FROM global_currency_exchanges WHERE id = $id", { - id, - }); - } - - async getGlobalCurrencyExchange( - currency: string, - exchangeBaseUrl: string, - exchangeMasterPub: string, - ): Promise<WalletGlobalCurrencyExchange | undefined> { - const row = await this.first( - "SELECT * FROM global_currency_exchanges" + - " WHERE currency = $cur AND exchange_base_url = $url" + - " AND exchange_master_pub = $pub", - { - cur: currency, - url: exchangeBaseUrl, - pub: crockToDb(exchangeMasterPub), - }, - ); - if (!row) { - return undefined; - } - return { - id: num(row.id), - currency: str(row.currency), - exchangeBaseUrl: str(row.exchange_base_url), - exchangeMasterPub: dbToCrock(row.exchange_master_pub), - }; - } - - async listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]> { - const rows = await this.all("SELECT * FROM global_currency_auditors"); - return rows.map((row) => ({ - id: num(row.id), - currency: str(row.currency), - auditorBaseUrl: str(row.auditor_base_url), - auditorPub: dbToCrock(row.auditor_pub), - })); - } - - async upsertGlobalCurrencyAuditor( - rec: WalletGlobalCurrencyAuditor, - ): Promise<void> { - await this.run( - "INSERT OR IGNORE INTO global_currency_auditors" + - " (currency, auditor_base_url, auditor_pub)" + - " VALUES ($cur, $url, $pub)", - { - cur: rec.currency, - url: rec.auditorBaseUrl, - pub: crockToDb(rec.auditorPub), - }, - ); - } - - async deleteGlobalCurrencyAuditor(id: number): Promise<void> { - await this.run("DELETE FROM global_currency_auditors WHERE id = $id", { - id, - }); - } - - async getGlobalCurrencyAuditor( - currency: string, - auditorBaseUrl: string, - auditorPub: string, - ): Promise<WalletGlobalCurrencyAuditor | undefined> { - const row = await this.first( - "SELECT * FROM global_currency_auditors" + - " WHERE currency = $cur AND auditor_base_url = $url" + - " AND auditor_pub = $pub", - { cur: currency, url: auditorBaseUrl, pub: crockToDb(auditorPub) }, - ); - if (!row) { - return undefined; - } - return { - id: num(row.id), - currency: str(row.currency), - auditorBaseUrl: str(row.auditor_base_url), - auditorPub: dbToCrock(row.auditor_pub), - }; - } - - async checkExchangeInScope( - exchangeBaseUrl: string, - scope: ScopeInfo, - denomPubHash?: string, - ): Promise<boolean> { - return await checkExchangeInScopeGeneric( - this, - exchangeBaseUrl, - scope, - denomPubHash, - ); - } - - async getExchangeScopeInfo( - exchangeBaseUrl: string, - currency: string, - denomPubHash?: string, - ): Promise<ScopeInfo> { - return await getExchangeScopeInfoGeneric( - this, - exchangeBaseUrl, - currency, - denomPubHash, - ); - } - - // ---------------------------------------------------- bank accounts - - private rowToBankAccount(row: ResultRow): WalletBankAccount { - return { - bankAccountId: str(row.bank_account_id), - paytoUri: str(row.payto_uri), - label: optStr(row.label), - currencies: dbToOptJson(row.currencies), - kycCompleted: dbToBool(row.kyc_completed), - }; - } - - async listBankAccounts(): Promise<WalletBankAccount[]> { - const rows = await this.all("SELECT * FROM bank_accounts"); - return rows.map((r) => this.rowToBankAccount(r)); - } - - async getBankAccount( - bankAccountId: string, - ): Promise<WalletBankAccount | undefined> { - const row = await this.first( - "SELECT * FROM bank_accounts WHERE bank_account_id = $id", - { id: bankAccountId }, - ); - return row ? this.rowToBankAccount(row) : undefined; - } - - async getBankAccountByPaytoUri( - paytoUri: string, - ): Promise<WalletBankAccount | undefined> { - const row = await this.first( - "SELECT * FROM bank_accounts WHERE payto_uri = $uri", - { uri: paytoUri }, - ); - return row ? this.rowToBankAccount(row) : undefined; - } - - async upsertBankAccount(rec: WalletBankAccount): Promise<void> { - await this.run( - `INSERT INTO bank_accounts ( - bank_account_id, payto_uri, label, currencies, kyc_completed - ) VALUES ($id, $uri, $label, $cur, $kyc) - ON CONFLICT(bank_account_id) DO UPDATE SET - payto_uri = excluded.payto_uri, - label = excluded.label, - currencies = excluded.currencies, - kyc_completed = excluded.kyc_completed`, - { - id: rec.bankAccountId, - uri: rec.paytoUri, - label: rec.label ?? null, - cur: rec.currencies === undefined ? null : jsonToDb(rec.currencies), - kyc: boolToDb(rec.kycCompleted), - }, - ); - } - - async deleteBankAccount(bankAccountId: string): Promise<void> { - await this.run("DELETE FROM bank_accounts WHERE bank_account_id = $id", { - id: bankAccountId, - }); - } - - // ----------------------------------------------------------- tokens - - private rowToToken(row: ResultRow): WalletToken { - return { - 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: 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: dbToCrock(row.token_ev_hash), - blindingKey: dbToCrock(row.blinding_key), - slug: str(row.slug), - name: str(row.name), - description: str(row.description), - extraData: dbToJson(row.extra_data), - tokenIssuePub: dbToJson(row.token_issue_pub), - descriptionI18n: dbToOptJson(row.description_i18n), - ...(row.transaction_id != null - ? { transactionId: str(row.transaction_id) } - : undefined), - ...(row.choice_index != null - ? { choiceIndex: num(row.choice_index) } - : undefined), - ...(row.output_index != null - ? { outputIndex: num(row.output_index) } - : undefined), - ...(row.repeat_index != null - ? { repeatIndex: num(row.repeat_index) } - : undefined), - ...(row.token_family_hash != null - ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } - : undefined), - }; - } - - async listTokens(): Promise<WalletToken[]> { - const rows = await this.all("SELECT * FROM tokens"); - return rows.map((r) => this.rowToToken(r)); - } - - async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { - const row = await this.first( - "SELECT * FROM tokens WHERE token_use_pub = $pub", - { pub: crockToDb(tokenUsePub) }, - ); - return row ? this.rowToToken(row) : undefined; - } - - async upsertToken(rec: WalletToken): Promise<void> { - await this.run( - `INSERT INTO tokens ( - token_use_pub, token_use_priv, purchase_id, transaction_id, - choice_index, output_index, repeat_index, merchant_base_url, kind, - token_issue_pub_hash, token_family_hash, valid_after, valid_before, - token_issue_sig, - token_use_sig, token_ev, token_ev_hash, blinding_key, slug, name, - description, extra_data, token_issue_pub, description_i18n - ) VALUES ( - $pub, $priv, $pid, $tid, $ci, $oi, $ri, $url, $kind, $tiph, $tfh, - $va, $vb, $sig, $usig, $ev, $evh, $bk, $slug, $name, $desc, $extra, $tipub, $di18n - ) - ON CONFLICT(token_use_pub) DO UPDATE SET - token_use_priv = excluded.token_use_priv, - purchase_id = excluded.purchase_id, - transaction_id = excluded.transaction_id, - choice_index = excluded.choice_index, - output_index = excluded.output_index, - repeat_index = excluded.repeat_index, - merchant_base_url = excluded.merchant_base_url, - kind = excluded.kind, - token_issue_pub_hash = excluded.token_issue_pub_hash, - token_family_hash = excluded.token_family_hash, - valid_after = excluded.valid_after, - valid_before = excluded.valid_before, - token_issue_sig = excluded.token_issue_sig, - token_use_sig = excluded.token_use_sig, - token_ev = excluded.token_ev, - token_ev_hash = excluded.token_ev_hash, - blinding_key = excluded.blinding_key, - slug = excluded.slug, - name = excluded.name, - description = excluded.description, - extra_data = excluded.extra_data, - token_issue_pub = excluded.token_issue_pub, - description_i18n = excluded.description_i18n`, - { - pub: crockToDb(rec.tokenUsePub), - priv: crockToDb(rec.tokenUsePriv), - pid: rec.purchaseId, - tid: rec.transactionId ?? null, - ci: rec.choiceIndex ?? null, - oi: rec.outputIndex ?? null, - ri: rec.repeatIndex ?? null, - url: rec.merchantBaseUrl, - kind: rec.kind, - 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: crockToDb(rec.tokenEvHash), - bk: crockToDb(rec.blindingKey), - slug: rec.slug, - name: rec.name, - desc: rec.description, - extra: jsonToDb(rec.extraData), - tipub: jsonToDb(rec.tokenIssuePub), - di18n: - rec.descriptionI18n === undefined - ? null - : jsonToDb(rec.descriptionI18n), - }, - ); - } - - async deleteToken(tokenUsePub: string): Promise<void> { - await this.run("DELETE FROM tokens WHERE token_use_pub = $pub", { - pub: crockToDb(tokenUsePub), - }); - } - - async getTokensByIssuePubHash( - tokenIssuePubHash: string, - ): Promise<WalletToken[]> { - const rows = await this.all( - "SELECT * FROM tokens WHERE token_issue_pub_hash = $h", - { h: crockToDb(tokenIssuePubHash) }, - ); - return rows.map((r) => this.rowToToken(r)); - } - - async getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]> { - const rows = await this.all( - "SELECT * FROM tokens WHERE token_family_hash = $h", - { h: crockToDb(tokenFamilyHash) }, - ); - return rows.map((r) => this.rowToToken(r)); - } - - // ----------------------------------------------------------- slates - - private rowToSlate(row: ResultRow): WalletSlate { - return { - 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: 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: dbToCrock(row.token_ev_hash), - blindingKey: dbToCrock(row.blinding_key), - slug: str(row.slug), - name: str(row.name), - description: str(row.description), - extraData: dbToJson(row.extra_data), - tokenIssuePub: dbToJson(row.token_issue_pub), - descriptionI18n: dbToOptJson(row.description_i18n), - ...(row.transaction_id != null - ? { transactionId: str(row.transaction_id) } - : undefined), - ...(row.choice_index != null - ? { choiceIndex: num(row.choice_index) } - : undefined), - ...(row.output_index != null - ? { outputIndex: num(row.output_index) } - : undefined), - ...(row.repeat_index != null - ? { repeatIndex: num(row.repeat_index) } - : undefined), - ...(row.token_family_hash != null - ? { tokenFamilyHash: dbToCrock(row.token_family_hash) } - : undefined), - }; - } - - async listAllSlates(): Promise<WalletSlate[]> { - const rows = await this.all("SELECT * FROM slates"); - return rows.map((r) => this.rowToSlate(r)); - } - - async getSlate( - purchaseId: string, - choiceIndex: number, - outputIndex: number, - repeatIndex: number, - ): Promise<WalletSlate | undefined> { - const row = await this.first( - "SELECT * FROM slates" + - " WHERE purchase_id = $pid AND choice_index = $ci" + - " AND output_index = $oi AND repeat_index = $ri", - { pid: purchaseId, ci: choiceIndex, oi: outputIndex, ri: repeatIndex }, - ); - return row ? this.rowToSlate(row) : undefined; - } - - async getSlatesByPurchaseAndChoice( - purchaseId: string, - choiceIndex: number, - ): Promise<WalletSlate[]> { - const rows = await this.all( - "SELECT * FROM slates WHERE purchase_id = $pid AND choice_index = $ci", - { pid: purchaseId, ci: choiceIndex }, - ); - return rows.map((r) => this.rowToSlate(r)); - } - - async upsertSlate(rec: WalletSlate): Promise<void> { - await this.run( - `INSERT INTO slates ( - token_use_pub, token_use_priv, purchase_id, transaction_id, - choice_index, output_index, repeat_index, merchant_base_url, kind, - token_issue_pub_hash, token_family_hash, valid_after, valid_before, - token_use_sig, token_ev, token_ev_hash, blinding_key, slug, name, - description, extra_data, token_issue_pub, description_i18n - ) VALUES ( - $pub, $priv, $pid, $tid, $ci, $oi, $ri, $url, $kind, $tiph, $tfh, - $va, $vb, $usig, $ev, $evh, $bk, $slug, $name, $desc, $extra, $tipub, $di18n - ) - ON CONFLICT(token_use_pub) DO UPDATE SET - token_use_priv = excluded.token_use_priv, - purchase_id = excluded.purchase_id, - transaction_id = excluded.transaction_id, - choice_index = excluded.choice_index, - output_index = excluded.output_index, - repeat_index = excluded.repeat_index, - merchant_base_url = excluded.merchant_base_url, - kind = excluded.kind, - token_issue_pub_hash = excluded.token_issue_pub_hash, - token_family_hash = excluded.token_family_hash, - valid_after = excluded.valid_after, - valid_before = excluded.valid_before, - token_use_sig = excluded.token_use_sig, - token_ev = excluded.token_ev, - token_ev_hash = excluded.token_ev_hash, - blinding_key = excluded.blinding_key, - slug = excluded.slug, - name = excluded.name, - description = excluded.description, - extra_data = excluded.extra_data, - token_issue_pub = excluded.token_issue_pub, - description_i18n = excluded.description_i18n`, - { - pub: crockToDb(rec.tokenUsePub), - priv: crockToDb(rec.tokenUsePriv), - pid: rec.purchaseId, - tid: rec.transactionId ?? null, - ci: rec.choiceIndex ?? null, - oi: rec.outputIndex ?? null, - ri: rec.repeatIndex ?? null, - url: rec.merchantBaseUrl, - kind: rec.kind, - 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: crockToDb(rec.tokenEvHash), - bk: crockToDb(rec.blindingKey), - slug: rec.slug, - name: rec.name, - desc: rec.description, - extra: jsonToDb(rec.extraData), - tipub: jsonToDb(rec.tokenIssuePub), - di18n: - rec.descriptionI18n === undefined - ? null - : jsonToDb(rec.descriptionI18n), - }, - ); - } - - async deleteSlate(tokenUsePub: string): Promise<void> { - await this.run("DELETE FROM slates WHERE token_use_pub = $pub", { - pub: crockToDb(tokenUsePub), - }); - } - - // -------------------------------------------------- refresh sessions - - private rowToRefreshSession(row: ResultRow): WalletRefreshSession { - return { - refreshGroupId: str(row.refresh_group_id), - coinIndex: num(row.coin_index), - amountRefreshOutput: dbAmount(row.amount_refresh_output), - newDenoms: dbToJson(row.new_denoms), - ...(row.session_public_seed != null - ? { sessionPublicSeed: dbToCrock(row.session_public_seed) } - : undefined), - ...(row.refresh_protocol_version != null - ? { refreshProtocolVersion: num(row.refresh_protocol_version) } - : undefined), - ...(row.noreveal_index != null - ? { norevealIndex: num(row.noreveal_index) } - : undefined), - ...(row.last_error != null - ? { lastError: dbToJson(row.last_error) } - : undefined), - }; - } - - async getRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<WalletRefreshSession | undefined> { - const row = await this.first( - "SELECT * FROM refresh_sessions" + - " WHERE refresh_group_id = $id AND coin_index = $idx", - { id: refreshGroupId, idx: coinIndex }, - ); - return row ? this.rowToRefreshSession(row) : undefined; - } - - async upsertRefreshSession(rec: WalletRefreshSession): Promise<void> { - await this.run( - `INSERT INTO refresh_sessions ( - refresh_group_id, coin_index, session_public_seed, - refresh_protocol_version, amount_refresh_output, new_denoms, - noreveal_index, last_error - ) VALUES ($id, $idx, $seed, $rpv, $amt, $nd, $nri, $err) - ON CONFLICT(refresh_group_id, coin_index) DO UPDATE SET - session_public_seed = excluded.session_public_seed, - refresh_protocol_version = excluded.refresh_protocol_version, - amount_refresh_output = excluded.amount_refresh_output, - new_denoms = excluded.new_denoms, - noreveal_index = excluded.noreveal_index, - last_error = excluded.last_error`, - { - id: rec.refreshGroupId, - idx: rec.coinIndex, - seed: optCrockToDb(rec.sessionPublicSeed), - rpv: rec.refreshProtocolVersion ?? null, - amt: rec.amountRefreshOutput, - nd: jsonToDb(rec.newDenoms), - nri: rec.norevealIndex ?? null, - err: rec.lastError === undefined ? null : jsonToDb(rec.lastError), - }, - ); - } - - async deleteRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<void> { - await this.run( - "DELETE FROM refresh_sessions" + - " WHERE refresh_group_id = $id AND coin_index = $idx", - { id: refreshGroupId, idx: coinIndex }, - ); - } - - async getRefreshSessionsByGroup( - refreshGroupId: string, - ): Promise<WalletRefreshSession[]> { - const rows = await this.all( - "SELECT * FROM refresh_sessions WHERE refresh_group_id = $id" + - " ORDER BY coin_index", - { id: refreshGroupId }, - ); - return rows.map((r) => this.rowToRefreshSession(r)); - } - - async listAllRefreshSessions(): Promise<WalletRefreshSession[]> { - const rows = await this.all("SELECT * FROM refresh_sessions"); - return rows.map((r) => this.rowToRefreshSession(r)); - } - - // ----------------------------------------------------- recoup groups - - private rowToRecoupGroup(row: ResultRow): WalletRecoupGroup { - return { - recoupGroupId: str(row.recoup_group_id), - exchangeBaseUrl: str(row.exchange_base_url), - operationStatus: num(row.operation_status), - timestampStarted: dbTimestamp(row.timestamp_started), - timestampFinished: - row.timestamp_finished == null - ? undefined - : dbTimestamp(row.timestamp_finished), - coinPubs: dbToJson(row.coin_pubs), - recoupFinishedPerCoin: dbToJson(row.recoup_finished_per_coin), - scheduleRefreshCoins: dbToJson(row.schedule_refresh_coins), - }; - } - - async listAllRecoupGroups(): Promise<WalletRecoupGroup[]> { - const rows = await this.all("SELECT * FROM recoup_groups"); - return rows.map((r) => this.rowToRecoupGroup(r)); - } - - async getRecoupGroup( - recoupGroupId: string, - ): Promise<WalletRecoupGroup | undefined> { - const row = await this.first( - "SELECT * FROM recoup_groups WHERE recoup_group_id = $id", - { id: recoupGroupId }, - ); - return row ? this.rowToRecoupGroup(row) : undefined; - } - - async upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void> { - await this.run( - `INSERT INTO recoup_groups ( - recoup_group_id, exchange_base_url, operation_status, - timestamp_started, timestamp_finished, coin_pubs, - recoup_finished_per_coin, schedule_refresh_coins - ) VALUES ($id, $url, $status, $started, $finished, $pubs, $fin, $sched) - ON CONFLICT(recoup_group_id) DO UPDATE SET - exchange_base_url = excluded.exchange_base_url, - operation_status = excluded.operation_status, - timestamp_started = excluded.timestamp_started, - timestamp_finished = excluded.timestamp_finished, - coin_pubs = excluded.coin_pubs, - recoup_finished_per_coin = excluded.recoup_finished_per_coin, - schedule_refresh_coins = excluded.schedule_refresh_coins`, - { - id: rec.recoupGroupId, - url: rec.exchangeBaseUrl, - status: rec.operationStatus, - started: rec.timestampStarted, - finished: rec.timestampFinished ?? null, - pubs: jsonToDb(rec.coinPubs), - fin: jsonToDb(rec.recoupFinishedPerCoin), - sched: jsonToDb(rec.scheduleRefreshCoins), - }, - ); - } - - async deleteRecoupGroup(recoupGroupId: string): Promise<void> { - await this.run("DELETE FROM recoup_groups WHERE recoup_group_id = $id", { - id: recoupGroupId, - }); - } - - async getRecoupGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletRecoupGroup[]> { - const rows = await this.all( - "SELECT * FROM recoup_groups WHERE exchange_base_url = $url", - { url: exchangeBaseUrl }, - ); - return rows.map((r) => this.rowToRecoupGroup(r)); - } - - async getActiveRecoupGroups(): Promise<WalletRecoupGroup[]> { - const rows = await this.all( - "SELECT * FROM recoup_groups" + - " WHERE operation_status BETWEEN $lo AND $hi" + - " ORDER BY operation_status, recoup_group_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToRecoupGroup(r)); - } - - // ------------------------------------------------- remaining actives - - async getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]> { - const rows = await this.all( - "SELECT * FROM peer_pull_credit WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, purse_pub", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToPeerPullCredit(r)); - } - - async getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]> { - const rows = await this.all( - "SELECT * FROM peer_pull_debit WHERE status BETWEEN $lo AND $hi" + - " ORDER BY status, peer_pull_debit_id", - { - lo: OPERATION_STATUS_NONFINAL_FIRST, - hi: OPERATION_STATUS_NONFINAL_LAST, - }, - ); - return rows.map((r) => this.rowToPeerPullDebit(r)); - } - - // ------------------------------------------------------ diagnostics - - async getRecordCounts(): Promise<WalletDbRecordCounts> { - const count = async (table: string): Promise<number> => { - const row = await this.first(`SELECT COUNT(*) AS n FROM ${table}`); - return num(row?.n); - }; - return { - coins: await count("coins"), - coinAvailability: await count("coin_availability"), - denominations: await count("denominations"), - denominationFamilies: await count("denomination_families"), - exchanges: await count("exchanges"), - exchangeDetails: await count("exchange_details"), - exchangeSignKeys: await count("exchange_sign_keys"), - }; - } - - // ==================================================================== - // Not implemented yet. - // - // These exist so the class can satisfy WalletDbTransaction without a cast: - // the alternative is asserting the type, which would make a missing method - // a runtime "not a function" instead of a named error. Each throws, so a - // caller reaching one fails loudly and says which method it wanted. The - // conformance suite reports them as skipped rather than passing. - // ==================================================================== -} - -/** - * A native sqlite wallet database, ready to run transactions against. - * - * Holds the connection together with its prepared transaction-control - * statements, because those must be prepared once per connection and must not - * go through `exec` (see {@link SqliteTxControl}). - */ -/** - * Rows handed back by the statement layer, counted per connection. - * - * Exists so a test can assert that a query is *bounded* rather than scanning. - * Counting callbacks is not enough: a backend can materialise a whole range - * and still invoke a predicate once, which is exactly the regression the - * denomination keyset scan is guarded against. - */ -export interface SqliteAccessStats { - rowsRead: number; -} - -export interface NativeSqliteWalletDb { - db: Sqlite3Database; - txc: SqliteTxControl; - /** Cumulative across transactions on this connection. */ - stats: SqliteAccessStats; - /** Shared across transactions; see SqliteWalletTransaction.stmtCache. */ - stmtCache: Map<string, Sqlite3Statement>; - /** - * Serialises transactions on this connection. - * - * One sqlite connection can only have one transaction open at a time, so - * two overlapping callers produce "cannot start a transaction within a - * transaction". IndexedDB does not have this problem because its - * scheduler queues transactions; this queue is the equivalent, and keeps - * the DAL contract ("runWalletDbTx runs f in its own transaction") true - * for concurrent callers. - */ - lock: TxQueue; -} - -/** - * A minimal FIFO async mutex. - * - * Deliberately not reentrant: a transaction opened while another is already - * held on the same connection is a bug in the caller, and blocking makes it - * visible instead of silently merging two transactions into one — where a - * rollback of the inner would discard the outer's writes. - */ -export class TxQueue { - private tail: Promise<void> = Promise.resolve(); - - /** - * Transactions queued but not finished, including the running one. - * - * Used to detect the moment the queue drains, which is when the database - * can be checkpointed without stalling anybody. - */ - private outstanding = 0; - - /** - * True when the caller is the only transaction in the queue. - * - * Checked from inside a running transaction, which is still counted in - * `outstanding` at that point -- the decrement happens in this class's - * finally block, after the transaction body returns. So "nobody else is - * waiting" is 1, not 0. - */ - get noOtherWaiting(): boolean { - return this.outstanding <= 1; - } - - async run<T>(f: () => Promise<T>): Promise<T> { - this.outstanding++; - const prev = this.tail; - let release: () => void; - this.tail = new Promise<void>((resolve) => { - release = resolve; - }); - await prev; - try { - return await f(); - } finally { - this.outstanding--; - release!(); - } - } -} - -/** - * Open a native sqlite wallet database and bring its schema up to date. - */ -export async function openNativeSqliteWalletDb( - db: Sqlite3Database, -): Promise<NativeSqliteWalletDb> { - await initSqliteWalletDb(db); - const txc = await SqliteTxControl.create(db); - return { - db, - txc, - lock: new TxQueue(), - stmtCache: new Map(), - stats: { rowsRead: 0 }, - }; -} - -/** - * Run f in one native sqlite transaction. - * - * Notifications and commit hooks are released only after COMMIT succeeds: a - * transaction that rolls back must not have told anyone it happened. - */ -export async function runNativeSqliteWalletTx<T>( - ndb: NativeSqliteWalletDb, - notifyFn: (n: WalletNotification) => void, - f: (tx: SqliteWalletTransaction) => Promise<T>, -): Promise<T> { - return await ndb.lock.run(() => - runNativeSqliteWalletTxLocked(ndb, notifyFn, f), - ); -} - -async function runNativeSqliteWalletTxLocked<T>( - ndb: NativeSqliteWalletDb, - notifyFn: (n: WalletNotification) => void, - f: (tx: SqliteWalletTransaction) => Promise<T>, -): Promise<T> { - const tx = new SqliteWalletTransaction(ndb.db, ndb.stmtCache, ndb.stats); - await ndb.txc.begin(); - let res: T; - try { - res = await f(tx); - } catch (e) { - // Best-effort rollback: if ROLLBACK itself fails, the original error is - // the interesting one and must not be masked by it. - try { - await ndb.txc.rollback(); - } catch (rollbackErr) { - logger.warn(`rollback failed: ${rollbackErr}`); - } - throw e; - } - await ndb.txc.commit(); - await checkpointIfIdle(ndb); - // Same order as the IndexedDB backend: the handlers run before a client - // can observe the notification. - for (const h of tx.afterCommitHandlers) { - h(); - } - for (const notif of tx.pendingNotifications) { - notifyFn(notif); - } - return res; -} - -/** - * Fold the write-ahead log back into the database file, if nothing else is - * queued. - * - * This is what keeps the database file self-contained between operations, so - * that copying it is a valid snapshot (see the WAL note in - * {@link initSqliteWalletDb}). Skipped whenever another transaction is - * waiting: under load the log is allowed to grow, which is the point of WAL. - * - * TRUNCATE rather than PASSIVE so the -wal ends up zero-length rather than - * merely folded in; a leftover non-empty -wal next to a restored database - * file is the failure mode this exists to prevent. - * - * Best-effort: a failed checkpoint costs a stale snapshot, not correctness, - * and must not fail the transaction that already committed. - */ -async function checkpointIfIdle(ndb: NativeSqliteWalletDb): Promise<void> { - if (!ndb.lock.noOtherWaiting) { - return; - } - try { - await ndb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); - } catch (e) { - logger.warn(`WAL checkpoint failed: ${e}`); - } -} - -/** - * Delete every row from the native wallet database. - * - * The schema itself is kept, including schema_migrations: this clears data, - * it does not reset the database to "never initialised". - */ -export async function clearNativeSqliteWalletDb( - ndb: NativeSqliteWalletDb, -): Promise<void> { - await ndb.lock.run(async () => { - await ndb.txc.begin(); - try { - await clearNativeSqliteWalletDbInTransaction(ndb); - await ndb.txc.commit(); - } catch (e) { - try { - await ndb.txc.rollback(); - } catch (rollbackErr) { - logger.warn(`rollback failed: ${rollbackErr}`); - } - throw e; - } - }); -} - -/** Delete native wallet rows inside a transaction already owned by caller. */ -export async function clearNativeSqliteWalletDbInTransaction( - ndb: NativeSqliteWalletDb, -): Promise<void> { - const rows = await ( - await ndb.db.prepare( - `SELECT name FROM sqlite_master WHERE ${DATA_TABLES_CONDITION}`, - ) - ).getAll(); - for (const row of rows) { - // Table names come from sqlite_master, not from user input. - await (await ndb.db.prepare(`DELETE FROM "${row.name}"`)).run({}); - } -} - -/** - * Names of the tables holding wallet data, in a stable order. - * - * See DATA_TABLES_CONDITION for what is left out and why. - */ -async function listDataTables(ndb: NativeSqliteWalletDb): Promise<string[]> { - const rows = await ( - await ndb.db.prepare( - `SELECT name FROM sqlite_master WHERE ${DATA_TABLES_CONDITION}` + - ` ORDER BY name`, - ) - ).getAll(); - return rows.map((r) => r.name as string); -} - -/** - * A dump of every row in the native wallet database. - * - * Deliberately a plain row dump rather than a file copy so that it can be - * represented as JSON, like an IndexedDB export. - */ -export interface NativeSqliteDbDump { - schemaVersion: number; - tables: Record<string, Record<string, DumpValue>[]>; -} - -/** - * A BLOB column in a dump. - * - * Tagged rather than raw bytes because the dump must be JSON-compatible; - * typed arrays otherwise lose their type when serialized. - */ -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( - ndb: NativeSqliteWalletDb, -): Promise<NativeSqliteDbDump> { - return await ndb.lock.run(async () => { - const tables = await listDataTables(ndb); - const out: NativeSqliteDbDump = { - schemaVersion: SQLITE_SCHEMA_VERSION, - tables: {}, - }; - for (const table of tables) { - const rows = await ( - await ndb.db.prepare(`SELECT * FROM "${table}"`) - ).getAll(); - out.tables[table] = rows.map((row) => { - const clean: Record<string, DumpValue> = {}; - for (const [k, v] of Object.entries(row)) { - 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; - }); - } - return out; - }); -} - -export async function importNativeSqliteDb( - ndb: NativeSqliteWalletDb, - dump: NativeSqliteDbDump, - finalize: (tx: WalletDbTransaction) => Promise<void>, - notifyFn: (n: WalletNotification) => void, -): Promise<void> { - if (dump.schemaVersion !== SQLITE_SCHEMA_VERSION) { - throw Error( - `cannot import a native wallet DB dump of schema version` + - ` ${dump.schemaVersion} into version ${SQLITE_SCHEMA_VERSION}`, - ); - } - await ndb.lock.run(async () => { - const tables = await listDataTables(ndb); - await runNativeSqliteWalletTxLocked(ndb, notifyFn, async (tx) => { - // Clear and refill in one transaction: a partial import would leave - // the wallet with a mix of two databases. - for (const table of tables) { - await (await ndb.db.prepare(`DELETE FROM "${table}"`)).run({}); - } - for (const table of tables) { - const rows = dump.tables[table]; - if (!rows || rows.length === 0) { - continue; - } - for (const row of rows) { - const cols = Object.keys(row); - const params: Record<string, Sqlite3Value> = {}; - for (const c of cols) { - 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(", "); - await ( - await ndb.db.prepare( - `INSERT INTO "${table}" (${colList}) VALUES (${valList})`, - ) - ).run(params); - } - } - // Derived wallet state is part of the restore. Running this before the - // same COMMIT means an error cannot expose imported records with stale - // or absent materialized transactions. - await finalize(tx); - }); - }); -} diff --git a/packages/taler-wallet-core/src/dbtx.test.ts b/packages/taler-wallet-core/src/dbtx.test.ts @@ -1,158 +0,0 @@ -/* - 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/> - */ - -/** - * Runs the WalletDbTransaction conformance suite against every available - * implementation. - * - * Adding the sqlite3 implementation means adding one runner here; the cases - * themselves do not change. - */ - -import { BridgeIDBFactory, createSqliteBackend } from "@gnu-taler/idb-bridge"; -import { CancellationToken } from "@gnu-taler/taler-util"; -import { createNodeHelperSqlite3Impl } from "@gnu-taler/idb-bridge/node-helper-sqlite3-impl"; -import assert from "node:assert"; -import { test } from "node:test"; -import { Logger } from "@gnu-taler/taler-util"; - -import { openTalerDatabase, WalletIndexedDbStoresV1 } from "./db-indexeddb.js"; -import { DbAccessImpl } from "./query.js"; -import { conformanceCases } from "./dbtx-conformance-cases.js"; -import { ConformanceAsserts } from "./dbtx-conformance.js"; -import { runnerFactories } from "./dbtx-runners.js"; -import { IdbWalletTransaction } from "./dbtx-indexeddb.js"; -import { - initSqliteWalletDb, - SqliteTxControl, - SqliteWalletTransaction, -} from "./dbtx-sqlite.js"; -import { ConfigRecordKey } from "./db-common.js"; - -const logger = new Logger("dbtx.test.ts"); - -const asserts: ConformanceAsserts = { - equal: (a, e, m) => assert.strictEqual(a, e, m), - deepEqual: (a, e, m) => assert.deepStrictEqual(a, e, m), - ok: (v, m) => assert.ok(v, m), - fail: (m) => assert.fail(m), -}; - -/** - * A case is reported as skipped, not failed, when the implementation under - * test has not reached that method yet. - */ -function isNotImplemented(e: unknown): boolean { - if (!(e instanceof Error)) return false; - // Either an explicit NotImplementedError, or the method simply does not - // exist on the partial implementation yet. - return ( - /is not implemented yet/.test(e.message) || - /tx\.\w+ is not a function/.test(e.message) - ); -} - -for (const makeRunner of runnerFactories) { - for (const c of conformanceCases) { - test(`dbtx conformance: ${c.name}`, async (t) => { - const runner = await makeRunner(); - try { - await c.run(asserts, runner); - } catch (e) { - if (isNotImplemented(e)) { - t.skip(`not implemented in ${runner.name}`); - return; - } - throw e; - } finally { - await runner.close(); - } - }); - } -} - -for (const makeRunner of runnerFactories) { - test(`dbtx ${makeRunner.name}: notification sink exceptions do not fail committed work`, async () => { - const runner = await makeRunner(); - try { - runner.setNotificationSink(() => { - throw Error("host notification failure"); - }); - await runner.runReadWriteTx(async (tx) => { - await tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 123, - }); - tx.notify({ type: "balance-change" } as any); - }); - const record = await runner.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ); - assert.strictEqual(record?.value, 123); - } finally { - await runner.close(); - } - }); - - test(`dbtx ${makeRunner.name}: import and finalizer are atomic`, async () => { - const source = await makeRunner(); - const target = await makeRunner(); - try { - await source.runReadWriteTx((tx) => - tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 2, - }), - ); - const dump = await source.exportDatabase(); - await target.runReadWriteTx((tx) => - tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 1, - }), - ); - - await assert.rejects( - target.importDatabase(dump, async (tx) => { - await tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 3, - }); - throw Error("injected finalizer failure"); - }), - /injected finalizer failure/, - ); - const afterFailure = await target.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ); - assert.strictEqual(afterFailure?.value, 1); - - await target.importDatabase(dump, async (tx) => { - await tx.upsertConfig({ - key: ConfigRecordKey.TestLoopTx, - value: 3, - }); - }); - const afterSuccess = await target.runReadWriteTx((tx) => - tx.getConfig(ConfigRecordKey.TestLoopTx), - ); - assert.strictEqual(afterSuccess?.value, 3); - } finally { - await source.close(); - await target.close(); - } - }); -} diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -1,1294 +0,0 @@ -/* - 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/> - */ - -/** - * Backend-neutral data access layer for the wallet database. - * - * This file must only contain the {@link WalletDbTransaction} interface and the - * request/result types it uses. It must not depend on any storage backend: - * the IndexedDB implementation lives in dbtx-indexeddb.ts, and a sqlite3 - * implementation will be added alongside it. - * - * Types crossing this interface belong in db-common.ts and are named - * Wallet<Name>. The <Name>Record types in db-indexeddb.ts describe the - * IndexedDB object stores and must not appear here. - * - * This file imports nothing from db-indexeddb.ts, not even as types, and - * emits an empty JS module. - */ - -import { - ContactEntry, - CurrencySpecification, - MailboxConfiguration, - MailboxMessageRecord, - ScopeInfo, - TransactionIdStr, - WalletNotification, -} from "@gnu-taler/taler-util"; -import { - ConfigRecord, - WalletPeerPullCredit, - WalletPeerPushDebit, - WalletPeerPushCredit, - WalletPeerPullDebit, - WalletToken, - WalletSlate, - WalletDenomination, - WalletTransactionMeta, - WalletTransactionMetaCursor, - DbPreciseTimestamp, - DbProtocolTimestamp, - WalletOperationRetry, - WalletContractTerms, - DenominationVerificationStatus, - WalletCoinAvailability, - WalletCoinHistory, - WalletCoin, - WalletDepositGroup, - WalletRecoupGroup, - PurchaseStatus, - WalletReserve, - WalletRefreshGroup, - WalletRefreshSession, - WalletWithdrawalGroup, - WalletPlanchet, - WalletDonationSummary, - WalletDonationReceipt, - WalletDonationPlanchet, - DonationReceiptStatus, - WalletPurchase, - WalletRefundGroup, - WalletRefundItem, - WalletTombstone, - WalletExchangeEntry, - WalletDenomLossEvent, - WalletExchangeSignkeys, - WalletDenomFamilyParams, - WalletDenominationFamily, - WalletExchangeBaseUrlFixup, - WalletExchangeMigrationLog, - WalletGlobalCurrencyExchange, - WalletGlobalCurrencyAuditor, - WalletBankAccount, - WalletExchangeDetails, -} from "./db-common.js"; -/** - * A currency info record with its storage key. - * - * The scope string is kept opaque: stringifyScopeInfo has no exact inverse - * (parseScopeInfoShort reads a different format), so anything that needs to - * enumerate and re-store these records -- the database converter -- must - * round-trip the key without interpreting it. - */ -export interface WalletCurrencyInfoEntry { - scopeInfoStr: string; - currencySpec: CurrencySpecification; - source: "exchange" | "user" | "preset"; -} - -export interface GetCurrencyInfoDbResult { - /** - * Currency specification. - */ - currencySpec: CurrencySpecification; - - /** - * How did the currency info get set? - */ - source: "exchange" | "user" | "preset"; -} - -export interface StoreCurrencyInfoDbRequest { - scopeInfo: ScopeInfo; - currencySpec: CurrencySpecification; - source: "exchange" | "user" | "preset"; -} - -/** - * Record counts for diagnostics. - * - * Named per entity rather than per object store, so the numbers mean the same - * thing whichever backend produced them. - */ -export interface WalletDbRecordCounts { - coins: number; - coinAvailability: number; - denominations: number; - denominationFamilies: number; - exchanges: number; - exchangeDetails: number; - exchangeSignKeys: number; -} - -/** - * What identifies one denomination to the wallet. - * - * Passed as an object rather than as positional strings on purpose. The - * exchange base URL and the denomination hash are both plain strings, so - * every signature that took them in a row accepted them in either order and - * accepted any other string besides -- a wrong argument was a runtime bug - * that looked like a lookup miss. Naming the fields makes it a compile - * error, which is what made moving the identifying field from the exchange's - * URL to the key that signed the denomination a mechanical change. - * - * `WalletCoin`, `WalletCoinAvailability` and `WalletDenomination` all satisfy - * this structurally, so a caller that holds one of those records passes it - * directly. - */ -export interface WalletDenomRef { - exchangeMasterPub: string; - denomPubHash: string; -} - -/** A denomination together with an age restriction, keying availability. */ -export interface WalletCoinAvailabilityRef extends WalletDenomRef { - maxAge: number; -} - -/** Stores participating in backend conversion, named independently of layout. */ -export type WalletDbMigrationStore = - | "config" - | "currencyInfo" - | "contacts" - | "mailboxMessages" - | "mailboxConfigurations" - | "contractTerms" - | "tombstones" - | "operationRetries" - | "bankAccounts" - | "globalCurrencyExchanges" - | "globalCurrencyAuditors" - | "exchangeBaseUrlFixups" - | "exchangeBaseUrlMigrationLog" - | "reserves" - | "exchanges" - | "exchangeDetails" - | "exchangeSignKeys" - | "denominationFamilies" - | "denominations" - | "withdrawalGroups" - | "purchases" - | "refreshGroups" - | "coins" - | "planchets" - | "refreshSessions" - | "coinHistory" - | "coinAvailability" - | "refundGroups" - | "tokens" - | "slates" - | "depositGroups" - | "recoupGroups" - | "denomLossEvents" - | "peerPushDebit" - | "peerPushCredit" - | "peerPullDebit" - | "peerPullCredit" - | "donationSummaries" - | "donationPlanchets" - | "donationReceipts" - | "transactionsMeta" - | "refundItems"; - -export interface WalletDbMigrationPage<T> { - records: T[]; - /** Backend-private continuation token. Absent after an empty page. */ - nextCursor?: unknown; -} - -export interface WalletDbTransaction { - /** - * Read a bounded page for database conversion. - * - * `read` is the ordinary DAL enumeration used to construct records on the - * native backend. IndexedDB can scan its object store directly; the store - * name tells it which physical store corresponds to the neutral entity. - */ - scanMigrationRecords<T>( - store: WalletDbMigrationStore, - read: (tx: WalletDbTransaction) => Promise<T[]>, - cursor: unknown | undefined, - limit: number, - ): Promise<WalletDbMigrationPage<T>>; - - /** Get the currency specification for a scope, if one is stored. */ - getCurrencyInfo( - scopeInfo: ScopeInfo, - ): Promise<GetCurrencyInfoDbResult | undefined>; - - /** Get a config record by key. The result type narrows to the key. */ - getConfig<T extends ConfigRecord["key"]>( - key: T, - ): Promise<Extract<ConfigRecord, { key: T }> | undefined>; - - /** Create or update a config record. */ - upsertConfig(record: ConfigRecord): Promise<void>; - - /** List every config record. */ - listAllConfig(): Promise<ConfigRecord[]>; - - /** - * Store currency info for a scope. - * - * Overrides existing currency infos. - */ - upsertCurrencyInfo(req: StoreCurrencyInfoDbRequest): Promise<void>; - - /** List every currency info record with its storage key. */ - listAllCurrencyInfo(): Promise<WalletCurrencyInfoEntry[]>; - - /** - * Store a currency info record under an existing storage key. - * - * For the converter; regular code uses upsertCurrencyInfo, which derives - * the key from a ScopeInfo. - */ - upsertCurrencyInfoEntry(entry: WalletCurrencyInfoEntry): Promise<void>; - - /** Store currency info for a scope, keeping any existing entry. */ - insertCurrencyInfoUnlessExists( - req: StoreCurrencyInfoDbRequest, - ): Promise<void>; - - /** Create or update a contact, keyed by (alias, aliasType). */ - addContact(contact: ContactEntry): Promise<void>; - - /** Delete a contact by alias and alias type. */ - deleteContact(alias: string, aliasType: string): Promise<void>; - - /** List all stored contacts. */ - listContacts(): Promise<ContactEntry[]>; - - /** Create or update a mailbox message. */ - upsertMailboxMessage(message: MailboxMessageRecord): Promise<void>; - - /** Delete a mailbox message by origin mailbox and taler URI. */ - deleteMailboxMessage( - originMailboxBaseUrl: string, - talerUri: string, - ): Promise<void>; - - /** List all stored mailbox messages. */ - listMailboxMessages(): Promise<MailboxMessageRecord[]>; - - /** Get the configuration for a mailbox, if one is stored. */ - getMailboxConfiguration( - mailboxBaseUrl: string, - ): Promise<MailboxConfiguration | undefined>; - - /** Create or update a mailbox configuration. */ - upsertMailboxConfiguration(mailboxConf: MailboxConfiguration): Promise<void>; - - /** List every mailbox configuration. */ - listAllMailboxConfigurations(): Promise<MailboxConfiguration[]>; - - /** Get a purchase by proposal ID. */ - getPurchase(proposalId: string): Promise<WalletPurchase | undefined>; - - /** - * Create or update the transaction metadata for a transaction. - * - * The transactionsMeta store is a materialized view over the individual - * transaction stores, used to list transactions efficiently. - */ - upsertTransactionMeta(rec: WalletTransactionMeta): Promise<void>; - - /** - * Look up the locally assigned identifiers for transaction IDs in one - * batch. Backends without efficient local identifiers return an empty map. - */ - getLocalTransactionIdentifiers( - transactionIds: string[], - ): Promise<Map<string, string>>; - - /** Resolve one local transaction identifier, scoped by transaction type. */ - getTransactionIdByLocalIdentifier( - transactionType: string, - localIdent: string, - ): Promise<string | undefined>; - - /** - * Delete the transaction metadata for a transaction. - * - * Called when the underlying transaction record no longer exists. - */ - deleteTransactionMeta(transactionId: string): Promise<void>; - - /** - * Get the transaction metadata for a transaction. - */ - getTransactionMeta( - transactionId: string, - ): Promise<WalletTransactionMeta | undefined>; - - /** - * Get the transaction metadata at exactly the given timestamp, if any. - */ - getTransactionMetaAtTimestamp( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined>; - - /** - * Get the latest transaction metadata at or before the given timestamp. - * - * Used to resolve a pagination offset whose transaction has been deleted. - */ - getTransactionMetaBefore( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined>; - - /** - * Get the earliest transaction metadata at or after the given timestamp. - */ - getTransactionMetaAfter( - timestamp: DbPreciseTimestamp, - ): Promise<WalletTransactionMeta | undefined>; - - /** - * List transaction metadata ordered by timestamp ascending. - * - * If afterTimestamp is given, only records strictly after it are returned. - * If limit is given, at most that many records are returned. - */ - listTransactionMetaByTimestamp(req: { - afterTimestamp?: DbPreciseTimestamp; - limit?: number; - }): Promise<WalletTransactionMeta[]>; - - /** List a bounded page in stable (timestamp, transactionId) order. */ - listTransactionMetaPage(req: { - cursor?: WalletTransactionMetaCursor; - direction: "forward" | "backward"; - limit: number; - }): Promise<WalletTransactionMeta[]>; - - /** - * List all transaction metadata, optionally restricted to transactions in a - * non-final ("active") state. - */ - listTransactionMetaByStatus(req: { - onlyActive: boolean; - }): Promise<WalletTransactionMeta[]>; - - /** - * Delete all transaction metadata. - * - * Used when re-materializing the transactionsMeta view from the underlying - * transaction stores. - */ - deleteAllTransactionMeta(): Promise<void>; - - /** - * Get the retry state of a task, if the task has been retried before. - */ - getOperationRetry(taskId: string): Promise<WalletOperationRetry | undefined>; - - /** - * Create or update the retry state of a task. - */ - upsertOperationRetry(rec: WalletOperationRetry): Promise<void>; - - /** List the retry state of every task that has one. */ - listAllOperationRetries(): Promise<WalletOperationRetry[]>; - - /** - * Clear the retry state of a task. - */ - deleteOperationRetry(taskId: string): Promise<void>; - - /** - * Get downloaded contract terms by their hash. - */ - getContractTerms( - contractTermsHash: string, - ): Promise<WalletContractTerms | undefined>; - - /** - * Store downloaded contract terms. - */ - upsertContractTerms(rec: WalletContractTerms): Promise<void>; - - /** Count an exchange's withdrawal groups, to decide whether it is in use. */ - countWithdrawalGroupsByExchange(exchangeBaseUrl: string): Promise<number>; - - /** Get an exchange's withdrawal groups so their base URL can be rewritten. */ - getWithdrawalGroupsByExchangeForRekey( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]>; - - /** List every globally-trusted exchange entry. */ - listGlobalCurrencyExchanges(): Promise<WalletGlobalCurrencyExchange[]>; - - /** - * Add a globally-trusted exchange entry. The row id is generated. - * - * Entries are identified by (currency, exchange base URL, master public - * key), so adding one that is already stored does nothing. - */ - upsertGlobalCurrencyExchange( - rec: WalletGlobalCurrencyExchange, - ): Promise<void>; - - /** Remove a globally-trusted exchange entry by row id. */ - deleteGlobalCurrencyExchange(id: number): Promise<void>; - - /** List every globally-trusted auditor entry. */ - listGlobalCurrencyAuditors(): Promise<WalletGlobalCurrencyAuditor[]>; - - /** - * Add a globally-trusted auditor entry. The row id is generated. - * - * Entries are identified by (currency, auditor base URL, auditor public - * key), so adding one that is already stored does nothing. - */ - upsertGlobalCurrencyAuditor(rec: WalletGlobalCurrencyAuditor): Promise<void>; - - /** Remove a globally-trusted auditor entry by row id. */ - deleteGlobalCurrencyAuditor(id: number): Promise<void>; - - /** Delete the currency info stored for a scope. */ - deleteCurrencyInfo(scopeInfo: ScopeInfo): Promise<void>; - - /** Get the global-currency entry for an exchange, if it is trusted globally. */ - getGlobalCurrencyExchange( - currency: string, - exchangeBaseUrl: string, - exchangeMasterPub: string, - ): Promise<WalletGlobalCurrencyExchange | undefined>; - - /** Get the global-currency entry for an auditor, if it is trusted globally. */ - getGlobalCurrencyAuditor( - currency: string, - auditorBaseUrl: string, - auditorPub: string, - ): Promise<WalletGlobalCurrencyAuditor | undefined>; - - /** List every denomination-loss event, in any state. */ - listAllDenomLossEvents(): Promise<WalletDenomLossEvent[]>; - - /** - * Get up to `limit` fresh coins of a given denomination and age restriction. - */ - getFreshCoinsByDenomAndAge( - ref: WalletCoinAvailabilityRef, - limit: number, - ): Promise<WalletCoin[]>; - - /** - * Get coin availability for an exchange across an age-restriction band, - * restricted to denominations that have at least one fresh coin. - */ - getCoinAvailabilityByExchangeAndAgeRange( - exchangeBaseUrl: string, - ageLower: number, - ageUpper: number, - ): Promise<WalletCoinAvailability[]>; - - /** List every known bank account. */ - listBankAccounts(): Promise<WalletBankAccount[]>; - - /** Get a known bank account by its ID. */ - getBankAccount(bankAccountId: string): Promise<WalletBankAccount | undefined>; - - /** Delete a known bank account by its ID. */ - deleteBankAccount(bankAccountId: string): Promise<void>; - - /** Get a known bank account by its payto URI. */ - getBankAccountByPaytoUri( - paytoUri: string, - ): Promise<WalletBankAccount | undefined>; - - /** Create or update a known bank account. */ - upsertBankAccount(rec: WalletBankAccount): Promise<void>; - - /** Count stored records per entity, for diagnostics. */ - getRecordCounts(): Promise<WalletDbRecordCounts>; - - /** List every coin in the wallet. */ - listAllCoins(): Promise<WalletCoin[]>; - - /** Get all coins issued by an exchange. */ - getCoinsByExchange(exchangeBaseUrl: string): Promise<WalletCoin[]>; - - /** Count the coins issued by an exchange, to decide whether it is in use. */ - countCoinsByExchange(exchangeBaseUrl: string): Promise<number>; - - /** Get the coins of a given denomination. */ - getCoinsByDenomPubHash(denomPubHash: string): Promise<WalletCoin[]>; - - /** - * Get coins issued with any of the denomination hashes. Each matching coin - * is returned once even when a hash is repeated in the input. - */ - getCoinsByDenomPubHashes(denomPubHashes: string[]): Promise<WalletCoin[]>; - - /** Delete a coin by its public key. */ - deleteCoin(coinPub: string): Promise<void>; - - /** Delete the recorded history of a coin. */ - deleteCoinHistory(coinPub: string): Promise<void>; - - /** Get the coin availability records of an exchange. */ - getCoinAvailabilityByExchange( - exchangeBaseUrl: string, - ): Promise<WalletCoinAvailability[]>; - - /** Delete a coin availability record. */ - deleteCoinAvailability(ref: WalletCoinAvailabilityRef): Promise<void>; - - /** Get the recoup groups against an exchange. */ - getRecoupGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletRecoupGroup[]>; - - /** - * List every refresh group, in any state. - * - * Used by exchange purge and base-URL migration, which filter on - * infoPerExchange -- a field with no index. - */ - listAllRefreshGroups(): Promise<WalletRefreshGroup[]>; - - /** - * List every deposit group, in any state. As above, filtered on - * infoPerExchange by the caller. - */ - listAllDepositGroups(): Promise<WalletDepositGroup[]>; - - /** List every refund group, in any state. */ - listAllRefundGroups(): Promise<WalletRefundGroup[]>; - - /** List every withdrawal group, in any state. */ - listAllWithdrawalGroups(): Promise<WalletWithdrawalGroup[]>; - - /** List every purchase, in any state. */ - listAllPurchases(): Promise<WalletPurchase[]>; - - /** List every incoming peer pull payment, in any state. */ - listAllPeerPullCredits(): Promise<WalletPeerPullCredit[]>; - - /** List every outgoing peer pull payment, in any state. */ - listAllPeerPullDebits(): Promise<WalletPeerPullDebit[]>; - - /** List every incoming peer push payment, in any state. */ - listAllPeerPushCredits(): Promise<WalletPeerPushCredit[]>; - - /** List every outgoing peer push payment, in any state. */ - listAllPeerPushDebits(): Promise<WalletPeerPushDebit[]>; - - /** Look up a denomination family by its value and fee parameters. */ - getDenominationFamilyByParams( - params: WalletDenomFamilyParams, - ): Promise<WalletDenominationFamily | undefined>; - - /** - * Create or update a denomination family, returning its serial. - * - * The denominationFamilies store is auto-incrementing on - * denominationFamilySerial. - */ - upsertDenominationFamily(rec: WalletDenominationFamily): Promise<number>; - - /** Get the denomination families of an exchange. */ - getDenominationFamiliesByExchange( - exchangeBaseUrl: string, - ): Promise<WalletDenominationFamily[]>; - - /** Delete a denomination family by serial. */ - deleteDenominationFamily(denominationFamilySerial: number): Promise<void>; - - /** Get a pending base-URL fixup for an exchange, if one is recorded. */ - getExchangeBaseUrlFixup( - exchangeBaseUrl: string, - ): Promise<WalletExchangeBaseUrlFixup | undefined>; - - /** Record that an exchange base URL should be replaced. */ - upsertExchangeBaseUrlFixup(rec: WalletExchangeBaseUrlFixup): Promise<void>; - - /** List every pending base-URL fixup. */ - listAllExchangeBaseUrlFixups(): Promise<WalletExchangeBaseUrlFixup[]>; - - /** Get the log entry for a base-URL migration, if it has run. */ - getExchangeMigrationLog( - oldExchangeBaseUrl: string, - newExchangeBaseUrl: string, - ): Promise<WalletExchangeMigrationLog | undefined>; - - /** Record that a base-URL migration has run. */ - upsertExchangeMigrationLog(rec: WalletExchangeMigrationLog): Promise<void>; - - /** List every base-URL migration log entry. */ - listAllExchangeMigrationLogEntries(): Promise<WalletExchangeMigrationLog[]>; - - /** Get exchange details by the (baseUrl, currency, masterPub) pointer. */ - getExchangeDetailsByPointer( - exchangeBaseUrl: string, - currency: string, - masterPublicKey: string, - ): Promise<WalletExchangeDetails | undefined>; - - /** - * Get the exchange details record for a base URL, if there is exactly one. - */ - getExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined>; - - /** - * Get every exchange details record for a base URL. - */ - listExchangeDetailsByBaseUrl( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails[]>; - - /** - * Get every exchange details record signed by a master public key. - * - * More than one is possible: the same exchange can be known under two base - * URLs while a migration between them is still in progress. - */ - listExchangeDetailsByMasterPub( - masterPublicKey: string, - ): Promise<WalletExchangeDetails[]>; - - /** List every exchange details record, for all exchanges. */ - listAllExchangeDetails(): Promise<WalletExchangeDetails[]>; - - /** Get an exchange details record by its stable row identifier. */ - getExchangeDetailsByRowId( - rowId: number, - ): Promise<WalletExchangeDetails | undefined>; - - /** - * Create or update an exchange details record, returning its row id. - * - * The exchangeDetails store is auto-incrementing on rowId, and callers need - * the generated id to attach sign keys to it. - */ - upsertExchangeDetails(rec: WalletExchangeDetails): Promise<number>; - - /** Delete an exchange details record by row ID. */ - deleteExchangeDetails(rowId: number): Promise<void>; - - /** Get the signing keys attached to an exchange details record. */ - getExchangeSignKeysByDetailsRowId( - exchangeDetailsRowId: number, - ): Promise<WalletExchangeSignkeys[]>; - - /** List every exchange signing key, including orphaned legacy rows. */ - listAllExchangeSignKeys(): Promise<WalletExchangeSignkeys[]>; - - /** Create or update an exchange signing key. */ - upsertExchangeSignKey(rec: WalletExchangeSignkeys): Promise<void>; - - /** Delete an exchange signing key by details row ID and key. */ - deleteExchangeSignKey( - exchangeDetailsRowId: number, - signkeyPub: string, - ): Promise<void>; - - /** Get a denomination-loss event by ID. */ - getDenomLossEvent( - denomLossEventId: string, - ): Promise<WalletDenomLossEvent | undefined>; - - /** Create or update a denomination-loss event. */ - upsertDenomLossEvent(rec: WalletDenomLossEvent): Promise<void>; - - /** Delete a denomination-loss event by ID. */ - deleteDenomLossEvent(denomLossEventId: string): Promise<void>; - - /** Get an exchange entry by base URL. */ - getExchange(baseUrl: string): Promise<WalletExchangeEntry | undefined>; - - /** Create or update an exchange entry. */ - upsertExchange(rec: WalletExchangeEntry): Promise<void>; - - /** Delete an exchange entry by base URL. Does not touch related records. */ - deleteExchange(baseUrl: string): Promise<void>; - - /** Create or update a purchase. */ - upsertPurchase(rec: WalletPurchase): Promise<void>; - - /** Delete a purchase by proposal ID. */ - deletePurchase(proposalId: string): Promise<void>; - - /** Get the purchase for a merchant order, if there is exactly one. */ - getPurchaseByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase | undefined>; - - /** Get purchases for the requested proposal IDs, skipping missing IDs. */ - getPurchasesByIds(proposalIds: string[]): Promise<WalletPurchase[]>; - - /** Get every purchase for a merchant order, including repurchases. */ - getPurchasesByUrlAndOrderId( - merchantBaseUrl: string, - orderId: string, - ): Promise<WalletPurchase[]>; - - /** Get purchases sharing a fulfillment URL, used to detect repurchases. */ - getPurchasesByFulfillmentUrl( - fulfillmentUrl: string, - ): Promise<WalletPurchase[]>; - - /** Get purchases that involved a given exchange. */ - getPurchasesByExchange(exchangeBaseUrl: string): Promise<WalletPurchase[]>; - - /** Get a refund group by ID. */ - getRefundGroup(refundGroupId: string): Promise<WalletRefundGroup | undefined>; - - /** Create or update a refund group. */ - upsertRefundGroup(rec: WalletRefundGroup): Promise<void>; - - /** Delete a refund group by ID. */ - deleteRefundGroup(refundGroupId: string): Promise<void>; - - /** Get the refund groups belonging to a purchase. */ - getRefundGroupsByProposal(proposalId: string): Promise<WalletRefundGroup[]>; - - /** Get the refund items belonging to a refund group. */ - getRefundItemsByGroup(refundGroupId: string): Promise<WalletRefundItem[]>; - - /** List every refund item, including orphaned legacy rows. */ - listAllRefundItems(): Promise<WalletRefundItem[]>; - - /** - * Create or update a refund item, returning its row id. - * - * The refundItems store is auto-incrementing on id. - */ - upsertRefundItem(rec: WalletRefundItem): Promise<number>; - - /** Delete a refund item by row ID. */ - deleteRefundItem(id: number): Promise<void>; - - /** Get the refund item for a coin and merchant refund transaction ID. */ - getRefundItemByCoinAndRtxid( - coinPub: string, - rtxid: number, - ): Promise<WalletRefundItem | undefined>; - - /** - * Get a slate by purchase, choice, output and repeat index. - */ - getSlate( - purchaseId: string, - choiceIndex: number, - outputIndex: number, - repeatIndex: number, - ): Promise<WalletSlate | undefined>; - - /** Get the slates for a purchase and contract choice. */ - getSlatesByPurchaseAndChoice( - purchaseId: string, - choiceIndex: number, - ): Promise<WalletSlate[]>; - - /** Create or update a slate. */ - upsertSlate(rec: WalletSlate): Promise<void>; - - /** Delete a slate by its token use public key. */ - deleteSlate(tokenUsePub: string): Promise<void>; - - /** Record a tombstone, marking a deleted transaction as not to be revived. */ - upsertTombstone(rec: WalletTombstone): Promise<void>; - - /** List every tombstone. */ - listAllTombstones(): Promise<WalletTombstone[]>; - - /** Get the donation summary for a donau, year and currency. */ - getDonationSummary( - donauBaseUrl: string, - year: number, - currency: string, - ): Promise<WalletDonationSummary | undefined>; - - /** Create or update a donation summary. */ - upsertDonationSummary(rec: WalletDonationSummary): Promise<void>; - - /** Get a donation receipt by its unique donation identifier nonce. */ - getDonationReceipt( - udiNonce: string, - ): Promise<WalletDonationReceipt | undefined>; - - /** Create or update a donation receipt. */ - upsertDonationReceipt(rec: WalletDonationReceipt): Promise<void>; - - /** Get donation receipts in a given status. */ - getDonationReceiptsByStatus( - status: DonationReceiptStatus, - ): Promise<WalletDonationReceipt[]>; - - /** Get donation receipts in a given status for one donau. */ - getDonationReceiptsByStatusAndDonau( - status: DonationReceiptStatus, - donauBaseUrl: string, - ): Promise<WalletDonationReceipt[]>; - - /** Create or update a donation planchet. */ - upsertDonationPlanchet(rec: WalletDonationPlanchet): Promise<void>; - - /** Get the donation planchets of a purchase. */ - getDonationPlanchetsByProposal( - proposalId: string, - ): Promise<WalletDonationPlanchet[]>; - - /** Count the donation planchets of a purchase. */ - countDonationPlanchetsByProposal(proposalId: string): Promise<number>; - - /** Get a withdrawal group by ID. */ - getWithdrawalGroup( - withdrawalGroupId: string, - ): Promise<WalletWithdrawalGroup | undefined>; - - /** Create or update a withdrawal group. */ - upsertWithdrawalGroup(rec: WalletWithdrawalGroup): Promise<void>; - - /** Delete a withdrawal group by ID. */ - deleteWithdrawalGroup(withdrawalGroupId: string): Promise<void>; - - /** Get the withdrawal group for a taler-withdraw URI, for idempotent starts. */ - getWithdrawalGroupByTalerWithdrawUri( - talerWithdrawUri: string, - ): Promise<WalletWithdrawalGroup | undefined>; - - /** Get the withdrawal groups against an exchange. */ - getWithdrawalGroupsByExchange( - exchangeBaseUrl: string, - ): Promise<WalletWithdrawalGroup[]>; - - /** - * Get a planchet by its withdrawal group and coin index. - */ - getPlanchetByGroupAndIndex( - withdrawalGroupId: string, - coinIdx: number, - ): Promise<WalletPlanchet | undefined>; - - /** - * Get a planchet by its coin public key, which is the primary key. - */ - getPlanchet(coinPub: string): Promise<WalletPlanchet | undefined>; - - /** Create or update a planchet. */ - upsertPlanchet(rec: WalletPlanchet): Promise<void>; - - /** Delete a planchet by coin public key. */ - deletePlanchet(coinPub: string): Promise<void>; - - /** Get the planchets of a withdrawal group. */ - getPlanchetsByGroup(withdrawalGroupId: string): Promise<WalletPlanchet[]>; - - /** List every planchet, including orphaned legacy rows. */ - listAllPlanchets(): Promise<WalletPlanchet[]>; - - /** Count the planchets of a withdrawal group. */ - countPlanchetsByGroup(withdrawalGroupId: string): Promise<number>; - - /** - * Delete every planchet belonging to a withdrawal group. - */ - deletePlanchetsByGroup(withdrawalGroupId: string): Promise<void>; - - /** Get a refresh group by ID. */ - getRefreshGroup( - refreshGroupId: string, - ): Promise<WalletRefreshGroup | undefined>; - - /** Create or update a refresh group. */ - upsertRefreshGroup(rec: WalletRefreshGroup): Promise<void>; - - /** Delete a refresh group by ID. */ - deleteRefreshGroup(refreshGroupId: string): Promise<void>; - - /** Get the refresh groups spawned by a transaction. */ - getRefreshGroupsByOriginatingTransaction( - transactionId: string, - ): Promise<WalletRefreshGroup[]>; - - /** Get the refresh session for a group and coin index. */ - getRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<WalletRefreshSession | undefined>; - - /** Create or update a refresh session. */ - upsertRefreshSession(rec: WalletRefreshSession): Promise<void>; - - /** Delete the refresh session for a group and coin index. */ - deleteRefreshSession( - refreshGroupId: string, - coinIndex: number, - ): Promise<void>; - - /** Get the refresh sessions of a group. */ - getRefreshSessionsByGroup( - refreshGroupId: string, - ): Promise<WalletRefreshSession[]>; - - /** List every refresh session, including orphaned legacy rows. */ - listAllRefreshSessions(): Promise<WalletRefreshSession[]>; - - /** Get a recoup group by ID. */ - getRecoupGroup(recoupGroupId: string): Promise<WalletRecoupGroup | undefined>; - - /** Create or update a recoup group. */ - upsertRecoupGroup(rec: WalletRecoupGroup): Promise<void>; - - /** Delete a recoup group by ID. */ - deleteRecoupGroup(recoupGroupId: string): Promise<void>; - - /** - * Get a reserve by its row id. - */ - getReserve(reserveRowId: number): Promise<WalletReserve | undefined>; - - /** - * Get a reserve by its reserve public key. - */ - getReserveByReservePub( - reservePub: string, - ): Promise<WalletReserve | undefined>; - - /** Get reserves for the requested public keys, skipping missing keys. */ - getReservesByPubs(reservePubs: string[]): Promise<WalletReserve[]>; - - /** - * Create or update a reserve, returning its row id. - * - * The reserves store is auto-incrementing, and callers creating a new merge - * reserve need the generated id to reference it from the exchange entry. - */ - upsertReserve(rec: WalletReserve): Promise<number>; - - /** Get a deposit group by ID. */ - getDepositGroup( - depositGroupId: string, - ): Promise<WalletDepositGroup | undefined>; - - /** Create or update a deposit group. */ - upsertDepositGroup(rec: WalletDepositGroup): Promise<void>; - - /** Delete a deposit group by ID. */ - deleteDepositGroup(depositGroupId: string): Promise<void>; - - /** Get a coin by its public key. */ - getCoin(coinPub: string): Promise<WalletCoin | undefined>; - - /** Create or update a coin. */ - upsertCoin(coin: WalletCoin): Promise<void>; - - /** - * Get all coins whose source transaction is the given transaction. - */ - getCoinsBySourceTransaction(transactionId: string): Promise<WalletCoin[]>; - - /** Get the availability record for a denomination and age restriction. */ - getCoinAvailability( - ref: WalletCoinAvailabilityRef, - ): Promise<WalletCoinAvailability | undefined>; - - /** - * Get availability records for a list of denomination/age references. - * - * Found records follow input order, duplicates are preserved and missing - * references are skipped. - */ - getCoinAvailabilitiesByRefs( - refs: WalletCoinAvailabilityRef[], - ): Promise<WalletCoinAvailability[]>; - - /** Create or update a coin availability record. */ - upsertCoinAvailability(rec: WalletCoinAvailability): Promise<void>; - - /** Get the recorded history of a coin. */ - getCoinHistory(coinPub: string): Promise<WalletCoinHistory | undefined>; - - /** - * Get histories for a list of coin public keys, in input order, skipping - * missing records and preserving duplicates. - */ - getCoinHistoriesByPubs(coinPubs: string[]): Promise<WalletCoinHistory[]>; - - /** List every coin history, including orphaned legacy rows. */ - listAllCoinHistories(): Promise<WalletCoinHistory[]>; - - /** Create or update the recorded history of a coin. */ - upsertCoinHistory(rec: WalletCoinHistory): Promise<void>; - - /** - * List all stored wallet tokens. - */ - listTokens(): Promise<WalletToken[]>; - - /** List every slate. */ - listAllSlates(): Promise<WalletSlate[]>; - - /** - * Get a wallet token by its token use public key. - */ - getToken(tokenUsePub: string): Promise<WalletToken | undefined>; - - /** - * Create or update a wallet token. - */ - upsertToken(token: WalletToken): Promise<void>; - - /** - * Delete a wallet token by its token use public key. - */ - deleteToken(tokenUsePub: string): Promise<void>; - - /** - * Get all tokens matching a specific token issue public key hash. - */ - getTokensByIssuePubHash(tokenIssuePubHash: string): Promise<WalletToken[]>; - - /** Get all tokens belonging to a token family. */ - getTokensByFamilyHash(tokenFamilyHash: string): Promise<WalletToken[]>; - - /** - * Get an incoming peer pull payment (credit) record by purse public key. - */ - getPeerPullCredit( - pursePub: string, - ): Promise<WalletPeerPullCredit | undefined>; - - /** - * Create or update an incoming peer pull payment (credit) record. - */ - upsertPeerPullCredit(rec: WalletPeerPullCredit): Promise<void>; - - /** - * Delete an incoming peer pull payment (credit) record. - */ - deletePeerPullCredit(pursePub: string): Promise<void>; - - /** - * Get an outgoing peer push payment (debit) record by purse public key. - */ - getPeerPushDebit(pursePub: string): Promise<WalletPeerPushDebit | undefined>; - - /** - * Create or update an outgoing peer push payment (debit) record. - */ - upsertPeerPushDebit(rec: WalletPeerPushDebit): Promise<void>; - - /** - * Delete an outgoing peer push payment (debit) record. - */ - deletePeerPushDebit(pursePub: string): Promise<void>; - - /** - * Get an incoming peer push payment (credit) record by peer push credit ID. - */ - getPeerPushCredit( - peerPushCreditId: string, - ): Promise<WalletPeerPushCredit | undefined>; - - /** - * Create or update an incoming peer push payment (credit) record. - */ - upsertPeerPushCredit(rec: WalletPeerPushCredit): Promise<void>; - - /** - * Delete an incoming peer push payment (credit) record. - */ - deletePeerPushCredit(peerPushCreditId: string): Promise<void>; - - /** - * Get an incoming peer push payment (credit) record by exchange URL and contract private key. - */ - getPeerPushCreditByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPushCredit | undefined>; - - /** - * Get an incoming peer pull payment (debit) record by peer pull debit ID. - */ - getPeerPullDebit( - peerPullDebitId: string, - ): Promise<WalletPeerPullDebit | undefined>; - - /** - * Create or update an incoming peer pull payment (debit) record. - */ - upsertPeerPullDebit(rec: WalletPeerPullDebit): Promise<void>; - - /** - * Delete an incoming peer pull payment (debit) record. - */ - deletePeerPullDebit(peerPullDebitId: string): Promise<void>; - - /** - * Get an incoming peer pull payment (debit) record by exchange URL and contract private key. - */ - getPeerPullDebitByExchangeAndContractPriv( - exchangeBaseUrl: string, - contractPriv: string, - ): Promise<WalletPeerPullDebit | undefined>; - - /** Create or update a denomination. */ - upsertDenomination(rec: WalletDenomination): Promise<void>; - - /** Get a denomination by its reference. */ - getDenomination(ref: WalletDenomRef): Promise<WalletDenomination | undefined>; - - /** - * Get denominations for a list of references. Found records follow input - * order, duplicates are preserved and missing references are skipped. - */ - getDenominationsByRefs(refs: WalletDenomRef[]): Promise<WalletDenomination[]>; - - /** - * Find the first denomination of a family, scanning in withdraw-expiry order - * from the given timestamp, that satisfies the caller's predicate. - * - * The scan stops at the first match, so a family with many denominations - * normally costs a single record read. The predicate stays with the caller; - * only the ordered, early-terminating scan lives in the implementation. - */ - findDenominationByFamilyFromExpiry( - denominationFamilySerial: number, - minStampExpireWithdraw: DbProtocolTimestamp, - match: (d: WalletDenomination) => boolean, - ): Promise<WalletDenomination | undefined>; - - /** Get every denomination signed by a master public key. */ - getDenominationsByMasterPub( - exchangeMasterPub: string, - ): Promise<WalletDenomination[]>; - - /** Delete a denomination by its reference. */ - deleteDenomination(ref: WalletDenomRef): Promise<void>; - - /** Get denominations awaiting or failing signature verification. */ - getDenominationsByVerificationStatus( - verificationStatus: DenominationVerificationStatus, - ): Promise<WalletDenomination[]>; - - /** List all donation summaries. */ - getDonationSummaries(): Promise<WalletDonationSummary[]>; - - /** List all exchange entries. */ - getExchanges(): Promise<WalletExchangeEntry[]>; - - /** List all coin availability records. */ - getCoinAvailabilities(): Promise<WalletCoinAvailability[]>; - - /** List every reserve. */ - listAllReserves(): Promise<WalletReserve[]>; - - /** List every recoup group. */ - listAllRecoupGroups(): Promise<WalletRecoupGroup[]>; - - /** List every donation planchet. */ - listAllDonationPlanchets(): Promise<WalletDonationPlanchet[]>; - - /** List every donation receipt. */ - listAllDonationReceipts(): Promise<WalletDonationReceipt[]>; - - /** List every denomination family. */ - listAllDenominationFamilies(): Promise<WalletDenominationFamily[]>; - - /** List every denomination. */ - listAllDenominations(): Promise<WalletDenomination[]>; - - /** List every stored contract-terms record. */ - listAllContractTerms(): Promise<WalletContractTerms[]>; - - /** Get refresh groups in a non-final state. */ - getActiveRefreshGroups(): Promise<WalletRefreshGroup[]>; - - /** Get withdrawal groups in a non-final state. */ - getActiveWithdrawalGroups(): Promise<WalletWithdrawalGroup[]>; - - /** Get outgoing peer push payments in a non-final state. */ - getActivePeerPushDebits(): Promise<WalletPeerPushDebit[]>; - - /** Get incoming peer push payments in a non-final state. */ - getActivePeerPushCredits(): Promise<WalletPeerPushCredit[]>; - - /** Get incoming peer pull payments in a non-final state. */ - getActivePeerPullCredits(): Promise<WalletPeerPullCredit[]>; - - /** Get outgoing peer pull payments in a non-final state. */ - getActivePeerPullDebits(): Promise<WalletPeerPullDebit[]>; - - /** Get recoup groups in a non-final state. */ - getActiveRecoupGroups(): Promise<WalletRecoupGroup[]>; - - /** - * Get all purchases in a specific status. - */ - getPurchasesByStatus(status: PurchaseStatus): Promise<WalletPurchase[]>; - - /** Get purchases in a non-final state. */ - getActivePurchases(): Promise<WalletPurchase[]>; - - /** Get the coins for a list of public keys, skipping any that are missing. */ - getCoinsByPubs(coinPubs: string[]): Promise<WalletCoin[]>; - - /** Get deposit groups in a non-final state. */ - getActiveDepositGroups(): Promise<WalletDepositGroup[]>; - - /** Get the details currently pointed to by an exchange entry. */ - getExchangeDetails( - exchangeBaseUrl: string, - ): Promise<WalletExchangeDetails | undefined>; - - /** - * Check whether an exchange falls within a currency scope. - * - * For auditor scopes, a denomination hash requests exact membership. When - * omitted, the check is existential and is only suitable for candidate - * exchange filtering. - */ - checkExchangeInScope( - exchangeBaseUrl: string, - scope: ScopeInfo, - denomPubHash?: string, - ): Promise<boolean>; - - /** - * Compute the scope (global, auditor or exchange) for exchange funds. - * Auditor scope is returned only with a specifically attested denomination. - */ - getExchangeScopeInfo( - exchangeBaseUrl: string, - currency: string, - denomPubHash?: string, - ): Promise<ScopeInfo>; - - /** - * Run a callback once this transaction has committed. - * - * Used to trigger work that must not run inside the transaction, such as - * starting or stopping a shepherd task. - */ - scheduleOnCommit(f: () => void): void; - - /** - * Emit a wallet notification. - * - * Bound to the instance, so it is safe to pass around unbound. - */ - notify(notif: WalletNotification): void; -} diff --git a/packages/taler-wallet-core/src/denomSelection.ts b/packages/taler-wallet-core/src/denomSelection.ts @@ -35,8 +35,8 @@ import { import { DenominationVerificationStatus, timestampAbsoluteFromDb, -} from "./db-common.js"; -import { WalletDenomination } from "./db-common.js"; +} from "./db/records.js"; +import { WalletDenomination } from "./db/records.js"; import { isWithdrawableDenom } from "./denominations.js"; const logger = new Logger("denomSelection.ts"); diff --git a/packages/taler-wallet-core/src/denominations.ts b/packages/taler-wallet-core/src/denominations.ts @@ -36,8 +36,8 @@ import { DenominationVerificationStatus, timestampProtocolFromDb, WalletDenomination, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; /** diff --git a/packages/taler-wallet-core/src/deposits-performance.test.ts b/packages/taler-wallet-core/src/deposits-performance.test.ts @@ -21,7 +21,7 @@ import { DepositElementStatus, DepositOperationStatus, WalletDepositGroup, -} from "./db-common.js"; +} from "./db/records.js"; import { testing_applyDepositTrackingProgress, testing_applyKycRequiredTransition, diff --git a/packages/taler-wallet-core/src/deposits.test.ts b/packages/taler-wallet-core/src/deposits.test.ts @@ -28,7 +28,7 @@ import { RefreshCoinStatus, WalletDepositGroup, WalletRefreshGroup, -} from "./db-common.js"; +} from "./db/records.js"; import { classifyDepositAbortOutcome, computeDepositAbortAmountEffective, diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -113,9 +113,8 @@ import { timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, -} from "./db-common.js"; -import {} from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { ReadyExchangeSummary, fetchFreshExchange, @@ -133,7 +132,7 @@ import { } from "./exchange-signatures.js"; import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { SignContractTermsHashResponse } from "./crypto/cryptoTypes.js"; -import { WithdrawalGroupStatus } from "./db-common.js"; +import { WithdrawalGroupStatus } from "./db/records.js"; import { GenericKycStatusReq, checkDepositHardLimitExceeded, diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts @@ -67,8 +67,7 @@ import { WalletRefreshGroup, WithdrawalRecordType, WalletDenomLossEvent, -} from "./db-common.js"; -import {} from "./db-indexeddb.js"; +} from "./db/records.js"; import { DenomLossTransactionContext, fetchFreshExchange, diff --git a/packages/taler-wallet-core/src/donau.ts b/packages/taler-wallet-core/src/donau.ts @@ -61,7 +61,7 @@ import { DonationReceiptStatus, WalletDonationPlanchet, WalletDonationReceipt, -} from "./db-common.js"; +} from "./db/records.js"; import { WalletExecutionContext } from "./wallet.js"; /** diff --git a/packages/taler-wallet-core/src/exchange-master-pub.test.ts b/packages/taler-wallet-core/src/exchange-master-pub.test.ts @@ -42,9 +42,9 @@ import { WalletExchangeDetails, WalletExchangeEntry, timestampPreciseToDb, -} from "./db-common.js"; -import { DbTxRunner } from "./dbtx-conformance.js"; -import { runnerFactories } from "./dbtx-runners.js"; +} from "./db/records.js"; +import { DbTxRunner } from "./db/testing/conformance.js"; +import { runnerFactories } from "./db/testing/runners.js"; import { getExchangeBaseUrlForMasterPub, getExchangeBaseUrlForMasterPubOrThrow, diff --git a/packages/taler-wallet-core/src/exchange-signatures.test.ts b/packages/taler-wallet-core/src/exchange-signatures.test.ts @@ -23,7 +23,7 @@ import { } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { timestampProtocolToDb, WalletExchangeSignkeys } from "./db-common.js"; +import { timestampProtocolToDb, WalletExchangeSignkeys } from "./db/records.js"; import { exchangeSigningKeyIsUsable, requireValidDirectExchangeRefundConfirmation, diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts @@ -33,7 +33,7 @@ import { import { timestampProtocolFromDb, WalletExchangeSignkeys, -} from "./db-common.js"; +} from "./db/records.js"; import { WalletExecutionContext } from "./wallet.js"; const signingTimeTolerance = Duration.fromSpec({ hours: 1 }); diff --git a/packages/taler-wallet-core/src/exchanges.test.ts b/packages/taler-wallet-core/src/exchanges.test.ts @@ -16,7 +16,7 @@ import assert from "node:assert"; import { test } from "node:test"; -import { WalletCoin } from "./db-common.js"; +import { WalletCoin } from "./db/records.js"; import { filterCoinsByExchangeMasterPub, makeWireAccountValidationRequest, diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -148,6 +148,7 @@ import { DbProtocolTimestamp, DenomLossStatus, DenominationVerificationStatus, + ExchangeMigrationReason, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ReserveRecordStatus, @@ -167,9 +168,8 @@ import { timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, -} from "./db-common.js"; -import { ExchangeMigrationReason } from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { createTimeline, isCandidateWithdrawableDenomRec, @@ -386,10 +386,7 @@ export async function getScopeForAllCoins( const denom = denomsByRef.get(denomRefKey(coin)); const details = detailsByUrl.get(coin.exchangeBaseUrl); let scope: ScopeInfo; - if ( - details && - coin.exchangeMasterPub !== details.masterPublicKey - ) { + if (details && coin.exchangeMasterPub !== details.masterPublicKey) { scope = { type: ScopeType.ExchangeLegacyKeys, currency: denom?.currency ?? details.currency, @@ -3138,9 +3135,7 @@ export function filterCoinsByExchangeMasterPub( coins: WalletCoin[], exchangeMasterPub: string, ): WalletCoin[] { - return coins.filter( - (coin) => coin.exchangeMasterPub === exchangeMasterPub, - ); + return coins.filter((coin) => coin.exchangeMasterPub === exchangeMasterPub); } async function handleDenomLoss( diff --git a/packages/taler-wallet-core/src/host-impl.node.ts b/packages/taler-wallet-core/src/host-impl.node.ts @@ -41,9 +41,9 @@ import { DefaultNodeWalletArgs, getSqlite3FilenameFromStoragePath, } from "./host-common.js"; -import { openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; +import { openNativeSqliteWalletDb } from "./db/sqlite/database.js"; import { Wallet } from "./wallet.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; +import { WalletDbHandle } from "./db/handle.js"; import { dropExpiredMigrationBackup, inspectWalletDbFile, @@ -55,9 +55,10 @@ import { restoreMigrationBackup, resolveAmbiguousWalletDb, WalletDbFileKind, -} from "./db-native-migration.js"; +} from "./db/migration/native.js"; import * as fs from "node:fs"; -import { IdbWalletDbHandle, SqliteWalletDbHandle } from "./dbtx-handle-impl.js"; +import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; +import { SqliteWalletDbHandle } from "./db/sqlite/handle.js"; const logger = new Logger("host-impl.node.ts"); diff --git a/packages/taler-wallet-core/src/host-impl.qtart.ts b/packages/taler-wallet-core/src/host-impl.qtart.ts @@ -46,16 +46,17 @@ import { DefaultNodeWalletArgs, getSqlite3FilenameFromStoragePath, } from "./host-common.js"; -import { exportDb } from "./db-indexeddb.js"; +import { exportDb } from "./db/indexeddb/dump.js"; import { dropExpiredMigrationBackup, inspectWalletDbFile, migrateWalletDbToNative, -} from "./db-native-migration.js"; -import { openNativeSqliteWalletDb } from "./dbtx-sqlite.js"; +} from "./db/migration/native.js"; +import { openNativeSqliteWalletDb } from "./db/sqlite/database.js"; import { Wallet } from "./wallet.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; -import { IdbWalletDbHandle, SqliteWalletDbHandle } from "./dbtx-handle-impl.js"; +import { WalletDbHandle } from "./db/handle.js"; +import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; +import { SqliteWalletDbHandle } from "./db/sqlite/handle.js"; const logger = new Logger("host-impl.qtart.ts"); diff --git a/packages/taler-wallet-core/src/index.node.ts b/packages/taler-wallet-core/src/index.node.ts @@ -24,13 +24,13 @@ export * from "./crypto/workers/synchronousWorkerFactoryPlain.js"; // Storage-layer benchmark. Node-only: the runners spawn the sqlite helper // process, so this must not reach the browser entry point. -export * from "./dbtx-bench.js"; -export { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js"; +export * from "./db/testing/benchmark.js"; +export { makeIdbRunner, makeSqliteRunner } from "./db/testing/runners.js"; // The record-by-record copy between backends, which the in-place migration // runs. Node-only: the runners spawn the sqlite helper process. -export { convertWalletDb } from "./db-converter.js"; -export type { DbConversionReport } from "./db-converter.js"; +export { convertWalletDb } from "./db/migration/converter.js"; +export type { DbConversionReport } from "./db/migration/converter.js"; // In-place migration to the native schema. Inspecting and rolling one back // works on a file, so these are node-only too; performing the migration is @@ -43,5 +43,5 @@ export { export type { NativeMigrationInfo, WalletDbFileKind, -} from "./db-native-migration.js"; -export type { DbTxRunner } from "./dbtx-conformance.js"; +} from "./db/migration/native.js"; +export type { DbTxRunner } from "./db/testing/conformance.js"; diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts @@ -38,16 +38,13 @@ export { parseTransactionIdentifier } from "./transactions.js"; export { createPairTimeline } from "./denominations.js"; // FIXME: Should these really be exported?! -export { WithdrawalGroupStatus } from "./db-common.js"; -export { - deleteTalerDatabase, - exportDb, - importDb, - WalletIndexedDbStoresV1 as WalletStoresV1, -} from "./db-indexeddb.js"; - -export { DbAccess } from "./query.js"; -export { WalletDbHandle } from "./dbtx-handle.js"; -export { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; +export { WithdrawalGroupStatus } from "./db/records.js"; +export { deleteTalerDatabase } from "./db/indexeddb/database.js"; +export { exportDb, importDb } from "./db/indexeddb/dump.js"; +export { WalletIndexedDbStoresV1 as WalletStoresV1 } from "./db/indexeddb/schema.js"; + +export { DbAccess } from "./db/query.js"; +export { WalletDbHandle } from "./db/handle.js"; +export { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; export { TaskRunResult, TaskRunResultType } from "./common.js"; diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -29,8 +29,8 @@ import { checkDbInvariant, strcmp, } from "@gnu-taler/taler-util"; -import { timestampProtocolFromDb } from "./db-common.js"; -import { WalletDenomination } from "./db-common.js"; +import { timestampProtocolFromDb } from "./db/records.js"; +import { WalletDenomination } from "./db/records.js"; import { getAllDenominationsForExchange, getExchangeDetailsInTx, diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -34,7 +34,7 @@ import { DbPreciseTimestamp, timestampAbsoluteFromDb, timestampPreciseToDb, -} from "./db-common.js"; +} from "./db/records.js"; import { ReadyExchangeSummary } from "./exchanges.js"; import { WalletExecutionContext } from "./wallet.js"; import { walletExchangeClient } from "./wallet.js"; diff --git a/packages/taler-wallet-core/src/observable-wrappers.ts b/packages/taler-wallet-core/src/observable-wrappers.ts @@ -36,7 +36,7 @@ import { DbReadWriteTransaction, StoreMap, StoreNames, -} from "./query.js"; +} from "./db/query.js"; import { TaskScheduler } from "./shepherd.js"; /** diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -34,8 +34,8 @@ import { WalletRefundGroup, WalletSlate, WalletToken, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { applyFirstPaySuccessState, computePayMerchantTransactionActions, @@ -55,7 +55,7 @@ import { storeFirstPaySuccess, validateClaimResponseBindings, } from "./pay-merchant.js"; -import { makeIdbRunner } from "./dbtx-runners.js"; +import { makeIdbRunner } from "./db/testing/runners.js"; function makeSelectedCoin( coinPub: string, diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -138,6 +138,7 @@ import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { ConfigRecordKey, PurchaseStatus, + RefundReason, RefreshOperationStatus, RefundGroupStatus, RefundItemStatus, @@ -155,9 +156,8 @@ import { WalletRefundItem, WalletSlate, WalletToken, -} from "./db-common.js"; -import { RefundReason } from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { acceptDonauBlindSigs, generateDonauPlanchets } from "./donau.js"; import { getExchangeScopeInfoOrUndefined, diff --git a/packages/taler-wallet-core/src/pay-paivana.ts b/packages/taler-wallet-core/src/pay-paivana.ts @@ -34,7 +34,7 @@ import { getRandomBytes, } from "@gnu-taler/taler-util"; import { readTalerErrorResponse } from "@gnu-taler/taler-util/http"; -import { PurchaseStatus } from "./db-common.js"; +import { PurchaseStatus } from "./db/records.js"; import { computePayMerchantTransactionState, createOrReusePurchase, diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -24,9 +24,9 @@ import { TalerProtocolTimestamp, checkDbInvariant, } from "@gnu-taler/taler-util"; -import { WalletReserve } from "./db-common.js"; +import { WalletReserve } from "./db/records.js"; import { SpendCoinDetails } from "./crypto/cryptoImplementation.js"; -import { DbPeerPushPaymentCoinSelection } from "./db-indexeddb.js"; +import { DbPeerPushPaymentCoinSelection } from "./db/indexeddb/schema.js"; import { markExchangeUsed, requireExchangeCoinUseConfirmedOrThrow, @@ -38,7 +38,7 @@ import { WalletExecutionContext, } from "./wallet.js"; import { updateWithdrawalDenomsForExchange } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; /** * Get information about the coin selected for signatures. diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts @@ -25,8 +25,8 @@ import { test } from "node:test"; import { PeerPullPaymentCreditStatus, WalletPeerPullCredit, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { computePeerPullCreditTransactionActions, computePeerPullCreditTransactionState, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -76,9 +76,8 @@ import { WithdrawalRecordType, timestampPreciseFromDb, timestampPreciseToDb, -} from "./db-common.js"; -import {} from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { requireValidExchangePurseDepositConfirmation, requireValidExchangePurseStatus, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.test.ts @@ -17,7 +17,7 @@ import { Amounts } from "@gnu-taler/taler-util"; import assert from "node:assert/strict"; import { test } from "node:test"; -import { PeerPullPaymentCoinSelection } from "./db-common.js"; +import { PeerPullPaymentCoinSelection } from "./db/records.js"; import { getPeerPullDebitRemainder, getPeerPullDebitUnconfirmedCoins, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -87,7 +87,7 @@ import { WalletPeerPullDebit, timestampPreciseFromDb, timestampPreciseToDb, -} from "./db-common.js"; +} from "./db/records.js"; import { getExchangeScopeInfo, getScopeForAllExchanges, @@ -114,7 +114,7 @@ import { parseTransactionIdentifier, } from "./transactions.js"; import { WalletExecutionContext, walletExchangeClient } from "./wallet.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { requireValidExchangePurseDepositConfirmation, requireValidExchangePurseStatus, diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.test.ts @@ -21,7 +21,7 @@ import { } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { PeerPushCreditStatus, WalletPeerPushCredit } from "./db-common.js"; +import { PeerPushCreditStatus, WalletPeerPushCredit } from "./db/records.js"; import { computePeerPushCreditTransactionActions, computePeerPushCreditTransactionState, diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -79,8 +79,8 @@ import { WalletOperationRetry, WalletWithdrawalGroup, WithdrawalRecordType, -} from "./db-common.js"; -import { WalletIndexedDbTransaction } from "./db-indexeddb.js"; +} from "./db/records.js"; +import { WalletIndexedDbTransaction } from "./db/indexeddb/schema.js"; import { BalanceThresholdCheckResult, checkIncomingAmountLegalUnderKycBalanceThreshold, @@ -123,7 +123,7 @@ import { internalPrepareCreateWithdrawalGroup, waitWithdrawalFinal, } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { requireValidExchangePurseStatus } from "./exchange-signatures.js"; const logger = new Logger("pay-peer-push-credit.ts"); diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts @@ -5,7 +5,7 @@ import { TalerProtocolTimestamp, setGlobalLogLevelFromString, } from "@gnu-taler/taler-util"; -import { PeerPushDebitStatus } from "./db-common.js"; +import { PeerPushDebitStatus } from "./db/records.js"; import { PeerPushDebitTransactionContext, decodePeerPushDebitQuote, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -87,8 +87,8 @@ import { timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { requireValidExchangePurseDepositConfirmation, requireValidExchangePurseStatus, diff --git a/packages/taler-wallet-core/src/preset-exchanges.ts b/packages/taler-wallet-core/src/preset-exchanges.ts @@ -16,7 +16,7 @@ import { Logger } from "@gnu-taler/taler-util"; import { builtinExchanges } from "./builtin-exchanges.js"; -import { ConfigRecordKey } from "./db-common.js"; +import { ConfigRecordKey } from "./db/records.js"; import { putPresetExchangeEntry } from "./exchanges.js"; import { WalletExecutionContext } from "./wallet.js"; diff --git a/packages/taler-wallet-core/src/recoup.test.ts b/packages/taler-wallet-core/src/recoup.test.ts @@ -15,7 +15,7 @@ */ import assert from "node:assert"; import { test } from "node:test"; -import { WalletRecoupGroup } from "./db-common.js"; +import { WalletRecoupGroup } from "./db/records.js"; import { scheduleRecoupRefresh } from "./recoup.js"; test("recoup-refresh schedules and aggregates value on the old coin", () => { diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -49,6 +49,7 @@ import { } from "./common.js"; import { RecoupOperationStatus, + CoinSourceType, WithdrawalGroupStatus, timestampPreciseToDb, WalletCoin, @@ -56,8 +57,7 @@ import { WalletWithdrawCoinSource, WalletRecoupGroup, WithdrawalRecordType, -} from "./db-common.js"; -import { CoinSourceType } from "./db-indexeddb.js"; +} from "./db/records.js"; import { requireExchangeCoinUseConfirmedOrThrow } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { @@ -70,7 +70,7 @@ import { walletExchangeClient, } from "./wallet.js"; import { internalCreateWithdrawalGroup } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; const logger = new Logger("operations/recoup.ts"); diff --git a/packages/taler-wallet-core/src/refresh.test.ts b/packages/taler-wallet-core/src/refresh.test.ts @@ -29,7 +29,7 @@ import { RefreshTransactionContext, requireValidNorevealIndex, } from "./refresh.js"; -import { RefreshOperationStatus, WalletRefreshGroup } from "./db-common.js"; +import { RefreshOperationStatus, WalletRefreshGroup } from "./db/records.js"; import { WalletExecutionContext } from "./wallet.js"; test("melt noreveal index must be an integer inside kappa", () => { diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -95,6 +95,7 @@ import { RefreshNewDenomInfo } from "./crypto/cryptoTypes.js"; import { CryptoApiStoppedError } from "./crypto/workers/crypto-dispatcher.js"; import { RefreshCoinStatus, + CoinSourceType, RefreshOperationStatus, timestampPreciseFromDb, timestampPreciseToDb, @@ -106,8 +107,7 @@ import { WalletRefreshGroupPerExchangeInfo, WalletRefreshGroup, WalletRefreshSession, -} from "./db-common.js"; -import { CoinSourceType } from "./db-indexeddb.js"; +} from "./db/records.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; import { fetchFreshExchange, @@ -133,7 +133,7 @@ import { getWithdrawableDenomsTx, updateWithdrawalDenomsForExchange, } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; /** Maximum number of new coins. */ const maxRefreshSessionSize = 64; diff --git a/packages/taler-wallet-core/src/requests.test.ts b/packages/taler-wallet-core/src/requests.test.ts @@ -38,9 +38,9 @@ import { WalletCoin, WalletCoinAvailability, timestampPreciseToDb, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; -import { makeIdbRunner, makeSqliteRunner } from "./dbtx-runners.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; +import { makeIdbRunner, makeSqliteRunner } from "./db/testing/runners.js"; import { markExchangeAddedByUser } from "./exchanges.js"; import { SynchronousCryptoWorkerFactoryPlain } from "./crypto/workers/synchronousWorkerFactoryPlain.js"; import { @@ -295,11 +295,7 @@ test("unsuspending a coin is idempotent and only revives suspended coins", async } const suspended = makeCoinSuspensionContext(CoinStatus.FreshSuspended, 0); - await setCoinSuspended( - suspended.wex, - suspended.coin.coinPub, - false, - ); + await setCoinSuspended(suspended.wex, suspended.coin.coinPub, false); assert.strictEqual(suspended.coin.status, CoinStatus.Fresh); assert.strictEqual(suspended.availability.freshCoinCount, 1); }); diff --git a/packages/taler-wallet-core/src/requests.ts b/packages/taler-wallet-core/src/requests.ts @@ -280,11 +280,12 @@ import { import { addContact, deleteContact, listContacts } from "./contacts.js"; import { ConfigRecordKey, + CoinSourceType, timestampAbsoluteFromDb, timestampProtocolToDb, -} from "./db-common.js"; -import { CoinSourceType, walletDbFixups } from "./db-indexeddb.js"; -import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; +} from "./db/records.js"; +import { walletDbFixups } from "./db/indexeddb/fixups.js"; +import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; import { isCandidateWithdrawableDenomRec, isWithdrawableDenom, diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -58,8 +58,8 @@ import { timestampAbsoluteFromDb, timestampPreciseToDb, WalletOperationRetry, -} from "./db-common.js"; -import { WalletIndexedDbTransaction } from "./db-indexeddb.js"; +} from "./db/records.js"; +import { WalletIndexedDbTransaction } from "./db/indexeddb/schema.js"; import { processValidateDenoms } from "./denominations.js"; import { computeDepositTransactionStatus, @@ -112,7 +112,7 @@ import { computeWithdrawalTransactionStatus, processWithdrawalGroup, } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; const logger = new Logger("shepherd.ts"); diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts @@ -31,7 +31,7 @@ import { TalerError, TalerErrorCode, } from "@gnu-taler/taler-util"; -import { WalletToken } from "./db-common.js"; +import { WalletToken } from "./db/records.js"; import { WalletExecutionContext } from "./wallet.js"; import { expectProposalDownloadInTx } from "./pay-merchant.js"; import { isTokenInUse, isTokenValid } from "./tokenSelection.js"; diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts @@ -20,8 +20,8 @@ import { } from "@gnu-taler/taler-util"; import { test } from "node:test"; import assert from "node:assert"; -import { WalletToken } from "./db-common.js"; -import { timestampProtocolToDb } from "./db-common.js"; +import { WalletToken } from "./db/records.js"; +import { timestampProtocolToDb } from "./db/records.js"; import { isTokenValidBetween, selectTokenCandidates, diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -31,8 +31,8 @@ import { TokenAvailabilityHint, TransactionIdStr, } from "@gnu-taler/taler-util"; -import { timestampProtocolFromDb, WalletToken } from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { timestampProtocolFromDb, WalletToken } from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; const logger = new Logger("tokenSelection.ts"); @@ -302,10 +302,7 @@ export function selectTokenCandidates( // - sort ascending by expiration date // - choose the first n tokens in the list const usable = records - .filter( - (tok) => - !isTokenInUse(tok) || isPreviousToken(tok), - ) + .filter((tok) => !isTokenInUse(tok) || isPreviousToken(tok)) .filter((tok) => isTokenValid(tok)) .filter((tok) => { const res = verifyTokenMerchant( diff --git a/packages/taler-wallet-core/src/transactions.test.ts b/packages/taler-wallet-core/src/transactions.test.ts @@ -38,8 +38,8 @@ import { timestampPreciseToDb, timestampProtocolToDb, WalletPeerPushDebit, -} from "./db-common.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { WalletExecutionContext } from "./wallet.js"; const allIdentifiers: ParsedTransactionIdentifier[] = [ diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -58,9 +58,9 @@ import { OPERATION_STATUS_NONFINAL_LAST, PurchaseStatus, timestampPreciseToDb, -} from "./db-common.js"; -import { WalletTransactionMeta } from "./db-common.js"; -import { WalletTransactionMetaCursor } from "./db-common.js"; +} from "./db/records.js"; +import { WalletTransactionMeta } from "./db/records.js"; +import { WalletTransactionMetaCursor } from "./db/records.js"; import { DepositTransactionContext } from "./deposits.js"; import { DenomLossTransactionContext } from "./exchanges.js"; import { @@ -74,7 +74,7 @@ import { PeerPushDebitTransactionContext } from "./pay-peer-push-debit.js"; import { RefreshTransactionContext } from "./refresh.js"; import type { WalletExecutionContext } from "./wallet.js"; import { WithdrawTransactionContext } from "./withdraw.js"; -import { WalletDbTransaction } from "./dbtx.js"; +import { WalletDbTransaction } from "./db/transaction.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); diff --git a/packages/taler-wallet-core/src/wallet-db-gate.test.ts b/packages/taler-wallet-core/src/wallet-db-gate.test.ts @@ -10,7 +10,7 @@ import assert from "node:assert"; import { test } from "node:test"; -import { WalletDbHandle } from "./dbtx-handle.js"; +import { WalletDbHandle } from "./db/handle.js"; import { AdmittedWalletDbHandle, DbOperationGate } from "./wallet.js"; function deferred(): { promise: Promise<void>; resolve: () => void } { diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -70,15 +70,15 @@ import { CryptoDispatcher, CryptoWorkerFactory, } from "./crypto/workers/crypto-dispatcher.js"; -import { ConfigRecordKey, WalletDenomination } from "./db-common.js"; -import { IdbWalletDbHandle } from "./dbtx-handle-impl.js"; -import { WalletDbHandle } from "./dbtx-handle.js"; -import { watchForCacheInvalidation } from "./dbtx-shared.js"; +import { ConfigRecordKey, WalletDenomination } from "./db/records.js"; +import { IdbWalletDbHandle } from "./db/indexeddb/handle.js"; +import { WalletDbHandle } from "./db/handle.js"; +import { watchForCacheInvalidation } from "./db/shared.js"; import { WalletCoinAvailabilityRef, WalletDbTransaction, WalletDenomRef, -} from "./dbtx.js"; +} from "./db/transaction.js"; import { UnverifiedDenomError } from "./denomSelection.js"; import { DevExperimentHttpLib, DevExperimentState } from "./dev-experiments.js"; import { @@ -92,7 +92,7 @@ import { observeTalerCrypto, } from "./observable-wrappers.js"; import { ProgressContext } from "./progress.js"; -import { TransactionAbortedError } from "./query.js"; +import { TransactionAbortedError } from "./db/query.js"; import { dispatchRequestInternal, isWalletInitOperation } from "./requests.js"; import { TaskScheduler, TaskSchedulerImpl } from "./shepherd.js"; import { rematerializeTransactions } from "./transactions.js"; diff --git a/packages/taler-wallet-core/src/withdraw.test.ts b/packages/taler-wallet-core/src/withdraw.test.ts @@ -30,8 +30,8 @@ import { DenominationVerificationStatus, timestampProtocolToDb, WithdrawalGroupStatus, -} from "./db-common.js"; -import { WalletDenomination } from "./db-common.js"; +} from "./db/records.js"; +import { WalletDenomination } from "./db/records.js"; import { selectWithdrawalDenominations } from "./denomSelection.js"; import { classifyWithdrawalKycHardLimitRecovery, diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -136,6 +136,7 @@ import { import { EddsaKeyPairStrings } from "./crypto/cryptoImplementation.js"; import { DenominationVerificationStatus, + CoinSourceType, PlanchetStatus, WalletCoin, WalletDenomination, @@ -149,9 +150,8 @@ import { timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolToDb, -} from "./db-common.js"; -import { CoinSourceType } from "./db-indexeddb.js"; -import { WalletDbTransaction } from "./dbtx.js"; +} from "./db/records.js"; +import { WalletDbTransaction } from "./db/transaction.js"; import { selectForcedWithdrawalDenominations, selectWithdrawalDenominations, @@ -1760,10 +1760,7 @@ async function processPlanchetExchangeLegacyBatchRequest( resp, codecForExchangeLegacyWithdrawBatchResponse(), ); - requireWithdrawalBatchCardinality( - requestCoinIdxs.length, - r.ev_sigs.length, - ); + requireWithdrawalBatchCardinality(requestCoinIdxs.length, r.ev_sigs.length); return { coinIdxs: requestCoinIdxs, batchResp: { ev_sigs: r.ev_sigs.map((x) => x.ev_sig) }, @@ -2467,10 +2464,7 @@ async function processWithdrawalGroupPendingKyc( rec.kycLastDeny = updatedStatus.lastDeny; rec.kycLastRuleGen = updatedStatus.lastRuleGen; rec.kycAccessToken = updatedStatus.accessToken; - if ( - hardLimitReason && - rec.status === WithdrawalGroupStatus.PendingKyc - ) { + if (hardLimitReason && rec.status === WithdrawalGroupStatus.PendingKyc) { rec.status = WithdrawalGroupStatus.FinalizingKycHardLimit; rec.failReason = hardLimitReason; }