977 lines
30 KiB
TypeScript
977 lines
30 KiB
TypeScript
/*
|
|
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 <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
/**
|
|
* Imports.
|
|
*/
|
|
import {
|
|
AbsoluteTime,
|
|
AgeRestriction,
|
|
AmountJson,
|
|
Amounts,
|
|
CancellationToken,
|
|
CoinRefreshRequest,
|
|
CoinStatus,
|
|
Duration,
|
|
ExchangeEntryStatus,
|
|
ExchangeListItem,
|
|
ExchangeTosStatus,
|
|
ExchangeUpdateStatus,
|
|
getErrorDetailFromException,
|
|
j2s,
|
|
Logger,
|
|
makeErrorDetail,
|
|
NotificationType,
|
|
OperationErrorInfo,
|
|
RefreshReason,
|
|
TalerError,
|
|
TalerErrorCode,
|
|
TalerErrorDetail,
|
|
TalerPreciseTimestamp,
|
|
TombstoneIdStr,
|
|
TransactionIdStr,
|
|
TransactionType,
|
|
WalletNotification,
|
|
} from "@gnu-taler/taler-util";
|
|
import { CryptoApiStoppedError } from "../crypto/workers/crypto-dispatcher.js";
|
|
import {
|
|
BackupProviderRecord,
|
|
CoinRecord,
|
|
DbPreciseTimestamp,
|
|
DepositGroupRecord,
|
|
ExchangeDetailsRecord,
|
|
ExchangeEntryDbRecordStatus,
|
|
ExchangeEntryDbUpdateStatus,
|
|
ExchangeEntryRecord,
|
|
PeerPullCreditRecord,
|
|
PeerPullPaymentIncomingRecord,
|
|
PeerPushDebitRecord,
|
|
PeerPushPaymentIncomingRecord,
|
|
PurchaseRecord,
|
|
RecoupGroupRecord,
|
|
RefreshGroupRecord,
|
|
RewardRecord,
|
|
timestampPreciseToDb,
|
|
WalletStoresV1,
|
|
WithdrawalGroupRecord,
|
|
} 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 { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
|
|
import { constructTransactionIdentifier } from "./transactions.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: GetReadWriteAccess<{
|
|
coins: typeof WalletStoresV1.coins;
|
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
|
}>,
|
|
transactionId: string,
|
|
): Promise<void> {
|
|
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: GetReadWriteAccess<{
|
|
coins: typeof WalletStoresV1.coins;
|
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
|
denominations: typeof WalletStoresV1.denominations;
|
|
}>,
|
|
coinRecord: CoinRecord,
|
|
): Promise<void> {
|
|
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: GetReadWriteAccess<{
|
|
coins: typeof WalletStoresV1.coins;
|
|
coinAvailability: typeof WalletStoresV1.coinAvailability;
|
|
refreshGroups: typeof WalletStoresV1.refreshGroups;
|
|
denominations: typeof WalletStoresV1.denominations;
|
|
}>,
|
|
csi: CoinsSpendInfo,
|
|
): Promise<void> {
|
|
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 ws.refreshOps.createRefreshGroup(
|
|
ws,
|
|
tx,
|
|
Amounts.currencyOf(csi.contributions[0]),
|
|
refreshCoinPubs,
|
|
csi.refreshReason,
|
|
{
|
|
originatingTransactionId: csi.allocationId,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert the task ID for a task that processes a transaction int
|
|
* the ID for the transaction.
|
|
*/
|
|
function convertTaskToTransactionId(
|
|
taskId: string,
|
|
): TransactionIdStr | undefined {
|
|
const parsedTaskId = parseTaskIdentifier(taskId);
|
|
switch (parsedTaskId.tag) {
|
|
case PendingTaskType.PeerPullCredit:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPullCredit,
|
|
pursePub: parsedTaskId.pursePub,
|
|
});
|
|
case PendingTaskType.PeerPullDebit:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPullDebit,
|
|
peerPullDebitId: parsedTaskId.peerPullDebitId,
|
|
});
|
|
// FIXME: This doesn't distinguish internal-withdrawal.
|
|
// Maybe we should have a different task type for that as well?
|
|
// Or maybe transaction IDs should be valid task identifiers?
|
|
case PendingTaskType.Withdraw:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.Withdrawal,
|
|
withdrawalGroupId: parsedTaskId.withdrawalGroupId,
|
|
});
|
|
case PendingTaskType.PeerPushCredit:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPushCredit,
|
|
peerPushCreditId: parsedTaskId.peerPushCreditId,
|
|
});
|
|
case PendingTaskType.Deposit:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.Deposit,
|
|
depositGroupId: parsedTaskId.depositGroupId,
|
|
});
|
|
case PendingTaskType.Refresh:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.Refresh,
|
|
refreshGroupId: parsedTaskId.refreshGroupId,
|
|
});
|
|
case PendingTaskType.RewardPickup:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.Reward,
|
|
walletRewardId: parsedTaskId.walletRewardId,
|
|
});
|
|
case PendingTaskType.PeerPushDebit:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPushDebit,
|
|
pursePub: parsedTaskId.pursePub,
|
|
});
|
|
case PendingTaskType.Purchase:
|
|
return constructTransactionIdentifier({
|
|
tag: TransactionType.Payment,
|
|
proposalId: parsedTaskId.proposalId,
|
|
});
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* For tasks that process a transaction,
|
|
* generate a state transition notification.
|
|
*/
|
|
async function taskToTransactionNotification(
|
|
ws: InternalWalletState,
|
|
tx: GetReadOnlyAccess<typeof WalletStoresV1>,
|
|
pendingTaskId: string,
|
|
e: TalerErrorDetail | undefined,
|
|
): Promise<WalletNotification | undefined> {
|
|
const txId = convertTaskToTransactionId(pendingTaskId);
|
|
if (!txId) {
|
|
return undefined;
|
|
}
|
|
const txState = await ws.getTransactionState(ws, tx, txId);
|
|
if (!txState) {
|
|
return undefined;
|
|
}
|
|
const notif: WalletNotification = {
|
|
type: NotificationType.TransactionStateTransition,
|
|
transactionId: txId,
|
|
oldTxState: txState,
|
|
newTxState: txState,
|
|
};
|
|
if (e) {
|
|
notif.errorInfo = {
|
|
code: e.code as number,
|
|
hint: e.hint,
|
|
};
|
|
}
|
|
return notif;
|
|
}
|
|
|
|
async function storePendingTaskError(
|
|
ws: InternalWalletState,
|
|
pendingTaskId: string,
|
|
e: TalerErrorDetail,
|
|
): Promise<void> {
|
|
logger.info(`storing pending task error for ${pendingTaskId}`);
|
|
const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
|
|
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
|
if (!retryRecord) {
|
|
retryRecord = {
|
|
id: pendingTaskId,
|
|
lastError: e,
|
|
retryInfo: DbRetryInfo.reset(),
|
|
};
|
|
} else {
|
|
retryRecord.lastError = e;
|
|
retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
|
|
}
|
|
await tx.operationRetries.put(retryRecord);
|
|
return taskToTransactionNotification(ws, tx, pendingTaskId, e);
|
|
});
|
|
if (maybeNotification) {
|
|
ws.notify(maybeNotification);
|
|
}
|
|
}
|
|
|
|
export async function resetPendingTaskTimeout(
|
|
ws: InternalWalletState,
|
|
pendingTaskId: string,
|
|
): Promise<void> {
|
|
const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
|
|
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
|
if (retryRecord) {
|
|
// Note that we don't reset the lastError, it should still be visible
|
|
// while the retry runs.
|
|
retryRecord.retryInfo = DbRetryInfo.reset();
|
|
await tx.operationRetries.put(retryRecord);
|
|
}
|
|
return taskToTransactionNotification(ws, tx, pendingTaskId, undefined);
|
|
});
|
|
if (maybeNotification) {
|
|
ws.notify(maybeNotification);
|
|
}
|
|
}
|
|
|
|
async function storePendingTaskPending(
|
|
ws: InternalWalletState,
|
|
pendingTaskId: string,
|
|
): Promise<void> {
|
|
const maybeNotification = await ws.db.mktxAll().runReadWrite(async (tx) => {
|
|
let retryRecord = await tx.operationRetries.get(pendingTaskId);
|
|
let hadError = false;
|
|
if (!retryRecord) {
|
|
retryRecord = {
|
|
id: pendingTaskId,
|
|
retryInfo: DbRetryInfo.reset(),
|
|
};
|
|
} else {
|
|
if (retryRecord.lastError) {
|
|
hadError = true;
|
|
}
|
|
delete retryRecord.lastError;
|
|
retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo);
|
|
}
|
|
await tx.operationRetries.put(retryRecord);
|
|
if (hadError) {
|
|
return taskToTransactionNotification(ws, tx, pendingTaskId, undefined);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
});
|
|
if (maybeNotification) {
|
|
ws.notify(maybeNotification);
|
|
}
|
|
}
|
|
|
|
async function storePendingTaskFinished(
|
|
ws: InternalWalletState,
|
|
pendingTaskId: string,
|
|
): Promise<void> {
|
|
await ws.db
|
|
.mktx((x) => [x.operationRetries])
|
|
.runReadWrite(async (tx) => {
|
|
await tx.operationRetries.delete(pendingTaskId);
|
|
});
|
|
}
|
|
|
|
export async function runTaskWithErrorReporting(
|
|
ws: InternalWalletState,
|
|
opId: TaskId,
|
|
f: () => Promise<TaskRunResult>,
|
|
): Promise<TaskRunResult> {
|
|
let maybeError: TalerErrorDetail | undefined;
|
|
try {
|
|
const resp = await f();
|
|
switch (resp.type) {
|
|
case TaskRunResultType.Error:
|
|
await storePendingTaskError(ws, opId, resp.errorDetail);
|
|
return resp;
|
|
case TaskRunResultType.Finished:
|
|
await storePendingTaskFinished(ws, opId);
|
|
return resp;
|
|
case TaskRunResultType.Pending:
|
|
await storePendingTaskPending(ws, opId);
|
|
return resp;
|
|
case TaskRunResultType.Longpoll:
|
|
return resp;
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof CryptoApiStoppedError) {
|
|
if (ws.stopped) {
|
|
logger.warn("crypto API stopped during shutdown, ignoring error");
|
|
return {
|
|
type: TaskRunResultType.Error,
|
|
errorDetail: makeErrorDetail(
|
|
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
|
{},
|
|
"Crypto API stopped during shutdown",
|
|
),
|
|
};
|
|
}
|
|
}
|
|
if (e instanceof TalerError) {
|
|
logger.warn("operation processed resulted in error");
|
|
logger.warn(`error was: ${j2s(e.errorDetail)}`);
|
|
maybeError = e.errorDetail;
|
|
await storePendingTaskError(ws, opId, maybeError!);
|
|
return {
|
|
type: TaskRunResultType.Error,
|
|
errorDetail: e.errorDetail,
|
|
};
|
|
} else if (e instanceof Error) {
|
|
// This is a bug, as we expect pending operations to always
|
|
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
|
|
// or return something.
|
|
logger.error(`Uncaught exception: ${e.message}`);
|
|
logger.error(`Stack: ${e.stack}`);
|
|
maybeError = makeErrorDetail(
|
|
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
|
{
|
|
stack: e.stack,
|
|
},
|
|
`unexpected exception (message: ${e.message})`,
|
|
);
|
|
await storePendingTaskError(ws, opId, maybeError);
|
|
return {
|
|
type: TaskRunResultType.Error,
|
|
errorDetail: maybeError,
|
|
};
|
|
} else {
|
|
logger.error("Uncaught exception, value is not even an error.");
|
|
maybeError = makeErrorDetail(
|
|
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
|
|
{},
|
|
`unexpected exception (not even an error)`,
|
|
);
|
|
await storePendingTaskError(ws, opId, maybeError);
|
|
return {
|
|
type: TaskRunResultType.Error,
|
|
errorDetail: maybeError,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
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 getExchangeTosStatus(
|
|
exchangeDetails: ExchangeDetailsRecord,
|
|
): ExchangeTosStatus {
|
|
if (!exchangeDetails.tosAccepted) {
|
|
return ExchangeTosStatus.Proposed;
|
|
}
|
|
if (exchangeDetails.tosAccepted?.etag == exchangeDetails.tosCurrentEtag) {
|
|
return ExchangeTosStatus.Accepted;
|
|
}
|
|
return ExchangeTosStatus.Proposed;
|
|
}
|
|
|
|
export function makeExchangeListItem(
|
|
r: ExchangeEntryRecord,
|
|
exchangeDetails: ExchangeDetailsRecord | undefined,
|
|
lastError: TalerErrorDetail | undefined,
|
|
): ExchangeListItem {
|
|
const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError
|
|
? {
|
|
error: lastError,
|
|
}
|
|
: undefined;
|
|
|
|
let exchangeUpdateStatus: ExchangeUpdateStatus;
|
|
switch (r.updateStatus) {
|
|
case ExchangeEntryDbUpdateStatus.Failed:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.Failed;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.Initial:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.Initial;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.InitialUpdate:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.InitialUpdate;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.OutdatedUpdate:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.OutdatedUpdate;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.Ready:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.Ready;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.ReadyUpdate:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.ReadyUpdate;
|
|
break;
|
|
case ExchangeEntryDbUpdateStatus.Suspended:
|
|
exchangeUpdateStatus = ExchangeUpdateStatus.Suspended;
|
|
break;
|
|
}
|
|
|
|
let exchangeEntryStatus: ExchangeEntryStatus;
|
|
switch (r.entryStatus) {
|
|
case ExchangeEntryDbRecordStatus.Ephemeral:
|
|
exchangeEntryStatus = ExchangeEntryStatus.Ephemeral;
|
|
break;
|
|
case ExchangeEntryDbRecordStatus.Preset:
|
|
exchangeEntryStatus = ExchangeEntryStatus.Preset;
|
|
break;
|
|
case ExchangeEntryDbRecordStatus.Used:
|
|
exchangeEntryStatus = ExchangeEntryStatus.Used;
|
|
break;
|
|
}
|
|
|
|
return {
|
|
exchangeBaseUrl: r.baseUrl,
|
|
currency: exchangeDetails?.currency ?? r.presetCurrencyHint,
|
|
exchangeUpdateStatus,
|
|
exchangeEntryStatus,
|
|
tosStatus: exchangeDetails
|
|
? getExchangeTosStatus(exchangeDetails)
|
|
: ExchangeTosStatus.Pending,
|
|
ageRestrictionOptions: exchangeDetails?.ageMask
|
|
? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask)
|
|
: [],
|
|
paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [],
|
|
lastUpdateErrorInfo,
|
|
};
|
|
}
|
|
|
|
export interface LongpollResult {
|
|
ready: boolean;
|
|
}
|
|
|
|
export function runLongpollAsync(
|
|
ws: InternalWalletState,
|
|
retryTag: string,
|
|
reqFn: (ct: CancellationToken) => Promise<LongpollResult>,
|
|
): void {
|
|
const asyncFn = async () => {
|
|
if (ws.stopped) {
|
|
logger.trace("not long-polling reserve, wallet already stopped");
|
|
await storePendingTaskPending(ws, retryTag);
|
|
return;
|
|
}
|
|
const cts = CancellationToken.create();
|
|
let res: { ready: boolean } | undefined = undefined;
|
|
try {
|
|
ws.activeLongpoll[retryTag] = {
|
|
cancel: () => {
|
|
logger.trace("cancel of reserve longpoll requested");
|
|
cts.cancel();
|
|
},
|
|
};
|
|
res = await reqFn(cts.token);
|
|
} catch (e) {
|
|
const errDetail = getErrorDetailFromException(e);
|
|
logger.warn(`got error during long-polling: ${j2s(errDetail)}`);
|
|
await storePendingTaskError(ws, retryTag, errDetail);
|
|
return;
|
|
} finally {
|
|
delete ws.activeLongpoll[retryTag];
|
|
}
|
|
if (!res.ready) {
|
|
await storePendingTaskPending(ws, retryTag);
|
|
}
|
|
ws.workAvailable.trigger();
|
|
};
|
|
asyncFn();
|
|
}
|
|
|
|
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<void>;
|
|
abort(): Promise<void>;
|
|
suspend(): Promise<void>;
|
|
resume(): Promise<void>;
|
|
process(): Promise<TaskRunResult>;
|
|
}
|
|
|
|
export enum TaskRunResultType {
|
|
Finished = "finished",
|
|
Pending = "pending",
|
|
Error = "error",
|
|
Longpoll = "longpoll",
|
|
}
|
|
|
|
export type TaskRunResult =
|
|
| TaskRunFinishedResult
|
|
| TaskRunErrorResult
|
|
| TaskRunLongpollResult
|
|
| TaskRunPendingResult;
|
|
|
|
export namespace TaskRunResult {
|
|
export function finished(): TaskRunResult {
|
|
return {
|
|
type: TaskRunResultType.Finished,
|
|
};
|
|
}
|
|
export function pending(): TaskRunResult {
|
|
return {
|
|
type: TaskRunResultType.Pending,
|
|
};
|
|
}
|
|
export function longpoll(): TaskRunResult {
|
|
return {
|
|
type: TaskRunResultType.Longpoll,
|
|
};
|
|
}
|
|
}
|
|
|
|
export interface TaskRunFinishedResult {
|
|
type: TaskRunResultType.Finished;
|
|
}
|
|
|
|
export interface TaskRunPendingResult {
|
|
type: TaskRunResultType.Pending;
|
|
}
|
|
|
|
export interface TaskRunErrorResult {
|
|
type: TaskRunResultType.Error;
|
|
errorDetail: TalerErrorDetail;
|
|
}
|
|
|
|
export interface TaskRunLongpollResult {
|
|
type: TaskRunResultType.Longpoll;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.ExchangeCheckRefresh; exchangeBaseUrl: string }
|
|
| { tag: PendingTaskType.ExchangeUpdate; exchangeBaseUrl: 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: rest[0] };
|
|
case PendingTaskType.Deposit:
|
|
return { tag: type, depositGroupId: rest[0] };
|
|
case PendingTaskType.ExchangeCheckRefresh:
|
|
return { tag: type, exchangeBaseUrl: rest[0] };
|
|
case PendingTaskType.ExchangeUpdate:
|
|
return { tag: type, exchangeBaseUrl: 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.ExchangeCheckRefresh:
|
|
return `${p.tag}:${p.exchangeBaseUrl}` as TaskId;
|
|
case PendingTaskType.ExchangeUpdate:
|
|
return `${p.tag}:${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}:${exch.baseUrl}` as TaskId;
|
|
}
|
|
export function forExchangeUpdateFromUrl(exchBaseUrl: string): TaskId {
|
|
return `${PendingTaskType.ExchangeUpdate}:${exchBaseUrl}` as TaskId;
|
|
}
|
|
export function forExchangeCheckRefresh(exch: ExchangeEntryRecord): TaskId {
|
|
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}` 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}:${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;
|
|
}
|
|
}
|