From e951075d2ef52fa8e9e7489c62031777c3a7e66b Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 19 Feb 2024 18:05:48 +0100 Subject: wallet-core: flatten directory structure --- packages/taler-wallet-core/src/common.ts | 693 +++++++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 packages/taler-wallet-core/src/common.ts (limited to 'packages/taler-wallet-core/src/common.ts') diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts new file mode 100644 index 000000000..ae1e17ffb --- /dev/null +++ b/packages/taler-wallet-core/src/common.ts @@ -0,0 +1,693 @@ +/* + This file is part of GNU Taler + (C) 2022 GNUnet e.V. + + 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 { + AbsoluteTime, + AmountJson, + Amounts, + CoinRefreshRequest, + CoinStatus, + Duration, + ExchangeEntryState, + ExchangeEntryStatus, + ExchangeTosStatus, + ExchangeUpdateStatus, + Logger, + RefreshReason, + TalerErrorDetail, + TalerPreciseTimestamp, + TalerProtocolTimestamp, + TombstoneIdStr, + TransactionIdStr, + durationMul, +} from "@gnu-taler/taler-util"; +import { + BackupProviderRecord, + CoinRecord, + DbPreciseTimestamp, + DepositGroupRecord, + ExchangeEntryDbRecordStatus, + ExchangeEntryDbUpdateStatus, + ExchangeEntryRecord, + PeerPullCreditRecord, + PeerPullPaymentIncomingRecord, + PeerPushDebitRecord, + PeerPushPaymentIncomingRecord, + PurchaseRecord, + RecoupGroupRecord, + RefreshGroupRecord, + RewardRecord, + WalletDbReadWriteTransaction, + WithdrawalGroupRecord, + timestampPreciseToDb, +} from "./db.js"; +import { InternalWalletState } from "./internal-wallet-state.js"; +import { PendingTaskType, TaskId } from "./pending-types.js"; +import { assertUnreachable } from "./util/assertUnreachable.js"; +import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.js"; +import { createRefreshGroup } from "./refresh.js"; + +const logger = new Logger("operations/common.ts"); + +export interface CoinsSpendInfo { + coinPubs: string[]; + contributions: AmountJson[]; + refreshReason: RefreshReason; + /** + * Identifier for what the coin has been spent for. + */ + allocationId: TransactionIdStr; +} + +export async function makeCoinsVisible( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction<["coins", "coinAvailability"]>, + transactionId: string, +): Promise { + const coins = + await tx.coins.indexes.bySourceTransactionId.getAll(transactionId); + for (const coinRecord of coins) { + if (!coinRecord.visible) { + coinRecord.visible = 1; + await tx.coins.put(coinRecord); + const ageRestriction = coinRecord.maxAge; + const car = await tx.coinAvailability.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ageRestriction, + ]); + if (!car) { + logger.error("missing coin availability record"); + continue; + } + const visCount = car.visibleCoinCount ?? 0; + car.visibleCoinCount = visCount + 1; + await tx.coinAvailability.put(car); + } + } +} + +export async function makeCoinAvailable( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["coins", "coinAvailability", "denominations"] + >, + coinRecord: CoinRecord, +): Promise { + checkLogicInvariant(coinRecord.status === CoinStatus.Fresh); + const existingCoin = await tx.coins.get(coinRecord.coinPub); + if (existingCoin) { + return; + } + const denom = await tx.denominations.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ]); + checkDbInvariant(!!denom); + const ageRestriction = coinRecord.maxAge; + let car = await tx.coinAvailability.get([ + coinRecord.exchangeBaseUrl, + coinRecord.denomPubHash, + ageRestriction, + ]); + if (!car) { + car = { + maxAge: ageRestriction, + value: denom.value, + currency: denom.currency, + denomPubHash: denom.denomPubHash, + exchangeBaseUrl: denom.exchangeBaseUrl, + freshCoinCount: 0, + visibleCoinCount: 0, + }; + } + car.freshCoinCount++; + await tx.coins.put(coinRecord); + await tx.coinAvailability.put(car); +} + +export async function spendCoins( + ws: InternalWalletState, + tx: WalletDbReadWriteTransaction< + ["coins", "coinAvailability", "refreshGroups", "denominations"] + >, + csi: CoinsSpendInfo, +): Promise { + if (csi.coinPubs.length != csi.contributions.length) { + throw Error("assertion failed"); + } + if (csi.coinPubs.length === 0) { + return; + } + let refreshCoinPubs: CoinRefreshRequest[] = []; + for (let i = 0; i < csi.coinPubs.length; i++) { + const coin = await tx.coins.get(csi.coinPubs[i]); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + const denom = await ws.getDenomInfo( + ws, + tx, + coin.exchangeBaseUrl, + coin.denomPubHash, + ); + checkDbInvariant(!!denom); + const coinAvailability = await tx.coinAvailability.get([ + coin.exchangeBaseUrl, + coin.denomPubHash, + coin.maxAge, + ]); + checkDbInvariant(!!coinAvailability); + const contrib = csi.contributions[i]; + if (coin.status !== CoinStatus.Fresh) { + const alloc = coin.spendAllocation; + if (!alloc) { + continue; + } + if (alloc.id !== csi.allocationId) { + // FIXME: assign error code + logger.info("conflicting coin allocation ID"); + logger.info(`old ID: ${alloc.id}, new ID: ${csi.allocationId}`); + throw Error("conflicting coin allocation (id)"); + } + if (0 !== Amounts.cmp(alloc.amount, contrib)) { + // FIXME: assign error code + throw Error("conflicting coin allocation (contrib)"); + } + continue; + } + coin.status = CoinStatus.Dormant; + coin.spendAllocation = { + id: csi.allocationId, + amount: Amounts.stringify(contrib), + }; + const remaining = Amounts.sub(denom.value, contrib); + if (remaining.saturated) { + throw Error("not enough remaining balance on coin for payment"); + } + refreshCoinPubs.push({ + amount: Amounts.stringify(remaining.amount), + coinPub: coin.coinPub, + }); + checkDbInvariant(!!coinAvailability); + if (coinAvailability.freshCoinCount === 0) { + throw Error( + `invalid coin count ${coinAvailability.freshCoinCount} in DB`, + ); + } + coinAvailability.freshCoinCount--; + if (coin.visible) { + if (!coinAvailability.visibleCoinCount) { + logger.error("coin availability inconsistent"); + } else { + coinAvailability.visibleCoinCount--; + } + } + await tx.coins.put(coin); + await tx.coinAvailability.put(coinAvailability); + } + + await createRefreshGroup( + ws, + tx, + Amounts.currencyOf(csi.contributions[0]), + refreshCoinPubs, + csi.refreshReason, + csi.allocationId, + ); +} + +export enum TombstoneTag { + DeleteWithdrawalGroup = "delete-withdrawal-group", + DeleteReserve = "delete-reserve", + DeletePayment = "delete-payment", + DeleteReward = "delete-reward", + DeleteRefreshGroup = "delete-refresh-group", + DeleteDepositGroup = "delete-deposit-group", + DeleteRefund = "delete-refund", + DeletePeerPullDebit = "delete-peer-pull-debit", + DeletePeerPushDebit = "delete-peer-push-debit", + DeletePeerPullCredit = "delete-peer-pull-credit", + DeletePeerPushCredit = "delete-peer-push-credit", +} + +export function getExchangeTosStatusFromRecord( + exchange: ExchangeEntryRecord, +): ExchangeTosStatus { + if (!exchange.tosAcceptedEtag) { + return ExchangeTosStatus.Proposed; + } + if (exchange.tosAcceptedEtag == exchange.tosCurrentEtag) { + return ExchangeTosStatus.Accepted; + } + return ExchangeTosStatus.Proposed; +} + +export function getExchangeUpdateStatusFromRecord( + r: ExchangeEntryRecord, +): ExchangeUpdateStatus { + switch (r.updateStatus) { + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + return ExchangeUpdateStatus.UnavailableUpdate; + case ExchangeEntryDbUpdateStatus.Initial: + return ExchangeUpdateStatus.Initial; + case ExchangeEntryDbUpdateStatus.InitialUpdate: + return ExchangeUpdateStatus.InitialUpdate; + case ExchangeEntryDbUpdateStatus.Ready: + return ExchangeUpdateStatus.Ready; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + return ExchangeUpdateStatus.ReadyUpdate; + case ExchangeEntryDbUpdateStatus.Suspended: + return ExchangeUpdateStatus.Suspended; + } +} + +export function getExchangeEntryStatusFromRecord( + r: ExchangeEntryRecord, +): ExchangeEntryStatus { + switch (r.entryStatus) { + case ExchangeEntryDbRecordStatus.Ephemeral: + return ExchangeEntryStatus.Ephemeral; + case ExchangeEntryDbRecordStatus.Preset: + return ExchangeEntryStatus.Preset; + case ExchangeEntryDbRecordStatus.Used: + return ExchangeEntryStatus.Used; + } +} + +/** + * Compute the state of an exchange entry from the DB + * record. + */ +export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState { + return { + exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), + tosStatus: getExchangeTosStatusFromRecord(r), + }; +} + +export type ParsedTombstone = + | { + tag: TombstoneTag.DeleteWithdrawalGroup; + withdrawalGroupId: string; + } + | { tag: TombstoneTag.DeleteRefund; refundGroupId: string } + | { tag: TombstoneTag.DeleteReserve; reservePub: string } + | { tag: TombstoneTag.DeleteRefreshGroup; refreshGroupId: string } + | { tag: TombstoneTag.DeleteReward; walletTipId: string } + | { tag: TombstoneTag.DeletePayment; proposalId: string }; + +export function constructTombstone(p: ParsedTombstone): TombstoneIdStr { + switch (p.tag) { + case TombstoneTag.DeleteWithdrawalGroup: + return `tmb:${p.tag}:${p.withdrawalGroupId}` as TombstoneIdStr; + case TombstoneTag.DeleteRefund: + return `tmb:${p.tag}:${p.refundGroupId}` as TombstoneIdStr; + case TombstoneTag.DeleteReserve: + return `tmb:${p.tag}:${p.reservePub}` as TombstoneIdStr; + case TombstoneTag.DeletePayment: + return `tmb:${p.tag}:${p.proposalId}` as TombstoneIdStr; + case TombstoneTag.DeleteRefreshGroup: + return `tmb:${p.tag}:${p.refreshGroupId}` as TombstoneIdStr; + case TombstoneTag.DeleteReward: + return `tmb:${p.tag}:${p.walletTipId}` as TombstoneIdStr; + default: + assertUnreachable(p); + } +} + +/** + * Uniform interface for a particular wallet transaction. + */ +export interface TransactionManager { + get taskId(): TaskId; + get transactionId(): TransactionIdStr; + fail(): Promise; + abort(): Promise; + suspend(): Promise; + resume(): Promise; + process(): Promise; +} + +export enum TaskRunResultType { + Finished = "finished", + Backoff = "backoff", + Progress = "progress", + Error = "error", + ScheduleLater = "schedule-later", +} + +export type TaskRunResult = + | TaskRunFinishedResult + | TaskRunErrorResult + | TaskRunBackoffResult + | TaskRunProgressResult + | TaskRunScheduleLaterResult; + +export namespace TaskRunResult { + /** + * Task is finished and does not need to be processed again. + */ + export function finished(): TaskRunResult { + return { + type: TaskRunResultType.Finished, + }; + } + /** + * Task is waiting for something, should be invoked + * again with exponentiall back-off until some other + * result is returned. + */ + export function backoff(): TaskRunResult { + return { + type: TaskRunResultType.Backoff, + }; + } + /** + * Task made progress and should be processed again. + */ + export function progress(): TaskRunResult { + return { + type: TaskRunResultType.Progress, + }; + } + /** + * Run the task again at a fixed time in the future. + */ + export function runAgainAt(runAt: AbsoluteTime): TaskRunResult { + return { + type: TaskRunResultType.ScheduleLater, + runAt, + }; + } +} + +export interface TaskRunFinishedResult { + type: TaskRunResultType.Finished; +} + +export interface TaskRunBackoffResult { + type: TaskRunResultType.Backoff; +} + +export interface TaskRunProgressResult { + type: TaskRunResultType.Progress; +} + +export interface TaskRunScheduleLaterResult { + type: TaskRunResultType.ScheduleLater; + runAt: AbsoluteTime; +} + +export interface TaskRunErrorResult { + type: TaskRunResultType.Error; + errorDetail: TalerErrorDetail; +} + +export interface DbRetryInfo { + firstTry: DbPreciseTimestamp; + nextRetry: DbPreciseTimestamp; + retryCounter: number; +} + +export interface RetryPolicy { + readonly backoffDelta: Duration; + readonly backoffBase: number; + readonly maxTimeout: Duration; +} + +const defaultRetryPolicy: RetryPolicy = { + backoffBase: 1.5, + backoffDelta: Duration.fromSpec({ seconds: 1 }), + maxTimeout: Duration.fromSpec({ minutes: 2 }), +}; + +function updateTimeout( + r: DbRetryInfo, + p: RetryPolicy = defaultRetryPolicy, +): void { + const now = AbsoluteTime.now(); + if (now.t_ms === "never") { + throw Error("assertion failed"); + } + if (p.backoffDelta.d_ms === "forever") { + r.nextRetry = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(AbsoluteTime.never()), + ); + return; + } + + const nextIncrement = + p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); + + const t = + now.t_ms + + (p.maxTimeout.d_ms === "forever" + ? nextIncrement + : Math.min(p.maxTimeout.d_ms, nextIncrement)); + r.nextRetry = timestampPreciseToDb(TalerPreciseTimestamp.fromMilliseconds(t)); +} + +export namespace DbRetryInfo { + export function getDuration( + r: DbRetryInfo | undefined, + p: RetryPolicy = defaultRetryPolicy, + ): Duration { + if (!r) { + // If we don't have any retry info, run immediately. + return { d_ms: 0 }; + } + if (p.backoffDelta.d_ms === "forever") { + return { d_ms: "forever" }; + } + const t = p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); + return { + d_ms: + p.maxTimeout.d_ms === "forever" ? t : Math.min(p.maxTimeout.d_ms, t), + }; + } + + export function reset(p: RetryPolicy = defaultRetryPolicy): DbRetryInfo { + const now = TalerPreciseTimestamp.now(); + const info: DbRetryInfo = { + firstTry: timestampPreciseToDb(now), + nextRetry: timestampPreciseToDb(now), + retryCounter: 0, + }; + updateTimeout(info, p); + return info; + } + + export function increment( + r: DbRetryInfo | undefined, + p: RetryPolicy = defaultRetryPolicy, + ): DbRetryInfo { + if (!r) { + return reset(p); + } + const r2 = { ...r }; + r2.retryCounter++; + updateTimeout(r2, p); + return r2; + } +} + +/** + * Timestamp after which the wallet would do an auto-refresh. + */ +export function getAutoRefreshExecuteThreshold(d: { + stampExpireWithdraw: TalerProtocolTimestamp; + stampExpireDeposit: TalerProtocolTimestamp; +}): AbsoluteTime { + const expireWithdraw = AbsoluteTime.fromProtocolTimestamp( + d.stampExpireWithdraw, + ); + const expireDeposit = AbsoluteTime.fromProtocolTimestamp( + d.stampExpireDeposit, + ); + const delta = AbsoluteTime.difference(expireWithdraw, expireDeposit); + const deltaDiv = durationMul(delta, 0.5); + return AbsoluteTime.addDuration(expireWithdraw, deltaDiv); +} + +/** + * Parsed representation of task identifiers. + */ +export type ParsedTaskIdentifier = + | { + tag: PendingTaskType.Withdraw; + withdrawalGroupId: string; + } + | { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: string } + | { tag: PendingTaskType.Backup; backupProviderBaseUrl: string } + | { tag: PendingTaskType.Deposit; depositGroupId: string } + | { tag: PendingTaskType.PeerPullDebit; peerPullDebitId: string } + | { tag: PendingTaskType.PeerPullCredit; pursePub: string } + | { tag: PendingTaskType.PeerPushCredit; peerPushCreditId: string } + | { tag: PendingTaskType.PeerPushDebit; pursePub: string } + | { tag: PendingTaskType.Purchase; proposalId: string } + | { tag: PendingTaskType.Recoup; recoupGroupId: string } + | { tag: PendingTaskType.RewardPickup; walletRewardId: string } + | { tag: PendingTaskType.Refresh; refreshGroupId: string }; + +export function parseTaskIdentifier(x: string): ParsedTaskIdentifier { + const task = x.split(":"); + + if (task.length < 2) { + throw Error("task id should have al least 2 parts separated by ':'"); + } + + const [type, ...rest] = task; + switch (type) { + case PendingTaskType.Backup: + return { tag: type, backupProviderBaseUrl: decodeURIComponent(rest[0]) }; + case PendingTaskType.Deposit: + return { tag: type, depositGroupId: rest[0] }; + case PendingTaskType.ExchangeUpdate: + return { tag: type, exchangeBaseUrl: decodeURIComponent(rest[0]) }; + case PendingTaskType.PeerPullCredit: + return { tag: type, pursePub: rest[0] }; + case PendingTaskType.PeerPullDebit: + return { tag: type, peerPullDebitId: rest[0] }; + case PendingTaskType.PeerPushCredit: + return { tag: type, peerPushCreditId: rest[0] }; + case PendingTaskType.PeerPushDebit: + return { tag: type, pursePub: rest[0] }; + case PendingTaskType.Purchase: + return { tag: type, proposalId: rest[0] }; + case PendingTaskType.Recoup: + return { tag: type, recoupGroupId: rest[0] }; + case PendingTaskType.Refresh: + return { tag: type, refreshGroupId: rest[0] }; + case PendingTaskType.RewardPickup: + return { tag: type, walletRewardId: rest[0] }; + case PendingTaskType.Withdraw: + return { tag: type, withdrawalGroupId: rest[0] }; + default: + throw Error("invalid task identifier"); + } +} + +export function constructTaskIdentifier(p: ParsedTaskIdentifier): TaskId { + switch (p.tag) { + case PendingTaskType.Backup: + return `${p.tag}:${p.backupProviderBaseUrl}` as TaskId; + case PendingTaskType.Deposit: + return `${p.tag}:${p.depositGroupId}` as TaskId; + case PendingTaskType.ExchangeUpdate: + return `${p.tag}:${encodeURIComponent(p.exchangeBaseUrl)}` as TaskId; + case PendingTaskType.PeerPullDebit: + return `${p.tag}:${p.peerPullDebitId}` as TaskId; + case PendingTaskType.PeerPushCredit: + return `${p.tag}:${p.peerPushCreditId}` as TaskId; + case PendingTaskType.PeerPullCredit: + return `${p.tag}:${p.pursePub}` as TaskId; + case PendingTaskType.PeerPushDebit: + return `${p.tag}:${p.pursePub}` as TaskId; + case PendingTaskType.Purchase: + return `${p.tag}:${p.proposalId}` as TaskId; + case PendingTaskType.Recoup: + return `${p.tag}:${p.recoupGroupId}` as TaskId; + case PendingTaskType.Refresh: + return `${p.tag}:${p.refreshGroupId}` as TaskId; + case PendingTaskType.RewardPickup: + return `${p.tag}:${p.walletRewardId}` as TaskId; + case PendingTaskType.Withdraw: + return `${p.tag}:${p.withdrawalGroupId}` as TaskId; + default: + assertUnreachable(p); + } +} + +export namespace TaskIdentifiers { + export function forWithdrawal(wg: WithdrawalGroupRecord): TaskId { + return `${PendingTaskType.Withdraw}:${wg.withdrawalGroupId}` as TaskId; + } + export function forExchangeUpdate(exch: ExchangeEntryRecord): TaskId { + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exch.baseUrl, + )}` as TaskId; + } + export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId { + return `${PendingTaskType.ExchangeUpdate}:${encodeURIComponent( + exchBaseUrl, + )}` as TaskId; + } + export function forTipPickup(tipRecord: RewardRecord): TaskId { + return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskId; + } + export function forRefresh(refreshGroupRecord: RefreshGroupRecord): TaskId { + return `${PendingTaskType.Refresh}:${refreshGroupRecord.refreshGroupId}` as TaskId; + } + export function forPay(purchaseRecord: PurchaseRecord): TaskId { + return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}` as TaskId; + } + export function forRecoup(recoupRecord: RecoupGroupRecord): TaskId { + return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}` as TaskId; + } + export function forDeposit(depositRecord: DepositGroupRecord): TaskId { + return `${PendingTaskType.Deposit}:${depositRecord.depositGroupId}` as TaskId; + } + export function forBackup(backupRecord: BackupProviderRecord): TaskId { + return `${PendingTaskType.Backup}:${encodeURIComponent( + backupRecord.baseUrl, + )}` as TaskId; + } + export function forPeerPushPaymentInitiation( + ppi: PeerPushDebitRecord, + ): TaskId { + return `${PendingTaskType.PeerPushDebit}:${ppi.pursePub}` as TaskId; + } + export function forPeerPullPaymentInitiation( + ppi: PeerPullCreditRecord, + ): TaskId { + return `${PendingTaskType.PeerPullCredit}:${ppi.pursePub}` as TaskId; + } + export function forPeerPullPaymentDebit( + ppi: PeerPullPaymentIncomingRecord, + ): TaskId { + return `${PendingTaskType.PeerPullDebit}:${ppi.peerPullDebitId}` as TaskId; + } + export function forPeerPushCredit( + ppi: PeerPushPaymentIncomingRecord, + ): TaskId { + return `${PendingTaskType.PeerPushCredit}:${ppi.peerPushCreditId}` as TaskId; + } +} + +/** + * Result of a transaction transition. + */ +export enum TransitionResult { + Transition = 1, + Stay = 2, +} + +/** + * Transaction context. + * Uniform interface to all transactions. + */ +export interface TransactionContext { + abortTransaction(): Promise; + suspendTransaction(): Promise; + resumeTransaction(): Promise; + failTransaction(): Promise; + deleteTransaction(): Promise; +} -- cgit v1.2.3