/*
This file is part of GNU Taler
(C) 2019-2024 Taler Systems SA
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
*/
/**
* @fileoverview Implementation of Taler withdrawals, both
* bank-integrated and manual.
*/
/**
* Imports.
*/
import {
AbsoluteTime,
AcceptManualWithdrawalResult,
AcceptWithdrawalResponse,
AgeRestriction,
Amount,
AmountJson,
AmountLike,
AmountString,
Amounts,
AsyncFlag,
BankWithdrawDetails,
CancellationToken,
CoinStatus,
ConfirmWithdrawalRequest,
CurrencySpecification,
DenomKeyType,
DenomSelItem,
DenomSelectionState,
Duration,
EddsaPrivateKeyString,
ExchangeBatchWithdrawRequest,
ExchangeUpdateStatus,
ExchangeWireAccount,
ExchangeWithdrawBatchResponse,
ExchangeWithdrawRequest,
ExchangeWithdrawResponse,
ExchangeWithdrawalDetails,
ForcedDenomSel,
GetWithdrawalDetailsForAmountRequest,
HttpStatusCode,
LibtoolVersion,
Logger,
NotificationType,
ObservabilityEventType,
PrepareBankIntegratedWithdrawalResponse,
TalerBankIntegrationHttpClient,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
Transaction,
TransactionAction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
URL,
UnblindedSignature,
WalletNotification,
WithdrawUriInfoResponse,
WithdrawalDetailsForAmount,
WithdrawalExchangeAccountDetails,
WithdrawalType,
addPaytoQueryParams,
assertUnreachable,
checkDbInvariant,
checkLogicInvariant,
codeForBankWithdrawalOperationPostResponse,
codecForBankWithdrawalOperationStatus,
codecForCashinConversionResponse,
codecForConversionBankConfig,
codecForExchangeWithdrawBatchResponse,
codecForReserveStatus,
codecForWalletKycUuid,
codecForWithdrawOperationStatusResponse,
encodeCrock,
getErrorDetailFromException,
getRandomBytes,
j2s,
makeErrorDetail,
parseWithdrawUri,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
HttpResponse,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
PendingTaskType,
TaskIdStr,
TaskRunResult,
TaskRunResultType,
TombstoneTag,
TransactionContext,
TransitionResult,
TransitionResultType,
constructTaskIdentifier,
makeCoinAvailable,
makeCoinsVisible,
} from "./common.js";
import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
DenominationRecord,
DenominationVerificationStatus,
KycPendingInfo,
PlanchetRecord,
PlanchetStatus,
WalletDbReadOnlyTransaction,
WalletDbReadWriteTransaction,
WalletDbStoresArr,
WalletStoresV1,
WgInfo,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
timestampAbsoluteFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
} from "./db.js";
import {
selectForcedWithdrawalDenominations,
selectWithdrawalDenominations,
} from "./denomSelection.js";
import { isWithdrawableDenom } from "./denominations.js";
import {
ReadyExchangeSummary,
fetchFreshExchange,
getExchangePaytoUri,
getExchangeWireDetailsInTx,
listExchanges,
markExchangeUsed,
} from "./exchanges.js";
import { DbAccess } from "./query.js";
import {
TransitionInfo,
constructTransactionIdentifier,
isUnsuccessfulTransaction,
notifyTransition,
parseTransactionIdentifier,
} from "./transactions.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "./versions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
/**
* Logger for this file.
*/
const logger = new Logger("withdraw.ts");
/**
* Update the materialized withdrawal transaction based
* on the withdrawal group record.
*/
async function updateWithdrawalTransaction(
ctx: WithdrawTransactionContext,
tx: WalletDbReadWriteTransaction<
[
"withdrawalGroups",
"transactions",
"operationRetries",
"exchanges",
"exchangeDetails",
]
>,
): Promise {
const wgRecord = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
if (!wgRecord) {
await tx.transactions.delete(ctx.transactionId);
return;
}
const retryRecord = await tx.operationRetries.get(ctx.taskId);
let transactionItem: Transaction;
if (
!wgRecord.instructedAmount ||
!wgRecord.denomsSel ||
!wgRecord.exchangeBaseUrl
) {
// withdrawal group is in preparation, nothing to update
return;
}
if (wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankIntegrated) {
const txState = computeWithdrawalTransactionStatus(wgRecord);
transactionItem = {
type: TransactionType.Withdrawal,
txState,
txActions: computeWithdrawalTransactionActions(wgRecord),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
: Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wgRecord.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: wgRecord.wgInfo.bankInfo.timestampBankConfirmed
? true
: false,
exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
reservePub: wgRecord.reservePub,
bankConfirmationUrl: wgRecord.wgInfo.bankInfo.confirmUrl,
reserveIsReady:
wgRecord.status === WithdrawalGroupStatus.Done ||
wgRecord.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wgRecord.kycUrl,
exchangeBaseUrl: wgRecord.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
transactionId: ctx.transactionId,
};
} else if (
wgRecord.wgInfo.withdrawalType === WithdrawalRecordType.BankManual
) {
checkDbInvariant(
wgRecord.exchangeBaseUrl !== undefined,
"manual withdrawal without exchange can't be created",
);
checkDbInvariant(
wgRecord.instructedAmount !== undefined,
"manual withdrawal without amount can't be created",
);
checkDbInvariant(
wgRecord.denomsSel !== undefined,
"manual withdrawal without denoms can't be created",
);
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
wgRecord.exchangeBaseUrl,
);
const plainPaytoUris =
exchangeDetails?.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
const exchangePaytoUris = augmentPaytoUrisForWithdrawal(
plainPaytoUris,
wgRecord.reservePub,
wgRecord.instructedAmount,
);
const txState = computeWithdrawalTransactionStatus(wgRecord);
transactionItem = {
type: TransactionType.Withdrawal,
txState,
txActions: computeWithdrawalTransactionActions(wgRecord),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(wgRecord.instructedAmount))
: Amounts.stringify(wgRecord.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(wgRecord.instructedAmount),
withdrawalDetails: {
type: WithdrawalType.ManualTransfer,
reservePub: wgRecord.reservePub,
exchangePaytoUris,
exchangeCreditAccountDetails: wgRecord.wgInfo.exchangeCreditAccounts,
reserveIsReady:
wgRecord.status === WithdrawalGroupStatus.Done ||
wgRecord.status === WithdrawalGroupStatus.PendingReady,
},
kycUrl: wgRecord.kycUrl,
exchangeBaseUrl: wgRecord.exchangeBaseUrl,
timestamp: timestampPreciseFromDb(wgRecord.timestampStart),
transactionId: ctx.transactionId,
};
} else {
// FIXME: If this is an orphaned withdrawal for a p2p transaction, we
// still might want to report the withdrawal.
return;
}
if (retryRecord?.lastError) {
transactionItem.error = retryRecord.lastError;
}
await tx.transactions.put({
currency: Amounts.currencyOf(wgRecord.instructedAmount),
transactionItem,
exchanges: [wgRecord.exchangeBaseUrl],
});
// FIXME: Handle orphaned withdrawals where the p2p or recoup tx was deleted?
}
export class WithdrawTransactionContext implements TransactionContext {
readonly transactionId: TransactionIdStr;
readonly taskId: TaskIdStr;
constructor(
public wex: WalletExecutionContext,
public withdrawalGroupId: string,
) {
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId,
});
this.taskId = constructTaskIdentifier({
tag: PendingTaskType.Withdraw,
withdrawalGroupId,
});
}
/**
* Transition a withdrawal transaction.
* Extra object stores may be accessed during the transition.
*/
async transition(
opts: { extraStores?: StoreNameArray; transactionLabel?: string },
f: (
rec: WithdrawalGroupRecord | undefined,
tx: WalletDbReadWriteTransaction<
[
"withdrawalGroups",
"transactions",
"operationRetries",
"exchanges",
"exchangeDetails",
...StoreNameArray,
]
>,
) => Promise>,
): Promise {
const baseStores = [
"withdrawalGroups" as const,
"transactions" as const,
"operationRetries" as const,
"exchanges" as const,
"exchangeDetails" as const,
];
let stores = opts.extraStores
? [...baseStores, ...opts.extraStores]
: baseStores;
const transitionInfo = await this.wex.db.runReadWriteTx(
{ storeNames: stores },
async (tx) => {
const wgRec = await tx.withdrawalGroups.get(this.withdrawalGroupId);
let oldTxState: TransactionState;
if (wgRec) {
oldTxState = computeWithdrawalTransactionStatus(wgRec);
} else {
oldTxState = {
major: TransactionMajorState.None,
};
}
const res = await f(wgRec, tx);
switch (res.type) {
case TransitionResultType.Transition: {
await tx.withdrawalGroups.put(res.rec);
await updateWithdrawalTransaction(this, tx);
const newTxState = computeWithdrawalTransactionStatus(res.rec);
return {
oldTxState,
newTxState,
};
}
case TransitionResultType.Delete:
await tx.withdrawalGroups.delete(this.withdrawalGroupId);
await updateWithdrawalTransaction(this, tx);
return {
oldTxState,
newTxState: {
major: TransactionMajorState.None,
},
};
default:
return undefined;
}
},
);
notifyTransition(this.wex, this.transactionId, transitionInfo);
return transitionInfo;
}
async deleteTransaction(): Promise {
await this.transition(
{
extraStores: ["tombstones"],
transactionLabel: "delete-transaction-withdraw",
},
async (rec, tx) => {
if (!rec) {
return TransitionResult.stay();
}
if (rec) {
await tx.tombstones.put({
id:
TombstoneTag.DeleteWithdrawalGroup + ":" + rec.withdrawalGroupId,
});
}
return TransitionResult.delete();
},
);
}
async suspendTransaction(): Promise {
const { withdrawalGroupId } = this;
await this.transition(
{
transactionLabel: "suspend-transaction-withdraw",
},
async (wg, _tx) => {
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return TransitionResult.stay();
}
let newStatus: WithdrawalGroupStatus | undefined = undefined;
switch (wg.status) {
case WithdrawalGroupStatus.PendingReady:
newStatus = WithdrawalGroupStatus.SuspendedReady;
break;
case WithdrawalGroupStatus.AbortingBank:
newStatus = WithdrawalGroupStatus.SuspendedAbortingBank;
break;
case WithdrawalGroupStatus.PendingWaitConfirmBank:
newStatus = WithdrawalGroupStatus.SuspendedWaitConfirmBank;
break;
case WithdrawalGroupStatus.PendingRegisteringBank:
newStatus = WithdrawalGroupStatus.SuspendedRegisteringBank;
break;
case WithdrawalGroupStatus.PendingQueryingStatus:
newStatus = WithdrawalGroupStatus.SuspendedQueryingStatus;
break;
case WithdrawalGroupStatus.PendingKyc:
newStatus = WithdrawalGroupStatus.SuspendedKyc;
break;
case WithdrawalGroupStatus.PendingAml:
newStatus = WithdrawalGroupStatus.SuspendedAml;
break;
default:
logger.warn(
`Unsupported 'suspend' on withdrawal transaction in status ${wg.status}`,
);
return TransitionResult.stay();
}
wg.status = newStatus;
return TransitionResult.transition(wg);
},
);
}
async abortTransaction(): Promise {
const { withdrawalGroupId } = this;
await this.transition(
{
transactionLabel: "abort-transaction-withdraw",
},
async (wg, _tx) => {
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return TransitionResult.stay();
}
let newStatus: WithdrawalGroupStatus | undefined = undefined;
switch (wg.status) {
case WithdrawalGroupStatus.SuspendedRegisteringBank:
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
case WithdrawalGroupStatus.PendingRegisteringBank:
newStatus = WithdrawalGroupStatus.AbortingBank;
break;
case WithdrawalGroupStatus.SuspendedAml:
case WithdrawalGroupStatus.SuspendedKyc:
case WithdrawalGroupStatus.SuspendedQueryingStatus:
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.PendingAml:
case WithdrawalGroupStatus.PendingKyc:
case WithdrawalGroupStatus.PendingQueryingStatus:
newStatus = WithdrawalGroupStatus.AbortedExchange;
break;
case WithdrawalGroupStatus.PendingReady:
newStatus = WithdrawalGroupStatus.SuspendedReady;
break;
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.AbortingBank:
case WithdrawalGroupStatus.AbortedUserRefused:
// No transition needed, but not an error
return TransitionResult.stay();
case WithdrawalGroupStatus.DialogProposed:
newStatus = WithdrawalGroupStatus.AbortedUserRefused;
break;
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.AbortedOtherWallet:
// Not allowed
throw Error("abort not allowed in current state");
default:
assertUnreachable(wg.status);
}
wg.status = newStatus;
return TransitionResult.transition(wg);
},
);
}
async resumeTransaction(): Promise {
const { withdrawalGroupId } = this;
await this.transition(
{
transactionLabel: "resume-transaction-withdraw",
},
async (wg, _tx) => {
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return TransitionResult.stay();
}
let newStatus: WithdrawalGroupStatus | undefined = undefined;
switch (wg.status) {
case WithdrawalGroupStatus.SuspendedReady:
newStatus = WithdrawalGroupStatus.PendingReady;
break;
case WithdrawalGroupStatus.SuspendedAbortingBank:
newStatus = WithdrawalGroupStatus.AbortingBank;
break;
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
newStatus = WithdrawalGroupStatus.PendingWaitConfirmBank;
break;
case WithdrawalGroupStatus.SuspendedQueryingStatus:
newStatus = WithdrawalGroupStatus.PendingQueryingStatus;
break;
case WithdrawalGroupStatus.SuspendedRegisteringBank:
newStatus = WithdrawalGroupStatus.PendingRegisteringBank;
break;
case WithdrawalGroupStatus.SuspendedAml:
newStatus = WithdrawalGroupStatus.PendingAml;
break;
case WithdrawalGroupStatus.SuspendedKyc:
newStatus = WithdrawalGroupStatus.PendingKyc;
break;
default:
logger.warn(
`Unsupported 'resume' on withdrawal transaction in status ${wg.status}`,
);
return TransitionResult.stay();
}
wg.status = newStatus;
return TransitionResult.transition(wg);
},
);
}
async failTransaction(): Promise {
const { withdrawalGroupId } = this;
await this.transition(
{
transactionLabel: "fail-transaction-withdraw",
},
async (wg, _tx) => {
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return TransitionResult.stay();
}
let newStatus: WithdrawalGroupStatus | undefined = undefined;
switch (wg.status) {
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.AbortingBank:
newStatus = WithdrawalGroupStatus.FailedAbortingBank;
break;
default:
return TransitionResult.stay();
}
wg.status = newStatus;
return TransitionResult.transition(wg);
},
);
}
}
/**
* Compute the DD37 transaction state of a withdrawal transaction
* from the database's withdrawal group record.
*/
export function computeWithdrawalTransactionStatus(
wgRecord: WithdrawalGroupRecord,
): TransactionState {
switch (wgRecord.status) {
case WithdrawalGroupStatus.FailedBankAborted:
return {
major: TransactionMajorState.Aborted,
};
case WithdrawalGroupStatus.Done:
return {
major: TransactionMajorState.Done,
};
case WithdrawalGroupStatus.PendingRegisteringBank:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.BankRegisterReserve,
};
case WithdrawalGroupStatus.PendingReady:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.WithdrawCoins,
};
case WithdrawalGroupStatus.PendingQueryingStatus:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.ExchangeWaitReserve,
};
case WithdrawalGroupStatus.PendingWaitConfirmBank:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.BankConfirmTransfer,
};
case WithdrawalGroupStatus.AbortingBank:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.Bank,
};
case WithdrawalGroupStatus.SuspendedAbortingBank:
return {
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.Bank,
};
case WithdrawalGroupStatus.SuspendedQueryingStatus:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.ExchangeWaitReserve,
};
case WithdrawalGroupStatus.SuspendedRegisteringBank:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.BankRegisterReserve,
};
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.BankConfirmTransfer,
};
case WithdrawalGroupStatus.SuspendedReady:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.WithdrawCoins,
};
case WithdrawalGroupStatus.PendingAml:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.AmlRequired,
};
case WithdrawalGroupStatus.PendingKyc:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.KycRequired,
};
case WithdrawalGroupStatus.SuspendedAml:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.AmlRequired,
};
case WithdrawalGroupStatus.SuspendedKyc:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.KycRequired,
};
case WithdrawalGroupStatus.FailedAbortingBank:
return {
major: TransactionMajorState.Failed,
minor: TransactionMinorState.AbortingBank,
};
case WithdrawalGroupStatus.AbortedExchange:
return {
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.Exchange,
};
case WithdrawalGroupStatus.AbortedBank:
return {
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.Bank,
};
case WithdrawalGroupStatus.AbortedUserRefused:
return {
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.Refused,
};
case WithdrawalGroupStatus.DialogProposed:
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.Proposed,
};
case WithdrawalGroupStatus.AbortedOtherWallet:
return {
major: TransactionMajorState.Aborted,
minor: TransactionMinorState.CompletedByOtherWallet,
};
}
}
/**
* Compute DD37 transaction actions for a withdrawal transaction
* based on the database's withdrawal group record.
*/
export function computeWithdrawalTransactionActions(
wgRecord: WithdrawalGroupRecord,
): TransactionAction[] {
switch (wgRecord.status) {
case WithdrawalGroupStatus.FailedBankAborted:
return [TransactionAction.Delete];
case WithdrawalGroupStatus.Done:
return [TransactionAction.Delete];
case WithdrawalGroupStatus.PendingRegisteringBank:
return [TransactionAction.Suspend, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingReady:
return [TransactionAction.Suspend, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingQueryingStatus:
return [TransactionAction.Suspend, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingWaitConfirmBank:
return [TransactionAction.Suspend, TransactionAction.Abort];
case WithdrawalGroupStatus.AbortingBank:
return [TransactionAction.Suspend, TransactionAction.Fail];
case WithdrawalGroupStatus.SuspendedAbortingBank:
return [TransactionAction.Resume, TransactionAction.Fail];
case WithdrawalGroupStatus.SuspendedQueryingStatus:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.SuspendedRegisteringBank:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.SuspendedReady:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingAml:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.PendingKyc:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.SuspendedAml:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.SuspendedKyc:
return [TransactionAction.Resume, TransactionAction.Abort];
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedOtherWallet:
case WithdrawalGroupStatus.AbortedUserRefused:
return [TransactionAction.Delete];
case WithdrawalGroupStatus.DialogProposed:
return [TransactionAction.Abort];
}
}
async function processWithdrawalGroupDialogProposed(
ctx: WithdrawTransactionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
if (
withdrawalGroup.wgInfo.withdrawalType !==
WithdrawalRecordType.BankIntegrated
) {
throw new Error(
"processWithdrawalGroupDialogProposed called in unexpected state",
);
}
const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
const parsedUri = parseWithdrawUri(talerWithdrawUri);
checkLogicInvariant(!!parsedUri);
const wopid = parsedUri.withdrawalOperationId;
const url = new URL(
`withdrawal-operation/${wopid}`,
parsedUri.bankIntegrationApiBaseUrl,
);
url.searchParams.set("old_state", "pending");
url.searchParams.set("long_poll_ms", "30000");
const resp = await ctx.wex.http.fetch(url.href, {
method: "GET",
cancellationToken: ctx.wex.cancellationToken,
});
// If the bank claims that the withdrawal operation is already
// pending, but we're still in DialogProposed, some other wallet
// must've completed the withdrawal, we're giving up.
switch (resp.status) {
case HttpStatusCode.Ok: {
const body = await readSuccessResponseJsonOrThrow(
resp,
codecForBankWithdrawalOperationStatus(),
);
if (body.status !== "pending") {
await ctx.transition({}, async (rec) => {
switch (rec?.status) {
case WithdrawalGroupStatus.DialogProposed: {
rec.status = WithdrawalGroupStatus.AbortedOtherWallet;
return TransitionResult.transition(rec);
}
}
return TransitionResult.stay();
});
}
break;
}
}
return TaskRunResult.longpollReturnedPending();
}
/**
* Get information about a withdrawal from
* a taler://withdraw URI by asking the bank.
*
* FIXME: Move into bank client.
*/
export async function getBankWithdrawalInfo(
http: HttpRequestLibrary,
talerWithdrawUri: string,
): Promise {
const uriResult = parseWithdrawUri(talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse URL ${talerWithdrawUri}`);
}
const bankApi = new TalerBankIntegrationHttpClient(
uriResult.bankIntegrationApiBaseUrl,
http,
);
const { body: config } = await bankApi.getConfig();
if (!bankApi.isCompatible(config.version)) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
{
bankProtocolVersion: config.version,
walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
},
"bank integration protocol version not compatible with wallet",
);
}
const resp = await bankApi.getWithdrawalOperationById(
uriResult.withdrawalOperationId,
);
if (resp.type === "fail") {
throw TalerError.fromUncheckedDetail(resp.detail);
}
const { body: status } = resp;
return {
operationId: uriResult.withdrawalOperationId,
apiBaseUrl: uriResult.bankIntegrationApiBaseUrl,
amount: Amounts.parseOrThrow(status.amount),
confirmTransferUrl: status.confirm_transfer_url,
senderWire: status.sender_wire,
suggestedExchange: status.suggested_exchange,
wireTypes: status.wire_types,
status: status.status,
};
}
/**
* Return denominations that can potentially used for a withdrawal.
*/
async function getCandidateWithdrawalDenoms(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
currency: string,
): Promise {
return await wex.db.runReadOnlyTx(
{ storeNames: ["denominations"] },
async (tx) => {
return getCandidateWithdrawalDenomsTx(wex, tx, exchangeBaseUrl, currency);
},
);
}
export async function getCandidateWithdrawalDenomsTx(
wex: WalletExecutionContext,
tx: WalletDbReadOnlyTransaction<["denominations"]>,
exchangeBaseUrl: string,
currency: string,
): Promise {
// FIXME(https://bugs.taler.net/n/8446): Use denom groups instead of querying all denominations!
const allDenoms =
await tx.denominations.indexes.byExchangeBaseUrl.getAll(exchangeBaseUrl);
return allDenoms
.filter((d) => d.currency === currency)
.filter((d) =>
isWithdrawableDenom(d, wex.ws.config.testing.denomselAllowLate),
);
}
/**
* Generate a planchet for a coin index in a withdrawal group.
* Does not actually withdraw the coin yet.
*
* Split up so that we can parallelize the crypto, but serialize
* the exchange requests per reserve.
*/
async function processPlanchetGenerate(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
coinIdx: number,
): Promise {
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
checkDbInvariant(
withdrawalGroup.denomsSel !== undefined,
"can't process unitialized exchange",
);
const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
let planchet = await wex.db.runReadOnlyTx(
{ storeNames: ["planchets"] },
async (tx) => {
return tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
},
);
if (planchet) {
return;
}
let ci = 0;
let isSkipped = false;
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) {
maybeDenomPubHash = d.denomPubHash;
if (coinIdx >= ci + d.count - (d.skip ?? 0)) {
isSkipped = true;
}
break;
}
ci += d.count;
}
if (isSkipped) {
return;
}
if (!maybeDenomPubHash) {
throw Error("invariant violated");
}
const denomPubHash = maybeDenomPubHash;
const denom = await wex.db.runReadOnlyTx(
{ storeNames: ["denominations"] },
async (tx) => {
return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash);
},
);
checkDbInvariant(!!denom);
const r = await wex.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
reservePriv: withdrawalGroup.reservePriv,
reservePub: withdrawalGroup.reservePub,
value: Amounts.parseOrThrow(denom.value),
coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed,
restrictAge: withdrawalGroup.restrictAge,
});
const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey,
coinEv: r.coinEv,
coinEvHash: r.coinEvHash,
coinIdx,
coinPriv: r.coinPriv,
coinPub: r.coinPub,
denomPubHash: r.denomPubHash,
planchetStatus: PlanchetStatus.Pending,
withdrawSig: r.withdrawSig,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
ageCommitmentProof: r.ageCommitmentProof,
lastError: undefined,
};
await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
const p = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
if (p) {
planchet = p;
return;
}
await tx.planchets.put(newPlanchet);
planchet = newPlanchet;
});
}
interface WithdrawalRequestBatchArgs {
coinStartIndex: number;
batchSize: number;
}
interface WithdrawalBatchResult {
coinIdxs: number[];
batchResp: ExchangeWithdrawBatchResponse;
}
// FIXME: Move to exchange API types
enum ExchangeAmlStatus {
Normal = 0,
Pending = 1,
Frozen = 2,
}
async function handleKycRequired(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
resp: HttpResponse,
startIdx: number,
requestCoinIdxs: number[],
): Promise {
logger.info("withdrawal requires KYC");
const respJson = await resp.json();
const uuidResp = codecForWalletKycUuid().decode(respJson);
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
const userType = "individual";
const kycInfo: KycPendingInfo = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
});
let kycUrl: string;
let amlStatus: ExchangeAmlStatus | undefined;
if (
kycStatusRes.status === HttpStatusCode.Ok ||
// FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
// remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
logger.warn("kyc requested, but already fulfilled");
return;
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
kycUrl = kycStatus.kyc_url;
} else if (
kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
) {
const kycStatus = await kycStatusRes.json();
logger.info(`aml status: ${j2s(kycStatus)}`);
amlStatus = kycStatus.aml_status;
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
await ctx.transition(
{
extraStores: ["planchets"],
},
async (wg2, tx) => {
if (!wg2) {
return TransitionResult.stay();
}
for (let i = startIdx; i < requestCoinIdxs.length; i++) {
const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
requestCoinIdxs[i],
]);
if (!planchet) {
continue;
}
planchet.planchetStatus = PlanchetStatus.KycRequired;
await tx.planchets.put(planchet);
}
if (wg2.status !== WithdrawalGroupStatus.PendingReady) {
return TransitionResult.stay();
}
wg2.kycPending = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
wg2.kycUrl = kycUrl;
wg2.status =
amlStatus === ExchangeAmlStatus.Normal || amlStatus === undefined
? WithdrawalGroupStatus.PendingKyc
: amlStatus === ExchangeAmlStatus.Pending
? WithdrawalGroupStatus.PendingAml
: amlStatus === ExchangeAmlStatus.Frozen
? WithdrawalGroupStatus.SuspendedAml
: assertUnreachable(amlStatus);
return TransitionResult.transition(wg2);
},
);
}
/**
* Send the withdrawal request for a generated planchet to the exchange.
*
* The verification of the response is done asynchronously to enable parallelism.
*/
async function processPlanchetExchangeBatchRequest(
wex: WalletExecutionContext,
wgContext: WithdrawalGroupStatusInfo,
args: WithdrawalRequestBatchArgs,
): Promise {
const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
logger.info(
`processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
);
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
// Indices of coins that are included in the batch request
const requestCoinIdxs: number[] = [];
await wex.db.runReadOnlyTx(
{ storeNames: ["planchets", "denominations"] },
async (tx) => {
for (
let coinIdx = args.coinStartIndex;
coinIdx < args.coinStartIndex + args.batchSize &&
coinIdx < wgContext.numPlanchets;
coinIdx++
) {
const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
continue;
}
if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
logger.warn("processPlanchet: planchet already withdrawn");
continue;
}
if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) {
continue;
}
const denom = await getDenomInfo(
wex,
tx,
exchangeBaseUrl,
planchet.denomPubHash,
);
if (!denom) {
logger.error("db inconsistent: denom for planchet not found");
continue;
}
const planchetReq: ExchangeWithdrawRequest = {
denom_pub_hash: planchet.denomPubHash,
reserve_sig: planchet.withdrawSig,
coin_ev: planchet.coinEv,
};
batchReq.planchets.push(planchetReq);
requestCoinIdxs.push(coinIdx);
}
},
);
if (batchReq.planchets.length == 0) {
logger.warn("empty withdrawal batch");
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
async function storeCoinError(
errDetail: TalerErrorDetail,
coinIdx: number,
): Promise {
logger.trace(`withdrawal request failed: ${j2s(errDetail)}`);
await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
planchet.lastError = errDetail;
await tx.planchets.put(planchet);
});
}
// FIXME: handle individual error codes better!
const reqUrl = new URL(
`reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
withdrawalGroup.exchangeBaseUrl,
).href;
try {
const resp = await wex.http.fetch(reqUrl, {
method: "POST",
body: batchReq,
cancellationToken: wex.cancellationToken,
timeout: Duration.fromSpec({ seconds: 40 }),
});
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs);
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
if (resp.status === HttpStatusCode.Gone) {
const e = await readTalerErrorResponse(resp);
// FIXME: Store in place of the planchet that is actually affected!
await storeCoinError(e, requestCoinIdxs[0]);
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
const r = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeWithdrawBatchResponse(),
);
return {
coinIdxs: requestCoinIdxs,
batchResp: r,
};
} catch (e) {
const errDetail = getErrorDetailFromException(e);
// We don't know which coin is affected, so we store the error
// with the first coin of the batch.
await storeCoinError(errDetail, requestCoinIdxs[0]);
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
}
async function processPlanchetVerifyAndStoreCoin(
wex: WalletExecutionContext,
wgContext: WithdrawalGroupStatusInfo,
coinIdx: number,
resp: ExchangeWithdrawResponse,
): Promise {
const withdrawalGroup = wgContext.wgRecord;
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
logger.trace(`checking and storing planchet idx=${coinIdx}`);
const d = await wex.db.runReadOnlyTx(
{ storeNames: ["planchets", "denominations"] },
async (tx) => {
const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
logger.warn("processPlanchet: planchet already withdrawn");
return;
}
const denomInfo = await getDenomInfo(
wex,
tx,
exchangeBaseUrl,
planchet.denomPubHash,
);
if (!denomInfo) {
return;
}
return {
planchet,
denomInfo,
exchangeBaseUrl: exchangeBaseUrl,
};
},
);
if (!d) {
return;
}
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: wgContext.wgRecord.withdrawalGroupId,
});
const { planchet, denomInfo } = d;
const planchetDenomPub = denomInfo.denomPub;
if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
}
const evSig = resp.ev_sig;
if (!(evSig.cipher === DenomKeyType.Rsa)) {
throw Error("unsupported cipher");
}
const denomSigRsa = await wex.cryptoApi.rsaUnblind({
bk: planchet.blindingKey,
blindedSig: evSig.blinded_rsa_signature,
pk: planchetDenomPub.rsa_public_key,
});
const isValid = await wex.cryptoApi.rsaVerify({
hm: planchet.coinPub,
pk: planchetDenomPub.rsa_public_key,
sig: denomSigRsa.sig,
});
if (!isValid) {
await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => {
const planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
planchet.lastError = makeErrorDetail(
TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
{},
"invalid signature from the exchange after unblinding",
);
await tx.planchets.put(planchet);
});
return;
}
let denomSig: UnblindedSignature;
if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
denomSig = {
cipher: planchetDenomPub.cipher,
rsa_signature: denomSigRsa.sig,
};
} else {
throw Error("unsupported cipher");
}
const coin: CoinRecord = {
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
denomPubHash: planchet.denomPubHash,
denomSig,
coinEvHash: planchet.coinEvHash,
exchangeBaseUrl: d.exchangeBaseUrl,
status: CoinStatus.Fresh,
coinSource: {
type: CoinSourceType.Withdraw,
coinIndex: coinIdx,
reservePub: withdrawalGroup.reservePub,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
},
sourceTransactionId: transactionId,
maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
ageCommitmentProof: planchet.ageCommitmentProof,
spendAllocation: undefined,
};
const planchetCoinPub = planchet.coinPub;
wgContext.planchetsFinished.add(planchet.coinPub);
await wex.db.runReadWriteTx(
{ storeNames: ["planchets", "coins", "coinAvailability", "denominations"] },
async (tx) => {
const p = await tx.planchets.get(planchetCoinPub);
if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
return;
}
p.planchetStatus = PlanchetStatus.WithdrawalDone;
p.lastError = undefined;
await tx.planchets.put(p);
await makeCoinAvailable(wex, tx, coin);
},
);
}
/**
* Make sure that denominations that currently can be used for withdrawal
* are validated, and the result of validation is stored in the database.
*/
export async function updateWithdrawalDenoms(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
logger.trace(
`updating denominations used for withdrawal for ${exchangeBaseUrl}`,
);
const exchangeDetails = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
return getExchangeWireDetailsInTx(tx, exchangeBaseUrl);
},
);
if (!exchangeDetails) {
logger.error("exchange details not available");
throw Error(`exchange ${exchangeBaseUrl} details not available`);
}
// First do a pass where the validity of candidate denominations
// is checked and the result is stored in the database.
logger.trace("getting candidate denominations");
const denominations = await getCandidateWithdrawalDenoms(
wex,
exchangeBaseUrl,
exchangeDetails.currency,
);
logger.trace(`got ${denominations.length} candidate denominations`);
const batchSize = 500;
let current = 0;
while (current < denominations.length) {
const updatedDenominations: DenominationRecord[] = [];
// Do a batch of batchSize
for (
let batchIdx = 0;
batchIdx < batchSize && current < denominations.length;
batchIdx++, current++
) {
const denom = denominations[current];
if (
denom.verificationStatus === DenominationVerificationStatus.Unverified
) {
logger.trace(
`Validating denomination (${current + 1}/${denominations.length
}) signature of ${denom.denomPubHash}`,
);
let valid = false;
if (wex.ws.config.testing.insecureTrustExchange) {
valid = true;
} else {
const res = await wex.cryptoApi.isValidDenom({
denom,
masterPub: exchangeDetails.masterPublicKey,
});
valid = res.valid;
}
logger.trace(`Done validating ${denom.denomPubHash}`);
if (!valid) {
logger.warn(
`Signature check for denomination h=${denom.denomPubHash} failed`,
);
denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
} else {
denom.verificationStatus =
DenominationVerificationStatus.VerifiedGood;
}
updatedDenominations.push(denom);
}
}
if (updatedDenominations.length > 0) {
logger.trace("writing denomination batch to db");
await wex.db.runReadWriteTx(
{ storeNames: ["denominations"] },
async (tx) => {
for (let i = 0; i < updatedDenominations.length; i++) {
const denom = updatedDenominations[i];
await tx.denominations.put(denom);
}
},
);
wex.ws.denomInfoCache.clear();
logger.trace("done with DB write");
}
}
}
/**
* 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 processQueryReserve(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
withdrawalGroupId,
});
if (!withdrawalGroup) {
return TaskRunResult.finished();
}
if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
return TaskRunResult.backoff();
}
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
checkDbInvariant(
withdrawalGroup.denomsSel !== undefined,
"can't process unitialized exchange",
);
checkDbInvariant(
withdrawalGroup.instructedAmount !== undefined,
"can't process unitialized exchange",
);
const reservePub = withdrawalGroup.reservePub;
const reserveUrl = new URL(
`reserves/${reservePub}`,
withdrawalGroup.exchangeBaseUrl,
);
reserveUrl.searchParams.set("timeout_ms", "30000");
logger.trace(`querying reserve status via ${reserveUrl.href}`);
const resp = await wex.http.fetch(reserveUrl.href, {
timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken: wex.cancellationToken,
});
logger.trace(`reserve status code: HTTP ${resp.status}`);
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForReserveStatus(),
);
if (result.isError) {
logger.trace(
`got reserve status error, EC=${result.talerErrorResponse.code}`,
);
if (resp.status === HttpStatusCode.NotFound) {
return TaskRunResult.longpollReturnedPending();
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
logger.trace(`got reserve status ${j2s(result.response)}`);
let amountChanged = false;
if (
Amounts.cmp(
result.response.balance,
withdrawalGroup.denomsSel.totalWithdrawCost,
) === -1
) {
amountChanged = true;
}
console.log(`amount change ${j2s(result.response)}`)
console.log(`amount change ${j2s(withdrawalGroup.denomsSel.totalWithdrawCost)}`)
const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
const currency = Amounts.currencyOf(withdrawalGroup.instructedAmount);
const transitionResult = await ctx.transition(
{
extraStores: ["denominations"],
},
async (wg, tx) => {
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return TransitionResult.stay();
}
if (wg.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
return TransitionResult.stay();
}
if (amountChanged) {
const candidates = await getCandidateWithdrawalDenomsTx(
wex,
tx,
exchangeBaseUrl,
currency,
);
wg.denomsSel = selectWithdrawalDenominations(
Amounts.parseOrThrow(result.response.balance),
candidates,
);
}
wg.status = WithdrawalGroupStatus.PendingReady;
wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
return TransitionResult.transition(wg);
},
);
if (transitionResult) {
return TaskRunResult.progress();
} else {
return TaskRunResult.backoff();
}
}
/**
* Withdrawal context that is kept in-memory.
*
* Used to store some cached info during a withdrawal operation.
*/
interface WithdrawalGroupStatusInfo {
numPlanchets: number;
planchetsFinished: Set;
/**
* Cached withdrawal group record from the database.
*/
wgRecord: WithdrawalGroupRecord;
}
async function processWithdrawalGroupAbortingBank(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
const { withdrawalGroupId } = withdrawalGroup;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
const wgInfo = withdrawalGroup.wgInfo;
if (wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated) {
throw Error("invalid state (aborting(bank) without bank info");
}
const abortUrl = getBankAbortUrl(wgInfo.bankInfo.talerWithdrawUri);
logger.info(`aborting withdrawal at ${abortUrl}`);
const abortResp = await wex.http.fetch(abortUrl, {
method: "POST",
body: {},
cancellationToken: wex.cancellationToken,
});
logger.info(`abort response status: ${abortResp.status}`);
await ctx.transition({}, async (wg) => {
if (!wg) {
return TransitionResult.stay();
}
wg.status = WithdrawalGroupStatus.AbortedBank;
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
return TransitionResult.transition(wg);
});
return TaskRunResult.finished();
}
async function processWithdrawalGroupPendingKyc(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
const ctx = new WithdrawTransactionContext(
wex,
withdrawalGroup.withdrawalGroupId,
);
const userType = "individual";
const kycInfo = withdrawalGroup.kycPending;
if (!kycInfo) {
throw Error("no kyc info available in pending(kyc)");
}
const exchangeUrl = withdrawalGroup.exchangeBaseUrl;
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
url.searchParams.set("timeout_ms", "30000");
logger.info(`long-polling for withdrawal KYC status via ${url.href}`);
const kycStatusRes = await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
});
logger.info(`kyc long-polling response status: HTTP ${kycStatusRes.status}`);
if (
kycStatusRes.status === HttpStatusCode.Ok ||
// FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
// remove after the exchange is fixed or clarified
kycStatusRes.status === HttpStatusCode.NoContent
) {
await ctx.transition({}, async (rec) => {
if (!rec) {
return TransitionResult.stay();
}
switch (rec.status) {
case WithdrawalGroupStatus.PendingKyc: {
delete rec.kycPending;
delete rec.kycUrl;
rec.status = WithdrawalGroupStatus.PendingReady;
return TransitionResult.transition(rec);
}
default:
return TransitionResult.stay();
}
});
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
const kycStatus = await kycStatusRes.json();
logger.info(`kyc status: ${j2s(kycStatus)}`);
const kycUrl = kycStatus.kyc_url;
if (typeof kycUrl === "string") {
await ctx.transition({}, async (rec) => {
if (!rec) {
return TransitionResult.stay();
}
switch (rec.status) {
case WithdrawalGroupStatus.PendingReady: {
rec.kycUrl = kycUrl;
return TransitionResult.transition(rec);
}
}
return TransitionResult.stay();
});
}
} else if (
kycStatusRes.status === HttpStatusCode.UnavailableForLegalReasons
) {
const kycStatus = await kycStatusRes.json();
logger.info(`aml status: ${j2s(kycStatus)}`);
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
return TaskRunResult.backoff();
}
/**
* Select new denominations for a withdrawal group.
* Necessary when denominations expired or got revoked
* before the withdrawal could complete.
*/
async function redenominateWithdrawal(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
logger.trace(`redenominating withdrawal group ${withdrawalGroupId}`);
await wex.db.runReadWriteTx(
{ storeNames: ["withdrawalGroups", "planchets", "denominations"] },
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
return;
}
checkDbInvariant(
wg.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
checkDbInvariant(
wg.denomsSel !== undefined,
"can't process unitialized exchange",
);
const currency = Amounts.currencyOf(wg.denomsSel.totalWithdrawCost);
const exchangeBaseUrl = wg.exchangeBaseUrl;
const candidates = await getCandidateWithdrawalDenomsTx(
wex,
tx,
exchangeBaseUrl,
currency,
);
const oldSel = wg.denomsSel;
if (logger.shouldLogTrace()) {
logger.trace(`old denom sel: ${j2s(oldSel)}`);
}
const zero = Amount.zeroOfCurrency(currency);
let amountRemaining = zero;
let prevTotalCoinValue = zero;
let prevTotalWithdrawalCost = zero;
let prevHasDenomWithAgeRestriction = false;
let prevEarliestDepositExpiration = AbsoluteTime.never();
const prevDenoms: DenomSelItem[] = [];
let coinIndex = 0;
for (let i = 0; i < oldSel.selectedDenoms.length; i++) {
const sel = wg.denomsSel.selectedDenoms[i];
const denom = await tx.denominations.get([
exchangeBaseUrl,
sel.denomPubHash,
]);
if (!denom) {
throw Error("denom in use but not not found");
}
// FIXME: Also check planchet if there was a different error or planchet already withdrawn
const denomOkay = isWithdrawableDenom(
denom,
wex.ws.config.testing.denomselAllowLate,
);
const numCoins = sel.count - (sel.skip ?? 0);
const denomValue = Amount.from(denom.value).mult(numCoins);
const denomFeeWithdraw = Amount.from(denom.fees.feeWithdraw).mult(
numCoins,
);
if (denomOkay) {
prevTotalCoinValue = prevTotalCoinValue.add(denomValue);
prevTotalWithdrawalCost = prevTotalWithdrawalCost.add(
denomValue,
denomFeeWithdraw,
);
prevDenoms.push({
count: sel.count,
denomPubHash: sel.denomPubHash,
skip: sel.skip,
});
prevHasDenomWithAgeRestriction =
prevHasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
prevEarliestDepositExpiration = AbsoluteTime.min(
prevEarliestDepositExpiration,
timestampAbsoluteFromDb(denom.stampExpireDeposit),
);
} else {
amountRemaining = amountRemaining.add(denomValue, denomFeeWithdraw);
prevDenoms.push({
count: sel.count,
denomPubHash: sel.denomPubHash,
skip: (sel.skip ?? 0) + numCoins,
});
for (let j = 0; j < sel.count; j++) {
const ci = coinIndex + j;
const p = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
ci,
]);
if (!p) {
// Maybe planchet wasn't yet generated.
// No problem!
logger.info(
`not aborting planchet #${coinIndex}, planchet not found`,
);
continue;
}
logger.info(`aborting planchet #${coinIndex}`);
p.planchetStatus = PlanchetStatus.AbortedReplaced;
await tx.planchets.put(p);
}
}
coinIndex += sel.count;
}
const newSel = selectWithdrawalDenominations(
amountRemaining.toJson(),
candidates,
);
if (logger.shouldLogTrace()) {
logger.trace(`new denom sel: ${j2s(newSel)}`);
}
const mergedSel: DenomSelectionState = {
selectedDenoms: [...prevDenoms, ...newSel.selectedDenoms],
totalCoinValue: zero
.add(prevTotalCoinValue, newSel.totalCoinValue)
.toString(),
totalWithdrawCost: zero
.add(prevTotalWithdrawalCost, newSel.totalWithdrawCost)
.toString(),
hasDenomWithAgeRestriction:
prevHasDenomWithAgeRestriction || newSel.hasDenomWithAgeRestriction,
earliestDepositExpiration: AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.min(
prevEarliestDepositExpiration,
AbsoluteTime.fromProtocolTimestamp(
newSel.earliestDepositExpiration,
),
),
),
};
wg.denomsSel = mergedSel;
if (logger.shouldLogTrace()) {
logger.trace(`merged denom sel: ${j2s(mergedSel)}`);
}
await tx.withdrawalGroups.put(wg);
},
);
}
async function processWithdrawalGroupPendingReady(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
const { withdrawalGroupId } = withdrawalGroup;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't process unitialized exchange",
);
checkDbInvariant(
withdrawalGroup.denomsSel !== undefined,
"can't process unitialized exchange",
);
const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl;
await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
logger.warn("Finishing empty withdrawal group (no denoms)");
await ctx.transition({}, async (wg) => {
if (!wg) {
return TransitionResult.stay();
}
wg.status = WithdrawalGroupStatus.Done;
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
return TransitionResult.transition(wg);
});
return TaskRunResult.finished();
}
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
.map((x) => x.count)
.reduce((a, b) => a + b);
const wgContext: WithdrawalGroupStatusInfo = {
numPlanchets: numTotalCoins,
planchetsFinished: new Set(),
wgRecord: withdrawalGroup,
};
await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
const planchets =
await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
for (const p of planchets) {
if (p.planchetStatus === PlanchetStatus.WithdrawalDone) {
wgContext.planchetsFinished.add(p.coinPub);
}
}
});
// We sequentially generate planchets, so that
// large withdrawal groups don't make the wallet unresponsive.
for (let i = 0; i < numTotalCoins; i++) {
await processPlanchetGenerate(wex, withdrawalGroup, i);
}
const maxBatchSize = 100;
for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, {
batchSize: maxBatchSize,
coinStartIndex: i,
});
let work: Promise[] = [];
work = [];
for (let j = 0; j < resp.coinIdxs.length; j++) {
if (!resp.batchResp.ev_sigs[j]) {
// response may not be available when there is kyc needed
continue;
}
work.push(
processPlanchetVerifyAndStoreCoin(
wex,
wgContext,
resp.coinIdxs[j],
resp.batchResp.ev_sigs[j],
),
);
}
await Promise.all(work);
}
let redenomRequired = false;
await wex.db.runReadOnlyTx({ storeNames: ["planchets"] }, async (tx) => {
const planchets =
await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
for (const p of planchets) {
if (p.planchetStatus !== PlanchetStatus.Pending) {
continue;
}
if (!p.lastError) {
continue;
}
switch (p.lastError.code) {
case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_EXPIRED:
case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_REVOKED:
redenomRequired = true;
return;
}
}
});
if (redenomRequired) {
logger.warn(`withdrawal ${withdrawalGroupId} requires redenomination`);
await fetchFreshExchange(wex, exchangeBaseUrl, {
forceUpdate: true,
});
await updateWithdrawalDenoms(wex, exchangeBaseUrl);
await redenominateWithdrawal(wex, withdrawalGroupId);
return TaskRunResult.backoff();
}
const errorsPerCoin: Record = {};
let numPlanchetErrors = 0;
let numActive = 0;
const maxReportedErrors = 5;
const res = await ctx.transition(
{
extraStores: ["coins", "coinAvailability", "planchets"],
},
async (wg, tx) => {
if (!wg) {
return TransitionResult.stay();
}
const groupPlanchets =
await tx.planchets.indexes.byGroup.getAll(withdrawalGroupId);
for (const x of groupPlanchets) {
switch (x.planchetStatus) {
case PlanchetStatus.KycRequired:
case PlanchetStatus.Pending:
numActive++;
break;
case PlanchetStatus.WithdrawalDone:
break;
}
if (x.lastError) {
numPlanchetErrors++;
if (numPlanchetErrors < maxReportedErrors) {
errorsPerCoin[x.coinIdx] = x.lastError;
}
}
}
if (wg.timestampFinish === undefined && numActive === 0) {
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
wg.status = WithdrawalGroupStatus.Done;
await makeCoinsVisible(wex, tx, ctx.transactionId);
}
return TransitionResult.transition(wg);
},
);
if (!res) {
throw Error("withdrawal group does not exist anymore");
}
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
if (numPlanchetErrors > 0) {
return {
type: TaskRunResultType.Error,
errorDetail: makeErrorDetail(
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
{
errorsPerCoin,
numErrors: numPlanchetErrors,
},
),
};
}
return TaskRunResult.backoff();
}
export async function processWithdrawalGroup(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
logger.trace("processing withdrawal group", withdrawalGroupId);
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
},
);
if (!withdrawalGroup) {
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
}
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
switch (withdrawalGroup.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
return await processBankRegisterReserve(wex, withdrawalGroupId);
case WithdrawalGroupStatus.PendingQueryingStatus:
return processQueryReserve(wex, withdrawalGroupId);
case WithdrawalGroupStatus.PendingWaitConfirmBank:
return await processReserveBankStatus(wex, withdrawalGroupId);
case WithdrawalGroupStatus.PendingAml:
// FIXME: Handle this case, withdrawal doesn't support AML yet.
return TaskRunResult.backoff();
case WithdrawalGroupStatus.PendingKyc:
return processWithdrawalGroupPendingKyc(wex, withdrawalGroup);
case WithdrawalGroupStatus.PendingReady:
// Continue with the actual withdrawal!
return await processWithdrawalGroupPendingReady(wex, withdrawalGroup);
case WithdrawalGroupStatus.AbortingBank:
return await processWithdrawalGroupAbortingBank(wex, withdrawalGroup);
case WithdrawalGroupStatus.DialogProposed:
return await processWithdrawalGroupDialogProposed(ctx, withdrawalGroup);
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.SuspendedAml:
case WithdrawalGroupStatus.SuspendedKyc:
case WithdrawalGroupStatus.SuspendedQueryingStatus:
case WithdrawalGroupStatus.SuspendedReady:
case WithdrawalGroupStatus.SuspendedRegisteringBank:
case WithdrawalGroupStatus.SuspendedWaitConfirmBank:
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.AbortedUserRefused:
case WithdrawalGroupStatus.AbortedOtherWallet:
// Nothing to do.
return TaskRunResult.finished();
default:
assertUnreachable(withdrawalGroup.status);
}
}
const AGE_MASK_GROUPS = "8:10:12:14:16:18"
.split(":")
.map((n) => parseInt(n, 10));
export async function getExchangeWithdrawalInfo(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
instructedAmount: AmountJson,
ageRestricted: number | undefined,
): Promise {
logger.trace("updating exchange");
const exchange = await fetchFreshExchange(wex, exchangeBaseUrl, {});
wex.cancellationToken.throwIfCancelled();
if (exchange.currency != instructedAmount.currency) {
// Specifying the amount in the conversion input currency is not yet supported.
// We might add support for it later.
throw new Error(
`withdrawal only supported when specifying target currency ${exchange.currency}`,
);
}
const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
wex,
{
exchange,
instructedAmount,
},
wex.cancellationToken,
);
logger.trace("updating withdrawal denoms");
await updateWithdrawalDenoms(wex, exchangeBaseUrl);
wex.cancellationToken.throwIfCancelled();
logger.trace("getting candidate denoms");
const candidateDenoms = await getCandidateWithdrawalDenoms(
wex,
exchangeBaseUrl,
instructedAmount.currency,
);
wex.cancellationToken.throwIfCancelled();
logger.trace("selecting withdrawal denoms");
// FIXME: Why not in a transaction?
const selectedDenoms = selectWithdrawalDenominations(
instructedAmount,
candidateDenoms,
wex.ws.config.testing.denomselAllowLate,
);
logger.trace("selection done");
if (selectedDenoms.selectedDenoms.length === 0) {
throw Error(
`unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify(
instructedAmount,
)}`,
);
}
const exchangeWireAccounts: string[] = [];
for (const account of exchange.wireInfo.accounts) {
exchangeWireAccounts.push(account.payto_uri);
}
let versionMatch;
if (exchange.protocolVersionRange) {
versionMatch = LibtoolVersion.compare(
WALLET_EXCHANGE_PROTOCOL_VERSION,
exchange.protocolVersionRange,
);
if (
versionMatch &&
!versionMatch.compatible &&
versionMatch.currentCmp === -1
) {
logger.warn(
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
`(exchange has ${exchange.protocolVersionRange}), checking for updates`,
);
}
}
let tosAccepted = false;
if (exchange.tosAcceptedTimestamp) {
if (exchange.tosAcceptedEtag === exchange.tosCurrentEtag) {
tosAccepted = true;
}
}
const paytoUris = exchange.wireInfo.accounts.map((x) => x.payto_uri);
if (!paytoUris) {
throw Error("exchange is in invalid state");
}
const ret: ExchangeWithdrawalDetails = {
earliestDepositExpiration: selectedDenoms.earliestDepositExpiration,
exchangePaytoUris: paytoUris,
exchangeWireAccounts,
exchangeCreditAccountDetails: withdrawalAccountsList,
exchangeVersion: exchange.protocolVersionRange || "unknown",
selectedDenoms,
versionMatch,
walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
termsOfServiceAccepted: tosAccepted,
withdrawalAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue),
withdrawalAmountRaw: Amounts.stringify(instructedAmount),
// TODO: remove hardcoding, this should be calculated from the denominations info
// force enabled for testing
ageRestrictionOptions: selectedDenoms.hasDenomWithAgeRestriction
? AGE_MASK_GROUPS
: undefined,
scopeInfo: exchange.scopeInfo,
};
return ret;
}
export interface GetWithdrawalDetailsForUriOpts {
restrictAge?: number;
notifyChangeFromPendingTimeoutMs?: number;
}
/**
* Get more information about a taler://withdraw URI.
*
* As side effects, the bank (via the bank integration API) is queried
* and the exchange suggested by the bank is ephemerally added
* to the wallet's list of known exchanges.
*/
export async function getWithdrawalDetailsForUri(
wex: WalletExecutionContext,
talerWithdrawUri: string,
): Promise {
logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
logger.trace(`got bank info`);
if (info.suggestedExchange) {
try {
// If the exchange entry doesn't exist yet,
// it'll be created as an ephemeral entry.
await fetchFreshExchange(wex, info.suggestedExchange);
} catch (e) {
// We still continued if it failed, as other exchanges might be available.
// We don't want to fail if the bank-suggested exchange is broken/offline.
logger.trace(
`querying bank-suggested exchange (${info.suggestedExchange}) failed`,
);
}
}
const currency = Amounts.currencyOf(info.amount);
const listExchangesResp = await listExchanges(wex);
const possibleExchanges = listExchangesResp.exchanges.filter((x) => {
return (
x.currency === currency &&
(x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready ||
x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate)
);
});
return {
operationId: info.operationId,
confirmTransferUrl: info.confirmTransferUrl,
status: info.status,
amount: Amounts.stringify(info.amount),
defaultExchangeBaseUrl: info.suggestedExchange,
possibleExchanges,
};
}
export function augmentPaytoUrisForWithdrawal(
plainPaytoUris: string[],
reservePub: string,
instructedAmount: AmountLike,
): string[] {
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(instructedAmount),
message: `Taler ${reservePub}`,
}),
);
}
/**
* Get payto URIs that can be used to fund a withdrawal operation.
*/
export async function getFundingPaytoUris(
tx: WalletDbReadOnlyTransaction<
["withdrawalGroups", "exchanges", "exchangeDetails"]
>,
withdrawalGroupId: string,
): Promise {
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
checkDbInvariant(!!withdrawalGroup);
checkDbInvariant(
withdrawalGroup.exchangeBaseUrl !== undefined,
"can't get funding uri from uninitialized wg",
);
checkDbInvariant(
withdrawalGroup.instructedAmount !== undefined,
"can't get funding uri from uninitialized wg",
);
const exchangeDetails = await getExchangeWireDetailsInTx(
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 augmentPaytoUrisForWithdrawal(
plainPaytoUris,
withdrawalGroup.reservePub,
withdrawalGroup.instructedAmount,
);
}
async function getWithdrawalGroupRecordTx(
db: DbAccess,
req: {
withdrawalGroupId: string;
},
): Promise {
return await db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return tx.withdrawalGroups.get(req.withdrawalGroupId);
},
);
}
export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
return { d_ms: 60000 };
}
export function getBankStatusUrl(talerWithdrawUri: string): string {
const uriResult = parseWithdrawUri(talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
}
const url = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}`,
uriResult.bankIntegrationApiBaseUrl,
);
return url.href;
}
export function getBankAbortUrl(talerWithdrawUri: string): string {
const uriResult = parseWithdrawUri(talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse withdrawal URL ${talerWithdrawUri}`);
}
const url = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}/abort`,
uriResult.bankIntegrationApiBaseUrl,
);
return url.href;
}
async function registerReserveWithBank(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
},
);
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
switch (withdrawalGroup?.status) {
case WithdrawalGroupStatus.PendingWaitConfirmBank:
case WithdrawalGroupStatus.PendingRegisteringBank:
break;
default:
return;
}
if (
withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
) {
throw Error("expecting withdrawal type = bank integrated");
}
const bankInfo = withdrawalGroup.wgInfo.bankInfo;
if (!bankInfo) {
return;
}
const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
const reqBody = {
reserve_pub: withdrawalGroup.reservePub,
selected_exchange: bankInfo.exchangePaytoUri,
};
logger.info(`registering reserve with bank: ${j2s(reqBody)}`);
const httpResp = await wex.http.fetch(bankStatusUrl, {
method: "POST",
body: reqBody,
timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken: wex.cancellationToken,
});
const status = await readSuccessResponseJsonOrThrow(
httpResp,
codeForBankWithdrawalOperationPostResponse(),
);
await ctx.transition({}, async (r) => {
if (!r) {
return TransitionResult.stay();
}
switch (r.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return TransitionResult.stay();
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
);
r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
return TransitionResult.transition(r);
});
}
async function transitionBankAborted(
ctx: WithdrawTransactionContext,
): Promise {
logger.info("bank aborted the withdrawal");
await ctx.transition({}, async (r) => {
if (!r) {
return TransitionResult.stay();
}
switch (r.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return TransitionResult.stay();
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
r.status = WithdrawalGroupStatus.FailedBankAborted;
return TransitionResult.transition(r);
});
return TaskRunResult.progress();
}
async function processBankRegisterReserve(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
withdrawalGroupId,
});
if (!withdrawalGroup) {
return TaskRunResult.finished();
}
if (
withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
) {
throw Error("wrong withdrawal record type");
}
const bankInfo = withdrawalGroup.wgInfo.bankInfo;
if (!bankInfo) {
throw Error("no bank info in bank-integrated withdrawal");
}
const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
}
const url = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}`,
uriResult.bankIntegrationApiBaseUrl,
);
const statusResp = await wex.http.fetch(url.href, {
timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken: wex.cancellationToken,
});
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (status.aborted) {
return transitionBankAborted(ctx);
}
// FIXME: Put confirm transfer URL in the DB!
await registerReserveWithBank(wex, withdrawalGroupId);
return TaskRunResult.progress();
}
async function processReserveBankStatus(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
withdrawalGroupId,
});
if (!withdrawalGroup) {
return TaskRunResult.finished();
}
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
if (
withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
) {
throw Error("wrong withdrawal record type");
}
const bankInfo = withdrawalGroup.wgInfo.bankInfo;
if (!bankInfo) {
throw Error("no bank info in bank-integrated withdrawal");
}
const uriResult = parseWithdrawUri(bankInfo.talerWithdrawUri);
if (!uriResult) {
throw Error(`can't parse withdrawal URL ${bankInfo.talerWithdrawUri}`);
}
const bankStatusUrl = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}`,
uriResult.bankIntegrationApiBaseUrl,
);
bankStatusUrl.searchParams.set("long_poll_ms", "30000");
logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`);
const statusResp = await wex.http.fetch(bankStatusUrl.href, {
timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken: wex.cancellationToken,
});
logger.info(
`long-polling for withdrawal operation returned status ${statusResp.status}`,
);
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (logger.shouldLogTrace()) {
logger.trace(`response body: ${j2s(status)}`);
}
if (status.aborted) {
return transitionBankAborted(ctx);
}
if (!status.transfer_done) {
return TaskRunResult.longpollReturnedPending();
}
const transitionInfo = await ctx.transition({}, async (r) => {
if (!r) {
return TransitionResult.stay();
}
// Re-check reserve status within transaction
switch (r.status) {
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return TransitionResult.stay();
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
if (status.transfer_done) {
logger.info("withdrawal: transfer confirmed by bank.");
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
r.status = WithdrawalGroupStatus.PendingQueryingStatus;
return TransitionResult.transition(r);
} else {
return TransitionResult.stay();
}
});
if (transitionInfo) {
return TaskRunResult.progress();
} else {
return TaskRunResult.backoff();
}
}
export interface PrepareCreateWithdrawalGroupResult {
withdrawalGroup: WithdrawalGroupRecord;
transactionId: string;
creationInfo?: {
amount: AmountJson;
canonExchange: string;
};
}
async function getInitialDenomsSelection(
wex: WalletExecutionContext,
exchange: string,
amount: AmountJson,
forcedDenoms: ForcedDenomSel | undefined,
): Promise {
const currency = Amounts.currencyOf(amount);
await updateWithdrawalDenoms(wex, exchange);
const denoms = await getCandidateWithdrawalDenoms(wex, exchange, currency);
if (forcedDenoms) {
logger.warn("using forced denom selection");
const initialDenomSel = selectForcedWithdrawalDenominations(
amount,
denoms,
forcedDenoms,
wex.ws.config.testing.denomselAllowLate,
);
return initialDenomSel;
} else {
const initialDenomSel = selectWithdrawalDenominations(
amount,
denoms,
wex.ws.config.testing.denomselAllowLate,
);
return initialDenomSel;
}
}
export async function internalPrepareCreateWithdrawalGroup(
wex: WalletExecutionContext,
args: {
reserveStatus: WithdrawalGroupStatus;
amount?: AmountJson;
exchangeBaseUrl?: string;
forcedWithdrawalGroupId?: string;
forcedDenomSel?: ForcedDenomSel;
reserveKeyPair?: EddsaKeypair;
restrictAge?: number;
wgInfo: WgInfo;
},
): Promise {
const reserveKeyPair =
args.reserveKeyPair ?? (await wex.cryptoApi.createEddsaKeypair({}));
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
const secretSeed = encodeCrock(getRandomBytes(32));
const exchangeBaseUrl = args.exchangeBaseUrl;
const amount = args.amount;
let withdrawalGroupId: string;
if (args.forcedWithdrawalGroupId) {
withdrawalGroupId = args.forcedWithdrawalGroupId;
const wgId = withdrawalGroupId;
const existingWg = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return tx.withdrawalGroups.get(wgId);
},
);
if (existingWg) {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: existingWg.withdrawalGroupId,
});
return { withdrawalGroup: existingWg, transactionId };
}
} else {
withdrawalGroupId = encodeCrock(getRandomBytes(32));
}
let initialDenomSel: DenomSelectionState | undefined;
const denomSelUid = encodeCrock(getRandomBytes(16));
const creationInfo =
exchangeBaseUrl !== undefined && amount !== undefined
? {
canonExchange: exchangeBaseUrl,
amount,
}
: undefined;
if (creationInfo) {
initialDenomSel = await getInitialDenomsSelection(
wex,
creationInfo.canonExchange,
creationInfo.amount,
args.forcedDenomSel,
);
}
const withdrawalGroup: WithdrawalGroupRecord = {
denomSelUid,
// next fields will be undefined if exchange or amount is not specified
denomsSel: initialDenomSel,
exchangeBaseUrl: exchangeBaseUrl,
instructedAmount:
amount === undefined ? undefined : Amounts.stringify(amount),
rawWithdrawalAmount: initialDenomSel?.totalWithdrawCost,
effectiveWithdrawalAmount: initialDenomSel?.totalCoinValue,
// end of optional fields
timestampStart: timestampPreciseToDb(now),
secretSeed,
reservePriv: reserveKeyPair.priv,
reservePub: reserveKeyPair.pub,
status: args.reserveStatus,
withdrawalGroupId,
restrictAge: args.restrictAge,
senderWire: undefined,
timestampFinish: undefined,
wgInfo: args.wgInfo,
};
if (creationInfo) {
await fetchFreshExchange(wex, creationInfo.canonExchange);
}
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
});
return {
withdrawalGroup,
transactionId,
creationInfo,
};
}
export interface PerformCreateWithdrawalGroupResult {
withdrawalGroup: WithdrawalGroupRecord;
transitionInfo: TransitionInfo | undefined;
/**
* Notification for the exchange state transition.
*
* Should be emitted after the transaction has succeeded.
*/
exchangeNotif: WalletNotification | undefined;
}
export async function internalPerformCreateWithdrawalGroup(
wex: WalletExecutionContext,
tx: WalletDbReadWriteTransaction<
["withdrawalGroups", "reserves", "exchanges"]
>,
prep: PrepareCreateWithdrawalGroupResult,
): Promise {
const { withdrawalGroup } = prep;
const existingWg = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (existingWg) {
return {
withdrawalGroup: existingWg,
exchangeNotif: undefined,
transitionInfo: undefined,
};
}
await tx.withdrawalGroups.add(withdrawalGroup);
await tx.reserves.put({
reservePub: withdrawalGroup.reservePub,
reservePriv: withdrawalGroup.reservePriv,
});
if (!prep.creationInfo) {
return {
withdrawalGroup,
transitionInfo: undefined,
exchangeNotif: undefined,
};
}
const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange);
if (exchange) {
exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now());
await tx.exchanges.put(exchange);
}
const oldTxState = {
major: TransactionMajorState.None,
minor: undefined,
};
const newTxState = computeWithdrawalTransactionStatus(withdrawalGroup);
const transitionInfo = {
oldTxState,
newTxState,
};
const exchangeUsedRes = await markExchangeUsed(
wex,
tx,
prep.creationInfo.canonExchange,
);
const ctx = new WithdrawTransactionContext(
wex,
withdrawalGroup.withdrawalGroupId,
);
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
withdrawalGroup,
transitionInfo,
exchangeNotif: exchangeUsedRes.notif,
};
}
/**
* Create a withdrawal group.
*
* If a forcedWithdrawalGroupId is given and a
* withdrawal group with this ID already exists,
* the existing one is returned. No conflict checking
* of the other arguments is done in that case.
*/
export async function internalCreateWithdrawalGroup(
wex: WalletExecutionContext,
args: {
reserveStatus: WithdrawalGroupStatus;
amount?: AmountJson;
exchangeBaseUrl?: string;
forcedWithdrawalGroupId?: string;
forcedDenomSel?: ForcedDenomSel;
reserveKeyPair?: EddsaKeypair;
restrictAge?: number;
wgInfo: WgInfo;
},
): Promise {
const prep = await internalPrepareCreateWithdrawalGroup(wex, args);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: prep.withdrawalGroup.withdrawalGroupId,
});
const ctx = new WithdrawTransactionContext(
wex,
prep.withdrawalGroup.withdrawalGroupId,
);
const res = await wex.db.runReadWriteTx(
{
storeNames: [
"withdrawalGroups",
"reserves",
"exchanges",
"exchangeDetails",
"transactions",
"operationRetries",
],
},
async (tx) => {
const res = await internalPerformCreateWithdrawalGroup(wex, tx, prep);
await updateWithdrawalTransaction(ctx, tx);
return res;
},
);
if (res.exchangeNotif) {
wex.ws.notify(res.exchangeNotif);
}
notifyTransition(wex, transactionId, res.transitionInfo);
return res.withdrawalGroup;
}
export async function prepareBankIntegratedWithdrawal(
wex: WalletExecutionContext,
req: {
talerWithdrawUri: string;
},
): Promise {
const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
req.talerWithdrawUri,
);
},
);
if (existingWithdrawalGroup) {
let url: string | undefined;
if (
existingWithdrawalGroup.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
}
const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri);
return {
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
}),
info,
};
}
/**
* Withdrawal group without exchange and amount
* this is an special case when the user haven't yet
* choose. We are still tracking this object since the state
* can change from the bank side or another wallet with the
* same URI
*/
const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
wgInfo: {
withdrawalType: WithdrawalRecordType.BankIntegrated,
bankInfo: {
talerWithdrawUri: req.talerWithdrawUri,
confirmUrl: undefined,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
},
},
reserveStatus: WithdrawalGroupStatus.DialogProposed,
});
const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri);
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
transactionId: ctx.transactionId,
info,
};
}
export async function confirmWithdrawal(
wex: WalletExecutionContext,
req: ConfirmWithdrawalRequest,
): Promise {
const parsedTx = parseTransactionIdentifier(req.transactionId);
if (parsedTx?.tag !== TransactionType.Withdrawal) {
throw Error("invalid withdrawal transaction ID");
}
const withdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.get(parsedTx.withdrawalGroupId);
},
);
if (!withdrawalGroup) {
throw Error("withdrawal group not found");
}
if (
withdrawalGroup.wgInfo.withdrawalType !==
WithdrawalRecordType.BankIntegrated
) {
throw Error("not a bank integrated withdrawal");
}
const selectedExchange = req.exchangeBaseUrl;
const exchange = await fetchFreshExchange(wex, selectedExchange);
const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri;
const withdrawInfo = await getBankWithdrawalInfo(wex.http, talerWithdrawUri);
const exchangePaytoUri = await getExchangePaytoUri(
wex,
selectedExchange,
withdrawInfo.wireTypes,
);
const withdrawalAccountList = await fetchWithdrawalAccountInfo(
wex,
{
exchange,
instructedAmount: withdrawInfo.amount,
},
wex.cancellationToken,
);
const ctx = new WithdrawTransactionContext(
wex,
withdrawalGroup.withdrawalGroupId,
);
const initalDenoms = await getInitialDenomsSelection(
wex,
req.exchangeBaseUrl,
Amounts.parseOrThrow(req.amount),
req.forcedDenomSel,
);
ctx.transition({}, async (rec) => {
if (!rec) {
return TransitionResult.stay();
}
switch (rec.status) {
case WithdrawalGroupStatus.DialogProposed: {
rec.exchangeBaseUrl = req.exchangeBaseUrl;
rec.instructedAmount = req.amount;
rec.denomsSel = initalDenoms;
rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost;
rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue;
rec.restrictAge = req.restrictAge;
rec.wgInfo = {
withdrawalType: WithdrawalRecordType.BankIntegrated,
exchangeCreditAccounts: withdrawalAccountList,
bankInfo: {
exchangePaytoUri,
talerWithdrawUri,
confirmUrl: withdrawInfo.confirmTransferUrl,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
},
};
rec.status = WithdrawalGroupStatus.PendingRegisteringBank;
return TransitionResult.transition(rec);
}
default:
throw Error("unable to confirm withdrawal in current state");
}
});
await wex.taskScheduler.resetTaskRetries(ctx.taskId);
wex.taskScheduler.startShepherdTask(ctx.taskId);
}
/**
* Accept a bank-integrated withdrawal.
*
* Before returning, the wallet tries to register the reserve with the bank.
*
* Thus after this call returns, the withdrawal operation can be confirmed
* with the bank.
*
* @deprecated in favor of prepare/accept
*/
export async function acceptWithdrawalFromUri(
wex: WalletExecutionContext,
req: {
talerWithdrawUri: string;
selectedExchange: string;
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
},
): Promise {
const selectedExchange = req.selectedExchange;
logger.info(
`accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
);
const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups"] },
async (tx) => {
return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get(
req.talerWithdrawUri,
);
},
);
if (existingWithdrawalGroup) {
let url: string | undefined;
if (
existingWithdrawalGroup.wgInfo.withdrawalType ===
WithdrawalRecordType.BankIntegrated
) {
url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl;
}
return {
reservePub: existingWithdrawalGroup.reservePub,
confirmTransferUrl: url,
transactionId: constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId,
}),
};
}
const exchange = await fetchFreshExchange(wex, selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(
wex.http,
req.talerWithdrawUri,
);
const exchangePaytoUri = await getExchangePaytoUri(
wex,
selectedExchange,
withdrawInfo.wireTypes,
);
const withdrawalAccountList = await fetchWithdrawalAccountInfo(
wex,
{
exchange,
instructedAmount: withdrawInfo.amount,
},
CancellationToken.CONTINUE,
);
const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
amount: withdrawInfo.amount,
exchangeBaseUrl: req.selectedExchange,
wgInfo: {
withdrawalType: WithdrawalRecordType.BankIntegrated,
exchangeCreditAccounts: withdrawalAccountList,
bankInfo: {
exchangePaytoUri,
talerWithdrawUri: req.talerWithdrawUri,
confirmUrl: withdrawInfo.confirmTransferUrl,
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
},
},
restrictAge: req.restrictAge,
forcedDenomSel: req.forcedDenomSel,
reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank,
});
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
await waitWithdrawalRegistered(wex, ctx);
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
reservePub: withdrawalGroup.reservePub,
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
transactionId: ctx.transactionId,
};
}
async function internalWaitWithdrawalRegistered(
wex: WalletExecutionContext,
ctx: WithdrawTransactionContext,
withdrawalNotifFlag: AsyncFlag,
): Promise {
while (true) {
const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups", "operationRetries"] },
async (tx) => {
return {
withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
retryRec: await tx.operationRetries.get(ctx.taskId),
};
},
);
if (!withdrawalRec) {
throw Error("withdrawal not found anymore");
}
switch (withdrawalRec.status) {
case WithdrawalGroupStatus.FailedBankAborted:
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
{},
);
case WithdrawalGroupStatus.PendingKyc:
case WithdrawalGroupStatus.PendingAml:
case WithdrawalGroupStatus.PendingQueryingStatus:
case WithdrawalGroupStatus.PendingReady:
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
return;
case WithdrawalGroupStatus.PendingRegisteringBank:
break;
default: {
if (retryRec) {
if (retryRec.lastError) {
throw TalerError.fromUncheckedDetail(retryRec.lastError);
} else {
throw Error("withdrawal unexpectedly pending");
}
}
}
}
await withdrawalNotifFlag.wait();
withdrawalNotifFlag.reset();
}
}
async function waitWithdrawalRegistered(
wex: WalletExecutionContext,
ctx: WithdrawTransactionContext,
): Promise {
// FIXME: Doesn't support cancellation yet
// FIXME: We should use Symbol.dispose magic here for cleanup!
const withdrawalNotifFlag = new AsyncFlag();
// Raise exchangeNotifFlag whenever we get a notification
// about our exchange.
const cancelNotif = wex.ws.addNotificationListener((notif) => {
if (
notif.type === NotificationType.TransactionStateTransition &&
notif.transactionId === ctx.transactionId
) {
logger.info(`raising update notification: ${j2s(notif)}`);
withdrawalNotifFlag.raise();
}
});
try {
const res = await internalWaitWithdrawalRegistered(
wex,
ctx,
withdrawalNotifFlag,
);
logger.info("done waiting for ready exchange");
return res;
} finally {
cancelNotif();
}
}
async function fetchAccount(
wex: WalletExecutionContext,
instructedAmount: AmountJson,
acct: ExchangeWireAccount,
reservePub: string | undefined,
cancellationToken: CancellationToken,
): Promise {
let paytoUri: string;
let transferAmount: AmountString | undefined = undefined;
let currencySpecification: CurrencySpecification | undefined = undefined;
if (acct.conversion_url != null) {
const reqUrl = new URL("cashin-rate", acct.conversion_url);
reqUrl.searchParams.set(
"amount_credit",
Amounts.stringify(instructedAmount),
);
const httpResp = await wex.http.fetch(reqUrl.href, {
cancellationToken,
});
const respOrErr = await readSuccessResponseJsonOrErrorCode(
httpResp,
codecForCashinConversionResponse(),
);
if (respOrErr.isError) {
return {
status: "error",
paytoUri: acct.payto_uri,
conversionError: respOrErr.talerErrorResponse,
};
}
const resp = respOrErr.response;
paytoUri = acct.payto_uri;
transferAmount = resp.amount_debit;
const configUrl = new URL("config", acct.conversion_url);
const configResp = await wex.http.fetch(configUrl.href, {
cancellationToken,
});
const configRespOrError = await readSuccessResponseJsonOrErrorCode(
configResp,
codecForConversionBankConfig(),
);
if (configRespOrError.isError) {
return {
status: "error",
paytoUri: acct.payto_uri,
conversionError: configRespOrError.talerErrorResponse,
};
}
const configParsed = configRespOrError.response;
currencySpecification = configParsed.fiat_currency_specification;
} else {
paytoUri = acct.payto_uri;
transferAmount = Amounts.stringify(instructedAmount);
}
paytoUri = addPaytoQueryParams(paytoUri, {
amount: Amounts.stringify(transferAmount),
});
if (reservePub != null) {
paytoUri = addPaytoQueryParams(paytoUri, {
message: `Taler ${reservePub}`,
});
}
const acctInfo: WithdrawalExchangeAccountDetails = {
status: "ok",
paytoUri,
transferAmount,
bankLabel: acct.bank_label,
priority: acct.priority,
currencySpecification,
creditRestrictions: acct.credit_restrictions,
};
if (transferAmount != null) {
acctInfo.transferAmount = transferAmount;
}
return acctInfo;
}
/**
* Gather information about bank accounts that can be used for
* withdrawals. This includes accounts that are in a different
* currency and require conversion.
*/
async function fetchWithdrawalAccountInfo(
wex: WalletExecutionContext,
req: {
exchange: ReadyExchangeSummary;
instructedAmount: AmountJson;
reservePub?: string;
},
cancellationToken: CancellationToken,
): Promise {
const { exchange } = req;
const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = [];
for (let acct of exchange.wireInfo.accounts) {
const acctInfo = await fetchAccount(
wex,
req.instructedAmount,
acct,
req.reservePub,
cancellationToken,
);
withdrawalAccounts.push(acctInfo);
}
withdrawalAccounts.sort((x1, x2) => {
// Accounts without explicit priority have prio 0.
const n1 = x1.priority ?? 0;
const n2 = x2.priority ?? 0;
return Math.sign(n2 - n1);
});
return withdrawalAccounts;
}
/**
* 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(
wex: WalletExecutionContext,
req: {
exchangeBaseUrl: string;
amount: AmountLike;
restrictAge?: number;
forcedDenomSel?: ForcedDenomSel;
forceReservePriv?: EddsaPrivateKeyString;
},
): Promise {
const { exchangeBaseUrl } = req;
const amount = Amounts.parseOrThrow(req.amount);
const exchange = await fetchFreshExchange(wex, exchangeBaseUrl);
if (exchange.currency != amount.currency) {
throw Error(
"manual withdrawal with conversion from foreign currency is not yet supported",
);
}
let reserveKeyPair: EddsaKeypair;
if (req.forceReservePriv) {
const pubResp = await wex.cryptoApi.eddsaGetPublic({
priv: req.forceReservePriv,
});
reserveKeyPair = {
priv: req.forceReservePriv,
pub: pubResp.pub,
};
} else {
reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({});
}
const withdrawalAccountsList = await fetchWithdrawalAccountInfo(
wex,
{
exchange,
instructedAmount: amount,
reservePub: reserveKeyPair.pub,
},
CancellationToken.CONTINUE,
);
const withdrawalGroup = await internalCreateWithdrawalGroup(wex, {
amount: Amounts.jsonifyAmount(req.amount),
wgInfo: {
withdrawalType: WithdrawalRecordType.BankManual,
exchangeCreditAccounts: withdrawalAccountsList,
},
exchangeBaseUrl: req.exchangeBaseUrl,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
reserveKeyPair,
});
const ctx = new WithdrawTransactionContext(
wex,
withdrawalGroup.withdrawalGroupId,
);
const exchangePaytoUris = await wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups", "exchanges", "exchangeDetails"] },
async (tx) => {
return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
},
);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
reservePub: withdrawalGroup.reservePub,
exchangePaytoUris: exchangePaytoUris,
withdrawalAccountsList: withdrawalAccountsList,
transactionId: ctx.transactionId,
};
}
/**
* Wait until a refresh operation is final.
*/
export async function waitWithdrawalFinal(
wex: WalletExecutionContext,
withdrawalGroupId: string,
): Promise {
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
wex.taskScheduler.startShepherdTask(ctx.taskId);
// FIXME: Clean up using the new JS "using" / Symbol.dispose syntax.
const withdrawalNotifFlag = new AsyncFlag();
// Raise purchaseNotifFlag whenever we get a notification
// about our refresh.
const cancelNotif = wex.ws.addNotificationListener((notif) => {
if (
notif.type === NotificationType.TransactionStateTransition &&
notif.transactionId === ctx.transactionId
) {
withdrawalNotifFlag.raise();
}
});
const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => {
cancelNotif();
withdrawalNotifFlag.raise();
});
try {
await internalWaitWithdrawalFinal(ctx, withdrawalNotifFlag);
} catch (e) {
unregisterOnCancelled();
cancelNotif();
}
}
async function internalWaitWithdrawalFinal(
ctx: WithdrawTransactionContext,
flag: AsyncFlag,
): Promise {
while (true) {
if (ctx.wex.cancellationToken.isCancelled) {
throw Error("cancelled");
}
// Check if refresh is final
const res = await ctx.wex.db.runReadOnlyTx(
{ storeNames: ["withdrawalGroups", "operationRetries"] },
async (tx) => {
return {
wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId),
};
},
);
const { wg } = res;
if (!wg) {
// Must've been deleted, we consider that final.
return;
}
switch (wg.status) {
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedAbortingBank:
case WithdrawalGroupStatus.FailedBankAborted:
// Transaction is final
return;
}
// Wait for the next transition
await flag.wait();
flag.reset();
}
}
export async function getWithdrawalDetailsForAmount(
wex: WalletExecutionContext,
cts: CancellationToken.Source,
req: GetWithdrawalDetailsForAmountRequest,
): Promise {
const clientCancelKey = req.clientCancellationId
? `ccid:getWithdrawalDetailsForAmount:${req.clientCancellationId}`
: undefined;
if (clientCancelKey) {
const prevCts = wex.ws.clientCancellationMap.get(clientCancelKey);
if (prevCts) {
wex.oc.observe({
type: ObservabilityEventType.Message,
contents: `Cancelling previous key ${clientCancelKey}`,
});
prevCts.cancel();
} else {
wex.oc.observe({
type: ObservabilityEventType.Message,
contents: `No previous key ${clientCancelKey}`,
});
}
wex.oc.observe({
type: ObservabilityEventType.Message,
contents: `Setting clientCancelKey ${clientCancelKey} to ${cts}`,
});
wex.ws.clientCancellationMap.set(clientCancelKey, cts);
}
try {
return await internalGetWithdrawalDetailsForAmount(wex, req);
} finally {
wex.oc.observe({
type: ObservabilityEventType.Message,
contents: `Deleting clientCancelKey ${clientCancelKey} to ${cts}`,
});
if (clientCancelKey && !cts.token.isCancelled) {
wex.ws.clientCancellationMap.delete(clientCancelKey);
}
}
}
async function internalGetWithdrawalDetailsForAmount(
wex: WalletExecutionContext,
req: GetWithdrawalDetailsForAmountRequest,
): Promise {
const wi = await getExchangeWithdrawalInfo(
wex,
req.exchangeBaseUrl,
Amounts.parseOrThrow(req.amount),
req.restrictAge,
);
let numCoins = 0;
for (const x of wi.selectedDenoms.selectedDenoms) {
numCoins += x.count;
}
const resp: WithdrawalDetailsForAmount = {
amountRaw: req.amount,
amountEffective: Amounts.stringify(wi.selectedDenoms.totalCoinValue),
paytoUris: wi.exchangePaytoUris,
tosAccepted: wi.termsOfServiceAccepted,
ageRestrictionOptions: wi.ageRestrictionOptions,
withdrawalAccountsList: wi.exchangeCreditAccountDetails,
numCoins,
scopeInfo: wi.scopeInfo,
};
return resp;
}