/* This file is part of GNU Taler (C) 2021-2024 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 */ /** * Imports. */ import { Event, IDBDatabase, IDBFactory, IDBObjectStore, IDBRequest, IDBTransaction, structuredEncapsulate, structuredRevive, } from "@gnu-taler/idb-bridge"; import { AbsoluteTime, AgeCommitmentProof, AmountString, Amounts, AttentionInfo, BackupProviderTerms, CancellationToken, Codec, CoinEnvelope, CoinPublicKeyString, CoinRefreshRequest, CoinStatus, DenomLossEventType, DenomSelectionState, DenominationInfo, DenominationPubKey, EddsaPublicKeyString, EddsaSignatureString, ExchangeAuditor, ExchangeGlobalFees, HashCodeString, Logger, RefreshReason, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolDuration, TalerProtocolTimestamp, Transaction, TransactionIdStr, UnblindedSignature, WireInfo, WithdrawalExchangeAccountDetails, codecForAny, } from "@gnu-taler/taler-util"; import { DbRetryInfo, TaskIdentifiers } from "./common.js"; import { DbAccess, DbAccessImpl, DbReadOnlyTransaction, DbReadWriteTransaction, IndexDescriptor, StoreDescriptor, StoreNames, StoreWithIndexes, describeContents, describeIndex, describeStore, describeStoreV2, openDatabase, } 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 "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 */ /** 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 "stored backups" database. * Stored backups are created before manually importing a backup. * We use IndexedDB for this purpose, since we don't have file system * access on some platforms. */ export const TALER_WALLET_STORED_BACKUPS_DB_NAME = "taler-wallet-stored-backups"; /** * Name of the "meta config" database. */ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; /** * 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 = 10; 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 { return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); } export function timestampOptionalPreciseFromDb( dbTs: DbPreciseTimestamp | undefined, ): TalerPreciseTimestamp | undefined { if (!dbTs) { return undefined; } return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); } 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 { 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)); } /** * 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=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_ACTIVE_FIRST = 0x0100_0000; /** * LAST possible operation status in the active range (inclusive). */ export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_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, /** * We are telling the bank that we don't want to complete * the withdrawal! */ AbortingBank = 0x0103_0001, SuspendedAbortingBank = 0x0113_0001, /** * Exchange wants KYC info from the user. */ PendingKyc = 0x0100_0005, SuspendedKyc = 0x0110_005, /** * Exchange is doing AML checks. */ PendingAml = 0x0100_0006, SuspendedAml = 0x0110_0006, /** * 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, /** * 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, } /** * 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; } /** * 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, } export interface DenomFees { /** * Fee for withdrawing. */ feeWithdraw: AmountString; /** * Fee for depositing. */ feeDeposit: AmountString; /** * Fee for refreshing. */ feeRefresh: AmountString; /** * Fee for refunding. */ feeRefund: AmountString; } /** * Denomination record as stored in the wallet's database. */ export interface DenominationRecord { /** * 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; /** * 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 namespace DenominationRecord { export function toDenomInfo(d: DenominationRecord): DenominationInfo { return { denomPub: d.denomPub, 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, }; } } export interface ExchangeSignkeysRecord { stampStart: DbProtocolTimestamp; stampExpire: DbProtocolTimestamp; stampEnd: DbProtocolTimestamp; signkeyPub: EddsaPublicKeyString; masterSig: EddsaSignatureString; /** * Exchange details that thiis signkeys record belongs to. */ exchangeDetailsRowId: number; } /** * Exchange details for a particular * (exchangeBaseUrl, masterPublicKey, currency) tuple. */ export interface ExchangeDetailsRecord { 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: ExchangeAuditor[]; /** * Last observed protocol version. */ protocolVersionRange: string; reserveClosingDelay: TalerProtocolDuration; /** * Fees for exchange services */ globalFees: ExchangeGlobalFees[]; wireInfo: WireInfo; /** * Age restrictions supported by the exchange (bitmask). */ ageMask?: number; } export interface ExchangeDetailsPointer { masterPublicKey: string; currency: string; /** * Timestamp when the (masterPublicKey, currency) pointer * has been updated. */ updateClock: DbPreciseTimestamp; } 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, } /** * Exchange record as stored in the wallet's database. */ export interface ExchangeEntryRecord { /** * 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; /** * 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: ExchangeDetailsPointer | undefined; entryStatus: ExchangeEntryDbRecordStatus; updateStatus: ExchangeEntryDbUpdateStatus; /** * 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. */ lastUpdate: DbPreciseTimestamp | undefined; /** * Next scheduled update for the exchange. */ nextUpdateStamp: DbPreciseTimestamp; updateRetryCounter?: number; 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; /** * Defaults to false. */ peerPaymentsDisabled?: boolean; /** * Defaults to false. */ noFees?: boolean; } export enum PlanchetStatus { Pending = 0x0100_0000, KycRequired = 0x0100_0001, WithdrawalDone = 0x0500_000, AbortedReplaced = 0x0503_0001, } /** * A coin that isn't yet signed by an exchange. */ export interface PlanchetRecord { /** * 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; } export enum CoinSourceType { Withdraw = "withdraw", Refresh = "refresh", Reward = "reward", } export interface WithdrawCoinSource { 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 RefreshCoinSource { type: CoinSourceType.Refresh; refreshGroupId: string; oldCoinPub: string; } export interface RewardCoinSource { type: CoinSourceType.Reward; walletRewardId: string; coinIndex: number; } export type CoinSource = | WithdrawCoinSource | RefreshCoinSource | RewardCoinSource; /** * CoinRecord as stored in the "coins" data store * of the wallet database. */ export interface CoinRecord { /** * Where did the coin come from? Used for recouping coins. */ coinSource: CoinSource; /** * 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: UnblindedSignature; /** * Base URL that identifies the exchange from which we got the * coin. */ exchangeBaseUrl: string; /** * Blinding key used when withdrawing the coin. * Potentionally used again during payback. */ blindingKey: string; /** * 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; /** * Information about what the coin has been allocated for. * * Used for: * - Diagnostics * - Idempotency of applying a coin selection (e.g. after re-selection) */ spendAllocation: CoinAllocation | undefined; /** * Maximum age of purchases that can be made with this coin. * * (Used for indexing, redundant with {@link ageCommitmentProof}). */ maxAge: number; ageCommitmentProof: AgeCommitmentProof | undefined; } /** * Coin allocation, i.e. what a coin has been used for. */ export interface CoinAllocation { /** * ID of the allocation, should be the ID of the transaction that */ id: TransactionIdStr; amount: AmountString; } /** * Status of a reward we got from a merchant. */ export interface RewardRecord { /** * Has the user accepted the tip? Only after the tip has been accepted coins * withdrawn from the tip may be used. */ acceptedTimestamp: DbPreciseTimestamp | undefined; /** * The tipped amount. */ rewardAmountRaw: AmountString; /** * Effect on the balance (including fees etc). */ rewardAmountEffective: AmountString; /** * Timestamp, the tip can't be picked up anymore after this deadline. */ rewardExpiration: DbProtocolTimestamp; /** * The exchange that will sign our coins, chosen by the merchant. */ exchangeBaseUrl: string; /** * Base URL of the merchant that is giving us the tip. */ merchantBaseUrl: string; /** * Denomination selection made by the wallet for picking up * this tip. * * FIXME: Put this into some DenomSelectionCacheRecord instead of * storing it here! */ denomsSel: DenomSelectionState; denomSelUid: string; /** * Tip ID chosen by the wallet. */ walletRewardId: string; /** * Secret seed used to derive planchets for this tip. */ secretSeed: string; /** * The merchant's identifier for this reward. */ merchantRewardId: string; createdTimestamp: DbPreciseTimestamp; /** * The url to be redirected after the tip is accepted. */ next_url: string | undefined; /** * Timestamp for when the wallet finished picking up the tip * from the merchant. */ pickedUpTimestamp: DbPreciseTimestamp | undefined; status: RewardRecordStatus; } export enum RewardRecordStatus { PendingPickup = 0x0100_0000, SuspendedPickup = 0x0110_0000, DialogAccept = 0x0101_0000, Done = 0x0500_0000, Aborted = 0x0500_0000, Failed = 0x0501_000, } export enum RefreshCoinStatus { Pending = 0x0100_0000, Finished = 0x0500_0000, /** * The refresh for this coin has been frozen, because of a permanent error. * More info in lastErrorPerCoin. */ Failed = 0x0501_000, } export enum RefreshOperationStatus { Pending = 0x0100_0000, Suspended = 0x0110_0000, Finished = 0x0500_000, Failed = 0x0501_000, } /** * 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, RefundSuccess = 0x0503_0000, RefundFailed = 0x0501_0000, } export interface RefreshGroupPerExchangeInfo { /** * (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 RefreshGroupRecord { 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; /** * 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[]; timestampCreated: DbPreciseTimestamp; /** * Timestamp when the refresh session finished. */ timestampFinished: DbPreciseTimestamp | undefined; } /** * Ongoing refresh */ export interface RefreshSessionRecord { refreshGroupId: string; /** * Index of the coin in the refresh group. */ coinIndex: number; /** * 512-bit secret that can be used to derive * the other cryptographic material for the refresh session. */ sessionSecretSeed: string; /** * 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; lastError?: TalerErrorDetail; } export enum RefundReason { /** * Normal refund given by the merchant. */ NormalRefund = "normal-refund", /** * Refund from an aborted payment. */ AbortRefund = "abort-pay-refund", } 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). */ PendingQueryingAutoRefund = 0x0100_0004, SuspendedQueryingAutoRefund = 0x0110_0004, 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, /** * The user has rejected the proposal. */ AbortedProposalRefused = 0x0503_0000, /** * Downloading or processing the proposal has failed permanently. */ FailedClaim = 0x0501_0000, /** * Tried to abort, but aborting failed or was cancelled. */ FailedAbort = 0x0501_0001, FailedPaidByOther = 0x0501_0002, /** * Payment was successful. */ Done = 0x0500_0000, /** * Downloaded proposal was detected as a re-purchase. */ DoneRepurchaseDetected = 0x0500_0001, /** * The payment has been aborted. */ AbortedIncompletePayment = 0x0503_0000, AbortedRefunded = 0x0503_0001, AbortedOrderDeleted = 0x0503_0002, } /** * Partial information about the downloaded proposal. * Only contains data that is relevant for indexing on the * "purchases" object stores. */ export interface ProposalDownloadInfo { contractTermsHash: string; fulfillmentUrl?: string; currency: string; contractTermsMerchantSig: string; } export interface DbCoinSelection { coinPubs: string[]; coinContributions: AmountString[]; } export interface PurchasePayInfo { /** * Undefined if payment is blocked by a pending refund. */ payCoinSelection?: DbCoinSelection; /** * Undefined if payment is blocked by a pending refund. */ payCoinSelectionUid?: string; 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 PurchaseRecord { /** * 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; /** * Private key for the nonce. */ noncePriv: string; /** * Public key for the nonce. */ noncePub: string; /** * Downloaded and parsed proposal data. */ download: ProposalDownloadInfo | undefined; payInfo: PurchasePayInfo | undefined; /** * 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; /** * This purchase was created by reading * a payment share or the wallet * the nonce public by a payment share */ shared: 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; /** * 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; } export enum ConfigRecordKey { WalletBackupState = "walletBackupState", CurrencyDefaultsApplied = "currencyDefaultsApplied", DevMode = "devMode", // Only for testing, do not use! TestLoopTx = "testTxLoop", LastInitInfo = "lastInitInfo", } /** * Configuration key/value entries to configure * the wallet. */ export type ConfigRecord = | { key: ConfigRecordKey.WalletBackupState; value: WalletBackupConfState; } | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean } | { key: ConfigRecordKey.TestLoopTx; value: number } | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp }; 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; } // FIXME: Should these be numeric codes? export const enum WithdrawalRecordType { BankManual = "bank-manual", BankIntegrated = "bank-integrated", PeerPullCredit = "peer-pull-credit", PeerPushCredit = "peer-push-credit", Recoup = "recoup", } 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; export type KycUserType = "individual" | "business"; export interface KycPendingInfo { paytoHash: string; requirementRow: number; } /** * 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 WithdrawalGroupRecord { /** * Unique identifier for the withdrawal group. */ withdrawalGroupId: string; wgInfo: WgInfo; kycPending?: KycPendingInfo; kycUrl?: string; /** * 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; /** * Wire information (as payto URI) for the bank account that * transferred funds for this reserve. * * FIXME: Doesn't this belong to the bankAccounts object store? */ senderWire?: string; /** * 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; /** * UID of the denomination selection. * * Used for merging backups. * * FIXME: Should this not also include a timestamp for more logical merging? */ denomSelUid: string; } export interface BankWithdrawUriRecord { /** * The withdraw URI we got from the bank. */ talerWithdrawUri: string; /** * Reserve that was created for the withdraw URI. */ reservePub: string; } export enum RecoupOperationStatus { Pending = 0x0100_0000, Suspended = 0x0110_0000, Finished = 0x0500_000, Failed = 0x0501_000, } /** * 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 RecoupGroupRecord. */ export interface RecoupGroupRecord { /** * 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[]; } export enum BackupProviderStateTag { Provisional = "provisional", Ready = "ready", Retrying = "retrying", } export type BackupProviderState = | { tag: BackupProviderStateTag.Provisional; } | { tag: BackupProviderStateTag.Ready; nextBackupTimestamp: DbPreciseTimestamp; } | { tag: BackupProviderStateTag.Retrying; }; export interface BackupProviderRecord { /** * Base URL of the provider. * * Primary key for the record. */ baseUrl: string; /** * Name of the provider */ name: string; /** * Terms of service of the provider. * Might be unavailable in the DB in certain situations * (such as loading a recovery document). */ terms?: BackupProviderTerms; /** * Hash of the last encrypted backup that we already merged * or successfully uploaded ourselves. */ lastBackupHash?: string; /** * Last time that we successfully uploaded a backup (or * the uploaded backup was already current). * * Does NOT correspond to the timestamp of the backup, * which only changes when the backup content changes. */ lastBackupCycleTimestamp?: DbPreciseTimestamp; /** * Proposal that we're currently trying to pay for. * * (Also included in paymentProposalIds.) * * FIXME: Make this part of a proper BackupProviderState? */ currentPaymentProposalId?: string; shouldRetryFreshProposal: boolean; /** * Proposals that were used to pay (or attempt to pay) the provider. * * Stored to display a history of payments to the provider, and * to make sure that the wallet isn't overpaying. */ paymentProposalIds: string[]; state: BackupProviderState; /** * UIDs for the operation that added the backup provider. */ uids: string[]; } export enum DepositOperationStatus { PendingDeposit = 0x0100_0000, PendingTrack = 0x0100_0001, PendingKyc = 0x0100_0002, Aborting = 0x0103_0000, SuspendedDeposit = 0x0110_0000, SuspendedTrack = 0x0110_0001, SuspendedKyc = 0x0110_0002, SuspendedAborting = 0x0113_0000, Finished = 0x0500_0000, Failed = 0x0501_0000, Aborted = 0x0503_0000, } export interface DepositTrackingInfo { // 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; } export interface DepositInfoPerExchange { /** * Expected effective amount that will be deposited * from coins of this exchange. */ amountEffective: AmountString; } /** * Group of deposits made by the wallet. */ export interface DepositGroupRecord { 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?: DbCoinSelection; payCoinSelectionUid?: string; totalPayCost: AmountString; /** * The counterparty effective deposit amount. */ counterpartyEffectiveDepositAmount: AmountString; timestampCreated: DbPreciseTimestamp; timestampFinished: DbPreciseTimestamp | undefined; operationStatus: DepositOperationStatus; statusPerCoin?: DepositElementStatus[]; infoPerExchange?: Record; /** * When the deposit transaction was aborted and * refreshes were tried, we create a refresh * group and store the ID here. */ abortRefreshGroupId?: string; kycInfo?: DepositKycInfo; // FIXME: Do we need this and should it be in this object store? trackingState?: { [signature: string]: DepositTrackingInfo; }; } export interface DepositKycInfo { kycUrl: string; requirementRow: number; paytoHash: string; exchangeBaseUrl: string; } export interface TombstoneRecord { /** * Tombstone ID, with the syntax "tmb::". */ id: string; } export enum PeerPushDebitStatus { /** * Initiated, but no purse created yet. */ PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */, PendingReady = 0x0100_0001, AbortingDeletePurse = 0x0103_0000, /** * Refresh after the purse got deleted by the wallet. */ AbortingRefreshDeleted = 0x0103_0001, /** * Refresh after the purse expired. */ AbortingRefreshExpired = 0x0103_0002, SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedAbortingDeletePurse = 0x0113_0000, SuspendedAbortingRefreshDeleted = 0x0113_0001, SuspendedAbortingRefreshExpired = 0x0113_0002, Done = 0x0500_0000, Aborted = 0x0503_0000, Failed = 0x0501_0000, Expired = 0x0502_0000, } 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; /** * Instructed amount. */ amount: AmountString; 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; /** * Status of the peer push payment initiation. */ status: PeerPushDebitStatus; } export enum PeerPullPaymentCreditStatus { PendingCreatePurse = 0x0100_0000, /** * Purse created, waiting for the other party to accept the * invoice and deposit money into it. */ PendingReady = 0x0100_0001, PendingMergeKycRequired = 0x0100_0002, PendingWithdrawing = 0x0100_0003, AbortingDeletePurse = 0x0103_0000, SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedMergeKycRequired = 0x0110_0002, SuspendedWithdrawing = 0x0110_0000, SuspendedAbortingDeletePurse = 0x0113_0000, Done = 0x0500_0000, Failed = 0x0501_0000, Expired = 0x0502_0000, Aborted = 0x0503_0000, } 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; kycInfo?: KycPendingInfo; kycUrl?: string; withdrawalGroupId: string | undefined; } export enum PeerPushCreditStatus { PendingMerge = 0x0100_0000, PendingMergeKycRequired = 0x0100_0001, /** * Merge was successful and withdrawal group has been created, now * everything is in the hand of the withdrawal group. */ PendingWithdrawing = 0x0100_0002, SuspendedMerge = 0x0110_0000, SuspendedMergeKycRequired = 0x0110_0001, SuspendedWithdrawing = 0x0110_0002, DialogProposed = 0x0101_0000, Done = 0x0500_0000, Aborted = 0x0503_0000, Failed = 0x0501_0000, } /** * Record for a push P2P payment that this wallet was offered. * * Unique: (exchangeBaseUrl, pursePub) */ export interface PeerPushPaymentIncomingRecord { 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; /** * 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; kycInfo?: KycPendingInfo; kycUrl?: string; } export enum PeerPullDebitRecordStatus { PendingDeposit = 0x0100_0001, AbortingRefresh = 0x0103_0001, SuspendedDeposit = 0x0110_0001, SuspendedAbortingRefresh = 0x0113_0001, DialogProposed = 0x0101_0001, Done = 0x0500_0000, Aborted = 0x0503_0000, Failed = 0x0501_0000, } 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; coinSel?: PeerPullPaymentCoinSelection; } /** * 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. */ export interface ReserveRecord { rowId?: number; reservePub: string; reservePriv: string; } export interface OperationRetryRecord { /** * Unique identifier for the operation. Typically of * the format `${opType}-${opUniqueKey}` * * @see {@link TaskIdentifiers} */ id: string; lastError?: TalerErrorDetail; retryInfo: DbRetryInfo; } /** * 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 CoinAvailabilityRecord { currency: string; value: AmountString; denomPubHash: string; exchangeBaseUrl: 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; /** * 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; } export interface ContractTermsRecord { /** * Contract terms hash. */ h: string; /** * Contract terms JSON. */ contractTermsRaw: any; } export interface UserAttentionRecord { info: AttentionInfo; entityId: string; /** * When the notification was created. */ created: DbPreciseTimestamp; /** * When the user mark this notification as read. */ read: DbPreciseTimestamp | undefined; } export interface DbExchangeHandle { url: string; exchangeMasterPub: string; } export interface DbAuditorHandle { url: string; auditorPub: string; } export enum RefundGroupStatus { Pending = 0x0100_0000, Done = 0x0500_0000, Failed = 0x0501_0000, Aborted = 0x0503_0000, Expired = 0x0502_0000, } /** * Metadata about a group of refunds with the merchant. */ export interface RefundGroupRecord { 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; } 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, } /** * Refund for a single coin in a payment with a merchant. */ export interface RefundItemRecord { /** * Auto-increment DB record ID. */ id?: number; status: RefundItemStatus; 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 function passthroughCodec(): Codec { return codecForAny(); } export interface GlobalCurrencyAuditorRecord { id?: number; currency: string; auditorBaseUrl: string; auditorPub: string; } export interface GlobalCurrencyExchangeRecord { id?: number; currency: string; exchangeBaseUrl: string; exchangeMasterPub: string; } /** * Primary key: transactionItem.transactionId */ export interface TransactionRecord { /** * Transaction item returned to the client. */ transactionItem: Transaction; /** * Exchanges involved in the transaction. */ exchanges: string[]; currency: string; } 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 interface DenomLossEventRecord { denomLossEventId: string; currency: string; denomPubHashes: string[]; status: DenomLossStatus; timestampCreated: DbPreciseTimestamp; amount: string; eventType: DenomLossEventType; exchangeBaseUrl: string; } /** * Schema definition for the IndexedDB * wallet database. */ export const WalletStoresV1 = { denomLossEvents: describeStoreV2({ recordCodec: passthroughCodec(), storeName: "denomLossEvents", keyPath: "denomLossEventId", versionAdded: 9, indexes: { byCurrency: describeIndex("byCurrency", "currency", { versionAdded: 9, }), byStatus: describeIndex("byStatus", "status", { versionAdded: 10, }), }, }), transactions: describeStoreV2({ recordCodec: passthroughCodec(), storeName: "transactions", keyPath: "transactionItem.transactionId", versionAdded: 7, indexes: { byCurrency: describeIndex("byCurrency", "currency", { versionAdded: 7, }), byExchange: describeIndex("byExchange", "exchanges", { versionAdded: 7, multiEntry: true, }), }, }), globalCurrencyAuditors: describeStoreV2({ recordCodec: passthroughCodec(), storeName: "globalCurrencyAuditors", keyPath: "id", autoIncrement: true, versionAdded: 3, indexes: { byCurrencyAndUrlAndPub: describeIndex( "byCurrencyAndUrlAndPub", ["currency", "auditorBaseUrl", "auditorPub"], { unique: true, versionAdded: 4, }, ), }, }), globalCurrencyExchanges: describeStoreV2({ recordCodec: passthroughCodec(), storeName: "globalCurrencyExchanges", keyPath: "id", autoIncrement: true, versionAdded: 3, indexes: { byCurrencyAndUrlAndPub: describeIndex( "byCurrencyAndUrlAndPub", ["currency", "exchangeBaseUrl", "exchangeMasterPub"], { unique: true, versionAdded: 4, }, ), }, }), coinAvailability: describeStore( "coinAvailability", describeContents({ keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"], }), { byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [ "exchangeBaseUrl", "maxAge", "freshCoinCount", ]), byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { versionAdded: 8, }), }, ), coins: describeStore( "coins", describeContents({ keyPath: "coinPub", }), { byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"), byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"), byExchangeDenomPubHashAndAgeAndStatus: describeIndex( "byExchangeDenomPubHashAndAgeAndStatus", ["exchangeBaseUrl", "denomPubHash", "maxAge", "status"], ), byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"), bySourceTransactionId: describeIndex( "bySourceTransactionId", "sourceTransactionId", { versionAdded: 9, }, ), }, ), reserves: describeStore( "reserves", describeContents({ keyPath: "rowId", autoIncrement: true, }), { byReservePub: describeIndex("byReservePub", "reservePub", {}), }, ), config: describeStore( "config", describeContents({ keyPath: "key" }), {}, ), denominations: describeStore( "denominations", describeContents({ keyPath: ["exchangeBaseUrl", "denomPubHash"], }), { byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"), }, ), exchanges: describeStore( "exchanges", describeContents({ keyPath: "baseUrl", }), {}, ), exchangeDetails: describeStore( "exchangeDetails", describeContents({ keyPath: "rowId", autoIncrement: true, }), { byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { versionAdded: 2, }), byPointer: describeIndex( "byDetailsPointer", ["exchangeBaseUrl", "currency", "masterPublicKey"], { unique: true, }, ), }, ), exchangeSignKeys: describeStore( "exchangeSignKeys", describeContents({ keyPath: ["exchangeDetailsRowId", "signkeyPub"], }), { byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [ "exchangeDetailsRowId", ]), }, ), refreshGroups: describeStore( "refreshGroups", describeContents({ keyPath: "refreshGroupId", }), { byStatus: describeIndex("byStatus", "operationStatus"), byOriginatingTransactionId: describeIndex( "byOriginatingTransactionId", "originatingTransactionId", { versionAdded: 5, }, ), }, ), refreshSessions: describeStore( "refreshSessions", describeContents({ keyPath: ["refreshGroupId", "coinIndex"], }), {}, ), recoupGroups: describeStore( "recoupGroups", describeContents({ keyPath: "recoupGroupId", }), { byStatus: describeIndex("byStatus", "operationStatus", { versionAdded: 6, }), }, ), purchases: describeStore( "purchases", describeContents({ keyPath: "proposalId" }), { byStatus: describeIndex("byStatus", "purchaseStatus"), byFulfillmentUrl: describeIndex( "byFulfillmentUrl", "download.fulfillmentUrl", ), byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ "merchantBaseUrl", "orderId", ]), }, ), rewards: describeStore( "rewards", describeContents({ keyPath: "walletRewardId" }), { byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ "merchantRewardId", "merchantBaseUrl", ]), byStatus: describeIndex("byStatus", "status", { versionAdded: 8, }), }, ), withdrawalGroups: describeStore( "withdrawalGroups", describeContents({ keyPath: "withdrawalGroupId", }), { byStatus: describeIndex("byStatus", "status"), byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { versionAdded: 2, }), byTalerWithdrawUri: describeIndex( "byTalerWithdrawUri", "wgInfo.bankInfo.talerWithdrawUri", ), }, ), planchets: describeStore( "planchets", describeContents({ keyPath: "coinPub" }), { byGroupAndIndex: describeIndex( "byGroupAndIndex", ["withdrawalGroupId", "coinIdx"], { unique: true, }, ), byGroup: describeIndex("byGroup", "withdrawalGroupId"), byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"), }, ), bankWithdrawUris: describeStore( "bankWithdrawUris", describeContents({ keyPath: "talerWithdrawUri", }), {}, ), backupProviders: describeStore( "backupProviders", describeContents({ keyPath: "baseUrl", }), { byPaymentProposalId: describeIndex( "byPaymentProposalId", "paymentProposalIds", { multiEntry: true, }, ), }, ), depositGroups: describeStore( "depositGroups", describeContents({ keyPath: "depositGroupId", }), { byStatus: describeIndex("byStatus", "operationStatus"), }, ), tombstones: describeStore( "tombstones", describeContents({ keyPath: "id" }), {}, ), operationRetries: describeStore( "operationRetries", describeContents({ keyPath: "id", }), {}, ), peerPushCredit: describeStore( "peerPushCredit", describeContents({ 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({ keyPath: "peerPullDebitId", }), { byExchangeAndPurse: describeIndex("byExchangeAndPurse", [ "exchangeBaseUrl", "pursePub", ]), byExchangeAndContractPriv: describeIndex( "byExchangeAndContractPriv", ["exchangeBaseUrl", "contractPriv"], { unique: true, }, ), byStatus: describeIndex("byStatus", "status"), }, ), peerPullCredit: describeStore( "peerPullCredit", describeContents({ keyPath: "pursePub", }), { byStatus: describeIndex("byStatus", "status"), byWithdrawalGroupId: describeIndex( "byWithdrawalGroupId", "withdrawalGroupId", {}, ), }, ), peerPushDebit: describeStore( "peerPushDebit", describeContents({ keyPath: "pursePub", }), { byStatus: describeIndex("byStatus", "status"), }, ), bankAccounts: describeStore( "bankAccounts", describeContents({ keyPath: "uri", }), {}, ), contractTerms: describeStore( "contractTerms", describeContents({ keyPath: "h", }), {}, ), userAttention: describeStore( "userAttention", describeContents({ keyPath: ["entityId", "info.type"], }), {}, ), refundGroups: describeStore( "refundGroups", describeContents({ keyPath: "refundGroupId", }), { byProposalId: describeIndex("byProposalId", "proposalId"), byStatus: describeIndex("byStatus", "status", {}), }, ), refundItems: describeStore( "refundItems", describeContents({ 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({ keyPath: "fixupName", }), {}, ), }; export type WalletDbStoresArr = Array>; export type WalletDbReadWriteTransaction = DbReadWriteTransaction; export type WalletDbReadOnlyTransaction = DbReadOnlyTransaction; export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction< typeof WalletStoresV1, WalletDbStoresArr >; export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction< typeof WalletStoresV1, WalletDbStoresArr >; /** * An applied migration. */ export interface FixupRecord { fixupName: string; } /** * User accounts */ export interface BankAccountsRecord { uri: string; currency: string; kycCompleted: boolean; alias: string; } export interface MetaConfigRecord { key: string; value: any; } export const walletMetadataStore = { metaConfig: describeStore( "metaConfig", describeContents({ keyPath: "key" }), {}, ), }; export interface StoredBackupMeta { name: string; } export const StoredBackupStores = { backupMeta: describeStore( "backupMeta", describeContents({ keyPath: "name" }), {}, ), backupData: describeStore("backupData", describeContents({}), {}), }; 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; }; } export async function exportSingleDb( idb: IDBFactory, dbName: string, ): Promise { 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) => { const tx = myDb.transaction(Array.from(myDb.objectStoreNames)); tx.addEventListener("complete", () => { //myDb.close(); resolve(singleDbDump); }); // 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(); } }); } }); } export async function exportDb(idb: IDBFactory): Promise { const dbDump: DbDump = { databases: {}, }; dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb( idb, TALER_WALLET_META_DB_NAME, ); dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb( idb, TALER_WALLET_MAIN_DB_NAME, ); return dbDump; } async function recoverFromDump( db: IDBDatabase, dbDump: DbDumpDatabase, ): Promise { 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]; if (!storeDump) continue; await promiseFromRequest(tx.objectStore(name).clear()); 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.info("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 { 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: DbReadWriteTransaction< typeof WalletStoresV1, Array> >, ): Promise; } /** * Manual migrations between minor versions of the DB schema. */ export const walletDbFixups: FixupDescription[] = []; const logger = new Logger("db.ts"); export async function applyFixups( db: DbAccess, ): Promise { logger.trace("applying fixups"); await db.runAllStoresReadWriteTx({}, async (tx) => { for (const fixupInstruction of walletDbFixups) { logger.trace(`checking fixup ${fixupInstruction.name}`); const fixupRecord = await tx.fixups.get(fixupInstruction.name); if (fixupRecord) { continue; } logger.info(`applying DB fixup ${fixupInstruction.name}`); await fixupInstruction.fn(tx); await tx.fixups.put({ fixupName: fixupInstruction.name, }); } }); } /** * 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, any > = storeMap[n]; const storeDesc: StoreDescriptor = 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> = storeMap[ n ]; const storeDesc: StoreDescriptor = 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}`, // @ts-expect-error no support for options.cause yet { 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}`, // @ts-expect-error no support for options.cause yet { cause: e }, ); } } } } } function promiseFromTransaction(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => { resolve(); }; transaction.onerror = () => { reject(); }; }); } export function promiseFromRequest(request: IDBRequest): Promise { 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 { // 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]); } 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( WalletStoresV1, db, oldVersion, newVersion, upgradeTransaction, ); } function onMetaDbUpgradeNeeded( db: IDBDatabase, oldVersion: number, newVersion: number, upgradeTransaction: IDBTransaction, ) { upgradeFromStoreMap( walletMetadataStore, db, oldVersion, newVersion, upgradeTransaction, ); } function onStoredBackupsDbUpgradeNeeded( db: IDBDatabase, oldVersion: number, newVersion: number, upgradeTransaction: IDBTransaction, ) { upgradeFromStoreMap( StoredBackupStores, db, oldVersion, newVersion, upgradeTransaction, ); } export async function openStoredBackupsDatabase( idbFactory: IDBFactory, ): Promise> { const backupsDbHandle = await openDatabase( idbFactory, TALER_WALLET_STORED_BACKUPS_DB_NAME, 1, () => {}, onStoredBackupsDbUpgradeNeeded, ); const handle = new DbAccessImpl( backupsDbHandle, StoredBackupStores, {}, CancellationToken.CONTINUE, ); return handle; } /** * 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 { const metaDbHandle = await openDatabase( idbFactory, TALER_WALLET_META_DB_NAME, 1, () => {}, onMetaDbUpgradeNeeded, ); const metaDb = new DbAccessImpl( metaDbHandle, walletMetadataStore, {}, CancellationToken.CONTINUE, ); let currentMainVersion: string | undefined; await metaDb.runReadWriteTx({ storeNames: ["metaConfig"] }, async (tx) => { const dbVersionRecord = await tx.metaConfig.get(CURRENT_DB_CONFIG_KEY); if (!dbVersionRecord) { currentMainVersion = TALER_WALLET_MAIN_DB_NAME; await tx.metaConfig.put({ key: CURRENT_DB_CONFIG_KEY, value: TALER_WALLET_MAIN_DB_NAME, }); } else { currentMainVersion = dbVersionRecord.value; } }); if (currentMainVersion !== TALER_WALLET_MAIN_DB_NAME) { switch (currentMainVersion) { case "taler-wallet-main-v2": case "taler-wallet-main-v3": case "taler-wallet-main-v4": // temporary, we might migrate v4 later 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": // We consider this a pre-release // development version, no migration is done. await metaDb.runReadWriteTx( { storeNames: ["metaConfig"] }, async (tx) => { await tx.metaConfig.put({ key: CURRENT_DB_CONFIG_KEY, value: TALER_WALLET_MAIN_DB_NAME, }); }, ); break; default: throw Error( `major migration from database major=${currentMainVersion} not supported`, ); } } const mainDbHandle = await openDatabase( idbFactory, TALER_WALLET_MAIN_DB_NAME, WALLET_DB_MINOR_VERSION, onVersionChange, onTalerDbUpgradeNeeded, ); const mainDbAccess = new DbAccessImpl( mainDbHandle, WalletStoresV1, {}, CancellationToken.CONTINUE, ); await applyFixups(mainDbAccess); return mainDbHandle; } export async function deleteTalerDatabase( idbFactory: IDBFactory, ): Promise { return new Promise((resolve, reject) => { const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME); req.onerror = () => reject(req.error); req.onsuccess = () => resolve(); }); }