/*
This file is part of GNU Taler
(C) 2019 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
*/
import {
AbsoluteTime,
AcceptWithdrawalResponse,
addPaytoQueryParams,
Amounts,
canonicalizeBaseUrl,
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForWithdrawOperationStatusResponse,
CreateReserveRequest,
CreateReserveResponse,
Duration,
durationMax,
durationMin,
encodeCrock,
ForcedDenomSel,
getRandomBytes,
j2s,
Logger,
NotificationType,
randomBytes,
TalerErrorCode,
TalerErrorDetail,
URL,
} from "@gnu-taler/taler-util";
import {
DenomSelectionState,
OperationStatus,
ReserveBankInfo,
ReserveRecord,
ReserveRecordStatus,
WalletStoresV1,
WithdrawalGroupRecord,
} from "../db.js";
import { TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import {
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "../util/http.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { RetryInfo } from "../util/retries.js";
import { guardOperationException } from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl,
} from "./exchanges.js";
import {
getBankWithdrawalInfo,
getCandidateWithdrawalDenoms,
processWithdrawGroup,
selectForcedWithdrawalDenominations,
selectWithdrawalDenominations,
updateWithdrawalDenoms,
} from "./withdraw.js";
const logger = new Logger("taler-wallet-core:reserves.ts");
/**
* Set up the reserve's retry timeout in preparation for
* processing the reserve.
*/
async function setupReserveRetry(
ws: InternalWalletState,
reservePub: string,
options: {
reset: boolean;
},
): Promise {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
if (options.reset) {
r.retryInfo = RetryInfo.reset();
} else {
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
delete r.lastError;
await tx.reserves.put(r);
});
}
/**
* Report an error that happened while processing the reserve.
*
* Logs the error via a notification and by storing it in the database.
*/
async function reportReserveError(
ws: InternalWalletState,
reservePub: string,
err: TalerErrorDetail,
): Promise {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
if (!r.retryInfo) {
logger.error(`got reserve error for inactive reserve (no retryInfo)`);
return;
}
r.lastError = err;
await tx.reserves.put(r);
});
ws.notify({
type: NotificationType.ReserveOperationError,
error: err,
});
}
/**
* Create a reserve, but do not flag it as confirmed yet.
*
* Adds the corresponding exchange as a trusted exchange if it is neither
* audited nor trusted already.
*/
export async function createReserve(
ws: InternalWalletState,
req: CreateReserveRequest,
): Promise {
const keypair = await ws.cryptoApi.createEddsaKeypair({});
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
const canonExchange = canonicalizeBaseUrl(req.exchange);
let reserveStatus;
if (req.bankWithdrawStatusUrl) {
reserveStatus = ReserveRecordStatus.RegisteringBank;
} else {
reserveStatus = ReserveRecordStatus.QueryingStatus;
}
let bankInfo: ReserveBankInfo | undefined;
if (req.bankWithdrawStatusUrl) {
if (!req.exchangePaytoUri) {
throw Error(
"Exchange payto URI must be specified for a bank-integrated withdrawal",
);
}
bankInfo = {
statusUrl: req.bankWithdrawStatusUrl,
exchangePaytoUri: req.exchangePaytoUri,
};
}
const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32));
await updateWithdrawalDenoms(ws, canonExchange);
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
let initialDenomSel: DenomSelectionState;
if (req.forcedDenomSel) {
logger.warn("using forced denom selection");
initialDenomSel = selectForcedWithdrawalDenominations(
req.amount,
denoms,
req.forcedDenomSel,
);
} else {
initialDenomSel = selectWithdrawalDenominations(req.amount, denoms);
}
const reserveRecord: ReserveRecord = {
instructedAmount: req.amount,
initialWithdrawalGroupId,
initialDenomSel,
initialWithdrawalStarted: false,
timestampCreated: now,
exchangeBaseUrl: canonExchange,
reservePriv: keypair.priv,
reservePub: keypair.pub,
senderWire: req.senderWire,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
bankInfo,
reserveStatus,
retryInfo: RetryInfo.reset(),
lastError: undefined,
currency: req.amount.currency,
operationStatus: OperationStatus.Pending,
restrictAge: req.restrictAge,
};
const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
const exchangeDetails = exchangeInfo.exchangeDetails;
if (!exchangeDetails) {
logger.trace(exchangeDetails);
throw Error("exchange not updated");
}
const { isAudited, isTrusted } = await getExchangeTrust(
ws,
exchangeInfo.exchange,
);
const resp = await ws.db
.mktx((x) => ({
exchangeTrust: x.exchangeTrust,
reserves: x.reserves,
bankWithdrawUris: x.bankWithdrawUris,
}))
.runReadWrite(async (tx) => {
// Check if we have already created a reserve for that bankWithdrawStatusUrl
if (reserveRecord.bankInfo?.statusUrl) {
const bwi = await tx.bankWithdrawUris.get(
reserveRecord.bankInfo.statusUrl,
);
if (bwi) {
const otherReserve = await tx.reserves.get(bwi.reservePub);
if (otherReserve) {
logger.trace(
"returning existing reserve for bankWithdrawStatusUri",
);
return {
exchange: otherReserve.exchangeBaseUrl,
reservePub: otherReserve.reservePub,
};
}
}
await tx.bankWithdrawUris.put({
reservePub: reserveRecord.reservePub,
talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
});
}
if (!isAudited && !isTrusted) {
await tx.exchangeTrust.put({
currency: reserveRecord.currency,
exchangeBaseUrl: reserveRecord.exchangeBaseUrl,
exchangeMasterPub: exchangeDetails.masterPublicKey,
uids: [encodeCrock(getRandomBytes(32))],
});
}
await tx.reserves.put(reserveRecord);
const r: CreateReserveResponse = {
exchange: canonExchange,
reservePub: keypair.pub,
};
return r;
});
if (reserveRecord.reservePub === resp.reservePub) {
// Only emit notification when a new reserve was created.
ws.notify({
type: NotificationType.ReserveCreated,
reservePub: reserveRecord.reservePub,
});
}
// Asynchronously process the reserve, but return
// to the caller already.
processReserve(ws, resp.reservePub, { forceNow: true }).catch((e) => {
logger.error("Processing reserve (after createReserve) failed:", e);
});
return resp;
}
/**
* Re-query the status of a reserve.
*/
export async function forceQueryReserve(
ws: InternalWalletState,
reservePub: string,
): Promise {
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const reserve = await tx.reserves.get(reservePub);
if (!reserve) {
return;
}
// Only force status query where it makes sense
switch (reserve.reserveStatus) {
case ReserveRecordStatus.Dormant:
reserve.reserveStatus = ReserveRecordStatus.QueryingStatus;
reserve.operationStatus = OperationStatus.Pending;
reserve.retryInfo = RetryInfo.reset();
break;
default:
break;
}
await tx.reserves.put(reserve);
});
await processReserve(ws, reservePub, { forceNow: true });
}
/**
* First fetch information required to withdraw from the reserve,
* then deplete the reserve, withdrawing coins until it is empty.
*
* The returned promise resolves once the reserve is set to the
* state "Dormant".
*/
export async function processReserve(
ws: InternalWalletState,
reservePub: string,
options: {
forceNow?: boolean;
} = {},
): Promise {
return ws.memoProcessReserve.memo(reservePub, async () => {
const onOpError = (err: TalerErrorDetail): Promise =>
reportReserveError(ws, reservePub, err);
await guardOperationException(
() => processReserveImpl(ws, reservePub, options),
onOpError,
);
});
}
async function registerReserveWithBank(
ws: InternalWalletState,
reservePub: string,
): Promise {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return await tx.reserves.get(reservePub);
});
switch (reserve?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankInfo = reserve.bankInfo;
if (!bankInfo) {
return;
}
const bankStatusUrl = bankInfo.statusUrl;
const httpResp = await ws.http.postJson(
bankStatusUrl,
{
reserve_pub: reservePub,
selected_exchange: bankInfo.exchangePaytoUri,
},
{
timeout: getReserveRequestTimeout(reserve),
},
);
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBankWithdrawalOperationPostResponse(),
);
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
r.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
AbsoluteTime.now(),
);
r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
r.operationStatus = OperationStatus.Pending;
if (!r.bankInfo) {
throw Error("invariant failed");
}
r.retryInfo = RetryInfo.reset();
await tx.reserves.put(r);
});
ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
return processReserveBankStatus(ws, reservePub);
}
export function getReserveRequestTimeout(r: ReserveRecord): Duration {
return durationMax(
{ d_ms: 60000 },
durationMin({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
);
}
async function processReserveBankStatus(
ws: InternalWalletState,
reservePub: string,
): Promise {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
switch (reserve?.reserveStatus) {
case ReserveRecordStatus.WaitConfirmBank:
case ReserveRecordStatus.RegisteringBank:
break;
default:
return;
}
const bankStatusUrl = reserve.bankInfo?.statusUrl;
if (!bankStatusUrl) {
return;
}
const statusResp = await ws.http.get(bankStatusUrl, {
timeout: getReserveRequestTimeout(reserve),
});
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (status.aborted) {
logger.info("bank aborted the withdrawal");
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.BankAborted;
r.operationStatus = OperationStatus.Finished;
r.retryInfo = RetryInfo.reset();
await tx.reserves.put(r);
});
return;
}
// Bank still needs to know our reserve info
if (!status.selection_done) {
await registerReserveWithBank(ws, reservePub);
return await processReserveBankStatus(ws, reservePub);
}
// FIXME: Why do we do this?!
if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) {
await registerReserveWithBank(ws, reservePub);
return await processReserveBankStatus(ws, reservePub);
}
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadWrite(async (tx) => {
const r = await tx.reserves.get(reservePub);
if (!r) {
return;
}
// Re-check reserve status within transaction
switch (r.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
case ReserveRecordStatus.WaitConfirmBank:
break;
default:
return;
}
if (status.transfer_done) {
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.timestampBankConfirmed = now;
r.reserveStatus = ReserveRecordStatus.QueryingStatus;
r.operationStatus = OperationStatus.Pending;
r.retryInfo = RetryInfo.reset();
} else {
logger.info("Withdrawal operation not yet confirmed by bank");
if (r.bankInfo) {
r.bankInfo.confirmUrl = status.confirm_transfer_url;
}
r.retryInfo = RetryInfo.increment(r.retryInfo);
}
await tx.reserves.put(r);
});
}
/**
* Update the information about a reserve that is stored in the wallet
* by querying the reserve's exchange.
*
* If the reserve have funds that are not allocated in a withdrawal group yet
* and are big enough to withdraw with available denominations,
* create a new withdrawal group for the remaining amount.
*/
async function updateReserve(
ws: InternalWalletState,
reservePub: string,
): Promise<{ ready: boolean }> {
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
if (!reserve) {
throw Error("reserve not in db");
}
if (reserve.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
return { ready: true };
}
const reserveUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl);
reserveUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying reserve status via ${reserveUrl}`);
const resp = await ws.http.get(reserveUrl.href, {
timeout: getReserveRequestTimeout(reserve),
});
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForReserveStatus(),
);
if (result.isError) {
if (
resp.status === 404 &&
result.talerErrorResponse.code ===
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
) {
ws.notify({
type: NotificationType.ReserveNotYetFound,
reservePub,
});
return { ready: false };
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
logger.trace(`got reserve status ${j2s(result.response)}`);
const reserveInfo = result.response;
const reserveBalance = Amounts.parseOrThrow(reserveInfo.balance);
const currency = reserveBalance.currency;
await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl);
const denoms = await getCandidateWithdrawalDenoms(
ws,
reserve.exchangeBaseUrl,
);
const newWithdrawalGroup = await ws.db
.mktx((x) => ({
planchets: x.planchets,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
denominations: x.denominations,
}))
.runReadWrite(async (tx) => {
const newReserve = await tx.reserves.get(reserve.reservePub);
if (!newReserve) {
return;
}
let amountReservePlus = reserveBalance;
let amountReserveMinus = Amounts.getZero(currency);
// Subtract amount allocated in unfinished withdrawal groups
// for this reserve from the available amount.
await tx.withdrawalGroups.indexes.byReservePub
.iter(reservePub)
.forEachAsync(async (wg) => {
if (wg.timestampFinish) {
return;
}
await tx.planchets.indexes.byGroup
.iter(wg.withdrawalGroupId)
.forEachAsync(async (pr) => {
if (pr.withdrawalDone) {
return;
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
wg.exchangeBaseUrl,
pr.denomPubHash,
);
if (!denomInfo) {
logger.error(`no denom info found for ${pr.denomPubHash}`);
return;
}
amountReserveMinus = Amounts.add(
amountReserveMinus,
denomInfo.value,
denomInfo.feeWithdraw,
).amount;
});
});
const remainingAmount = Amounts.sub(
amountReservePlus,
amountReserveMinus,
).amount;
let withdrawalGroupId: string;
let denomSel: DenomSelectionState;
if (!newReserve.initialWithdrawalStarted) {
withdrawalGroupId = newReserve.initialWithdrawalGroupId;
newReserve.initialWithdrawalStarted = true;
denomSel = newReserve.initialDenomSel;
} else {
withdrawalGroupId = encodeCrock(randomBytes(32));
denomSel = selectWithdrawalDenominations(remainingAmount, denoms);
logger.trace(
`Remaining unclaimed amount in reseve is ${Amounts.stringify(
remainingAmount,
)} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`,
);
if (denomSel.selectedDenoms.length === 0) {
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
newReserve.operationStatus = OperationStatus.Finished;
delete newReserve.lastError;
delete newReserve.retryInfo;
await tx.reserves.put(newReserve);
return;
}
}
const withdrawalRecord: WithdrawalGroupRecord = {
withdrawalGroupId: withdrawalGroupId,
exchangeBaseUrl: reserve.exchangeBaseUrl,
reservePub: reserve.reservePub,
rawWithdrawalAmount: remainingAmount,
timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
retryInfo: RetryInfo.reset(),
lastError: undefined,
denomsSel: denomSel,
secretSeed: encodeCrock(getRandomBytes(64)),
denomSelUid: encodeCrock(getRandomBytes(32)),
operationStatus: OperationStatus.Pending,
};
delete newReserve.lastError;
delete newReserve.retryInfo;
newReserve.reserveStatus = ReserveRecordStatus.Dormant;
newReserve.operationStatus = OperationStatus.Finished;
await tx.reserves.put(newReserve);
await tx.withdrawalGroups.put(withdrawalRecord);
return withdrawalRecord;
});
if (newWithdrawalGroup) {
logger.trace("processing new withdraw group");
ws.notify({
type: NotificationType.WithdrawGroupCreated,
withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
});
await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
}
return { ready: true };
}
async function processReserveImpl(
ws: InternalWalletState,
reservePub: string,
options: {
forceNow?: boolean;
} = {},
): Promise {
const forceNow = options.forceNow ?? false;
await setupReserveRetry(ws, reservePub, { reset: forceNow });
const reserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reservePub);
});
if (!reserve) {
logger.error(
`not processing reserve: reserve ${reservePub} does not exist`,
);
return;
}
logger.trace(
`Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
);
switch (reserve.reserveStatus) {
case ReserveRecordStatus.RegisteringBank:
await processReserveBankStatus(ws, reservePub);
return await processReserveImpl(ws, reservePub, { forceNow: true });
case ReserveRecordStatus.QueryingStatus: {
const res = await updateReserve(ws, reservePub);
if (res.ready) {
return await processReserveImpl(ws, reservePub, { forceNow: true });
}
break;
}
case ReserveRecordStatus.Dormant:
// nothing to do
break;
case ReserveRecordStatus.WaitConfirmBank:
await processReserveBankStatus(ws, reservePub);
break;
case ReserveRecordStatus.BankAborted:
break;
default:
console.warn("unknown reserve record status:", reserve.reserveStatus);
assertUnreachable(reserve.reserveStatus);
break;
}
}
/**
* Create a reserve for a bank-integrated withdrawal from
* a taler://withdraw URI.
*/
export async function createTalerWithdrawReserve(
ws: InternalWalletState,
talerWithdrawUri: string,
selectedExchange: string,
options: {
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
} = {},
): Promise {
await updateExchangeFromUrl(ws, selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
const exchangePaytoUri = await getExchangePaytoUri(
ws,
selectedExchange,
withdrawInfo.wireTypes,
);
const reserve = await createReserve(ws, {
amount: withdrawInfo.amount,
bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
exchange: selectedExchange,
senderWire: withdrawInfo.senderWire,
exchangePaytoUri: exchangePaytoUri,
restrictAge: options.restrictAge,
forcedDenomSel: options.forcedDenomSel,
});
// We do this here, as the reserve should be registered before we return,
// so that we can redirect the user to the bank's status page.
await processReserveBankStatus(ws, reserve.reservePub);
const processedReserve = await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
return tx.reserves.get(reserve.reservePub);
});
if (processedReserve?.reserveStatus === ReserveRecordStatus.BankAborted) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
{},
);
}
return {
reservePub: reserve.reservePub,
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
};
}
/**
* Get payto URIs that can be used to fund a reserve.
*/
export async function getFundingPaytoUris(
tx: GetReadOnlyAccess<{
reserves: typeof WalletStoresV1.reserves;
exchanges: typeof WalletStoresV1.exchanges;
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
}>,
reservePub: string,
): Promise {
const r = await tx.reserves.get(reservePub);
if (!r) {
logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
return [];
}
const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl);
if (!exchangeDetails) {
logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
return [];
}
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
if (!plainPaytoUris) {
logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`);
return [];
}
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(r.instructedAmount),
message: `Taler Withdrawal ${r.reservePub}`,
}),
);
}