summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/withdraw.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/withdraw.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts657
1 files changed, 612 insertions, 45 deletions
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
index ea9e22331..484b9b962 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -19,20 +19,29 @@
*/
import {
AbsoluteTime,
+ AcceptManualWithdrawalResult,
+ AcceptWithdrawalResponse,
+ addPaytoQueryParams,
AmountJson,
+ AmountLike,
Amounts,
AmountString,
BankWithdrawDetails,
+ canonicalizeBaseUrl,
+ codecForBankWithdrawalOperationPostResponse,
+ codecForReserveStatus,
codecForTalerConfigResponse,
codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
DenomKeyType,
Duration,
- durationFromSpec,
+ durationFromSpec, encodeCrock,
ExchangeListItem,
ExchangeWithdrawRequest,
ForcedDenomSel,
+ getRandomBytes,
+ j2s,
LibtoolVersion,
Logger,
NotificationType,
@@ -45,8 +54,9 @@ import {
VersionMatchResult,
WithdrawBatchResponse,
WithdrawResponse,
- WithdrawUriInfoResponse,
+ WithdrawUriInfoResponse
} from "@gnu-taler/taler-util";
+import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
@@ -58,26 +68,42 @@ import {
ExchangeRecord,
OperationStatus,
PlanchetRecord,
- WithdrawalGroupRecord,
+ ReserveBankInfo,
+ ReserveRecordStatus,
+ WalletStoresV1,
+ WithdrawalGroupRecord
} from "../db.js";
import {
getErrorDetailFromException,
makeErrorDetail,
- TalerError,
+ TalerError
} from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
+import { assertUnreachable } from "../util/assertUnreachable.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js";
import {
HttpRequestLibrary,
+ readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
+ throwUnexpectedRequestError
} from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
+import {
+ DbAccess,
+ GetReadOnlyAccess
+} from "../util/query.js";
import { RetryInfo } from "../util/retries.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
- WALLET_EXCHANGE_PROTOCOL_VERSION,
+ WALLET_EXCHANGE_PROTOCOL_VERSION
} from "../versions.js";
import { guardOperationException } from "./common.js";
+import {
+ getExchangeDetails,
+ getExchangePaytoUri,
+ getExchangeTrust,
+ updateExchangeFromUrl
+} from "./exchanges.js";
/**
* Logger for this file.
@@ -215,7 +241,7 @@ export function selectWithdrawalDenominations(
for (const d of denoms) {
let count = 0;
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
- for (; ;) {
+ for (;;) {
if (Amounts.cmp(remaining, cost) < 0) {
break;
}
@@ -410,47 +436,42 @@ async function processPlanchetGenerate(
return;
}
let ci = 0;
- let denomPubHash: string | undefined;
+ let maybeDenomPubHash: string | undefined;
for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
const d = withdrawalGroup.denomsSel.selectedDenoms[di];
if (coinIdx >= ci && coinIdx < ci + d.count) {
- denomPubHash = d.denomPubHash;
+ maybeDenomPubHash = d.denomPubHash;
break;
}
ci += d.count;
}
- if (!denomPubHash) {
+ if (!maybeDenomPubHash) {
throw Error("invariant violated");
}
+ const denomPubHash = maybeDenomPubHash;
- const { denom, reserve } = await ws.db
+ const denom = await ws.db
.mktx((x) => ({
- reserves: x.reserves,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
- const denom = await tx.denominations.get([
+ return ws.getDenomInfo(
+ ws,
+ tx,
withdrawalGroup.exchangeBaseUrl,
- denomPubHash!,
- ]);
- if (!denom) {
- throw Error("invariant violated");
- }
- const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
- if (!reserve) {
- throw Error("invariant violated");
- }
- return { denom, reserve };
+ denomPubHash,
+ );
});
+ checkDbInvariant(!!denom);
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw,
- reservePriv: reserve.reservePriv,
- reservePub: reserve.reservePub,
+ reservePriv: withdrawalGroup.reservePriv,
+ reservePub: withdrawalGroup.reservePub,
value: denom.value,
coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed,
- restrictAge: reserve.restrictAge,
+ restrictAge: withdrawalGroup.restrictAge,
});
const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey,
@@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin(
const planchetCoinPub = planchet.coinPub;
+ // Check if this is the first time that the whole
+ // withdrawal succeeded. If so, mark the withdrawal
+ // group as finished.
const firstSuccess = await ws.db
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
planchets: x.planchets,
}))
.runReadWrite(async (tx) => {
@@ -875,7 +898,8 @@ export async function updateWithdrawalDenoms(
denom.verificationStatus === DenominationVerificationStatus.Unverified
) {
logger.trace(
- `Validating denomination (${current + 1}/${denominations.length
+ `Validating denomination (${current + 1}/${
+ denominations.length
}) signature of ${denom.denomPubHash}`,
);
let valid = false;
@@ -960,7 +984,80 @@ async function reportWithdrawalError(
ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
}
-export async function processWithdrawGroup(
+/**
+ * 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 queryReserve(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<{ ready: boolean }> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ checkDbInvariant(!!withdrawalGroup);
+ if (withdrawalGroup.reserveStatus !== ReserveRecordStatus.QueryingStatus) {
+ return { ready: true };
+ }
+ const reservePub = withdrawalGroup.reservePub;
+
+ const reserveUrl = new URL(
+ `reserves/${reservePub}`,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ reserveUrl.searchParams.set("timeout_ms", "30000");
+
+ logger.info(`querying reserve status via ${reserveUrl}`);
+
+ const resp = await ws.http.get(reserveUrl.href, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ });
+
+ 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)}`);
+
+ await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadWrite(async (tx) => {
+ const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!wg) {
+ logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
+ return;
+ }
+ wg.reserveStatus = ReserveRecordStatus.Dormant;
+ await tx.withdrawalGroups.put(wg);
+ });
+
+ return { ready: true };
+}
+
+export async function processWithdrawalGroup(
ws: InternalWalletState,
withdrawalGroupId: string,
options: {
@@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl(
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
});
+
if (!withdrawalGroup) {
- // Withdrawal group doesn't exist yet, but reserve might exist
- // (and reference the yet to be created withdrawal group)
- const reservePub = await ws.db
- .mktx((x) => ({ reserves: x.reserves }))
- .runReadOnly(async (tx) => {
- const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
- withdrawalGroupId,
- );
- return r?.reservePub;
+ throw Error(`withdrawal group ${withdrawalGroupId} not found`);
+ }
+
+ switch (withdrawalGroup.reserveStatus) {
+ case ReserveRecordStatus.RegisteringBank:
+ await processReserveBankStatus(ws, withdrawalGroupId);
+ return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+ forceNow: true,
});
- if (!reservePub) {
- logger.warn(
- "withdrawal group doesn't exist (and reserve doesn't exist either)",
- );
+ case ReserveRecordStatus.QueryingStatus: {
+ const res = await queryReserve(ws, withdrawalGroupId);
+ if (res.ready) {
+ return await processWithdrawGroupImpl(ws, withdrawalGroupId, {
+ forceNow: true,
+ });
+ }
return;
}
- return await ws.reserveOps.processReserve(ws, reservePub, { forceNow });
+ case ReserveRecordStatus.WaitConfirmBank:
+ await processReserveBankStatus(ws, withdrawalGroupId);
+ return;
+ case ReserveRecordStatus.BankAborted:
+ // FIXME
+ return;
+ case ReserveRecordStatus.Dormant:
+ // We can try to withdraw, nothing needs to be done with the reserve.
+ break;
+ default:
+ logger.warn(
+ "unknown reserve record status:",
+ withdrawalGroup.reserveStatus,
+ );
+ assertUnreachable(withdrawalGroup.reserveStatus);
+ break;
}
await ws.exchangeOps.updateExchangeFromUrl(
@@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl(
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
- reserves: x.reserves,
planchets: x.planchets,
}))
.runReadWrite(async (tx) => {
@@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo(
!versionMatch.compatible &&
versionMatch.currentCmp === -1
) {
- console.warn(
+ logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
- `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
+ `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
);
}
}
@@ -1308,3 +1422,456 @@ export async function getWithdrawalDetailsForUri(
possibleExchanges: exchanges,
};
}
+
+export async function getFundingPaytoUrisTx(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ return await ws.db
+ .mktx((x) => ({
+ exchanges: x.exchanges,
+ exchangeDetails: x.exchangeDetails,
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
+}
+
+/**
+ * Get payto URIs that can be used to fund a withdrawal operation.
+ */
+export async function getFundingPaytoUris(
+ tx: GetReadOnlyAccess<{
+ withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
+ exchanges: typeof WalletStoresV1.exchanges;
+ exchangeDetails: typeof WalletStoresV1.exchangeDetails;
+ }>,
+ withdrawalGroupId: string,
+): Promise<string[]> {
+ const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
+ checkDbInvariant(!!withdrawalGroup);
+ const exchangeDetails = await getExchangeDetails(
+ tx,
+ withdrawalGroup.exchangeBaseUrl,
+ );
+ if (!exchangeDetails) {
+ logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
+ return [];
+ }
+ const plainPaytoUris =
+ exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
+ if (!plainPaytoUris) {
+ logger.error(
+ `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
+ );
+ return [];
+ }
+ return plainPaytoUris.map((x) =>
+ addPaytoQueryParams(x, {
+ amount: Amounts.stringify(withdrawalGroup.instructedAmount),
+ message: `Taler Withdrawal ${withdrawalGroup.reservePub}`,
+ }),
+ );
+}
+
+async function getWithdrawalGroupRecordTx(
+ db: DbAccess<typeof WalletStoresV1>,
+ req: {
+ withdrawalGroupId: string;
+ },
+): Promise<WithdrawalGroupRecord | undefined> {
+ return await db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadOnly(async (tx) => {
+ return tx.withdrawalGroups.get(req.withdrawalGroupId);
+ });
+}
+
+export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
+ return Duration.max(
+ { d_ms: 60000 },
+ Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)),
+ );
+}
+
+async function registerReserveWithBank(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadOnly(async (tx) => {
+ return await tx.withdrawalGroups.get(withdrawalGroupId);
+ });
+ switch (withdrawalGroup?.reserveStatus) {
+ case ReserveRecordStatus.WaitConfirmBank:
+ case ReserveRecordStatus.RegisteringBank:
+ break;
+ default:
+ return;
+ }
+ const bankInfo = withdrawalGroup.bankInfo;
+ if (!bankInfo) {
+ return;
+ }
+ const bankStatusUrl = bankInfo.statusUrl;
+ const httpResp = await ws.http.postJson(
+ bankStatusUrl,
+ {
+ reserve_pub: withdrawalGroup.reservePub,
+ selected_exchange: bankInfo.exchangePaytoUri,
+ },
+ {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ },
+ );
+ await readSuccessResponseJsonOrThrow(
+ httpResp,
+ codecForBankWithdrawalOperationPostResponse(),
+ );
+ await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadWrite(async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!r) {
+ return;
+ }
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.RegisteringBank:
+ case ReserveRecordStatus.WaitConfirmBank:
+ break;
+ default:
+ return;
+ }
+ if (!r.bankInfo) {
+ throw Error("invariant failed");
+ }
+ r.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
+ AbsoluteTime.now(),
+ );
+ r.reserveStatus = ReserveRecordStatus.WaitConfirmBank;
+ r.operationStatus = OperationStatus.Pending;
+ r.retryInfo = RetryInfo.reset();
+ await tx.withdrawalGroups.put(r);
+ });
+ ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
+ return processReserveBankStatus(ws, withdrawalGroupId);
+}
+
+async function processReserveBankStatus(
+ ws: InternalWalletState,
+ withdrawalGroupId: string,
+): Promise<void> {
+ const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ switch (withdrawalGroup?.reserveStatus) {
+ case ReserveRecordStatus.WaitConfirmBank:
+ case ReserveRecordStatus.RegisteringBank:
+ break;
+ default:
+ return;
+ }
+ const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl;
+ if (!bankStatusUrl) {
+ return;
+ }
+
+ const statusResp = await ws.http.get(bankStatusUrl, {
+ timeout: getReserveRequestTimeout(withdrawalGroup),
+ });
+ const status = await readSuccessResponseJsonOrThrow(
+ statusResp,
+ codecForWithdrawOperationStatusResponse(),
+ );
+
+ if (status.aborted) {
+ logger.info("bank aborted the withdrawal");
+ await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadWrite(async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (!r) {
+ return;
+ }
+ switch (r.reserveStatus) {
+ case ReserveRecordStatus.RegisteringBank:
+ case ReserveRecordStatus.WaitConfirmBank:
+ break;
+ default:
+ return;
+ }
+ if (!r.bankInfo) {
+ throw Error("invariant failed");
+ }
+ const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+ r.bankInfo.timestampBankConfirmed = now;
+ r.reserveStatus = ReserveRecordStatus.BankAborted;
+ r.operationStatus = OperationStatus.Finished;
+ r.retryInfo = RetryInfo.reset();
+ await tx.withdrawalGroups.put(r);
+ });
+ return;
+ }
+
+ // Bank still needs to know our reserve info
+ if (!status.selection_done) {
+ await registerReserveWithBank(ws, withdrawalGroupId);
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+
+ // FIXME: Why do we do this?!
+ if (withdrawalGroup.reserveStatus === ReserveRecordStatus.RegisteringBank) {
+ await registerReserveWithBank(ws, withdrawalGroupId);
+ return await processReserveBankStatus(ws, withdrawalGroupId);
+ }
+
+ await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ }))
+ .runReadWrite(async (tx) => {
+ const r = await tx.withdrawalGroups.get(withdrawalGroupId);
+ 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) {
+ logger.info("withdrawal: transfer confirmed by bank.");
+ const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+ if (!r.bankInfo) {
+ throw Error("invariant failed");
+ }
+ r.bankInfo.timestampBankConfirmed = now;
+ r.reserveStatus = ReserveRecordStatus.QueryingStatus;
+ r.operationStatus = OperationStatus.Pending;
+ r.retryInfo = RetryInfo.reset();
+ } else {
+ logger.info("withdrawal: transfer not yet confirmed by bank");
+ if (r.bankInfo) {
+ r.bankInfo.confirmUrl = status.confirm_transfer_url;
+ }
+ r.retryInfo = RetryInfo.increment(r.retryInfo);
+ }
+ await tx.withdrawalGroups.put(r);
+ });
+}
+
+export async function internalCreateWithdrawalGroup(
+ ws: InternalWalletState,
+ args: {
+ reserveStatus: ReserveRecordStatus;
+ amount: AmountJson;
+ bankInfo?: ReserveBankInfo;
+ exchangeBaseUrl: string;
+ forcedDenomSel?: ForcedDenomSel;
+ reserveKeyPair?: EddsaKeypair;
+ restrictAge?: number;
+ },
+): Promise<WithdrawalGroupRecord> {
+ const reserveKeyPair =
+ args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
+ const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
+ const secretSeed = encodeCrock(getRandomBytes(32));
+ const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+ const amount = args.amount;
+
+ await updateWithdrawalDenoms(ws, canonExchange);
+ const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
+
+ let initialDenomSel: DenomSelectionState;
+ const denomSelUid = encodeCrock(getRandomBytes(16));
+ if (args.forcedDenomSel) {
+ logger.warn("using forced denom selection");
+ initialDenomSel = selectForcedWithdrawalDenominations(
+ amount,
+ denoms,
+ args.forcedDenomSel,
+ );
+ } else {
+ initialDenomSel = selectWithdrawalDenominations(amount, denoms);
+ }
+
+ const withdrawalGroup: WithdrawalGroupRecord = {
+ denomSelUid,
+ denomsSel: initialDenomSel,
+ exchangeBaseUrl: canonExchange,
+ instructedAmount: amount,
+ timestampStart: now,
+ lastError: undefined,
+ operationStatus: OperationStatus.Pending,
+ rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
+ secretSeed,
+ reservePriv: reserveKeyPair.priv,
+ reservePub: reserveKeyPair.pub,
+ reserveStatus: args.reserveStatus,
+ retryInfo: RetryInfo.reset(),
+ withdrawalGroupId,
+ bankInfo: args.bankInfo,
+ restrictAge: args.restrictAge,
+ senderWire: undefined,
+ timestampFinish: undefined,
+ };
+
+ const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
+ const exchangeDetails = exchangeInfo.exchangeDetails;
+ if (!exchangeDetails) {
+ logger.trace(exchangeDetails);
+ throw Error("exchange not updated");
+ }
+ const { isAudited, isTrusted } = await getExchangeTrust(
+ ws,
+ exchangeInfo.exchange,
+ );
+
+ await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ exchanges: x.exchanges,
+ exchangeDetails: x.exchangeDetails,
+ exchangeTrust: x.exchangeTrust,
+ }))
+ .runReadWrite(async (tx) => {
+ await tx.withdrawalGroups.add(withdrawalGroup);
+
+ if (!isAudited && !isTrusted) {
+ await tx.exchangeTrust.put({
+ currency: amount.currency,
+ exchangeBaseUrl: canonExchange,
+ exchangeMasterPub: exchangeDetails.masterPublicKey,
+ uids: [encodeCrock(getRandomBytes(32))],
+ });
+ }
+ });
+
+ return withdrawalGroup;
+}
+
+export async function acceptWithdrawalFromUri(
+ ws: InternalWalletState,
+ req: {
+ talerWithdrawUri: string;
+ selectedExchange: string;
+ forcedDenomSel?: ForcedDenomSel;
+ restrictAge?: number;
+ },
+): Promise<AcceptWithdrawalResponse> {
+ await updateExchangeFromUrl(ws, req.selectedExchange);
+ const withdrawInfo = await getBankWithdrawalInfo(
+ ws.http,
+ req.talerWithdrawUri,
+ );
+ const exchangePaytoUri = await getExchangePaytoUri(
+ ws,
+ req.selectedExchange,
+ withdrawInfo.wireTypes,
+ );
+
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: withdrawInfo.amount,
+ exchangeBaseUrl: req.selectedExchange,
+ forcedDenomSel: req.forcedDenomSel,
+ reserveStatus: ReserveRecordStatus.RegisteringBank,
+ bankInfo: {
+ exchangePaytoUri,
+ statusUrl: withdrawInfo.extractedStatusUrl,
+ confirmUrl: withdrawInfo.confirmTransferUrl,
+ },
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ // 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, withdrawalGroupId);
+ const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
+ withdrawalGroupId,
+ });
+ if (
+ processedWithdrawalGroup?.reserveStatus === ReserveRecordStatus.BankAborted
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
+ {},
+ );
+ }
+
+ // Start withdrawal in the background.
+ await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
+ (err) => {
+ logger.error("Processing withdrawal (after creation) failed:", err);
+ },
+ );
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ confirmTransferUrl: withdrawInfo.confirmTransferUrl,
+ };
+}
+
+/**
+ * Create a manual withdrawal operation.
+ *
+ * Adds the corresponding exchange as a trusted exchange if it is neither
+ * audited nor trusted already.
+ *
+ * Asynchronously starts the withdrawal.
+ */
+export async function createManualWithdrawal(
+ ws: InternalWalletState,
+ req: {
+ exchangeBaseUrl: string;
+ amount: AmountLike;
+ restrictAge?: number;
+ forcedDenomSel?: ForcedDenomSel;
+ },
+): Promise<AcceptManualWithdrawalResult> {
+ const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
+ amount: Amounts.jsonifyAmount(req.amount),
+ exchangeBaseUrl: req.exchangeBaseUrl,
+ bankInfo: undefined,
+ forcedDenomSel: req.forcedDenomSel,
+ restrictAge: req.restrictAge,
+ reserveStatus: ReserveRecordStatus.QueryingStatus,
+ });
+
+ const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
+
+ const exchangePaytoUris = await ws.db
+ .mktx((x) => ({
+ withdrawalGroups: x.withdrawalGroups,
+ exchanges: x.exchanges,
+ exchangeDetails: x.exchangeDetails,
+ exchangeTrust: x.exchangeTrust,
+ }))
+ .runReadWrite(async (tx) => {
+ return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
+ });
+
+ // Start withdrawal in the background.
+ await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch(
+ (err) => {
+ logger.error("Processing withdrawal (after creation) failed:", err);
+ },
+ );
+
+ return {
+ reservePub: withdrawalGroup.reservePub,
+ exchangePaytoUris: exchangePaytoUris,
+ };
+}