summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/reserves.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2020-08-03 13:00:48 +0530
committerFlorian Dold <florian.dold@gmail.com>2020-08-03 13:01:05 +0530
commitffd2a62c3f7df94365980302fef3bc3376b48182 (patch)
tree270af6f16b4cc7f5da2afdba55c8bc9dbea5eca5 /packages/taler-wallet-core/src/operations/reserves.ts
parentaa481e42675fb7c4dcbbeec0ba1c61e1953b9596 (diff)
downloadwallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.gz
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.tar.bz2
wallet-core-ffd2a62c3f7df94365980302fef3bc3376b48182.zip
modularize repo, use pnpm, improve typechecking
Diffstat (limited to 'packages/taler-wallet-core/src/operations/reserves.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/reserves.ts841
1 files changed, 841 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts
new file mode 100644
index 000000000..58095affd
--- /dev/null
+++ b/packages/taler-wallet-core/src/operations/reserves.ts
@@ -0,0 +1,841 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+import {
+ CreateReserveRequest,
+ CreateReserveResponse,
+ OperationErrorDetails,
+ AcceptWithdrawalResponse,
+} from "../types/walletTypes";
+import { canonicalizeBaseUrl } from "../util/helpers";
+import { InternalWalletState } from "./state";
+import {
+ ReserveRecordStatus,
+ ReserveRecord,
+ CurrencyRecord,
+ Stores,
+ WithdrawalGroupRecord,
+ initRetryInfo,
+ updateRetryInfoTimeout,
+ ReserveUpdatedEventRecord,
+ WalletReserveHistoryItemType,
+ WithdrawalSourceType,
+ ReserveHistoryRecord,
+ ReserveBankInfo,
+} from "../types/dbTypes";
+import { Logger } from "../util/logging";
+import { Amounts } from "../util/amounts";
+import {
+ updateExchangeFromUrl,
+ getExchangeTrust,
+ getExchangePaytoUri,
+} from "./exchanges";
+import {
+ codecForWithdrawOperationStatusResponse,
+ codecForBankWithdrawalOperationPostResponse,
+} from "../types/talerTypes";
+import { assertUnreachable } from "../util/assertUnreachable";
+import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
+import { randomBytes } from "../crypto/primitives/nacl-fast";
+import {
+ selectWithdrawalDenoms,
+ processWithdrawGroup,
+ getBankWithdrawalInfo,
+ denomSelectionInfoToState,
+} from "./withdraw";
+import {
+ guardOperationException,
+ OperationFailedAndReportedError,
+ makeErrorDetails,
+} from "./errors";
+import { NotificationType } from "../types/notifications";
+import { codecForReserveStatus } from "../types/ReserveStatus";
+import { getTimestampNow } from "../util/time";
+import {
+ reconcileReserveHistory,
+ summarizeReserveHistory,
+} from "../util/reserveHistoryUtil";
+import { TransactionHandle } from "../util/query";
+import { addPaytoQueryParams } from "../util/payto";
+import { TalerErrorCode } from "../TalerErrorCode";
+import {
+ readSuccessResponseJsonOrErrorCode,
+ throwUnexpectedRequestError,
+ readSuccessResponseJsonOrThrow,
+} from "../util/http";
+import { codecForAny } from "../util/codec";
+import { URL } from "../util/url";
+
+const logger = new Logger("reserves.ts");
+
+async function resetReserveRetry(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ await ws.db.mutate(Stores.reserves, reservePub, (x) => {
+ if (x.retryInfo.active) {
+ x.retryInfo = initRetryInfo();
+ }
+ return x;
+ });
+}
+
+/**
+ * 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<CreateReserveResponse> {
+ const keypair = await ws.cryptoApi.createEddsaKeypair();
+ const now = getTimestampNow();
+ const canonExchange = canonicalizeBaseUrl(req.exchange);
+
+ let reserveStatus;
+ if (req.bankWithdrawStatusUrl) {
+ reserveStatus = ReserveRecordStatus.REGISTERING_BANK;
+ } else {
+ reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ }
+
+ 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));
+
+ const denomSelInfo = await selectWithdrawalDenoms(
+ ws,
+ canonExchange,
+ req.amount,
+ );
+ const initialDenomSel = denomSelectionInfoToState(denomSelInfo);
+
+ 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,
+ lastSuccessfulStatusQuery: undefined,
+ retryInfo: initRetryInfo(),
+ lastError: undefined,
+ currency: req.amount.currency,
+ };
+
+ const reserveHistoryRecord: ReserveHistoryRecord = {
+ reservePub: keypair.pub,
+ reserveTransactions: [],
+ };
+
+ reserveHistoryRecord.reserveTransactions.push({
+ type: WalletReserveHistoryItemType.Credit,
+ expectedAmount: req.amount,
+ });
+
+ const senderWire = req.senderWire;
+ if (senderWire) {
+ const rec = {
+ paytoUri: senderWire,
+ };
+ await ws.db.put(Stores.senderWires, rec);
+ }
+
+ const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange);
+ const exchangeDetails = exchangeInfo.details;
+ if (!exchangeDetails) {
+ console.log(exchangeDetails);
+ throw Error("exchange not updated");
+ }
+ const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo);
+ let currencyRecord = await ws.db.get(
+ Stores.currencies,
+ exchangeDetails.currency,
+ );
+ if (!currencyRecord) {
+ currencyRecord = {
+ auditors: [],
+ exchanges: [],
+ fractionalDigits: 2,
+ name: exchangeDetails.currency,
+ };
+ }
+
+ if (!isAudited && !isTrusted) {
+ currencyRecord.exchanges.push({
+ baseUrl: req.exchange,
+ exchangePub: exchangeDetails.masterPublicKey,
+ });
+ }
+
+ const cr: CurrencyRecord = currencyRecord;
+
+ const resp = await ws.db.runWithWriteTransaction(
+ [
+ Stores.currencies,
+ Stores.reserves,
+ Stores.reserveHistory,
+ Stores.bankWithdrawUris,
+ ],
+ async (tx) => {
+ // Check if we have already created a reserve for that bankWithdrawStatusUrl
+ if (reserveRecord.bankInfo?.statusUrl) {
+ const bwi = await tx.get(
+ Stores.bankWithdrawUris,
+ reserveRecord.bankInfo.statusUrl,
+ );
+ if (bwi) {
+ const otherReserve = await tx.get(Stores.reserves, bwi.reservePub);
+ if (otherReserve) {
+ logger.trace(
+ "returning existing reserve for bankWithdrawStatusUri",
+ );
+ return {
+ exchange: otherReserve.exchangeBaseUrl,
+ reservePub: otherReserve.reservePub,
+ };
+ }
+ }
+ await tx.put(Stores.bankWithdrawUris, {
+ reservePub: reserveRecord.reservePub,
+ talerWithdrawUri: reserveRecord.bankInfo.statusUrl,
+ });
+ }
+ await tx.put(Stores.currencies, cr);
+ await tx.put(Stores.reserves, reserveRecord);
+ await tx.put(Stores.reserveHistory, reserveHistoryRecord);
+ 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, 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<void> {
+ await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
+ const reserve = await tx.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ return;
+ }
+ // Only force status query where it makes sense
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.DORMANT:
+ case ReserveRecordStatus.WITHDRAWING:
+ case ReserveRecordStatus.QUERYING_STATUS:
+ break;
+ default:
+ return;
+ }
+ reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ reserve.retryInfo = initRetryInfo();
+ await tx.put(Stores.reserves, reserve);
+ });
+ await processReserve(ws, reservePub, true);
+}
+
+/**
+ * First fetch information requred 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,
+ forceNow = false,
+): Promise<void> {
+ return ws.memoProcessReserve.memo(reservePub, async () => {
+ const onOpError = (err: OperationErrorDetails): Promise<void> =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveImpl(ws, reservePub, forceNow),
+ onOpError,
+ );
+ });
+}
+
+async function registerReserveWithBank(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ 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,
+ });
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ r.timestampReserveInfoPosted = getTimestampNow();
+ r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK;
+ if (!r.bankInfo) {
+ throw Error("invariant failed");
+ }
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
+ return processReserveBankStatus(ws, reservePub);
+}
+
+export async function processReserveBankStatus(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const onOpError = (err: OperationErrorDetails): Promise<void> =>
+ incrementReserveRetry(ws, reservePub, err);
+ await guardOperationException(
+ () => processReserveBankStatusImpl(ws, reservePub),
+ onOpError,
+ );
+}
+
+async function processReserveBankStatusImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ switch (reserve?.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ case ReserveRecordStatus.REGISTERING_BANK:
+ break;
+ default:
+ return;
+ }
+ const bankStatusUrl = reserve.bankInfo?.statusUrl;
+ if (!bankStatusUrl) {
+ return;
+ }
+
+ const statusResp = await ws.http.get(bankStatusUrl);
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.selection_done) {
+ if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) {
+ await registerReserveWithBank(ws, reservePub);
+ return await processReserveBankStatus(ws, reservePub);
+ }
+ } else {
+ await registerReserveWithBank(ws, reservePub);
+ return await processReserveBankStatus(ws, reservePub);
+ }
+
+ if (status.transfer_done) {
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ const now = getTimestampNow();
+ r.timestampBankConfirmed = now;
+ r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
+ r.retryInfo = initRetryInfo();
+ return r;
+ });
+ await processReserveImpl(ws, reservePub, true);
+ } else {
+ await ws.db.mutate(Stores.reserves, reservePub, (r) => {
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ break;
+ default:
+ return;
+ }
+ if (r.bankInfo) {
+ r.bankInfo.confirmUrl = status.confirm_transfer_url;
+ }
+ return r;
+ });
+ await incrementReserveRetry(ws, reservePub, undefined);
+ }
+}
+
+async function incrementReserveRetry(
+ ws: InternalWalletState,
+ reservePub: string,
+ err: OperationErrorDetails | undefined,
+): Promise<void> {
+ await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ return;
+ }
+ if (!r.retryInfo) {
+ return;
+ }
+ r.retryInfo.retryCounter++;
+ updateRetryInfoTimeout(r.retryInfo);
+ r.lastError = err;
+ await tx.put(Stores.reserves, r);
+ });
+ if (err) {
+ ws.notify({
+ type: NotificationType.ReserveOperationError,
+ error: err,
+ });
+ }
+}
+
+/**
+ * Update the information about a reserve that is stored in the wallet
+ * by quering the reserve's exchange.
+ */
+async function updateReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<{ ready: boolean }> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ throw Error("reserve not in db");
+ }
+
+ if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return { ready: true };
+ }
+
+ const resp = await ws.http.get(
+ new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl).href,
+ );
+
+ const result = await readSuccessResponseJsonOrErrorCode(
+ resp,
+ codecForReserveStatus(),
+ );
+ if (result.isError) {
+ if (
+ resp.status === 404 &&
+ result.talerErrorResponse.code === TalerErrorCode.RESERVE_STATUS_UNKNOWN
+ ) {
+ ws.notify({
+ type: NotificationType.ReserveNotYetFound,
+ reservePub,
+ });
+ await incrementReserveRetry(ws, reservePub, undefined);
+ return { ready: false };
+ } else {
+ throwUnexpectedRequestError(resp, result.talerErrorResponse);
+ }
+ }
+
+ const reserveInfo = result.response;
+
+ const balance = Amounts.parseOrThrow(reserveInfo.balance);
+ const currency = balance.currency;
+ await ws.db.runWithWriteTransaction(
+ [Stores.reserves, Stores.reserveUpdatedEvents, Stores.reserveHistory],
+ async (tx) => {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ return;
+ }
+ if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) {
+ return;
+ }
+
+ const hist = await tx.get(Stores.reserveHistory, reservePub);
+ if (!hist) {
+ throw Error("inconsistent database");
+ }
+
+ const newHistoryTransactions = reserveInfo.history.slice(
+ hist.reserveTransactions.length,
+ );
+
+ const reserveUpdateId = encodeCrock(getRandomBytes(32));
+
+ const reconciled = reconcileReserveHistory(
+ hist.reserveTransactions,
+ reserveInfo.history,
+ );
+
+ const summary = summarizeReserveHistory(
+ reconciled.updatedLocalHistory,
+ currency,
+ );
+
+ if (
+ reconciled.newAddedItems.length + reconciled.newMatchedItems.length !=
+ 0
+ ) {
+ const reserveUpdate: ReserveUpdatedEventRecord = {
+ reservePub: r.reservePub,
+ timestamp: getTimestampNow(),
+ amountReserveBalance: Amounts.stringify(balance),
+ amountExpected: Amounts.stringify(summary.awaitedReserveAmount),
+ newHistoryTransactions,
+ reserveUpdateId,
+ };
+ await tx.put(Stores.reserveUpdatedEvents, reserveUpdate);
+ r.reserveStatus = ReserveRecordStatus.WITHDRAWING;
+ r.retryInfo = initRetryInfo();
+ } else {
+ r.reserveStatus = ReserveRecordStatus.DORMANT;
+ r.retryInfo = initRetryInfo(false);
+ }
+ r.lastSuccessfulStatusQuery = getTimestampNow();
+ hist.reserveTransactions = reconciled.updatedLocalHistory;
+ r.lastError = undefined;
+ await tx.put(Stores.reserves, r);
+ await tx.put(Stores.reserveHistory, hist);
+ },
+ );
+ ws.notify({ type: NotificationType.ReserveUpdated });
+ return { ready: true };
+}
+
+async function processReserveImpl(
+ ws: InternalWalletState,
+ reservePub: string,
+ forceNow = false,
+): Promise<void> {
+ const reserve = await ws.db.get(Stores.reserves, reservePub);
+ if (!reserve) {
+ console.log("not processing reserve: reserve does not exist");
+ return;
+ }
+ if (!forceNow) {
+ const now = getTimestampNow();
+ if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) {
+ logger.trace("processReserve retry not due yet");
+ return;
+ }
+ } else {
+ await resetReserveRetry(ws, reservePub);
+ }
+ logger.trace(
+ `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`,
+ );
+ switch (reserve.reserveStatus) {
+ case ReserveRecordStatus.REGISTERING_BANK:
+ await processReserveBankStatus(ws, reservePub);
+ return await processReserveImpl(ws, reservePub, true);
+ case ReserveRecordStatus.QUERYING_STATUS: {
+ const res = await updateReserve(ws, reservePub);
+ if (res.ready) {
+ return await processReserveImpl(ws, reservePub, true);
+ } else {
+ break;
+ }
+ }
+ case ReserveRecordStatus.WITHDRAWING:
+ await depleteReserve(ws, reservePub);
+ break;
+ case ReserveRecordStatus.DORMANT:
+ // nothing to do
+ break;
+ case ReserveRecordStatus.WAIT_CONFIRM_BANK:
+ await processReserveBankStatus(ws, reservePub);
+ break;
+ default:
+ console.warn("unknown reserve record status:", reserve.reserveStatus);
+ assertUnreachable(reserve.reserveStatus);
+ break;
+ }
+}
+
+/**
+ * Withdraw coins from a reserve until it is empty.
+ *
+ * When finished, marks the reserve as depleted by setting
+ * the depleted timestamp.
+ */
+async function depleteReserve(
+ ws: InternalWalletState,
+ reservePub: string,
+): Promise<void> {
+ let reserve: ReserveRecord | undefined;
+ let hist: ReserveHistoryRecord | undefined;
+ await ws.db.runWithReadTransaction(
+ [Stores.reserves, Stores.reserveHistory],
+ async (tx) => {
+ reserve = await tx.get(Stores.reserves, reservePub);
+ hist = await tx.get(Stores.reserveHistory, reservePub);
+ },
+ );
+
+ if (!reserve) {
+ return;
+ }
+ if (!hist) {
+ throw Error("inconsistent database");
+ }
+ if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return;
+ }
+ logger.trace(`depleting reserve ${reservePub}`);
+
+ const summary = summarizeReserveHistory(
+ hist.reserveTransactions,
+ reserve.currency,
+ );
+
+ const withdrawAmount = summary.unclaimedReserveAmount;
+
+ const denomsForWithdraw = await selectWithdrawalDenoms(
+ ws,
+ reserve.exchangeBaseUrl,
+ withdrawAmount,
+ );
+ if (!denomsForWithdraw) {
+ // Only complain about inability to withdraw if we
+ // didn't withdraw before.
+ if (Amounts.isZero(summary.withdrawnAmount)) {
+ const opErr = makeErrorDetails(
+ TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT,
+ `Unable to withdraw from reserve, no denominations are available to withdraw.`,
+ {},
+ );
+ await incrementReserveRetry(ws, reserve.reservePub, opErr);
+ throw new OperationFailedAndReportedError(opErr);
+ }
+ return;
+ }
+
+ logger.trace(
+ `Selected coins total cost ${Amounts.stringify(
+ denomsForWithdraw.totalWithdrawCost,
+ )} for withdrawal of ${Amounts.stringify(withdrawAmount)}`,
+ );
+
+ logger.trace("selected denominations");
+
+ const newWithdrawalGroup = await ws.db.runWithWriteTransaction(
+ [
+ Stores.withdrawalGroups,
+ Stores.reserves,
+ Stores.reserveHistory,
+ Stores.planchets,
+ ],
+ async (tx) => {
+ const newReserve = await tx.get(Stores.reserves, reservePub);
+ if (!newReserve) {
+ return false;
+ }
+ if (newReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) {
+ return false;
+ }
+ const newHist = await tx.get(Stores.reserveHistory, reservePub);
+ if (!newHist) {
+ throw Error("inconsistent database");
+ }
+ const newSummary = summarizeReserveHistory(
+ newHist.reserveTransactions,
+ newReserve.currency,
+ );
+ if (
+ Amounts.cmp(
+ newSummary.unclaimedReserveAmount,
+ denomsForWithdraw.totalWithdrawCost,
+ ) < 0
+ ) {
+ // Something must have happened concurrently!
+ logger.error(
+ "aborting withdrawal session, likely concurrent withdrawal happened",
+ );
+ logger.error(
+ `unclaimed reserve amount is ${newSummary.unclaimedReserveAmount}`,
+ );
+ logger.error(
+ `withdrawal cost is ${denomsForWithdraw.totalWithdrawCost}`,
+ );
+ return false;
+ }
+ for (let i = 0; i < denomsForWithdraw.selectedDenoms.length; i++) {
+ const sd = denomsForWithdraw.selectedDenoms[i];
+ for (let j = 0; j < sd.count; j++) {
+ const amt = Amounts.add(sd.denom.value, sd.denom.feeWithdraw).amount;
+ newHist.reserveTransactions.push({
+ type: WalletReserveHistoryItemType.Withdraw,
+ expectedAmount: amt,
+ });
+ }
+ }
+ newReserve.reserveStatus = ReserveRecordStatus.DORMANT;
+ newReserve.retryInfo = initRetryInfo(false);
+
+ let withdrawalGroupId: string;
+
+ if (!newReserve.initialWithdrawalStarted) {
+ withdrawalGroupId = newReserve.initialWithdrawalGroupId;
+ newReserve.initialWithdrawalStarted = true;
+ } else {
+ withdrawalGroupId = encodeCrock(randomBytes(32));
+ }
+
+ const withdrawalRecord: WithdrawalGroupRecord = {
+ withdrawalGroupId: withdrawalGroupId,
+ exchangeBaseUrl: newReserve.exchangeBaseUrl,
+ source: {
+ type: WithdrawalSourceType.Reserve,
+ reservePub: newReserve.reservePub,
+ },
+ rawWithdrawalAmount: withdrawAmount,
+ timestampStart: getTimestampNow(),
+ retryInfo: initRetryInfo(),
+ lastErrorPerCoin: {},
+ lastError: undefined,
+ denomsSel: denomSelectionInfoToState(denomsForWithdraw),
+ };
+
+ await tx.put(Stores.reserves, newReserve);
+ await tx.put(Stores.reserveHistory, newHist);
+ await tx.put(Stores.withdrawalGroups, withdrawalRecord);
+ return withdrawalRecord;
+ },
+ );
+
+ if (newWithdrawalGroup) {
+ logger.trace("processing new withdraw group");
+ ws.notify({
+ type: NotificationType.WithdrawGroupCreated,
+ withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId,
+ });
+ await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId);
+ } else {
+ console.trace("withdraw session already existed");
+ }
+}
+
+export async function createTalerWithdrawReserve(
+ ws: InternalWalletState,
+ talerWithdrawUri: string,
+ selectedExchange: string,
+): Promise<AcceptWithdrawalResponse> {
+ const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri);
+ const exchangeWire = await getExchangePaytoUri(
+ ws,
+ selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+ const reserve = await createReserve(ws, {
+ amount: withdrawInfo.amount,
+ bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl,
+ exchange: selectedExchange,
+ senderWire: withdrawInfo.senderWire,
+ exchangePaytoUri: exchangeWire,
+ });
+ // 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);
+ return {
+ reservePub: reserve.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ };
+}
+
+/**
+ * Get payto URIs needed to fund a reserve.
+ */
+export async function getFundingPaytoUris(
+ tx: TransactionHandle,
+ reservePub: string,
+): Promise<string[]> {
+ const r = await tx.get(Stores.reserves, reservePub);
+ if (!r) {
+ logger.error(`reserve ${reservePub} not found (DB corrupted?)`);
+ return [];
+ }
+ const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl);
+ if (!exchange) {
+ logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchange.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}`,
+ }),
+ );
+}