/*
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 { createRefreshGroup } from "./refresh.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
import { checkDbInvariant, checkLogicInvariant } from "./util/invariants.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;
}
/**
* Type and schema definitions for pending tasks in the wallet.
*
* These are only used internally, and are not part of the stable public
* interface to the wallet.
*/
export enum PendingTaskType {
ExchangeUpdate = "exchange-update",
Purchase = "purchase",
Refresh = "refresh",
Recoup = "recoup",
RewardPickup = "reward-pickup",
Withdraw = "withdraw",
Deposit = "deposit",
Backup = "backup",
PeerPushDebit = "peer-push-debit",
PeerPullCredit = "peer-pull-credit",
PeerPushCredit = "peer-push-credit",
PeerPullDebit = "peer-pull-debit",
}
declare const __taskId: unique symbol;
export type TaskId = string & { [__taskId]: true };