/*
This file is part of GNU Taler
(C) 2021-2022 Taler Systems S.A.
GNU Taler is free software; you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software
Foundation; either version 3, or (at your option) any later version.
GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
GNU Taler; see the file COPYING. If not, see
*/
/**
* Imports.
*/
import {
Event,
IDBDatabase,
IDBFactory,
IDBObjectStore,
IDBRequest,
IDBTransaction,
structuredEncapsulate,
structuredRevive,
} from "@gnu-taler/idb-bridge";
import {
AbsoluteTime,
AgeCommitmentProof,
AmountString,
Amounts,
AttentionInfo,
BackupProviderTerms,
Codec,
CoinEnvelope,
CoinPublicKeyString,
CoinRefreshRequest,
CoinStatus,
DenomSelectionState,
DenominationInfo,
DenominationPubKey,
EddsaPublicKeyString,
EddsaSignatureString,
ExchangeAuditor,
ExchangeGlobalFees,
HashCodeString,
Logger,
PayCoinSelection,
RefreshReason,
TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolDuration,
TalerProtocolTimestamp,
TransactionIdStr,
UnblindedSignature,
WireInfo,
WithdrawalExchangeAccountDetails,
codecForAny,
} from "@gnu-taler/taler-util";
import { DbRetryInfo, TaskIdentifiers } from "./common.js";
import {
DbAccess,
DbReadOnlyTransaction,
DbReadWriteTransaction,
IndexDescriptor,
StoreDescriptor,
StoreNames,
StoreWithIndexes,
describeContents,
describeIndex,
describeStore,
describeStoreV2,
openDatabase,
} from "./query.js";
/**
* This file contains the database schema of the Taler wallet together
* with some helper functions.
*
* Some design considerations:
* - By convention, each object store must have a corresponding "Record"
* interface defined for it.
* - For records that represent operations, there should be exactly
* one top-level enum field that indicates the status of the operation.
* This field should be present even if redundant, because the field
* will have an index.
* - Amounts are stored as strings, except when they are needed for
* indexing.
* - Every record that has a corresponding transaction item must have
* an index for a mandatory timestamp field.
* - Optional fields should be avoided, use "T | undefined" instead.
* - Do all records have some obvious, indexed field that can
* be used for range queries?
*
* @author Florian Dold
*/
/**
FIXMEs:
- Contract terms can be quite large. We currently tend to read the
full contract terms from the DB quite often.
Instead, we should probably extract what we need into a separate object
store.
- More object stores should have an "id" primary key,
as this makes referencing less expensive.
- Coin selections should probably go into a separate object store.
- Some records should be split up into an extra "details" record
that we don't always need to iterate over.
*/
/**
* Name of the Taler database. This is effectively the major
* version of the DB schema. Whenever it changes, custom import logic
* for all previous versions must be written, which should be
* avoided.
*/
export const TALER_WALLET_MAIN_DB_NAME = "taler-wallet-main-v10";
/**
* Name of the metadata database. This database is used
* to track major migrations of the main Taler database.
*
* (Minor migrations are handled via upgrade transactions.)
*/
export const TALER_WALLET_META_DB_NAME = "taler-wallet-meta";
/**
* Name of the "stored backups" database.
* Stored backups are created before manually importing a backup.
* We use IndexedDB for this purpose, since we don't have file system
* access on some platforms.
*/
export const TALER_WALLET_STORED_BACKUPS_DB_NAME =
"taler-wallet-stored-backups";
/**
* Name of the "meta config" database.
*/
export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
/**
* Current database minor version, should be incremented
* each time we do minor schema changes on the database.
* A change is considered minor when fields are added in a
* backwards-compatible way or object stores and indices
* are added.
*/
export const WALLET_DB_MINOR_VERSION = 6;
declare const symDbProtocolTimestamp: unique symbol;
declare const symDbPreciseTimestamp: unique symbol;
/**
* Timestamp, stored as microseconds.
*
* Always rounded to a full second.
*/
export type DbProtocolTimestamp = number & { [symDbProtocolTimestamp]: true };
/**
* Timestamp, stored as microseconds.
*/
export type DbPreciseTimestamp = number & { [symDbPreciseTimestamp]: true };
const DB_TIMESTAMP_FOREVER = Number.MAX_SAFE_INTEGER;
export function timestampPreciseFromDb(
dbTs: DbPreciseTimestamp,
): TalerPreciseTimestamp {
return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}
export function timestampOptionalPreciseFromDb(
dbTs: DbPreciseTimestamp | undefined,
): TalerPreciseTimestamp | undefined {
if (!dbTs) {
return undefined;
}
return TalerPreciseTimestamp.fromMilliseconds(Math.floor(dbTs / 1000));
}
export function timestampPreciseToDb(
stamp: TalerPreciseTimestamp,
): DbPreciseTimestamp {
if (stamp.t_s === "never") {
return DB_TIMESTAMP_FOREVER as DbPreciseTimestamp;
} else {
let tUs = stamp.t_s * 1000000;
if (stamp.off_us) {
tUs += stamp.off_us;
}
return tUs as DbPreciseTimestamp;
}
}
export function timestampProtocolToDb(
stamp: TalerProtocolTimestamp,
): DbProtocolTimestamp {
if (stamp.t_s === "never") {
return DB_TIMESTAMP_FOREVER as DbProtocolTimestamp;
} else {
let tUs = stamp.t_s * 1000000;
return tUs as DbProtocolTimestamp;
}
}
export function timestampProtocolFromDb(
stamp: DbProtocolTimestamp,
): TalerProtocolTimestamp {
return TalerProtocolTimestamp.fromSeconds(Math.floor(stamp / 1000000));
}
export function timestampAbsoluteFromDb(
stamp: DbProtocolTimestamp | DbPreciseTimestamp,
): AbsoluteTime {
if (stamp >= DB_TIMESTAMP_FOREVER) {
return AbsoluteTime.never();
}
return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}
export function timestampOptionalAbsoluteFromDb(
stamp: DbProtocolTimestamp | DbPreciseTimestamp | undefined,
): AbsoluteTime | undefined {
if (stamp == null) {
return undefined;
}
if (stamp >= DB_TIMESTAMP_FOREVER) {
return AbsoluteTime.never();
}
return AbsoluteTime.fromMilliseconds(Math.floor(stamp / 1000));
}
/**
* Format of the operation status code: 0x0abc_nnnn
* a=1: active
* 0x0100_nnnn: pending
* 0x0101_nnnn: dialog
* 0x0102_nnnn: (reserved)
* 0x0103_nnnn: aborting
* 0x0110_nnnn: suspended
* 0x0113_nnnn: suspended-aborting
* a=5: final
* 0x0500_nnnn: done
* 0x0501_nnnn: failed
* 0x0502_nnnn: expired
* 0x0503_nnnn: aborted
*
* nnnn=0000 should always be the most generic minor state for the major state
*/
/**
* First possible operation status in the active range (inclusive).
*/
export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000;
/**
* LAST possible operation status in the active range (inclusive).
*/
export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff;
/**
* Status of a withdrawal.
*/
export enum WithdrawalGroupStatus {
/**
* Reserve must be registered with the bank.
*/
PendingRegisteringBank = 0x0100_0001,
SuspendedRegisteringBank = 0x0110_0001,
/**
* We've registered reserve's information with the bank
* and are now waiting for the user to confirm the withdraw
* with the bank (typically 2nd factor auth).
*/
PendingWaitConfirmBank = 0x0100_0002,
SuspendedWaitConfirmBank = 0x0110_0002,
/**
* Querying reserve status with the exchange.
*/
PendingQueryingStatus = 0x0100_0003,
SuspendedQueryingStatus = 0x0110_0003,
/**
* Ready for withdrawal.
*/
PendingReady = 0x0100_0004,
SuspendedReady = 0x0110_0004,
/**
* We are telling the bank that we don't want to complete
* the withdrawal!
*/
AbortingBank = 0x0103_0001,
SuspendedAbortingBank = 0x0113_0001,
/**
* Exchange wants KYC info from the user.
*/
PendingKyc = 0x0100_0005,
SuspendedKyc = 0x0110_005,
/**
* Exchange is doing AML checks.
*/
PendingAml = 0x0100_0006,
SuspendedAml = 0x0110_0006,
/**
* The corresponding withdraw record has been created.
* No further processing is done, unless explicitly requested
* by the user.
*/
Done = 0x0500_0000,
/**
* The bank aborted the withdrawal.
*/
FailedBankAborted = 0x0501_0001,
FailedAbortingBank = 0x0501_0002,
/**
* Aborted in a state where we were supposed to
* talk to the exchange. Money might have been
* wired or not.
*/
AbortedExchange = 0x0503_0001,
AbortedBank = 0x0503_0002,
}
/**
* Extra info about a withdrawal that is used
* with a bank-integrated withdrawal.
*/
export interface ReserveBankInfo {
talerWithdrawUri: string;
/**
* URL that the user can be redirected to, and allows
* them to confirm (or abort) the bank-integrated withdrawal.
*/
confirmUrl: string | undefined;
/**
* Exchange payto URI that the bank will use to fund the reserve.
*/
exchangePaytoUri: string;
/**
* Time when the information about this reserve was posted to the bank.
*
* Only applies if bankWithdrawStatusUrl is defined.
*
* Set to undefined if that hasn't happened yet.
*/
timestampReserveInfoPosted: DbPreciseTimestamp | undefined;
/**
* Time when the reserve was confirmed by the bank.
*
* Set to undefined if not confirmed yet.
*/
timestampBankConfirmed: DbPreciseTimestamp | undefined;
}
/**
* Status of a denomination.
*/
export enum DenominationVerificationStatus {
/**
* Verification was delayed (pending).
*/
Unverified = 0x0100_0000,
/**
* Verified as valid.
*/
VerifiedGood = 0x0500_0000,
/**
* Verified as invalid.
*/
VerifiedBad = 0x0501_0000,
}
export interface DenomFees {
/**
* Fee for withdrawing.
*/
feeWithdraw: AmountString;
/**
* Fee for depositing.
*/
feeDeposit: AmountString;
/**
* Fee for refreshing.
*/
feeRefresh: AmountString;
/**
* Fee for refunding.
*/
feeRefund: AmountString;
}
/**
* Denomination record as stored in the wallet's database.
*/
export interface DenominationRecord {
/**
* Currency of the denomination.
*
* Stored separately as we have an index on it.
*/
currency: string;
value: AmountString;
/**
* The denomination public key.
*/
denomPub: DenominationPubKey;
/**
* Hash of the denomination public key.
* Stored in the database for faster lookups.
*/
denomPubHash: string;
fees: DenomFees;
/**
* Validity start date of the denomination.
*/
stampStart: DbProtocolTimestamp;
/**
* Date after which the currency can't be withdrawn anymore.
*/
stampExpireWithdraw: DbProtocolTimestamp;
/**
* Date after the denomination officially doesn't exist anymore.
*/
stampExpireLegal: DbProtocolTimestamp;
/**
* Data after which coins of this denomination can't be deposited anymore.
*/
stampExpireDeposit: DbProtocolTimestamp;
/**
* Signature by the exchange's master key over the denomination
* information.
*/
masterSig: string;
/**
* Did we verify the signature on the denomination?
*/
verificationStatus: DenominationVerificationStatus;
/**
* Was this denomination still offered by the exchange the last time
* we checked?
* Only false when the exchange redacts a previously published denomination.
*/
isOffered: boolean;
/**
* Did the exchange revoke the denomination?
* When this field is set to true in the database, the same transaction
* should also mark all affected coins as revoked.
*/
isRevoked: boolean;
/**
* Base URL of the exchange.
*/
exchangeBaseUrl: string;
/**
* Master public key of the exchange that made the signature
* on the denomination.
*/
exchangeMasterPub: string;
/**
* Latest list issue date of the "/keys" response
* that includes this denomination.
*/
listIssueDate: DbProtocolTimestamp;
}
export namespace DenominationRecord {
export function toDenomInfo(d: DenominationRecord): DenominationInfo {
return {
denomPub: d.denomPub,
denomPubHash: d.denomPubHash,
feeDeposit: Amounts.stringify(d.fees.feeDeposit),
feeRefresh: Amounts.stringify(d.fees.feeRefresh),
feeRefund: Amounts.stringify(d.fees.feeRefund),
feeWithdraw: Amounts.stringify(d.fees.feeWithdraw),
stampExpireDeposit: timestampProtocolFromDb(d.stampExpireDeposit),
stampExpireLegal: timestampProtocolFromDb(d.stampExpireLegal),
stampExpireWithdraw: timestampProtocolFromDb(d.stampExpireWithdraw),
stampStart: timestampProtocolFromDb(d.stampStart),
value: Amounts.stringify(d.value),
exchangeBaseUrl: d.exchangeBaseUrl,
};
}
}
export interface ExchangeSignkeysRecord {
stampStart: DbProtocolTimestamp;
stampExpire: DbProtocolTimestamp;
stampEnd: DbProtocolTimestamp;
signkeyPub: EddsaPublicKeyString;
masterSig: EddsaSignatureString;
/**
* Exchange details that thiis signkeys record belongs to.
*/
exchangeDetailsRowId: number;
}
/**
* Exchange details for a particular
* (exchangeBaseUrl, masterPublicKey, currency) tuple.
*/
export interface ExchangeDetailsRecord {
rowId?: number;
/**
* Master public key of the exchange.
*/
masterPublicKey: string;
exchangeBaseUrl: string;
/**
* Currency that the exchange offers.
*/
currency: string;
/**
* Auditors (partially) auditing the exchange.
*/
auditors: ExchangeAuditor[];
/**
* Last observed protocol version.
*/
protocolVersionRange: string;
reserveClosingDelay: TalerProtocolDuration;
/**
* Fees for exchange services
*/
globalFees: ExchangeGlobalFees[];
wireInfo: WireInfo;
/**
* Age restrictions supported by the exchange (bitmask).
*/
ageMask?: number;
}
export interface ExchangeDetailsPointer {
masterPublicKey: string;
currency: string;
/**
* Timestamp when the (masterPublicKey, currency) pointer
* has been updated.
*/
updateClock: DbPreciseTimestamp;
}
export enum ExchangeEntryDbRecordStatus {
Preset = 1,
Ephemeral = 2,
Used = 3,
}
// FIXME: Use status ranges for this as well?
export enum ExchangeEntryDbUpdateStatus {
Initial = 1,
InitialUpdate = 2,
Suspended = 3,
UnavailableUpdate = 4,
// Reserved 5 for backwards compatibility.
Ready = 6,
ReadyUpdate = 7,
}
/**
* Exchange record as stored in the wallet's database.
*/
export interface ExchangeEntryRecord {
/**
* Base url of the exchange.
*/
baseUrl: string;
/**
* Currency hint for a preset exchange, relevant
* when we didn't contact a preset exchange yet.
*/
presetCurrencyHint?: string;
/**
* When did we confirm the last withdrawal from this exchange?
*
* Used mostly in the UI to suggest exchanges.
*/
lastWithdrawal?: DbPreciseTimestamp;
/**
* Pointer to the current exchange details.
*
* Should usually not change. Only changes when the
* exchange advertises a different master public key and/or
* currency.
*
* We could use a rowID here, but having the currency in the
* details pointer lets us do fewer DB queries
*/
detailsPointer: ExchangeDetailsPointer | undefined;
entryStatus: ExchangeEntryDbRecordStatus;
updateStatus: ExchangeEntryDbUpdateStatus;
/**
* If set to true, the next update to the exchange
* status will request /keys with no-cache headers set.
*/
cachebreakNextUpdate?: boolean;
/**
* Etag of the current ToS of the exchange.
*/
tosCurrentEtag: string | undefined;
tosAcceptedEtag: string | undefined;
tosAcceptedTimestamp: DbPreciseTimestamp | undefined;
/**
* Last time when the exchange /keys info was updated.
*/
lastUpdate: DbPreciseTimestamp | undefined;
/**
* Next scheduled update for the exchange.
*/
nextUpdateStamp: DbPreciseTimestamp;
lastKeysEtag: string | undefined;
/**
* Next time that we should check if coins need to be refreshed.
*
* Updated whenever the exchange's denominations are updated or when
* the refresh check has been done.
*/
nextRefreshCheckStamp: DbPreciseTimestamp;
/**
* Public key of the reserve that we're currently using for
* receiving P2P payments.
*/
currentMergeReserveRowId?: number;
}
export enum PlanchetStatus {
Pending = 0x0100_0000,
KycRequired = 0x0100_0001,
WithdrawalDone = 0x0500_000,
}
/**
* A coin that isn't yet signed by an exchange.
*/
export interface PlanchetRecord {
/**
* Public key of the coin.
*/
coinPub: string;
/**
* Private key of the coin.
*/
coinPriv: string;
/**
* Withdrawal group that this planchet belongs to
* (or the empty string).
*/
withdrawalGroupId: string;
/**
* Index within the withdrawal group (or -1).
*/
coinIdx: number;
planchetStatus: PlanchetStatus;
lastError: TalerErrorDetail | undefined;
denomPubHash: string;
blindingKey: string;
withdrawSig: string;
coinEv: CoinEnvelope;
coinEvHash: string;
ageCommitmentProof?: AgeCommitmentProof;
}
export enum CoinSourceType {
Withdraw = "withdraw",
Refresh = "refresh",
Reward = "reward",
}
export interface WithdrawCoinSource {
type: CoinSourceType.Withdraw;
/**
* Can be the empty string for orphaned coins.
*/
withdrawalGroupId: string;
/**
* Index of the coin in the withdrawal session.
*/
coinIndex: number;
/**
* Reserve public key for the reserve we got this coin from.
*/
reservePub: string;
}
export interface RefreshCoinSource {
type: CoinSourceType.Refresh;
refreshGroupId: string;
oldCoinPub: string;
}
export interface RewardCoinSource {
type: CoinSourceType.Reward;
walletRewardId: string;
coinIndex: number;
}
export type CoinSource =
| WithdrawCoinSource
| RefreshCoinSource
| RewardCoinSource;
/**
* CoinRecord as stored in the "coins" data store
* of the wallet database.
*/
export interface CoinRecord {
/**
* Where did the coin come from? Used for recouping coins.
*/
coinSource: CoinSource;
/**
* Source transaction ID of the coin.
*
* Used to make the coin visible after the transaction
* has entered a final state.
*/
sourceTransactionId?: string;
/**
* Public key of the coin.
*/
coinPub: string;
/**
* Private key to authorize operations on the coin.
*/
coinPriv: string;
/**
* Hash of the public key that signs the coin.
*/
denomPubHash: string;
/**
* Unblinded signature by the exchange.
*/
denomSig: UnblindedSignature;
/**
* Base URL that identifies the exchange from which we got the
* coin.
*/
exchangeBaseUrl: string;
/**
* Blinding key used when withdrawing the coin.
* Potentionally used again during payback.
*/
blindingKey: string;
/**
* Hash of the coin envelope.
*
* Stored here for indexing purposes, so that when looking at a
* reserve history, we can quickly find the coin for a withdrawal transaction.
*/
coinEvHash: string;
/**
* Status of the coin.
*/
status: CoinStatus;
/**
* Non-zero for visible.
*
* A coin is visible when it is fresh and the
* source transaction is in a final state.
*/
visible?: number;
/**
* Information about what the coin has been allocated for.
*
* Used for:
* - Diagnostics
* - Idempotency of applying a coin selection (e.g. after re-selection)
*/
spendAllocation: CoinAllocation | undefined;
/**
* Maximum age of purchases that can be made with this coin.
*
* (Used for indexing, redundant with {@link ageCommitmentProof}).
*/
maxAge: number;
ageCommitmentProof: AgeCommitmentProof | undefined;
}
/**
* Coin allocation, i.e. what a coin has been used for.
*/
export interface CoinAllocation {
/**
* ID of the allocation, should be the ID of the transaction that
*/
id: TransactionIdStr;
amount: AmountString;
}
/**
* Status of a reward we got from a merchant.
*/
export interface RewardRecord {
/**
* Has the user accepted the tip? Only after the tip has been accepted coins
* withdrawn from the tip may be used.
*/
acceptedTimestamp: DbPreciseTimestamp | undefined;
/**
* The tipped amount.
*/
rewardAmountRaw: AmountString;
/**
* Effect on the balance (including fees etc).
*/
rewardAmountEffective: AmountString;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
*/
rewardExpiration: DbProtocolTimestamp;
/**
* The exchange that will sign our coins, chosen by the merchant.
*/
exchangeBaseUrl: string;
/**
* Base URL of the merchant that is giving us the tip.
*/
merchantBaseUrl: string;
/**
* Denomination selection made by the wallet for picking up
* this tip.
*
* FIXME: Put this into some DenomSelectionCacheRecord instead of
* storing it here!
*/
denomsSel: DenomSelectionState;
denomSelUid: string;
/**
* Tip ID chosen by the wallet.
*/
walletRewardId: string;
/**
* Secret seed used to derive planchets for this tip.
*/
secretSeed: string;
/**
* The merchant's identifier for this reward.
*/
merchantRewardId: string;
createdTimestamp: DbPreciseTimestamp;
/**
* The url to be redirected after the tip is accepted.
*/
next_url: string | undefined;
/**
* Timestamp for when the wallet finished picking up the tip
* from the merchant.
*/
pickedUpTimestamp: DbPreciseTimestamp | undefined;
status: RewardRecordStatus;
}
export enum RewardRecordStatus {
PendingPickup = 0x0100_0000,
SuspendedPickup = 0x0110_0000,
DialogAccept = 0x0101_0000,
Done = 0x0500_0000,
Aborted = 0x0500_0000,
Failed = 0x0501_000,
}
export enum RefreshCoinStatus {
Pending = 0x0100_0000,
Finished = 0x0500_0000,
/**
* The refresh for this coin has been frozen, because of a permanent error.
* More info in lastErrorPerCoin.
*/
Failed = 0x0501_000,
}
export enum RefreshOperationStatus {
Pending = 0x0100_0000,
Suspended = 0x0110_0000,
Finished = 0x0500_000,
Failed = 0x0501_000,
}
/**
* Status of a single element of a deposit group.
*/
export enum DepositElementStatus {
DepositPending = 0x0100_0000,
/**
* Accepted, but tracking.
*/
Tracking = 0x0100_0001,
KycRequired = 0x0100_0002,
Wired = 0x0500_0000,
RefundSuccess = 0x0503_0000,
RefundFailed = 0x0501_0000,
}
export interface RefreshGroupPerExchangeInfo {
/**
* (Expected) output once the refresh group succeeded.
*/
outputEffective: AmountString;
}
/**
* Group of refresh operations. The refreshed coins do not
* have to belong to the same exchange, but must have the same
* currency.
*/
export interface RefreshGroupRecord {
operationStatus: RefreshOperationStatus;
/**
* Unique, randomly generated identifier for this group of
* refresh operations.
*/
refreshGroupId: string;
/**
* Currency of this refresh group.
*/
currency: string;
/**
* Reason why this refresh group has been created.
*/
reason: RefreshReason;
originatingTransactionId?: string;
oldCoinPubs: string[];
inputPerCoin: AmountString[];
expectedOutputPerCoin: AmountString[];
infoPerExchange?: Record;
/**
* Flag for each coin whether refreshing finished.
* If a coin can't be refreshed (remaining value too small),
* it will be marked as finished, but no refresh session will
* be created.
*/
statusPerCoin: RefreshCoinStatus[];
timestampCreated: DbPreciseTimestamp;
/**
* Timestamp when the refresh session finished.
*/
timestampFinished: DbPreciseTimestamp | undefined;
}
/**
* Ongoing refresh
*/
export interface RefreshSessionRecord {
refreshGroupId: string;
/**
* Index of the coin in the refresh group.
*/
coinIndex: number;
/**
* 512-bit secret that can be used to derive
* the other cryptographic material for the refresh session.
*/
sessionSecretSeed: string;
/**
* Sum of the value of denominations we want
* to withdraw in this session, without fees.
*/
amountRefreshOutput: AmountString;
/**
* Hashed denominations of the newly requested coins.
*/
newDenoms: {
denomPubHash: string;
count: number;
}[];
/**
* The no-reveal-index after we've done the melting.
*/
norevealIndex?: number;
lastError?: TalerErrorDetail;
}
export enum RefundReason {
/**
* Normal refund given by the merchant.
*/
NormalRefund = "normal-refund",
/**
* Refund from an aborted payment.
*/
AbortRefund = "abort-pay-refund",
}
export enum PurchaseStatus {
/**
* Not downloaded yet.
*/
PendingDownloadingProposal = 0x0100_0000,
SuspendedDownloadingProposal = 0x0110_0000,
/**
* The user has accepted the proposal.
*/
PendingPaying = 0x0100_0001,
SuspendedPaying = 0x0110_0001,
/**
* Currently in the process of aborting with a refund.
*/
AbortingWithRefund = 0x0103_0000,
SuspendedAbortingWithRefund = 0x0113_0000,
/**
* Paying a second time, likely with different session ID
*/
PendingPayingReplay = 0x0100_0002,
SuspendedPayingReplay = 0x0110_0002,
/**
* Query for refunds (until query succeeds).
*/
PendingQueryingRefund = 0x0100_0003,
SuspendedQueryingRefund = 0x0110_0003,
/**
* Query for refund (until auto-refund deadline is reached).
*/
PendingQueryingAutoRefund = 0x0100_0004,
SuspendedQueryingAutoRefund = 0x0110_0004,
PendingAcceptRefund = 0x0100_0005,
SuspendedPendingAcceptRefund = 0x0100_0005,
/**
* Proposal downloaded, but the user needs to accept/reject it.
*/
DialogProposed = 0x0101_0000,
/**
* Proposal shared to other wallet or read from other wallet
* the user needs to accept/reject it.
*/
DialogShared = 0x0101_0001,
/**
* The user has rejected the proposal.
*/
AbortedProposalRefused = 0x0503_0000,
/**
* Downloading or processing the proposal has failed permanently.
*/
FailedClaim = 0x0501_0000,
/**
* Payment was successful.
*/
Done = 0x0500_0000,
/**
* Downloaded proposal was detected as a re-purchase.
*/
DoneRepurchaseDetected = 0x0500_0001,
/**
* The payment has been aborted.
*/
AbortedIncompletePayment = 0x0503_0000,
AbortedRefunded = 0x0503_0001,
AbortedOrderDeleted = 0x0503_0002,
/**
* Tried to abort, but aborting failed or was cancelled.
*/
FailedAbort = 0x0501_0001,
}
/**
* Partial information about the downloaded proposal.
* Only contains data that is relevant for indexing on the
* "purchases" object stores.
*/
export interface ProposalDownloadInfo {
contractTermsHash: string;
fulfillmentUrl?: string;
currency: string;
contractTermsMerchantSig: string;
}
export interface PurchasePayInfo {
payCoinSelection: PayCoinSelection;
totalPayCost: AmountString;
payCoinSelectionUid: string;
}
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
*
* Key: {@link proposalId}
* Operation status: {@link purchaseStatus}
*/
export interface PurchaseRecord {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
* Assigned by the wallet.
*/
proposalId: string;
/**
* Order ID, assigned by the merchant.
*/
orderId: string;
merchantBaseUrl: string;
/**
* Claim token used when downloading the contract terms.
*/
claimToken: string | undefined;
/**
* Session ID we got when downloading the contract.
*/
downloadSessionId: string | undefined;
/**
* If this purchase is a repurchase, this field identifies the original purchase.
*/
repurchaseProposalId: string | undefined;
purchaseStatus: PurchaseStatus;
/**
* Private key for the nonce.
*/
noncePriv: string;
/**
* Public key for the nonce.
*/
noncePub: string;
/**
* Downloaded and parsed proposal data.
*/
download: ProposalDownloadInfo | undefined;
payInfo: PurchasePayInfo | undefined;
/**
* Pending removals from pay coin selection.
*
* Used when a the pay coin selection needs to be changed
* because a coin became known as double-spent or invalid,
* but a new coin selection can't immediately be done, as
* there is not enough balance (e.g. when waiting for a refresh).
*/
pendingRemovedCoinPubs?: string[];
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.
*/
timestampFirstSuccessfulPay: DbPreciseTimestamp | undefined;
merchantPaySig: string | undefined;
posConfirmation: string | undefined;
/**
* This purchase was created by reading
* a payment share or the wallet
* the nonce public by a payment share
*/
shared: boolean;
/**
* When was the purchase record created?
*/
timestamp: DbPreciseTimestamp;
/**
* When was the purchase made?
* Refers to the time that the user accepted.
*/
timestampAccept: DbPreciseTimestamp | undefined;
/**
* When was the last refund made?
* Set to 0 if no refund was made on the purchase.
*/
timestampLastRefundStatus: DbPreciseTimestamp | undefined;
/**
* Last session signature that we submitted to /pay (if any).
*/
lastSessionId: string | undefined;
/**
* Continue querying the refund status until this deadline has expired.
*/
autoRefundDeadline: DbProtocolTimestamp | undefined;
/**
* How much merchant has refund to be taken but the wallet
* did not picked up yet
*/
refundAmountAwaiting: AmountString | undefined;
}
export enum ConfigRecordKey {
WalletBackupState = "walletBackupState",
CurrencyDefaultsApplied = "currencyDefaultsApplied",
DevMode = "devMode",
// Only for testing, do not use!
TestLoopTx = "testTxLoop",
LastInitInfo = "lastInitInfo",
}
/**
* Configuration key/value entries to configure
* the wallet.
*/
export type ConfigRecord =
| {
key: ConfigRecordKey.WalletBackupState;
value: WalletBackupConfState;
}
| { key: ConfigRecordKey.CurrencyDefaultsApplied; value: boolean }
| { key: ConfigRecordKey.DevMode; value: boolean }
| { key: ConfigRecordKey.TestLoopTx; value: number }
| { key: ConfigRecordKey.LastInitInfo; value: DbProtocolTimestamp };
export interface WalletBackupConfState {
deviceId: string;
walletRootPub: string;
walletRootPriv: string;
/**
* Last hash of the canonicalized plain-text backup.
*/
lastBackupPlainHash?: string;
/**
* Timestamp stored in the last backup.
*/
lastBackupTimestamp?: DbPreciseTimestamp;
/**
* Last time we tried to do a backup.
*/
lastBackupCheckTimestamp?: DbPreciseTimestamp;
lastBackupNonce?: string;
}
// FIXME: Should these be numeric codes?
export const enum WithdrawalRecordType {
BankManual = "bank-manual",
BankIntegrated = "bank-integrated",
PeerPullCredit = "peer-pull-credit",
PeerPushCredit = "peer-push-credit",
Recoup = "recoup",
}
export interface WgInfoBankIntegrated {
withdrawalType: WithdrawalRecordType.BankIntegrated;
/**
* Extra state for when this is a withdrawal involving
* a Taler-integrated bank.
*/
bankInfo: ReserveBankInfo;
/**
* Info about withdrawal accounts, possibly including currency conversion.
*/
exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
}
export interface WgInfoBankManual {
withdrawalType: WithdrawalRecordType.BankManual;
/**
* Info about withdrawal accounts, possibly including currency conversion.
*/
exchangeCreditAccounts?: WithdrawalExchangeAccountDetails[];
}
export interface WgInfoBankPeerPull {
withdrawalType: WithdrawalRecordType.PeerPullCredit;
// FIXME: include a transaction ID here?
/**
* Needed to quickly construct the taler:// URI for the counterparty
* without a join.
*/
contractPriv: string;
}
export interface WgInfoBankPeerPush {
withdrawalType: WithdrawalRecordType.PeerPushCredit;
// FIXME: include a transaction ID here?
}
export interface WgInfoBankRecoup {
withdrawalType: WithdrawalRecordType.Recoup;
}
export type WgInfo =
| WgInfoBankIntegrated
| WgInfoBankManual
| WgInfoBankPeerPull
| WgInfoBankPeerPush
| WgInfoBankRecoup;
export type KycUserType = "individual" | "business";
export interface KycPendingInfo {
paytoHash: string;
requirementRow: number;
}
/**
* Group of withdrawal operations that need to be executed.
* (Either for a normal withdrawal or from a reward.)
*
* The withdrawal group record is only created after we know
* the coin selection we want to withdraw.
*/
export interface WithdrawalGroupRecord {
/**
* Unique identifier for the withdrawal group.
*/
withdrawalGroupId: string;
wgInfo: WgInfo;
kycPending?: KycPendingInfo;
kycUrl?: string;
/**
* Secret seed used to derive planchets.
* Stored since planchets are created lazily.
*/
secretSeed: string;
/**
* Public key of the reserve that we're withdrawing from.
*/
reservePub: string;
/**
* The reserve private key.
*
* FIXME: Already in the reserves object store, redundant!
*/
reservePriv: string;
/**
* The exchange base URL that we're withdrawing from.
* (Redundantly stored, as the reserve record also has this info.)
*/
exchangeBaseUrl: string;
/**
* When was the withdrawal operation started started?
* Timestamp in milliseconds.
*/
timestampStart: DbPreciseTimestamp;
/**
* When was the withdrawal operation completed?
*/
timestampFinish?: DbPreciseTimestamp;
/**
* Current status of the reserve.
*/
status: WithdrawalGroupStatus;
/**
* Wire information (as payto URI) for the bank account that
* transferred funds for this reserve.
*
* FIXME: Doesn't this belong to the bankAccounts object store?
*/
senderWire?: string;
/**
* Restrict withdrawals from this reserve to this age.
*/
restrictAge?: number;
/**
* Amount that was sent by the user to fund the reserve.
*/
instructedAmount: AmountString;
/**
* Amount that was observed when querying the reserve that
* we are withdrawing from.
*
* Useful for diagnostics.
*/
reserveBalanceAmount?: AmountString;
/**
* Amount including fees (i.e. the amount subtracted from the
* reserve to withdraw all coins in this withdrawal session).
*
* (Initial amount confirmed by the user, might differ with denomSel
* on reselection.)
*/
rawWithdrawalAmount: AmountString;
/**
* Amount that will be added to the balance when the withdrawal succeeds.
*
* (Initial amount confirmed by the user, might differ with denomSel
* on reselection.)
*/
effectiveWithdrawalAmount: AmountString;
/**
* Denominations selected for withdrawal.
*/
denomsSel: DenomSelectionState;
/**
* UID of the denomination selection.
*
* Used for merging backups.
*
* FIXME: Should this not also include a timestamp for more logical merging?
*/
denomSelUid: string;
}
export interface BankWithdrawUriRecord {
/**
* The withdraw URI we got from the bank.
*/
talerWithdrawUri: string;
/**
* Reserve that was created for the withdraw URI.
*/
reservePub: string;
}
export enum RecoupOperationStatus {
Pending = 0x0100_0000,
Suspended = 0x0110_0000,
Finished = 0x0500_000,
Failed = 0x0501_000,
}
/**
* Status of recoup operations that were grouped together.
*
* The remaining amount of involved coins should be set to zero
* in the same transaction that inserts the RecoupGroupRecord.
*/
export interface RecoupGroupRecord {
/**
* Unique identifier for the recoup group record.
*/
recoupGroupId: string;
exchangeBaseUrl: string;
operationStatus: RecoupOperationStatus;
timestampStarted: DbPreciseTimestamp;
timestampFinished: DbPreciseTimestamp | undefined;
/**
* Public keys that identify the coins being recouped
* as part of this session.
*
* (Structured like this to enable multiEntry indexing in IndexedDB.)
*/
coinPubs: string[];
/**
* Array of flags to indicate whether the recoup finished on each individual coin.
*/
recoupFinishedPerCoin: boolean[];
/**
* Public keys of coins that should be scheduled for refreshing
* after all individual recoups are done.
*/
scheduleRefreshCoins: CoinRefreshRequest[];
}
export enum BackupProviderStateTag {
Provisional = "provisional",
Ready = "ready",
Retrying = "retrying",
}
export type BackupProviderState =
| {
tag: BackupProviderStateTag.Provisional;
}
| {
tag: BackupProviderStateTag.Ready;
nextBackupTimestamp: DbPreciseTimestamp;
}
| {
tag: BackupProviderStateTag.Retrying;
};
export interface BackupProviderRecord {
/**
* Base URL of the provider.
*
* Primary key for the record.
*/
baseUrl: string;
/**
* Name of the provider
*/
name: string;
/**
* Terms of service of the provider.
* Might be unavailable in the DB in certain situations
* (such as loading a recovery document).
*/
terms?: BackupProviderTerms;
/**
* Hash of the last encrypted backup that we already merged
* or successfully uploaded ourselves.
*/
lastBackupHash?: string;
/**
* Last time that we successfully uploaded a backup (or
* the uploaded backup was already current).
*
* Does NOT correspond to the timestamp of the backup,
* which only changes when the backup content changes.
*/
lastBackupCycleTimestamp?: DbPreciseTimestamp;
/**
* Proposal that we're currently trying to pay for.
*
* (Also included in paymentProposalIds.)
*
* FIXME: Make this part of a proper BackupProviderState?
*/
currentPaymentProposalId?: string;
shouldRetryFreshProposal: boolean;
/**
* Proposals that were used to pay (or attempt to pay) the provider.
*
* Stored to display a history of payments to the provider, and
* to make sure that the wallet isn't overpaying.
*/
paymentProposalIds: string[];
state: BackupProviderState;
/**
* UIDs for the operation that added the backup provider.
*/
uids: string[];
}
export enum DepositOperationStatus {
PendingDeposit = 0x0100_0000,
PendingTrack = 0x0100_0001,
PendingKyc = 0x0100_0002,
Aborting = 0x0103_0000,
SuspendedDeposit = 0x0110_0000,
SuspendedTrack = 0x0110_0001,
SuspendedKyc = 0x0110_0002,
SuspendedAborting = 0x0113_0000,
Finished = 0x0500_0000,
Failed = 0x0501_0000,
Aborted = 0x0503_0000,
}
export interface DepositTrackingInfo {
// Raw wire transfer identifier of the deposit.
wireTransferId: string;
// When was the wire transfer given to the bank.
timestampExecuted: DbProtocolTimestamp;
// Total amount transfer for this wtid (including fees)
amountRaw: AmountString;
// Wire fee amount for this exchange
wireFee: AmountString;
exchangePub: string;
}
export interface DepositInfoPerExchange {
/**
* Expected effective amount that will be deposited
* from coins of this exchange.
*/
amountEffective: AmountString;
}
/**
* Group of deposits made by the wallet.
*/
export interface DepositGroupRecord {
depositGroupId: string;
currency: string;
/**
* Instructed amount.
*/
amount: AmountString;
wireTransferDeadline: DbProtocolTimestamp;
merchantPub: string;
merchantPriv: string;
noncePriv: string;
noncePub: string;
/**
* Wire information used by all deposits in this
* deposit group.
*/
wire: {
payto_uri: string;
salt: string;
};
contractTermsHash: string;
payCoinSelection: PayCoinSelection;
payCoinSelectionUid: string;
totalPayCost: AmountString;
/**
* The counterparty effective deposit amount.
*/
counterpartyEffectiveDepositAmount: AmountString;
timestampCreated: DbPreciseTimestamp;
timestampFinished: DbPreciseTimestamp | undefined;
operationStatus: DepositOperationStatus;
statusPerCoin: DepositElementStatus[];
infoPerExchange?: Record;
/**
* When the deposit transaction was aborted and
* refreshes were tried, we create a refresh
* group and store the ID here.
*/
abortRefreshGroupId?: string;
kycInfo?: DepositKycInfo;
// FIXME: Do we need this and should it be in this object store?
trackingState?: {
[signature: string]: DepositTrackingInfo;
};
}
export interface DepositKycInfo {
kycUrl: string;
requirementRow: number;
paytoHash: string;
exchangeBaseUrl: string;
}
export interface TombstoneRecord {
/**
* Tombstone ID, with the syntax "tmb::".
*/
id: string;
}
export enum PeerPushDebitStatus {
/**
* Initiated, but no purse created yet.
*/
PendingCreatePurse = 0x0100_0000 /* ACTIVE_START */,
PendingReady = 0x0100_0001,
AbortingDeletePurse = 0x0103_0000,
/**
* Refresh after the purse got deleted by the wallet.
*/
AbortingRefreshDeleted = 0x0103_0001,
/**
* Refresh after the purse expired.
*/
AbortingRefreshExpired = 0x0103_0002,
SuspendedCreatePurse = 0x0110_0000,
SuspendedReady = 0x0110_0001,
SuspendedAbortingDeletePurse = 0x0113_0000,
SuspendedAbortingRefreshDeleted = 0x0113_0001,
SuspendedAbortingRefreshExpired = 0x0113_0002,
Done = 0x0500_0000,
Aborted = 0x0503_0000,
Failed = 0x0501_0000,
Expired = 0x0502_0000,
}
export interface PeerPushPaymentCoinSelection {
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: PeerPushPaymentCoinSelection;
contractTermsHash: HashCodeString;
/**
* Purse public key. Used as the primary key to look
* up this record.
*/
pursePub: string;
/**
* Purse private key.
*/
pursePriv: string;
/**
* Public key of the merge capability of the purse.
*/
mergePub: string;
/**
* Private key of the merge capability of the purse.
*/
mergePriv: string;
contractPriv: string;
contractPub: string;
/**
* 24 byte nonce.
*/
contractEncNonce: string;
purseExpiration: DbProtocolTimestamp;
timestampCreated: DbPreciseTimestamp;
abortRefreshGroupId?: string;
/**
* Status of the peer push payment initiation.
*/
status: PeerPushDebitStatus;
}
export enum PeerPullPaymentCreditStatus {
PendingCreatePurse = 0x0100_0000,
/**
* Purse created, waiting for the other party to accept the
* invoice and deposit money into it.
*/
PendingReady = 0x0100_0001,
PendingMergeKycRequired = 0x0100_0002,
PendingWithdrawing = 0x0100_0003,
AbortingDeletePurse = 0x0103_0000,
SuspendedCreatePurse = 0x0110_0000,
SuspendedReady = 0x0110_0001,
SuspendedMergeKycRequired = 0x0110_0002,
SuspendedWithdrawing = 0x0110_0000,
SuspendedAbortingDeletePurse = 0x0113_0000,
Done = 0x0500_0000,
Failed = 0x0501_0000,
Expired = 0x0502_0000,
Aborted = 0x0503_0000,
}
export interface PeerPullCreditRecord {
/**
* What exchange are we using for the payment request?
*/
exchangeBaseUrl: string;
/**
* Amount requested.
* FIXME: What type of instructed amount is i?
*/
amount: AmountString;
estimatedAmountEffective: AmountString;
/**
* Purse public key. Used as the primary key to look
* up this record.
*/
pursePub: string;
/**
* Purse private key.
*/
pursePriv: string;
/**
* Hash of the contract terms. Also
* used to look up the contract terms in the DB.
*/
contractTermsHash: string;
mergePub: string;
mergePriv: string;
contractPub: string;
contractPriv: string;
contractEncNonce: string;
mergeTimestamp: DbPreciseTimestamp;
mergeReserveRowId: number;
/**
* Status of the peer pull payment initiation.
*/
status: PeerPullPaymentCreditStatus;
kycInfo?: KycPendingInfo;
kycUrl?: string;
withdrawalGroupId: string | undefined;
}
export enum PeerPushCreditStatus {
PendingMerge = 0x0100_0000,
PendingMergeKycRequired = 0x0100_0001,
/**
* Merge was successful and withdrawal group has been created, now
* everything is in the hand of the withdrawal group.
*/
PendingWithdrawing = 0x0100_0002,
SuspendedMerge = 0x0110_0000,
SuspendedMergeKycRequired = 0x0110_0001,
SuspendedWithdrawing = 0x0110_0002,
DialogProposed = 0x0101_0000,
Done = 0x0500_0000,
Aborted = 0x0503_0000,
Failed = 0x0501_0000,
}
/**
* Record for a push P2P payment that this wallet was offered.
*
* Unique: (exchangeBaseUrl, pursePub)
*/
export interface PeerPushPaymentIncomingRecord {
peerPushCreditId: string;
exchangeBaseUrl: string;
pursePub: string;
mergePriv: string;
contractPriv: string;
timestamp: DbPreciseTimestamp;
estimatedAmountEffective: AmountString;
/**
* Hash of the contract terms. Also
* used to look up the contract terms in the DB.
*/
contractTermsHash: string;
/**
* Status of the peer push payment incoming initiation.
*/
status: PeerPushCreditStatus;
/**
* Associated withdrawal group.
*/
withdrawalGroupId: string | undefined;
/**
* Currency of the peer push payment credit transaction.
*
* Mandatory in current schema version, optional for compatibility
* with older (ver_minor<4) DB versions.
*/
currency: string | undefined;
kycInfo?: KycPendingInfo;
kycUrl?: string;
}
export enum PeerPullDebitRecordStatus {
PendingDeposit = 0x0100_0001,
AbortingRefresh = 0x0103_0001,
SuspendedDeposit = 0x0110_0001,
SuspendedAbortingRefresh = 0x0113_0001,
DialogProposed = 0x0101_0001,
Done = 0x0500_0000,
Aborted = 0x0503_0000,
Failed = 0x0501_0000,
}
export interface PeerPullPaymentCoinSelection {
contributions: AmountString[];
coinPubs: CoinPublicKeyString[];
/**
* Total cost based on the coin selection.
* Non undefined after status === "Accepted"
*/
totalCost: AmountString | undefined;
}
/**
* AKA PeerPullDebit.
*/
export interface PeerPullPaymentIncomingRecord {
peerPullDebitId: string;
pursePub: string;
exchangeBaseUrl: string;
amount: AmountString;
contractTermsHash: string;
timestampCreated: DbPreciseTimestamp;
/**
* Contract priv that we got from the other party.
*/
contractPriv: string;
/**
* Status of the peer push payment incoming initiation.
*/
status: PeerPullDebitRecordStatus;
/**
* Estimated total cost when the record was created.
*/
totalCostEstimated: AmountString;
abortRefreshGroupId?: string;
coinSel?: PeerPullPaymentCoinSelection;
}
/**
* Store for extra information about a reserve.
*
* Mostly used to store the private key for a reserve and to allow
* other records to reference the reserve key pair via a small row ID.
*
* In the future, we might also store KYC info about a reserve here.
*/
export interface ReserveRecord {
rowId?: number;
reservePub: string;
reservePriv: string;
}
export interface OperationRetryRecord {
/**
* Unique identifier for the operation. Typically of
* the format `${opType}-${opUniqueKey}`
*
* @see {@link TaskIdentifiers}
*/
id: string;
lastError?: TalerErrorDetail;
retryInfo: DbRetryInfo;
}
/**
* Availability of coins of a given denomination (and age restriction!).
*
* We can't store this information with the denomination record, as one denomination
* can be withdrawn with multiple age restrictions.
*/
export interface CoinAvailabilityRecord {
currency: string;
value: AmountString;
denomPubHash: string;
exchangeBaseUrl: string;
/**
* Age restriction on the coin, or 0 for no age restriction (or
* denomination without age restriction support).
*/
maxAge: number;
/**
* Number of fresh coins of this denomination that are available.
*/
freshCoinCount: number;
/**
* Number of fresh coins that are available
* and visible, i.e. the source transaction is in
* a final state.
*/
visibleCoinCount: number;
}
export interface ContractTermsRecord {
/**
* Contract terms hash.
*/
h: string;
/**
* Contract terms JSON.
*/
contractTermsRaw: any;
}
export interface UserAttentionRecord {
info: AttentionInfo;
entityId: string;
/**
* When the notification was created.
*/
created: DbPreciseTimestamp;
/**
* When the user mark this notification as read.
*/
read: DbPreciseTimestamp | undefined;
}
export interface DbExchangeHandle {
url: string;
exchangeMasterPub: string;
}
export interface DbAuditorHandle {
url: string;
auditorPub: string;
}
export enum RefundGroupStatus {
Pending = 0x0100_0000,
Done = 0x0500_0000,
Failed = 0x0501_0000,
Aborted = 0x0503_0000,
Expired = 0x0502_0000,
}
/**
* Metadata about a group of refunds with the merchant.
*/
export interface RefundGroupRecord {
status: RefundGroupStatus;
/**
* Timestamp when the refund group was created.
*/
timestampCreated: DbPreciseTimestamp;
proposalId: string;
refundGroupId: string;
refreshGroupId?: string;
amountRaw: AmountString;
/**
* Estimated effective amount, based on
* refund fees and refresh costs.
*/
amountEffective: AmountString;
}
export enum RefundItemStatus {
/**
* Intermittent error that the merchant is
* reporting from the exchange.
*
* We'll try again!
*/
Pending = 0x0100_0000,
/**
* Refund was obtained successfully.
*/
Done = 0x0500_0000,
/**
* Permanent error reported by the exchange
* for the refund.
*/
Failed = 0x0501_0000,
}
/**
* Refund for a single coin in a payment with a merchant.
*/
export interface RefundItemRecord {
/**
* Auto-increment DB record ID.
*/
id?: number;
status: RefundItemStatus;
refundGroupId: string;
/**
* Execution time as claimed by the merchant
*/
executionTime: DbProtocolTimestamp;
/**
* Time when the wallet became aware of the refund.
*/
obtainedTime: DbPreciseTimestamp;
refundAmount: AmountString;
coinPub: string;
rtxid: number;
}
export function passthroughCodec(): Codec {
return codecForAny();
}
export interface GlobalCurrencyAuditorRecord {
id?: number;
currency: string;
auditorBaseUrl: string;
auditorPub: string;
}
export interface GlobalCurrencyExchangeRecord {
id?: number;
currency: string;
exchangeBaseUrl: string;
exchangeMasterPub: string;
}
/**
* Schema definition for the IndexedDB
* wallet database.
*/
export const WalletStoresV1 = {
globalCurrencyAuditors: describeStoreV2({
recordCodec: passthroughCodec(),
storeName: "globalCurrencyAuditors",
keyPath: "id",
autoIncrement: true,
versionAdded: 3,
indexes: {
byCurrencyAndUrlAndPub: describeIndex(
"byCurrencyAndUrlAndPub",
["currency", "auditorBaseUrl", "auditorPub"],
{
unique: true,
versionAdded: 4,
},
),
},
}),
globalCurrencyExchanges: describeStoreV2({
recordCodec: passthroughCodec(),
storeName: "globalCurrencyExchanges",
keyPath: "id",
autoIncrement: true,
versionAdded: 3,
indexes: {
byCurrencyAndUrlAndPub: describeIndex(
"byCurrencyAndUrlAndPub",
["currency", "exchangeBaseUrl", "exchangeMasterPub"],
{
unique: true,
versionAdded: 4,
},
),
},
}),
coinAvailability: describeStore(
"coinAvailability",
describeContents({
keyPath: ["exchangeBaseUrl", "denomPubHash", "maxAge"],
}),
{
byExchangeAgeAvailability: describeIndex("byExchangeAgeAvailability", [
"exchangeBaseUrl",
"maxAge",
"freshCoinCount",
]),
},
),
coins: describeStore(
"coins",
describeContents({
keyPath: "coinPub",
}),
{
byBaseUrl: describeIndex("byBaseUrl", "exchangeBaseUrl"),
byDenomPubHash: describeIndex("byDenomPubHash", "denomPubHash"),
byExchangeDenomPubHashAndAgeAndStatus: describeIndex(
"byExchangeDenomPubHashAndAgeAndStatus",
["exchangeBaseUrl", "denomPubHash", "maxAge", "status"],
),
byCoinEvHash: describeIndex("byCoinEvHash", "coinEvHash"),
bySourceTransactionId: describeIndex(
"bySourceTransactionId",
"sourceTransactionId",
{
versionAdded: 9,
},
),
},
),
reserves: describeStore(
"reserves",
describeContents({
keyPath: "rowId",
autoIncrement: true,
}),
{
byReservePub: describeIndex("byReservePub", "reservePub", {}),
},
),
config: describeStore(
"config",
describeContents({ keyPath: "key" }),
{},
),
denominations: describeStore(
"denominations",
describeContents({
keyPath: ["exchangeBaseUrl", "denomPubHash"],
}),
{
byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl"),
},
),
exchanges: describeStore(
"exchanges",
describeContents({
keyPath: "baseUrl",
}),
{},
),
exchangeDetails: describeStore(
"exchangeDetails",
describeContents({
keyPath: "rowId",
autoIncrement: true,
}),
{
byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
versionAdded: 2,
}),
byPointer: describeIndex(
"byDetailsPointer",
["exchangeBaseUrl", "currency", "masterPublicKey"],
{
unique: true,
},
),
},
),
exchangeSignKeys: describeStore(
"exchangeSignKeys",
describeContents({
keyPath: ["exchangeDetailsRowId", "signkeyPub"],
}),
{
byExchangeDetailsRowId: describeIndex("byExchangeDetailsRowId", [
"exchangeDetailsRowId",
]),
},
),
refreshGroups: describeStore(
"refreshGroups",
describeContents({
keyPath: "refreshGroupId",
}),
{
byStatus: describeIndex("byStatus", "operationStatus"),
byOriginatingTransactionId: describeIndex(
"byOriginatingTransactionId",
"originatingTransactionId",
{
versionAdded: 5,
},
),
},
),
refreshSessions: describeStore(
"refreshSessions",
describeContents({
keyPath: ["refreshGroupId", "coinIndex"],
}),
{},
),
recoupGroups: describeStore(
"recoupGroups",
describeContents({
keyPath: "recoupGroupId",
}),
{
byStatus: describeIndex("byStatus", "operationStatus", {
versionAdded: 6,
}),
},
),
purchases: describeStore(
"purchases",
describeContents({ keyPath: "proposalId" }),
{
byStatus: describeIndex("byStatus", "purchaseStatus"),
byFulfillmentUrl: describeIndex(
"byFulfillmentUrl",
"download.fulfillmentUrl",
),
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
"merchantBaseUrl",
"orderId",
]),
},
),
rewards: describeStore(
"rewards",
describeContents({ keyPath: "walletRewardId" }),
{
byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [
"merchantRewardId",
"merchantBaseUrl",
]),
byStatus: describeIndex("byStatus", "status", {
versionAdded: 8,
}),
},
),
withdrawalGroups: describeStore(
"withdrawalGroups",
describeContents({
keyPath: "withdrawalGroupId",
}),
{
byStatus: describeIndex("byStatus", "status"),
byExchangeBaseUrl: describeIndex("byExchangeBaseUrl", "exchangeBaseUrl", {
versionAdded: 2,
}),
byTalerWithdrawUri: describeIndex(
"byTalerWithdrawUri",
"wgInfo.bankInfo.talerWithdrawUri",
),
},
),
planchets: describeStore(
"planchets",
describeContents({ keyPath: "coinPub" }),
{
byGroupAndIndex: describeIndex(
"byGroupAndIndex",
["withdrawalGroupId", "coinIdx"],
{
unique: true,
},
),
byGroup: describeIndex("byGroup", "withdrawalGroupId"),
byCoinEvHash: describeIndex("byCoinEv", "coinEvHash"),
},
),
bankWithdrawUris: describeStore(
"bankWithdrawUris",
describeContents({
keyPath: "talerWithdrawUri",
}),
{},
),
backupProviders: describeStore(
"backupProviders",
describeContents({
keyPath: "baseUrl",
}),
{
byPaymentProposalId: describeIndex(
"byPaymentProposalId",
"paymentProposalIds",
{
multiEntry: true,
},
),
},
),
depositGroups: describeStore(
"depositGroups",
describeContents({
keyPath: "depositGroupId",
}),
{
byStatus: describeIndex("byStatus", "operationStatus"),
},
),
tombstones: describeStore(
"tombstones",
describeContents({ keyPath: "id" }),
{},
),
operationRetries: describeStore(
"operationRetries",
describeContents({
keyPath: "id",
}),
{},
),
peerPushCredit: describeStore(
"peerPushCredit",
describeContents({
keyPath: "peerPushCreditId",
}),
{
byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
"exchangeBaseUrl",
"pursePub",
]),
byExchangeAndContractPriv: describeIndex(
"byExchangeAndContractPriv",
["exchangeBaseUrl", "contractPriv"],
{
unique: true,
},
),
byWithdrawalGroupId: describeIndex(
"byWithdrawalGroupId",
"withdrawalGroupId",
{},
),
byStatus: describeIndex("byStatus", "status"),
},
),
peerPullDebit: describeStore(
"peerPullDebit",
describeContents({
keyPath: "peerPullDebitId",
}),
{
byExchangeAndPurse: describeIndex("byExchangeAndPurse", [
"exchangeBaseUrl",
"pursePub",
]),
byExchangeAndContractPriv: describeIndex(
"byExchangeAndContractPriv",
["exchangeBaseUrl", "contractPriv"],
{
unique: true,
},
),
byStatus: describeIndex("byStatus", "status"),
},
),
peerPullCredit: describeStore(
"peerPullCredit",
describeContents({
keyPath: "pursePub",
}),
{
byStatus: describeIndex("byStatus", "status"),
byWithdrawalGroupId: describeIndex(
"byWithdrawalGroupId",
"withdrawalGroupId",
{},
),
},
),
peerPushDebit: describeStore(
"peerPushDebit",
describeContents({
keyPath: "pursePub",
}),
{
byStatus: describeIndex("byStatus", "status"),
},
),
bankAccounts: describeStore(
"bankAccounts",
describeContents({
keyPath: "uri",
}),
{},
),
contractTerms: describeStore(
"contractTerms",
describeContents({
keyPath: "h",
}),
{},
),
userAttention: describeStore(
"userAttention",
describeContents({
keyPath: ["entityId", "info.type"],
}),
{},
),
refundGroups: describeStore(
"refundGroups",
describeContents({
keyPath: "refundGroupId",
}),
{
byProposalId: describeIndex("byProposalId", "proposalId"),
byStatus: describeIndex("byStatus", "status", {}),
},
),
refundItems: describeStore(
"refundItems",
describeContents({
keyPath: "id",
autoIncrement: true,
}),
{
byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [
"coinPub",
"rtxid",
]),
// FIXME: Why is this a list of index keys? Confusing!
byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
},
),
fixups: describeStore(
"fixups",
describeContents({
keyPath: "fixupName",
}),
{},
),
};
export type WalletDbReadWriteTransaction<
StoresArr extends Array>,
> = DbReadWriteTransaction;
export type WalletDbReadOnlyTransaction<
StoresArr extends Array>,
> = DbReadOnlyTransaction;
export type WalletDbAllStoresReadOnlyTransaction<> = DbReadOnlyTransaction<
typeof WalletStoresV1,
Array>
>;
export type WalletDbAllStoresReadWriteTransaction<> = DbReadWriteTransaction<
typeof WalletStoresV1,
Array>
>;
/**
* An applied migration.
*/
export interface FixupRecord {
fixupName: string;
}
/**
* User accounts
*/
export interface BankAccountsRecord {
uri: string;
currency: string;
kycCompleted: boolean;
alias: string;
}
export interface MetaConfigRecord {
key: string;
value: any;
}
export const walletMetadataStore = {
metaConfig: describeStore(
"metaConfig",
describeContents({ keyPath: "key" }),
{},
),
};
export interface StoredBackupMeta {
name: string;
}
export const StoredBackupStores = {
backupMeta: describeStore(
"backupMeta",
describeContents({ keyPath: "name" }),
{},
),
backupData: describeStore("backupData", describeContents({}), {}),
};
export interface DbDumpRecord {
/**
* Key, serialized with structuredEncapsulated.
*
* Only present for out-of-line keys (i.e. no key path).
*/
key?: any;
/**
* Value, serialized with structuredEncapsulated.
*/
value: any;
}
export interface DbIndexDump {
keyPath: string | string[];
multiEntry: boolean;
unique: boolean;
}
export interface DbStoreDump {
keyPath?: string | string[];
autoIncrement: boolean;
indexes: { [indexName: string]: DbIndexDump };
records: DbDumpRecord[];
}
export interface DbDumpDatabase {
version: number;
stores: { [storeName: string]: DbStoreDump };
}
export interface DbDump {
databases: {
[name: string]: DbDumpDatabase;
};
}
export async function exportSingleDb(
idb: IDBFactory,
dbName: string,
): Promise {
const myDb = await openDatabase(
idb,
dbName,
undefined,
() => {
logger.info(`unexpected onversionchange in exportSingleDb of ${dbName}`);
},
() => {
logger.info(`unexpected onupgradeneeded in exportSingleDb of ${dbName}`);
},
);
const singleDbDump: DbDumpDatabase = {
version: myDb.version,
stores: {},
};
return new Promise((resolve, reject) => {
const tx = myDb.transaction(Array.from(myDb.objectStoreNames));
tx.addEventListener("complete", () => {
//myDb.close();
resolve(singleDbDump);
});
// tslint:disable-next-line:prefer-for-of
for (let i = 0; i < myDb.objectStoreNames.length; i++) {
const name = myDb.objectStoreNames[i];
const store = tx.objectStore(name);
const storeDump: DbStoreDump = {
autoIncrement: store.autoIncrement,
keyPath: store.keyPath,
indexes: {},
records: [],
};
const indexNames = store.indexNames;
for (let j = 0; j < indexNames.length; j++) {
const idxName = indexNames[j];
const index = store.index(idxName);
storeDump.indexes[idxName] = {
keyPath: index.keyPath,
multiEntry: index.multiEntry,
unique: index.unique,
};
}
singleDbDump.stores[name] = storeDump;
store.openCursor().addEventListener("success", (e: Event) => {
const cursor = (e.target as any).result;
if (cursor) {
const rec: DbDumpRecord = {
value: structuredEncapsulate(cursor.value),
};
// Only store key if necessary, i.e. when
// the key is not stored as part of the object via
// a key path.
if (store.keyPath == null) {
rec.key = structuredEncapsulate(cursor.key);
}
storeDump.records.push(rec);
cursor.continue();
}
});
}
});
}
export async function exportDb(idb: IDBFactory): Promise {
const dbDump: DbDump = {
databases: {},
};
dbDump.databases[TALER_WALLET_META_DB_NAME] = await exportSingleDb(
idb,
TALER_WALLET_META_DB_NAME,
);
dbDump.databases[TALER_WALLET_MAIN_DB_NAME] = await exportSingleDb(
idb,
TALER_WALLET_MAIN_DB_NAME,
);
return dbDump;
}
async function recoverFromDump(
db: IDBDatabase,
dbDump: DbDumpDatabase,
): Promise {
const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite");
const txProm = promiseFromTransaction(tx);
const storeNames = db.objectStoreNames;
for (let i = 0; i < storeNames.length; i++) {
const name = db.objectStoreNames[i];
const storeDump = dbDump.stores[name];
if (!storeDump) continue;
await promiseFromRequest(tx.objectStore(name).clear());
logger.info(`importing ${storeDump.records.length} records into ${name}`);
for (let rec of storeDump.records) {
await promiseFromRequest(tx.objectStore(name).put(rec.value, rec.key));
logger.info("importing record done");
}
}
tx.commit();
return await txProm;
}
function checkDbDump(x: any): x is DbDump {
return "databases" in x;
}
export async function importDb(db: IDBDatabase, dumpJson: any): Promise {
const d = structuredRevive(dumpJson);
if (checkDbDump(d)) {
const walletDb = d.databases[TALER_WALLET_MAIN_DB_NAME];
if (!walletDb) {
throw Error(
`unable to import, main wallet database (${TALER_WALLET_MAIN_DB_NAME}) not found`,
);
}
await recoverFromDump(db, walletDb);
} else {
throw Error("unable to import, doesn't look like a valid DB dump");
}
}
export interface FixupDescription {
name: string;
fn(
tx: DbReadWriteTransaction<
typeof WalletStoresV1,
Array>
>,
): Promise;
}
/**
* Manual migrations between minor versions of the DB schema.
*/
export const walletDbFixups: FixupDescription[] = [];
const logger = new Logger("db.ts");
export async function applyFixups(
db: DbAccess,
): Promise {
logger.trace("applying fixups");
await db.runAllStoresReadWriteTx(async (tx) => {
for (const fixupInstruction of walletDbFixups) {
logger.trace(`checking fixup ${fixupInstruction.name}`);
const fixupRecord = await tx.fixups.get(fixupInstruction.name);
if (fixupRecord) {
continue;
}
logger.info(`applying DB fixup ${fixupInstruction.name}`);
await fixupInstruction.fn(tx);
await tx.fixups.put({
fixupName: fixupInstruction.name,
});
}
});
}
/**
* Upgrade an IndexedDB in an upgrade transaction.
*
* The upgrade is made based on a store map, i.e. the metadata
* structure that describes all the object stores and indexes.
*/
function upgradeFromStoreMap(
storeMap: any, // FIXME: nail down type
db: IDBDatabase,
oldVersion: number,
newVersion: number,
upgradeTransaction: IDBTransaction,
): void {
if (oldVersion === 0) {
for (const n in storeMap) {
const swi: StoreWithIndexes<
any,
StoreDescriptor,
any
> = storeMap[n];
const storeDesc: StoreDescriptor = swi.store;
const s = db.createObjectStore(swi.storeName, {
autoIncrement: storeDesc.autoIncrement,
keyPath: storeDesc.keyPath,
});
for (const indexName in swi.indexMap as any) {
const indexDesc: IndexDescriptor = swi.indexMap[indexName];
s.createIndex(indexDesc.name, indexDesc.keyPath, {
multiEntry: indexDesc.multiEntry,
unique: indexDesc.unique,
});
}
}
return;
}
if (oldVersion === newVersion) {
return;
}
logger.info(`upgrading database from ${oldVersion} to ${newVersion}`);
for (const n in storeMap) {
const swi: StoreWithIndexes, any> = storeMap[
n
];
const storeDesc: StoreDescriptor = swi.store;
const storeAddedVersion = storeDesc.versionAdded ?? 0;
let s: IDBObjectStore;
if (storeAddedVersion > oldVersion) {
// Be tolerant if object store already exists.
// Probably means somebody deployed without
// adding the "addedInVersion" attribute.
if (!upgradeTransaction.objectStoreNames.contains(swi.storeName)) {
try {
s = db.createObjectStore(swi.storeName, {
autoIncrement: storeDesc.autoIncrement,
keyPath: storeDesc.keyPath,
});
} catch (e) {
const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
throw new Error(
`Migration failed. Could not create store ${swi.storeName}.${moreInfo}`,
// @ts-expect-error no support for options.cause yet
{ cause: e },
);
}
}
}
s = upgradeTransaction.objectStore(swi.storeName);
for (const indexName in swi.indexMap as any) {
const indexDesc: IndexDescriptor = swi.indexMap[indexName];
const indexAddedVersion = indexDesc.versionAdded ?? 0;
if (indexAddedVersion <= oldVersion) {
continue;
}
// Be tolerant if index already exists.
// Probably means somebody deployed without
// adding the "addedInVersion" attribute.
if (!s.indexNames.contains(indexDesc.name)) {
try {
s.createIndex(indexDesc.name, indexDesc.keyPath, {
multiEntry: indexDesc.multiEntry,
unique: indexDesc.unique,
});
} catch (e) {
const moreInfo = e instanceof Error ? ` Reason: ${e.message}` : "";
throw Error(
`Migration failed. Could not create index ${indexDesc.name}/${indexDesc.keyPath}. ${moreInfo}`,
// @ts-expect-error no support for options.cause yet
{ cause: e },
);
}
}
}
}
}
function promiseFromTransaction(transaction: IDBTransaction): Promise {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
reject();
};
});
}
export function promiseFromRequest(request: IDBRequest): Promise {
return new Promise((resolve, reject) => {
request.onsuccess = () => {
resolve(request.result);
};
request.onerror = () => {
reject(request.error);
};
});
}
/**
* Purge all data in the given database.
*/
export function clearDatabase(db: IDBDatabase): Promise {
// db.objectStoreNames is a DOMStringList, so we need to convert
let stores: string[] = [];
for (let i = 0; i < db.objectStoreNames.length; i++) {
stores.push(db.objectStoreNames[i]);
}
const tx = db.transaction(stores, "readwrite");
for (const store of stores) {
tx.objectStore(store).clear();
}
return promiseFromTransaction(tx);
}
function onTalerDbUpgradeNeeded(
db: IDBDatabase,
oldVersion: number,
newVersion: number,
upgradeTransaction: IDBTransaction,
) {
upgradeFromStoreMap(
WalletStoresV1,
db,
oldVersion,
newVersion,
upgradeTransaction,
);
}
function onMetaDbUpgradeNeeded(
db: IDBDatabase,
oldVersion: number,
newVersion: number,
upgradeTransaction: IDBTransaction,
) {
upgradeFromStoreMap(
walletMetadataStore,
db,
oldVersion,
newVersion,
upgradeTransaction,
);
}
function onStoredBackupsDbUpgradeNeeded(
db: IDBDatabase,
oldVersion: number,
newVersion: number,
upgradeTransaction: IDBTransaction,
) {
upgradeFromStoreMap(
StoredBackupStores,
db,
oldVersion,
newVersion,
upgradeTransaction,
);
}
export async function openStoredBackupsDatabase(
idbFactory: IDBFactory,
): Promise> {
const backupsDbHandle = await openDatabase(
idbFactory,
TALER_WALLET_STORED_BACKUPS_DB_NAME,
1,
() => {},
onStoredBackupsDbUpgradeNeeded,
);
const handle = new DbAccess(backupsDbHandle, StoredBackupStores);
return handle;
}
/**
* Return a promise that resolves
* to the taler wallet db.
*
* @param onVersionChange Called when another client concurrenctly connects to the database
* with a higher version.
*/
export async function openTalerDatabase(
idbFactory: IDBFactory,
onVersionChange: () => void,
): Promise> {
const metaDbHandle = await openDatabase(
idbFactory,
TALER_WALLET_META_DB_NAME,
1,
() => {},
onMetaDbUpgradeNeeded,
);
const metaDb = new DbAccess(metaDbHandle, walletMetadataStore);
let currentMainVersion: string | undefined;
await metaDb.runReadWriteTx(["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(["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 handle = new DbAccess(mainDbHandle, WalletStoresV1);
await applyFixups(handle);
return handle;
}
export async function deleteTalerDatabase(
idbFactory: IDBFactory,
): Promise {
return new Promise((resolve, reject) => {
const req = idbFactory.deleteDatabase(TALER_WALLET_MAIN_DB_NAME);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}