diff options
Diffstat (limited to 'packages/taler-wallet-core/src/db.ts')
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 3114 |
1 files changed, 2373 insertions, 741 deletions
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 902f749cf..1edafb315 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021 Taler Systems S.A. + (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 @@ -18,27 +18,101 @@ * Imports. */ import { - describeStore, - describeContents, - describeIndex, -} from "./util/query.js"; + Event, + IDBDatabase, + IDBFactory, + IDBObjectStore, + IDBRequest, + IDBTransaction, + structuredEncapsulate, + structuredRevive, +} from "@gnu-taler/idb-bridge"; import { - AmountJson, + AbsoluteTime, + AgeCommitmentProof, AmountString, - Auditor, - CoinDepositPermission, - ContractTerms, - Duration, - ExchangeSignKeyJson, - InternationalizedString, - MerchantInfo, - Product, + Amounts, + AttentionInfo, + BackupProviderTerms, + CancellationToken, + Codec, + CoinEnvelope, + CoinPublicKeyString, + CoinRefreshRequest, + CoinStatus, + DenomLossEventType, + DenomSelectionState, + DenominationInfo, + DenominationPubKey, + EddsaPublicKeyString, + EddsaSignatureString, + ExchangeAuditor, + ExchangeGlobalFees, + HashCodeString, + Logger, RefreshReason, - TalerErrorDetails, - Timestamp, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolDuration, + TalerProtocolTimestamp, + Transaction, + TransactionIdStr, + UnblindedSignature, + WireInfo, + WithdrawalExchangeAccountDetails, + codecForAny, } from "@gnu-taler/taler-util"; -import { RetryInfo } from "./util/retries.js"; -import { PayCoinSelection } from "./util/coinSelection.js"; +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 "<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 @@ -46,7 +120,7 @@ import { PayCoinSelection } from "./util/coinSelection.js"; * for all previous versions must be written, which should be * avoided. */ -export const TALER_DB_NAME = "taler-wallet-main-v3"; +export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10"; /** * Name of the metadata database. This database is used @@ -54,8 +128,20 @@ export const TALER_DB_NAME = "taler-wallet-main-v3"; * * (Minor migrations are handled via upgrade transactions.) */ -export const TALER_META_DB_NAME = "taler-wallet-meta"; +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"; /** @@ -65,237 +151,290 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName"; * backwards-compatible way or object stores and indices * are added. */ -export const WALLET_DB_MINOR_VERSION = 1; +export const WALLET_DB_MINOR_VERSION = 10; -export enum ReserveRecordStatus { - /** - * Reserve must be registered with the bank. - */ - REGISTERING_BANK = "registering-bank", +declare const symDbProtocolTimestamp: unique symbol; - /** - * 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). - */ - WAIT_CONFIRM_BANK = "wait-confirm-bank", +declare const symDbPreciseTimestamp: unique symbol; - /** - * Querying reserve status with the exchange. - */ - QUERYING_STATUS = "querying-status", +/** + * Timestamp, stored as microseconds. + * + * Always rounded to a full second. + */ +export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true }; - /** - * The corresponding withdraw record has been created. - * No further processing is done, unless explicitly requested - * by the user. - */ - DORMANT = "dormant", +/** + * Timestamp, stored as microseconds. + */ +export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true }; - /** - * The bank aborted the withdrawal. - */ - BANK_ABORTED = "bank-aborted", +const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER; + +export function timestampPreciseFromDb( + dbTs: DbPreciseTimestamp, +): TalerPreciseTimestamp { + return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); } -/** - * Extra info about a reserve that is used - * with a bank-integrated withdrawal. - */ -export interface ReserveBankInfo { - /** - * Status URL that the wallet will use to query the status - * of the Taler withdrawal operation on the bank's side. - */ - statusUrl: string; +export function timestampOptionalPreciseFromDb( + dbTs: DbPreciseTimestamp | undefined, +): TalerPreciseTimestamp | undefined { + if (!dbTs) { + return undefined; + } + return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000)); +} - /** - * URL that the user can be redirected to, and allows - * them to confirm (or abort) the bank-integrated withdrawal. - */ - confirmUrl?: string; +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; + } +} - /** - * Exchange payto URI that the bank will use to fund the reserve. - */ - exchangePaytoUri: string; +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)); } /** - * A reserve record as stored in the wallet's database. + * 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 */ -export interface ReserveRecord { - /** - * The reserve public key. - */ - reservePub: string; - /** - * The reserve private key. - */ - reservePriv: string; +/** + * 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 { /** - * The exchange base URL. + * Reserve must be registered with the bank. */ - exchangeBaseUrl: string; + PendingRegisteringBank = 0x0100_0001, + SuspendedRegisteringBank = 0x0110_0001, /** - * Currency of the reserve. + * 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). */ - currency: string; + PendingWaitConfirmBank = 0x0100_0002, + SuspendedWaitConfirmBank = 0x0110_0002, /** - * Time when the reserve was created. + * Querying reserve status with the exchange. */ - timestampCreated: Timestamp; + PendingQueryingStatus = 0x0100_0003, + SuspendedQueryingStatus = 0x0110_0003, /** - * Time when the information about this reserve was posted to the bank. - * - * Only applies if bankWithdrawStatusUrl is defined. - * - * Set to 0 if that hasn't happened yet. + * Ready for withdrawal. */ - timestampReserveInfoPosted: Timestamp | undefined; + PendingReady = 0x0100_0004, + SuspendedReady = 0x0110_0004, /** - * Time when the reserve was confirmed by the bank. - * - * Set to undefined if not confirmed yet. + * Proposed to the user, has can choose to accept/refuse. */ - timestampBankConfirmed: Timestamp | undefined; + DialogProposed = 0x0101_0000, /** - * Wire information (as payto URI) for the bank account that - * transferred funds for this reserve. + * We are telling the bank that we don't want to complete + * the withdrawal! */ - senderWire?: string; + AbortingBank = 0x0103_0001, + SuspendedAbortingBank = 0x0113_0001, /** - * Amount that was sent by the user to fund the reserve. + * Exchange wants KYC info from the user. */ - instructedAmount: AmountJson; + PendingKyc = 0x0100_0005, + SuspendedKyc = 0x0110_005, /** - * Extra state for when this is a withdrawal involving - * a Taler-integrated bank. + * Exchange is doing AML checks. */ - bankInfo?: ReserveBankInfo; - - initialWithdrawalGroupId: string; + PendingAml = 0x0100_0006, + SuspendedAml = 0x0110_0006, /** - * Did we start the first withdrawal for this reserve? - * - * We only report a pending withdrawal for the reserve before - * the first withdrawal has started. + * The corresponding withdraw record has been created. + * No further processing is done, unless explicitly requested + * by the user. */ - initialWithdrawalStarted: boolean; + Done = 0x0500_0000, /** - * Initial denomination selection, stored here so that - * we can show this information in the transactions/balances - * before we have a withdrawal group. + * The bank aborted the withdrawal. */ - initialDenomSel: DenomSelectionState; + FailedBankAborted = 0x0501_0001, - reserveStatus: ReserveRecordStatus; + FailedAbortingBank = 0x0501_0002, /** - * Was a reserve query requested? If so, query again instead - * of going into dormant status. + * Aborted in a state where we were supposed to + * talk to the exchange. Money might have been + * wired or not. */ - requestedQuery: boolean; + AbortedExchange = 0x0503_0001, - /** - * Time of the last successful status query. - */ - lastSuccessfulStatusQuery: Timestamp | undefined; + AbortedBank = 0x0503_0002, /** - * Retry info. This field is present even if no retry is scheduled, - * because we need it to be present for the index on the object store - * to work. + * User didn't refused the withdrawal. */ - retryInfo: RetryInfo; + AbortedUserRefused = 0x0503_0003, /** - * Last error that happened in a reserve operation - * (either talking to the bank or the exchange). + * Another wallet confirmed the withdrawal + * (by POSTing the reseve pub to the bank) + * before we had the chance. + * + * In this situation, we'll let the other wallet continue + * and give up ourselves. */ - lastError: TalerErrorDetails | undefined; + AbortedOtherWallet = 0x0503_0004, } /** - * Record that indicates the wallet trusts - * a particular auditor. + * Extra info about a withdrawal that is used + * with a bank-integrated withdrawal. */ -export interface AuditorTrustRecord { +export interface ReserveBankInfo { + talerWithdrawUri: string; + /** - * Currency that we trust this auditor for. + * URL that the user can be redirected to, and allows + * them to confirm (or abort) the bank-integrated withdrawal. */ - currency: string; + confirmUrl: string | undefined; /** - * Base URL of the auditor. + * Exchange payto URI that the bank will use to fund the reserve. */ - auditorBaseUrl: string; + exchangePaytoUri: string; /** - * Public key of the auditor. + * 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. */ - auditorPub: string; + timestampReserveInfoPosted: DbPreciseTimestamp | undefined; /** - * UIDs for the operation of adding this auditor - * as a trusted auditor. + * Time when the reserve was confirmed by the bank. + * + * Set to undefined if not confirmed yet. */ - uids: string[]; + timestampBankConfirmed: DbPreciseTimestamp | undefined; } /** - * Record to indicate trust for a particular exchange. + * Status of a denomination. */ -export interface ExchangeTrustRecord { +export enum DenominationVerificationStatus { /** - * Currency that we trust this exchange for. + * Verification was delayed (pending). */ - currency: string; + Unverified = 0x0100_0000, /** - * Canonicalized exchange base URL. + * Verified as valid. */ - exchangeBaseUrl: string; + VerifiedGood = 0x0500_0000, /** - * Master public key of the exchange. + * Verified as invalid. */ - exchangeMasterPub: string; + VerifiedBad = 0x0501_0000, +} +export interface DenomFees { /** - * UIDs for the operation of adding this exchange - * as trusted. + * Fee for withdrawing. */ - uids: string[]; -} + feeWithdraw: AmountString; -/** - * Status of a denomination. - */ -export enum DenominationVerificationStatus { /** - * Verification was delayed. + * Fee for depositing. */ - Unverified = "unverified", + feeDeposit: AmountString; + /** - * Verified as valid. + * Fee for refreshing. */ - VerifiedGood = "verified-good", + feeRefresh: AmountString; + /** - * Verified as invalid. + * Fee for refunding. */ - VerifiedBad = "verified-bad", + feeRefund: AmountString; } /** @@ -303,14 +442,18 @@ export enum DenominationVerificationStatus { */ export interface DenominationRecord { /** - * Value of one coin of the denomination. + * Currency of the denomination. + * + * Stored separately as we have an index on it. */ - value: AmountJson; + currency: string; + + value: AmountString; /** * The denomination public key. */ - denomPub: string; + denomPub: DenominationPubKey; /** * Hash of the denomination public key. @@ -318,45 +461,27 @@ export interface DenominationRecord { */ denomPubHash: string; - /** - * Fee for withdrawing. - */ - feeWithdraw: AmountJson; - - /** - * Fee for depositing. - */ - feeDeposit: AmountJson; - - /** - * Fee for refreshing. - */ - feeRefresh: AmountJson; - - /** - * Fee for refunding. - */ - feeRefund: AmountJson; + fees: DenomFees; /** * Validity start date of the denomination. */ - stampStart: Timestamp; + stampStart: DbProtocolTimestamp; /** * Date after which the currency can't be withdrawn anymore. */ - stampExpireWithdraw: Timestamp; + stampExpireWithdraw: DbProtocolTimestamp; /** * Date after the denomination officially doesn't exist anymore. */ - stampExpireLegal: Timestamp; + stampExpireLegal: DbProtocolTimestamp; /** * Data after which coins of this denomination can't be deposited anymore. */ - stampExpireDeposit: Timestamp; + stampExpireDeposit: DbProtocolTimestamp; /** * Signature by the exchange's master key over the denomination @@ -384,6 +509,13 @@ export interface DenominationRecord { 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; @@ -393,23 +525,47 @@ export interface DenominationRecord { * 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; /** - * Latest list issue date of the "/keys" response - * that includes this denomination. + * Exchange details that thiis signkeys record belongs to. */ - listIssueDate: Timestamp; + exchangeDetailsRowId: number; } /** - * Information about one of the exchange's bank accounts. + * Exchange details for a particular + * (exchangeBaseUrl, masterPublicKey, currency) tuple. */ -export interface ExchangeBankAccount { - payto_uri: string; - master_sig: string; -} - export interface ExchangeDetailsRecord { + rowId?: number; + /** * Master public key of the exchange. */ @@ -425,102 +581,123 @@ export interface ExchangeDetailsRecord { /** * Auditors (partially) auditing the exchange. */ - auditors: Auditor[]; + auditors: ExchangeAuditor[]; /** * Last observed protocol version. */ - protocolVersion: string; - - reserveClosingDelay: Duration; - - /** - * Signing keys we got from the exchange, can also contain - * older signing keys that are not returned by /keys anymore. - * - * FIXME: Should this be put into a separate object store? - */ - signingKeys: ExchangeSignKeyJson[]; + protocolVersionRange: string; - /** - * Terms of service text or undefined if not downloaded yet. - * - * This is just used as a cache of the last downloaded ToS. - */ - termsOfServiceText: string | undefined; + reserveClosingDelay: TalerProtocolDuration; /** - * content-type of the last downloaded termsOfServiceText. + * Fees for exchange services */ - termsOfServiceContentType: string | undefined; + globalFees: ExchangeGlobalFees[]; - /** - * ETag for last terms of service download. - */ - termsOfServiceLastEtag: string | undefined; + wireInfo: WireInfo; /** - * ETag for last terms of service accepted. + * Age restrictions supported by the exchange (bitmask). */ - termsOfServiceAcceptedEtag: string | undefined; - - /** - * Timestamp when the ToS was accepted. - * - * Used during backup merging. - */ - termsOfServiceAcceptedTimestamp: Timestamp | undefined; - - wireInfo: WireInfo; -} - -export interface WireInfo { - feesForType: { [wireMethod: string]: WireFee[] }; - - accounts: ExchangeBankAccount[]; + ageMask?: number; } export interface ExchangeDetailsPointer { masterPublicKey: string; + currency: string; /** * Timestamp when the (masterPublicKey, currency) pointer * has been updated. */ - updateClock: Timestamp; + 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 ExchangeRecord { +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; + /** - * Is this a permanent or temporary exchange record? + * If set to true, the next update to the exchange + * status will request /keys with no-cache headers set. */ - permanent: boolean; + cachebreakNextUpdate?: boolean; + + /** + * Etag of the current ToS of the exchange. + */ + tosCurrentEtag: string | undefined; + + tosAcceptedEtag: string | undefined; + + tosAcceptedTimestamp: DbPreciseTimestamp | undefined; /** - * Last time when the exchange was updated. + * Last time when the exchange /keys info was updated. */ - lastUpdate: Timestamp | undefined; + lastUpdate: DbPreciseTimestamp | undefined; /** * Next scheduled update for the exchange. - * - * (This field must always be present, so we can index on the timestamp.) */ - nextUpdate: Timestamp; + nextUpdateStamp: DbPreciseTimestamp; + + updateRetryCounter?: number; + + lastKeysEtag: string | undefined; /** * Next time that we should check if coins need to be refreshed. @@ -528,14 +705,30 @@ export interface ExchangeRecord { * Updated whenever the exchange's denominations are updated or when * the refresh check has been done. */ - nextRefreshCheck: Timestamp; + nextRefreshCheckStamp: DbPreciseTimestamp; - lastError?: TalerErrorDetails; + /** + * Public key of the reserve that we're currently using for + * receiving P2P payments. + */ + currentMergeReserveRowId?: number; + + /** + * Defaults to false. + */ + peerPaymentsDisabled?: boolean; /** - * Retry status for fetching updated information about the exchange. + * Defaults to false. */ - retryInfo: RetryInfo; + noFees?: boolean; +} + +export enum PlanchetStatus { + Pending = 0x0100_0000, + KycRequired = 0x0100_0001, + WithdrawalDone = 0x0500_000, + AbortedReplaced = 0x0503_0001, } /** @@ -563,54 +756,27 @@ export interface PlanchetRecord { */ coinIdx: number; - withdrawalDone: boolean; + planchetStatus: PlanchetStatus; - lastError: TalerErrorDetails | undefined; - - /** - * Public key of the reserve that this planchet - * is being withdrawn from. - * - * Can be the empty string (non-null/undefined for DB indexing) - * if this is a tipping reserve. - */ - reservePub: string; + lastError: TalerErrorDetail | undefined; denomPubHash: string; - denomPub: string; - blindingKey: string; withdrawSig: string; - coinEv: string; + coinEv: CoinEnvelope; coinEvHash: string; - coinValue: AmountJson; - - isFromTip: boolean; -} - -/** - * Status of a coin. - */ -export enum CoinStatus { - /** - * Withdrawn and never shown to anybody. - */ - Fresh = "fresh", - /** - * A coin that has been spent and refreshed. - */ - Dormant = "dormant", + ageCommitmentProof?: AgeCommitmentProof; } export enum CoinSourceType { Withdraw = "withdraw", Refresh = "refresh", - Tip = "tip", + Reward = "reward", } export interface WithdrawCoinSource { @@ -634,16 +800,20 @@ export interface WithdrawCoinSource { export interface RefreshCoinSource { type: CoinSourceType.Refresh; + refreshGroupId: string; oldCoinPub: string; } -export interface TipCoinSource { - type: CoinSourceType.Tip; - walletTipId: string; +export interface RewardCoinSource { + type: CoinSourceType.Reward; + walletRewardId: string; coinIndex: number; } -export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource; +export type CoinSource = + | WithdrawCoinSource + | RefreshCoinSource + | RewardCoinSource; /** * CoinRecord as stored in the "coins" data store @@ -656,6 +826,14 @@ export interface CoinRecord { 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; @@ -666,11 +844,6 @@ export interface CoinRecord { coinPriv: string; /** - * Key used by the exchange used to sign the coin. - */ - denomPub: string; - - /** * Hash of the public key that signs the coin. */ denomPubHash: string; @@ -678,12 +851,7 @@ export interface CoinRecord { /** * Unblinded signature by the exchange. */ - denomSig: string; - - /** - * Amount that's left on the coin. - */ - currentAmount: AmountJson; + denomSig: UnblindedSignature; /** * Base URL that identifies the exchange from which we got the @@ -692,11 +860,6 @@ export interface CoinRecord { exchangeBaseUrl: string; /** - * The coin is currently suspended, and will not be used for payments. - */ - suspended: boolean; - - /** * Blinding key used when withdrawing the coin. * Potentionally used again during payback. */ @@ -716,130 +879,67 @@ export interface CoinRecord { status: CoinStatus; /** - * Information about what the coin has been allocated for. - * Used to prevent allocation of the same coin for two different payments. + * Non-zero for visible. + * + * A coin is visible when it is fresh and the + * source transaction is in a final state. */ - allocation?: CoinAllocation; -} + visible?: number; -export interface CoinAllocation { - id: string; - amount: AmountString; -} - -export enum ProposalStatus { - /** - * Not downloaded yet. - */ - DOWNLOADING = "downloading", - /** - * Proposal downloaded, but the user needs to accept/reject it. - */ - PROPOSED = "proposed", /** - * The user has accepted the proposal. - */ - ACCEPTED = "accepted", - /** - * The user has rejected the proposal. - */ - REFUSED = "refused", - /** - * Downloading or processing the proposal has failed permanently. - */ - PERMANENTLY_FAILED = "permanently-failed", - /** - * Downloaded proposal was detected as a re-purchase. + * Information about what the coin has been allocated for. + * + * Used for: + * - Diagnostics + * - Idempotency of applying a coin selection (e.g. after re-selection) */ - REPURCHASE = "repurchase", -} + spendAllocation: CoinAllocation | undefined; -export interface ProposalDownload { /** - * The contract that was offered by the merchant. + * Maximum age of purchases that can be made with this coin. + * + * (Used for indexing, redundant with {@link ageCommitmentProof}). */ - contractTermsRaw: any; + maxAge: number; - contractData: WalletContractData; + ageCommitmentProof: AgeCommitmentProof | undefined; } /** - * Record for a downloaded order, stored in the wallet's database. + * Coin allocation, i.e. what a coin has been used for. */ -export interface ProposalRecord { - orderId: string; - - merchantBaseUrl: string; - - /** - * Downloaded data from the merchant. - */ - download: ProposalDownload | undefined; - - /** - * Unique ID when the order is stored in the wallet DB. - */ - proposalId: string; - - /** - * Timestamp (in ms) of when the record - * was created. - */ - timestamp: Timestamp; - - /** - * Private key for the nonce. - */ - noncePriv: string; - - /** - * Public key for the nonce. - */ - noncePub: string; - - claimToken: string | undefined; - - proposalStatus: ProposalStatus; - - repurchaseProposalId: string | undefined; - - /** - * Session ID we got when downloading the contract. - */ - downloadSessionId?: string; - +export interface CoinAllocation { /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. + * ID of the allocation, should be the ID of the transaction that */ - retryInfo?: RetryInfo; - - lastError: TalerErrorDetails | undefined; + id: TransactionIdStr; + amount: AmountString; } /** - * Status of a tip we got from a merchant. + * Status of a reward we got from a merchant. */ -export interface TipRecord { - lastError: TalerErrorDetails | undefined; - +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: Timestamp | undefined; + acceptedTimestamp: DbPreciseTimestamp | undefined; /** * The tipped amount. */ - tipAmountRaw: AmountJson; + rewardAmountRaw: AmountString; - tipAmountEffective: AmountJson; + /** + * Effect on the balance (including fees etc). + */ + rewardAmountEffective: AmountString; /** * Timestamp, the tip can't be picked up anymore after this deadline. */ - tipExpiration: Timestamp; + rewardExpiration: DbProtocolTimestamp; /** * The exchange that will sign our coins, chosen by the merchant. @@ -854,6 +954,9 @@ export interface TipRecord { /** * Denomination selection made by the wallet for picking up * this tip. + * + * FIXME: Put this into some DenomSelectionCacheRecord instead of + * storing it here! */ denomsSel: DenomSelectionState; @@ -862,7 +965,7 @@ export interface TipRecord { /** * Tip ID chosen by the wallet. */ - walletTipId: string; + walletRewardId: string; /** * Secret seed used to derive planchets for this tip. @@ -870,46 +973,83 @@ export interface TipRecord { secretSeed: string; /** - * The merchant's identifier for this tip. + * The merchant's identifier for this reward. */ - merchantTipId: string; + merchantRewardId: string; - createdTimestamp: Timestamp; + createdTimestamp: DbPreciseTimestamp; /** - * Timestamp for when the wallet finished picking up the tip - * from the merchant. + * The url to be redirected after the tip is accepted. */ - pickedUpTimestamp: Timestamp | undefined; + next_url: string | undefined; /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. + * Timestamp for when the wallet finished picking up the tip + * from the merchant. */ - retryInfo: RetryInfo; + 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 = "pending", - Finished = "finished", + Pending = 0x0100_0000, + Finished = 0x0500_0000, /** * The refresh for this coin has been frozen, because of a permanent error. * More info in lastErrorPerCoin. */ - Frozen = "frozen", + Failed = 0x0501_000, } -export interface RefreshGroupRecord { +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, /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. + * Accepted, but tracking. */ - retryInfo: RetryInfo; + Tracking = 0x0100_0001, + KycRequired = 0x0100_0002, + Wired = 0x0500_0000, + RefundSuccess = 0x0503_0000, + RefundFailed = 0x0501_0000, +} - lastError: TalerErrorDetails | undefined; +export interface RefreshGroupPerExchangeInfo { + /** + * (Expected) output once the refresh group succeeded. + */ + outputEffective: AmountString; +} - lastErrorPerCoin: { [coinIndex: number]: TalerErrorDetails }; +/** + * 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 @@ -918,19 +1058,24 @@ export interface RefreshGroupRecord { refreshGroupId: string; /** + * Currency of this refresh group. + */ + currency: string; + + /** * Reason why this refresh group has been created. */ reason: RefreshReason; + originatingTransactionId?: string; + oldCoinPubs: string[]; - // FIXME: Should this go into a separate - // object store for faster updates? - refreshSessionPerCoin: (RefreshSessionRecord | undefined)[]; + inputPerCoin: AmountString[]; - inputPerCoin: AmountJson[]; + expectedOutputPerCoin: AmountString[]; - estimatedOutputPerCoin: AmountJson[]; + infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>; /** * Flag for each coin whether refreshing finished. @@ -940,23 +1085,25 @@ export interface RefreshGroupRecord { */ statusPerCoin: RefreshCoinStatus[]; - timestampCreated: Timestamp; + timestampCreated: DbPreciseTimestamp; /** * Timestamp when the refresh session finished. */ - timestampFinished: Timestamp | undefined; - - /** - * No coins are pending, but at least one is frozen. - */ - frozen?: boolean; + 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. @@ -967,7 +1114,7 @@ export interface RefreshSessionRecord { * Sum of the value of denominations we want * to withdraw in this session, without fees. */ - amountRefreshOutput: AmountJson; + amountRefreshOutput: AmountString; /** * Hashed denominations of the newly requested coins. @@ -981,173 +1128,178 @@ export interface RefreshSessionRecord { * The no-reveal-index after we've done the melting. */ norevealIndex?: number; + + lastError?: TalerErrorDetail; } -/** - * Wire fee for one wire method as stored in the - * wallet's database. - */ -export interface WireFee { +export enum RefundReason { /** - * Fee for wire transfers. + * Normal refund given by the merchant. */ - wireFee: AmountJson; - + NormalRefund = "normal-refund", /** - * Fees to close and refund a reserve. + * Refund from an aborted payment. */ - closingFee: AmountJson; + AbortRefund = "abort-pay-refund", +} +export enum PurchaseStatus { /** - * Start date of the fee. + * Not downloaded yet. */ - startStamp: Timestamp; + PendingDownloadingProposal = 0x0100_0000, + SuspendedDownloadingProposal = 0x0110_0000, /** - * End date of the fee. + * The user has accepted the proposal. */ - endStamp: Timestamp; + PendingPaying = 0x0100_0001, + SuspendedPaying = 0x0110_0001, /** - * Signature made by the exchange master key. + * Currently in the process of aborting with a refund. */ - sig: string; -} + AbortingWithRefund = 0x0103_0000, + SuspendedAbortingWithRefund = 0x0113_0000, -export enum RefundState { - Failed = "failed", - Applied = "applied", - Pending = "pending", -} - -/** - * State of one refund from the merchant, maintained by the wallet. - */ -export type WalletRefundItem = - | WalletRefundFailedItem - | WalletRefundPendingItem - | WalletRefundAppliedItem; + /** + * Paying a second time, likely with different session ID + */ + PendingPayingReplay = 0x0100_0002, + SuspendedPayingReplay = 0x0110_0002, -export interface WalletRefundItemCommon { - // Execution time as claimed by the merchant - executionTime: Timestamp; + /** + * Query for refunds (until query succeeds). + */ + PendingQueryingRefund = 0x0100_0003, + SuspendedQueryingRefund = 0x0110_0003, /** - * Time when the wallet became aware of the refund. + * Query for refund (until auto-refund deadline is reached). */ - obtainedTime: Timestamp; + PendingQueryingAutoRefund = 0x0100_0004, + SuspendedQueryingAutoRefund = 0x0110_0004, - refundAmount: AmountJson; + PendingAcceptRefund = 0x0100_0005, + SuspendedPendingAcceptRefund = 0x0110_0005, - refundFee: AmountJson; + /** + * Proposal downloaded, but the user needs to accept/reject it. + */ + DialogProposed = 0x0101_0000, /** - * Upper bound on the refresh cost incurred by - * applying this refund. - * - * Might be lower in practice when two refunds on the same - * coin are refreshed in the same refresh operation. + * Proposal shared to other wallet or read from other wallet + * the user needs to accept/reject it. */ - totalRefreshCostBound: AmountJson; + DialogShared = 0x0101_0001, - coinPub: string; + /** + * The user has rejected the proposal. + */ + AbortedProposalRefused = 0x0503_0000, - rtransactionId: number; -} + /** + * Downloading or processing the proposal has failed permanently. + */ + FailedClaim = 0x0501_0000, -/** - * Failed refund, either because the merchant did - * something wrong or it expired. - */ -export interface WalletRefundFailedItem extends WalletRefundItemCommon { - type: RefundState.Failed; -} + /** + * Tried to abort, but aborting failed or was cancelled. + */ + FailedAbort = 0x0501_0001, -export interface WalletRefundPendingItem extends WalletRefundItemCommon { - type: RefundState.Pending; -} + FailedPaidByOther = 0x0501_0002, -export interface WalletRefundAppliedItem extends WalletRefundItemCommon { - type: RefundState.Applied; -} + /** + * Payment was successful. + */ + Done = 0x0500_0000, -export enum RefundReason { /** - * Normal refund given by the merchant. + * Downloaded proposal was detected as a re-purchase. */ - NormalRefund = "normal-refund", + DoneRepurchaseDetected = 0x0500_0001, + /** - * Refund from an aborted payment. + * The payment has been aborted. */ - AbortRefund = "abort-pay-refund", -} + AbortedIncompletePayment = 0x0503_0000, -export interface AllowedAuditorInfo { - auditorBaseUrl: string; - auditorPub: string; -} + AbortedRefunded = 0x0503_0001, -export interface AllowedExchangeInfo { - exchangeBaseUrl: string; - exchangePub: string; + AbortedOrderDeleted = 0x0503_0002, } /** - * Data extracted from the contract terms that is relevant for payment - * processing in the wallet. + * Partial information about the downloaded proposal. + * Only contains data that is relevant for indexing on the + * "purchases" object stores. */ -export interface WalletContractData { - products?: Product[]; - summaryI18n: { [lang_tag: string]: string } | undefined; +export interface ProposalDownloadInfo { + contractTermsHash: string; + fulfillmentUrl?: string; + currency: string; + contractTermsMerchantSig: string; +} +export interface DbCoinSelection { + coinPubs: string[]; + coinContributions: AmountString[]; +} + +export interface PurchasePayInfo { /** - * Fulfillment URL, or the empty string if the order has no fulfillment URL. - * - * Stored as a non-nullable string as we use this field for IndexedDB indexing. + * Undefined if payment is blocked by a pending refund. */ - fulfillmentUrl: string; - - contractTermsHash: string; - fulfillmentMessage?: string; - fulfillmentMessageI18n?: InternationalizedString; - merchantSig: string; - merchantPub: string; - merchant: MerchantInfo; - amount: AmountJson; - orderId: string; - merchantBaseUrl: string; - summary: string; - autoRefund: Duration | undefined; - maxWireFee: AmountJson; - wireFeeAmortization: number; - payDeadline: Timestamp; - refundDeadline: Timestamp; - allowedAuditors: AllowedAuditorInfo[]; - allowedExchanges: AllowedExchangeInfo[]; - timestamp: Timestamp; - wireMethod: string; - wireInfoHash: string; - maxDepositFee: AmountJson; -} - -export enum AbortStatus { - None = "none", - AbortRefund = "abort-refund", - AbortFinished = "abort-finished", + 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; @@ -1159,22 +1311,10 @@ export interface PurchaseRecord { /** * Downloaded and parsed proposal data. - * - * FIXME: Move this into another object store, - * to improve read/write perf on purchases. */ - download: ProposalDownload; + download: ProposalDownloadInfo | undefined; - /** - * Deposit permissions, available once the user has accepted the payment. - * - * This value is cached and derived from payCoinSelection. - */ - coinDepositPermissions: CoinDepositPermission[] | undefined; - - payCoinSelection: PayCoinSelection; - - payCoinSelectionUid: string; + payInfo: PurchasePayInfo | undefined; /** * Pending removals from pay coin selection. @@ -1186,79 +1326,65 @@ export interface PurchaseRecord { */ pendingRemovedCoinPubs?: string[]; - totalPayCost: AmountJson; - /** * Timestamp of the first time that sending a payment to the merchant * for this purchase was successful. */ - timestampFirstSuccessfulPay: Timestamp | undefined; + timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined; merchantPaySig: string | undefined; - /** - * When was the purchase made? - * Refers to the time that the user accepted. - */ - timestampAccept: Timestamp; + posConfirmation: string | undefined; /** - * Pending refunds for the purchase. A refund is pending - * when the merchant reports a transient error from the exchange. - */ - refunds: { [refundKey: string]: WalletRefundItem }; - - /** - * When was the last refund made? - * Set to 0 if no refund was made on the purchase. + * This purchase was created by reading + * a payment share or the wallet + * the nonce public by a payment share */ - timestampLastRefundStatus: Timestamp | undefined; + shared: boolean; /** - * Last session signature that we submitted to /pay (if any). + * When was the purchase record created? */ - lastSessionId: string | undefined; + timestamp: DbPreciseTimestamp; /** - * Set for the first payment, or on re-plays. + * When was the purchase made? + * Refers to the time that the user accepted. */ - paymentSubmitPending: boolean; + timestampAccept: DbPreciseTimestamp | undefined; /** - * Do we need to query the merchant for the refund status - * of the payment? - */ - refundQueryRequested: boolean; - - abortStatus: AbortStatus; - - payRetryInfo?: RetryInfo; - - lastPayError: TalerErrorDetails | undefined; - - /** - * Retry information for querying the refund status with the merchant. + * When was the last refund made? + * Set to 0 if no refund was made on the purchase. */ - refundStatusRetryInfo: RetryInfo; + timestampLastRefundStatus: DbPreciseTimestamp | undefined; /** - * Last error (or undefined) for querying the refund status with the merchant. + * Last session signature that we submitted to /pay (if any). */ - lastRefundStatusError: TalerErrorDetails | undefined; + lastSessionId: string | undefined; /** * Continue querying the refund status until this deadline has expired. */ - autoRefundDeadline: Timestamp | undefined; + autoRefundDeadline: DbProtocolTimestamp | undefined; /** - * Is the payment frozen? I.e. did we encounter - * an error where it doesn't make sense to retry. + * How much merchant has refund to be taken but the wallet + * did not picked up yet */ - payFrozen?: boolean; + refundAmountAwaiting: AmountString | undefined; } -export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; +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 @@ -1266,10 +1392,12 @@ export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; */ export type ConfigRecord = | { - key: typeof WALLET_BACKUP_STATE_KEY; + key: ConfigRecordKey.WalletBackupState; value: WalletBackupConfState; } - | { key: "currencyDefaultsApplied"; value: boolean }; + | { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean } + | { key: ConfigRecordKey.TestLoopTx; value: number } + | { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp }; export interface WalletBackupConfState { deviceId: string; @@ -1284,73 +1412,196 @@ export interface WalletBackupConfState { /** * Timestamp stored in the last backup. */ - lastBackupTimestamp?: Timestamp; + lastBackupTimestamp?: DbPreciseTimestamp; /** * Last time we tried to do a backup. */ - lastBackupCheckTimestamp?: Timestamp; + lastBackupCheckTimestamp?: DbPreciseTimestamp; lastBackupNonce?: string; } -/** - * Selected denominations withn some extra info. - */ -export interface DenomSelectionState { - totalCoinValue: AmountJson; - totalWithdrawCost: AmountJson; - selectedDenoms: { - denomPubHash: string; - count: number; - }[]; +// 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 tip.) + * (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: Timestamp; + timestampStart: DbPreciseTimestamp; /** * When was the withdrawal operation completed? */ - timestampFinish?: Timestamp; + 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: AmountJson; + rawWithdrawalAmount: AmountString; - denomsSel: DenomSelectionState; - - denomSelUid: string; + /** + * 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; /** - * Retry info, always present even on completed operations so that indexing works. + * Denominations selected for withdrawal. */ - retryInfo: RetryInfo; + denomsSel: DenomSelectionState; - lastError: TalerErrorDetails | undefined; + /** + * 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 { @@ -1365,6 +1616,14 @@ export interface BankWithdrawUriRecord { 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. * @@ -1377,9 +1636,13 @@ export interface RecoupGroupRecord { */ recoupGroupId: string; - timestampStarted: Timestamp; + exchangeBaseUrl: string; + + operationStatus: RecoupOperationStatus; - timestampFinished: Timestamp | undefined; + timestampStarted: DbPreciseTimestamp; + + timestampFinished: DbPreciseTimestamp | undefined; /** * Public keys that identify the coins being recouped @@ -1395,27 +1658,10 @@ export interface RecoupGroupRecord { recoupFinishedPerCoin: boolean[]; /** - * We store old amount (i.e. before recoup) of recouped coins here, - * as the balance of a recouped coin is set to zero when the - * recoup group is created. - */ - oldAmountPerCoin: AmountJson[]; - - /** * Public keys of coins that should be scheduled for refreshing * after all individual recoups are done. */ - scheduleRefreshCoins: string[]; - - /** - * Retry info. - */ - retryInfo: RetryInfo; - - /** - * Last error that occurred, if any. - */ - lastError: TalerErrorDetails | undefined; + scheduleRefreshCoins: CoinRefreshRequest[]; } export enum BackupProviderStateTag { @@ -1430,20 +1676,12 @@ export type BackupProviderState = } | { tag: BackupProviderStateTag.Ready; - nextBackupTimestamp: Timestamp; + nextBackupTimestamp: DbPreciseTimestamp; } | { tag: BackupProviderStateTag.Retrying; - retryInfo: RetryInfo; - lastError?: TalerErrorDetails; }; -export interface BackupProviderTerms { - supportedProtocolVersion: string; - annualFee: AmountString; - storageLimitInMegabytes: number; -} - export interface BackupProviderRecord { /** * Base URL of the provider. @@ -1477,7 +1715,7 @@ export interface BackupProviderRecord { * Does NOT correspond to the timestamp of the backup, * which only changes when the backup content changes. */ - lastBackupCycleTimestamp?: Timestamp; + lastBackupCycleTimestamp?: DbPreciseTimestamp; /** * Proposal that we're currently trying to pay for. @@ -1488,6 +1726,8 @@ export interface BackupProviderRecord { */ currentPaymentProposalId?: string; + shouldRetryFreshProposal: boolean; + /** * Proposals that were used to pay (or attempt to pay) the provider. * @@ -1504,12 +1744,60 @@ export interface BackupProviderRecord { 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; @@ -1525,106 +1813,729 @@ export interface DepositGroupRecord { 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<string, DepositInfoPerExchange>; + + /** + * 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:<type>:<key>". + */ + 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; + /** - * Verbatim contract terms. + * Public key of the merge capability of the purse. */ - contractTermsRaw: ContractTerms; + 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; - payCoinSelection: PayCoinSelection; + mergePub: string; + mergePriv: string; - payCoinSelectionUid: string; + contractPub: string; + contractPriv: string; - totalPayCost: AmountJson; + contractEncNonce: string; - effectiveDepositAmount: AmountJson; + mergeTimestamp: DbPreciseTimestamp; - depositedPerCoin: boolean[]; + mergeReserveRowId: number; - timestampCreated: Timestamp; + /** + * Status of the peer pull payment initiation. + */ + status: PeerPullPaymentCreditStatus; - timestampFinished: Timestamp | undefined; + kycInfo?: KycPendingInfo; - lastError: TalerErrorDetails | undefined; + kycUrl?: string; + + withdrawalGroupId: string | undefined; +} +export enum PeerPushCreditStatus { + PendingMerge = 0x0100_0000, + PendingMergeKycRequired = 0x0100_0001, /** - * Retry info. + * Merge was successful and withdrawal group has been created, now + * everything is in the hand of the withdrawal group. */ - retryInfo?: RetryInfo; + 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 deposits that the wallet observed - * as a result of double spending, but which is not - * present in the wallet's own database otherwise. + * Record for a push P2P payment that this wallet was offered. + * + * Unique: (exchangeBaseUrl, pursePub) */ -export interface GhostDepositGroupRecord { +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[]; + /** - * When multiple deposits for the same contract terms hash - * have a different timestamp, we choose the earliest one. + * Total cost based on the coin selection. + * Non undefined after status === "Accepted" */ - timestamp: Timestamp; + totalCost: AmountString | undefined; +} + +/** + * AKA PeerPullDebit. + */ +export interface PeerPullPaymentIncomingRecord { + peerPullDebitId: string; + + pursePub: string; + + exchangeBaseUrl: string; + + amount: AmountString; contractTermsHash: string; - deposits: { - coinPub: string; - amount: AmountString; - timestamp: Timestamp; - depositFee: AmountString; - merchantPub: string; - coinSig: string; - wireHash: 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; } -export interface TombstoneRecord { +/** + * 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 { /** - * Tombstone ID, with the syntax "<type>:<key>". + * 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<T>(): Codec<T> { + 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<DenomLossEventRecord>(), + storeName: "denomLossEvents", + keyPath: "denomLossEventId", + versionAdded: 9, + indexes: { + byCurrency: describeIndex("byCurrency", "currency", { + versionAdded: 9, + }), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 10, + }), + }, + }), + transactions: describeStoreV2({ + recordCodec: passthroughCodec<TransactionRecord>(), + 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<GlobalCurrencyAuditorRecord>(), + storeName: "globalCurrencyAuditors", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "auditorBaseUrl", "auditorPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), + globalCurrencyExchanges: describeStoreV2({ + recordCodec: passthroughCodec<GlobalCurrencyExchangeRecord>(), + storeName: "globalCurrencyExchanges", + keyPath: "id", + autoIncrement: true, + versionAdded: 3, + indexes: { + byCurrencyAndUrlAndPub: describeIndex( + "byCurrencyAndUrlAndPub", + ["currency", "exchangeBaseUrl", "exchangeMasterPub"], + { + unique: true, + versionAdded: 4, + }, + ), + }, + }), + coinAvailability: describeStore( + "coinAvailability", + describeContents<CoinAvailabilityRecord>({ + keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"], + }), + { + byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [ + "exchangeBaseUrl", + "maxAge", + "freshCoinCount", + ]), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 8, + }), + }, + ), coins: describeStore( - describeContents<CoinRecord>("coins", { + "coins", + describeContents<CoinRecord>({ 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, + }, + ), }, ), - config: describeStore( - describeContents<ConfigRecord>("config", { keyPath: "key" }), - {}, - ), - auditorTrust: describeStore( - describeContents<AuditorTrustRecord>("auditorTrust", { - keyPath: ["currency", "auditorBaseUrl"], + reserves: describeStore( + "reserves", + describeContents<ReserveRecord>({ + keyPath: "rowId", + autoIncrement: true, }), { - byAuditorPub: describeIndex("byAuditorPub", "auditorPub"), - byUid: describeIndex("byUid", "uids", { - multiEntry: true, - }), + byReservePub: describeIndex("byReservePub", "reservePub", {}), }, ), - exchangeTrust: describeStore( - describeContents<ExchangeTrustRecord>("exchangeTrust", { - keyPath: ["currency", "exchangeBaseUrl"], - }), - { - byExchangeMasterPub: describeIndex( - "byExchangeMasterPub", - "exchangeMasterPub", - ), - }, + config: describeStore( + "config", + describeContents<ConfigRecord>({ keyPath: "key" }), + {}, ), denominations: describeStore( - describeContents<DenominationRecord>("denominations", { + "denominations", + describeContents<DenominationRecord>({ keyPath: ["exchangeBaseUrl", "denomPubHash"], }), { @@ -1632,96 +2543,145 @@ export const WalletStoresV1 = { }, ), exchanges: describeStore( - describeContents<ExchangeRecord>("exchanges", { + "exchanges", + describeContents<ExchangeEntryRecord>({ keyPath: "baseUrl", }), {}, ), exchangeDetails: describeStore( - describeContents<ExchangeDetailsRecord>("exchangeDetails", { - keyPath: ["exchangeBaseUrl", "currency", "masterPublicKey"], + "exchangeDetails", + describeContents<ExchangeDetailsRecord>({ + keyPath: "rowId", + autoIncrement: true, }), - {}, + { + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), + byPointer: describeIndex( + "byDetailsPointer", + ["exchangeBaseUrl", "currency", "masterPublicKey"], + { + unique: true, + }, + ), + }, ), - proposals: describeStore( - describeContents<ProposalRecord>("proposals", { keyPath: "proposalId" }), + exchangeSignKeys: describeStore( + "exchangeSignKeys", + describeContents<ExchangeSignkeysRecord>({ + keyPath: ["exchangeDetailsRowId", "signkeyPub"], + }), { - byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ - "merchantBaseUrl", - "orderId", + byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [ + "exchangeDetailsRowId", ]), }, ), refreshGroups: describeStore( - describeContents<RefreshGroupRecord>("refreshGroups", { + "refreshGroups", + describeContents<RefreshGroupRecord>({ keyPath: "refreshGroupId", }), + { + byStatus: describeIndex("byStatus", "operationStatus"), + byOriginatingTransactionId: describeIndex( + "byOriginatingTransactionId", + "originatingTransactionId", + { + versionAdded: 5, + }, + ), + }, + ), + refreshSessions: describeStore( + "refreshSessions", + describeContents<RefreshSessionRecord>({ + keyPath: ["refreshGroupId", "coinIndex"], + }), {}, ), recoupGroups: describeStore( - describeContents<RecoupGroupRecord>("recoupGroups", { + "recoupGroups", + describeContents<RecoupGroupRecord>({ keyPath: "recoupGroupId", }), - {}, - ), - reserves: describeStore( - describeContents<ReserveRecord>("reserves", { keyPath: "reservePub" }), { - byInitialWithdrawalGroupId: describeIndex( - "byInitialWithdrawalGroupId", - "initialWithdrawalGroupId", - ), + byStatus: describeIndex("byStatus", "operationStatus", { + versionAdded: 6, + }), }, ), purchases: describeStore( - describeContents<PurchaseRecord>("purchases", { keyPath: "proposalId" }), + "purchases", + describeContents<PurchaseRecord>({ keyPath: "proposalId" }), { + byStatus: describeIndex("byStatus", "purchaseStatus"), byFulfillmentUrl: describeIndex( "byFulfillmentUrl", - "download.contractData.fulfillmentUrl", + "download.fulfillmentUrl", ), - byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [ - "download.contractData.merchantBaseUrl", - "download.contractData.orderId", + byUrlAndOrderId: describeIndex("byUrlAndOrderId", [ + "merchantBaseUrl", + "orderId", ]), }, ), - tips: describeStore( - describeContents<TipRecord>("tips", { keyPath: "walletTipId" }), + rewards: describeStore( + "rewards", + describeContents<RewardRecord>({ keyPath: "walletRewardId" }), { - byMerchantTipIdAndBaseUrl: describeIndex("byMerchantTipIdAndBaseUrl", [ - "merchantTipId", + byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ + "merchantRewardId", "merchantBaseUrl", ]), + byStatus: describeIndex("byStatus", "status", { + versionAdded: 8, + }), }, ), withdrawalGroups: describeStore( - describeContents<WithdrawalGroupRecord>("withdrawalGroups", { + "withdrawalGroups", + describeContents<WithdrawalGroupRecord>({ keyPath: "withdrawalGroupId", }), { - byReservePub: describeIndex("byReservePub", "reservePub"), + byStatus: describeIndex("byStatus", "status"), + byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", { + versionAdded: 2, + }), + byTalerWithdrawUri: describeIndex( + "byTalerWithdrawUri", + "wgInfo.bankInfo.talerWithdrawUri", + ), }, ), planchets: describeStore( - describeContents<PlanchetRecord>("planchets", { keyPath: "coinPub" }), + "planchets", + describeContents<PlanchetRecord>({ keyPath: "coinPub" }), { - byGroupAndIndex: describeIndex("byGroupAndIndex", [ - "withdrawalGroupId", - "coinIdx", - ]), + byGroupAndIndex: describeIndex( + "byGroupAndIndex", + ["withdrawalGroupId", "coinIdx"], + { + unique: true, + }, + ), byGroup: describeIndex("byGroup", "withdrawalGroupId"), byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"), }, ), bankWithdrawUris: describeStore( - describeContents<BankWithdrawUriRecord>("bankWithdrawUris", { + "bankWithdrawUris", + describeContents<BankWithdrawUriRecord>({ keyPath: "talerWithdrawUri", }), {}, ), backupProviders: describeStore( - describeContents<BackupProviderRecord>("backupProviders", { + "backupProviders", + describeContents<BackupProviderRecord>({ keyPath: "baseUrl", }), { @@ -1735,23 +2695,184 @@ export const WalletStoresV1 = { }, ), depositGroups: describeStore( - describeContents<DepositGroupRecord>("depositGroups", { + "depositGroups", + describeContents<DepositGroupRecord>({ keyPath: "depositGroupId", }), - {}, + { + byStatus: describeIndex("byStatus", "operationStatus"), + }, ), tombstones: describeStore( - describeContents<TombstoneRecord>("tombstones", { keyPath: "id" }), + "tombstones", + describeContents<TombstoneRecord>({ keyPath: "id" }), {}, ), - ghostDepositGroups: describeStore( - describeContents<GhostDepositGroupRecord>("ghostDepositGroups", { - keyPath: "contractTermsHash", + operationRetries: describeStore( + "operationRetries", + describeContents<OperationRetryRecord>({ + keyPath: "id", + }), + {}, + ), + peerPushCredit: describeStore( + "peerPushCredit", + describeContents<PeerPushPaymentIncomingRecord>({ + 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"), + }, + ), + bankAccounts: describeStore( + "bankAccounts", + describeContents<BankAccountsRecord>({ + keyPath: "uri", + }), + {}, + ), + contractTerms: describeStore( + "contractTerms", + describeContents<ContractTermsRecord>({ + keyPath: "h", + }), + {}, + ), + userAttention: describeStore( + "userAttention", + describeContents<UserAttentionRecord>({ + keyPath: ["entityId", "info.type"], + }), + {}, + ), + refundGroups: describeStore( + "refundGroups", + describeContents<RefundGroupRecord>({ + keyPath: "refundGroupId", + }), + { + byProposalId: describeIndex("byProposalId", "proposalId"), + byStatus: describeIndex("byStatus", "status", {}), + }, + ), + refundItems: describeStore( + "refundItems", + describeContents<RefundItemRecord>({ + 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", }), {}, ), }; +export type WalletDbStoresArr = Array<StoreNames<typeof WalletStoresV1>>; + +export type WalletDbReadWriteTransaction<StoresArr extends WalletDbStoresArr> = + DbReadWriteTransaction<typeof WalletStoresV1, StoresArr>; + +export type WalletDbReadOnlyTransaction<StoresArr extends WalletDbStoresArr> = + DbReadOnlyTransaction<typeof WalletStoresV1, StoresArr>; + +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; @@ -1759,7 +2880,518 @@ export interface MetaConfigRecord { export const walletMetadataStore = { metaConfig: describeStore( - describeContents<MetaConfigRecord>("metaConfig", { keyPath: "key" }), + "metaConfig", + describeContents<MetaConfigRecord>({ keyPath: "key" }), {}, ), }; + +export interface StoredBackupMeta { + name: string; +} + +export const StoredBackupStores = { + backupMeta: describeStore( + "backupMeta", + describeContents<StoredBackupMeta>({ keyPath: "name" }), + {}, + ), + backupData: describeStore("backupData", describeContents<any>({}), {}), +}; + +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<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) => { + 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<DbDump> { + 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<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]; + 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<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: DbReadWriteTransaction< + typeof WalletStoresV1, + Array<StoreNames<typeof WalletStoresV1>> + >, + ): Promise<void>; +} + +/** + * 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<typeof WalletStoresV1>, +): Promise<void> { + 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<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}`, + // @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<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]); + } + 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<DbAccess<typeof StoredBackupStores>> { + 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<IDBDatabase> { + 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<void> { + return new Promise((resolve, reject) => { + const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME); + req.onerror = () => reject(req.error); + req.onsuccess = () => resolve(); + }); +} |