/*
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,
AmountJson,
AmountLike,
AmountString,
Amounts,
AsyncFlag,
BankWithdrawDetails,
CancellationToken,
CoinStatus,
CurrencySpecification,
DenomKeyType,
DenomSelectionState,
Duration,
ExchangeBatchWithdrawRequest,
ExchangeUpdateStatus,
ExchangeWireAccount,
ExchangeWithdrawBatchResponse,
ExchangeWithdrawRequest,
ExchangeWithdrawResponse,
ExchangeWithdrawalDetails,
ForcedDenomSel,
HttpStatusCode,
LibtoolVersion,
Logger,
NotificationType,
TalerBankIntegrationHttpClient,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TransactionAction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
URL,
UnblindedSignature,
WalletNotification,
WithdrawUriInfoResponse,
WithdrawalExchangeAccountDetails,
addPaytoQueryParams,
assertUnreachable,
canonicalizeBaseUrl,
checkDbInvariant,
checkLogicInvariant,
codeForBankWithdrawalOperationPostResponse,
codecForCashinConversionResponse,
codecForConversionBankConfig,
codecForExchangeWithdrawBatchResponse,
codecForReserveStatus,
codecForWalletKycUuid,
codecForWithdrawOperationStatusResponse,
encodeCrock,
getErrorDetailFromException,
getRandomBytes,
j2s,
makeErrorDetail,
parseWithdrawUri,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
HttpResponse,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
selectForcedWithdrawalDenominations,
selectWithdrawalDenominations,
} from "./coinSelection.js";
import {
PendingTaskType,
TaskIdStr,
TaskRunResult,
TaskRunResultType,
TombstoneTag,
TransactionContext,
constructTaskIdentifier,
makeCoinAvailable,
makeCoinsVisible,
} from "./common.js";
import { EddsaKeypair } from "./crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
DenominationRecord,
DenominationVerificationStatus,
KycPendingInfo,
PlanchetRecord,
PlanchetStatus,
WalletDbReadOnlyTransaction,
WalletDbReadWriteTransaction,
WalletStoresV1,
WgInfo,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
timestampPreciseToDb,
} from "./db.js";
import { isWithdrawableDenom } from "./denominations.js";
import {
ReadyExchangeSummary,
fetchFreshExchange,
getExchangePaytoUri,
getExchangeWireDetailsInTx,
listExchanges,
markExchangeUsed,
} from "./exchanges.js";
import { DbAccess } from "./query.js";
import {
TransitionInfo,
constructTransactionIdentifier,
notifyTransition,
} from "./transactions.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "./versions.js";
import {
WalletExecutionContext,
getDenomInfo,
type InternalWalletState,
} from "./wallet.js";
/**
* Logger for this file.
*/
const logger = new Logger("operations/withdraw.ts");
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,
});
}
async deleteTransaction(): Promise {
const { wex: ws, withdrawalGroupId } = this;
await ws.db.runReadWriteTx(
["withdrawalGroups", "tombstones"],
async (tx) => {
const withdrawalGroupRecord =
await tx.withdrawalGroups.get(withdrawalGroupId);
if (withdrawalGroupRecord) {
await tx.withdrawalGroups.delete(withdrawalGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
});
return;
}
},
);
}
async suspendTransaction(): Promise {
const { wex, withdrawalGroupId, transactionId, taskId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
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}`,
);
}
if (newStatus != null) {
const oldTxState = computeWithdrawalTransactionStatus(wg);
wg.status = newStatus;
const newTxState = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
wex.taskScheduler.stopShepherdTask(taskId);
notifyTransition(wex, transactionId, transitionInfo);
}
async abortTransaction(): Promise {
const { wex: ws, withdrawalGroupId, transactionId, taskId } = this;
const transitionInfo = await ws.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
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:
// No transition needed, but not an error
break;
case WithdrawalGroupStatus.Done:
case WithdrawalGroupStatus.FailedBankAborted:
case WithdrawalGroupStatus.AbortedExchange:
case WithdrawalGroupStatus.AbortedBank:
case WithdrawalGroupStatus.FailedAbortingBank:
// Not allowed
throw Error("abort not allowed in current state");
default:
assertUnreachable(wg.status);
}
if (newStatus != null) {
const oldTxState = computeWithdrawalTransactionStatus(wg);
wg.status = newStatus;
const newTxState = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
ws.taskScheduler.stopShepherdTask(taskId);
notifyTransition(ws, transactionId, transitionInfo);
ws.taskScheduler.startShepherdTask(taskId);
}
async resumeTransaction(): Promise {
const {
wex: ws,
withdrawalGroupId,
transactionId,
taskId: retryTag,
} = this;
const transitionInfo = await ws.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
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}`,
);
}
if (newStatus != null) {
const oldTxState = computeWithdrawalTransactionStatus(wg);
wg.status = newStatus;
const newTxState = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
notifyTransition(ws, transactionId, transitionInfo);
ws.taskScheduler.startShepherdTask(retryTag);
}
async failTransaction(): Promise {
const {
wex: ws,
withdrawalGroupId,
transactionId,
taskId: retryTag,
} = this;
const stateUpdate = await ws.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
let newStatus: WithdrawalGroupStatus | undefined = undefined;
switch (wg.status) {
case WithdrawalGroupStatus.SuspendedAbortingBank:
case WithdrawalGroupStatus.AbortingBank:
newStatus = WithdrawalGroupStatus.FailedAbortingBank;
break;
default:
break;
}
if (newStatus != null) {
const oldTxState = computeWithdrawalTransactionStatus(wg);
wg.status = newStatus;
const newTxState = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
ws.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(ws, transactionId, stateUpdate);
ws.taskScheduler.startShepherdTask(retryTag);
}
}
/**
* 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,
};
}
}
/**
* 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:
return [TransactionAction.Delete];
case WithdrawalGroupStatus.AbortedExchange:
return [TransactionAction.Delete];
case WithdrawalGroupStatus.AbortedBank:
return [TransactionAction.Delete];
}
}
/**
* 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;
logger.info(`bank withdrawal operation status: ${j2s(status)}`);
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(["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 {
let planchet = await wex.db.runReadOnlyTx(["planchets"], async (tx) => {
return tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
coinIdx,
]);
});
if (planchet) {
return;
}
let ci = 0;
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;
break;
}
ci += d.count;
}
if (!maybeDenomPubHash) {
throw Error("invariant violated");
}
const denomPubHash = maybeDenomPubHash;
const denom = await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
return getDenomInfo(wex, tx, withdrawalGroup.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(["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,
}
/**
* Transition a withdrawal transaction with a (new) KYC URL.
*
* Emit a notification for the (self-)transition.
*/
async function transitionKycUrlUpdate(
wex: WalletExecutionContext,
withdrawalGroupId: string,
kycUrl: string,
): Promise {
let notificationKycUrl: string | undefined = undefined;
const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId);
const transactionId = ctx.transactionId;
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg2 = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg2) {
return;
}
const oldTxState = computeWithdrawalTransactionStatus(wg2);
switch (wg2.status) {
case WithdrawalGroupStatus.PendingReady: {
wg2.kycUrl = kycUrl;
notificationKycUrl = kycUrl;
await tx.withdrawalGroups.put(wg2);
const newTxState = computeWithdrawalTransactionStatus(wg2);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
},
);
if (transitionInfo) {
// Always notify, even on self-transition, as the KYC URL might have changed.
wex.ws.notify({
type: NotificationType.TransactionStateTransition,
oldTxState: transitionInfo.oldTxState,
newTxState: transitionInfo.newTxState,
transactionId,
experimentalUserData: notificationKycUrl,
});
}
wex.taskScheduler.startShepherdTask(ctx.taskId);
}
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 transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
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})`);
}
let notificationKycUrl: string | undefined = undefined;
const transitionInfo = await wex.db.runReadWriteTx(
["planchets", "withdrawalGroups"],
async (tx) => {
for (let i = startIdx; i < requestCoinIdxs.length; i++) {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroup.withdrawalGroupId,
requestCoinIdxs[i],
]);
if (!planchet) {
continue;
}
planchet.planchetStatus = PlanchetStatus.KycRequired;
await tx.planchets.put(planchet);
}
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
const oldTxState = computeWithdrawalTransactionStatus(wg2);
switch (wg2.status) {
case WithdrawalGroupStatus.PendingReady: {
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);
notificationKycUrl = kycUrl;
await tx.withdrawalGroups.put(wg2);
const newTxState = computeWithdrawalTransactionStatus(wg2);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
},
);
notifyTransition(wex, transactionId, transitionInfo, notificationKycUrl);
}
/**
* 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: WithdrawalGroupContext,
args: WithdrawalRequestBatchArgs,
): Promise {
const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord;
logger.info(
`processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`,
);
const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] };
// Indices of coins that are included in the batch request
const requestCoinIdxs: number[] = [];
await wex.db.runReadOnlyTx(["planchets", "denominations"], async (tx) => {
for (
let coinIdx = args.coinStartIndex;
coinIdx < args.coinStartIndex + args.batchSize &&
coinIdx < wgContext.numPlanchets;
coinIdx++
) {
let 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;
}
const denom = await getDenomInfo(
wex,
tx,
withdrawalGroup.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(e: any, coinIdx: number): Promise {
const errDetail = getErrorDetailFromException(e);
logger.trace("withdrawal request failed", e);
logger.trace(String(e));
await wex.db.runReadWriteTx(["planchets"], async (tx) => {
let 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: [],
};
}
const r = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangeWithdrawBatchResponse(),
);
return {
coinIdxs: requestCoinIdxs,
batchResp: r,
};
} catch (e) {
await storeCoinError(e, requestCoinIdxs[0]);
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
}
async function processPlanchetVerifyAndStoreCoin(
wex: WalletExecutionContext,
wgContext: WithdrawalGroupContext,
coinIdx: number,
resp: ExchangeWithdrawResponse,
): Promise {
const withdrawalGroup = wgContext.wgRecord;
logger.trace(`checking and storing planchet idx=${coinIdx}`);
const d = await wex.db.runReadOnlyTx(
["planchets", "denominations"],
async (tx) => {
let 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,
withdrawalGroup.exchangeBaseUrl,
planchet.denomPubHash,
);
if (!denomInfo) {
return;
}
return {
planchet,
denomInfo,
exchangeBaseUrl: withdrawalGroup.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`);
}
let 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(["planchets"], async (tx) => {
let 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(
["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.
*/
async function updateWithdrawalDenoms(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
logger.trace(
`updating denominations used for withdrawal for ${exchangeBaseUrl}`,
);
const exchangeDetails = await wex.db.runReadOnlyTx(
["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(["denominations"], async (tx) => {
for (let i = 0; i < updatedDenominations.length; i++) {
const denom = updatedDenominations[i];
await tx.denominations.put(denom);
}
});
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 transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId,
});
const withdrawalGroup = await getWithdrawalGroupRecordTx(wex.db, {
withdrawalGroupId,
});
checkDbInvariant(!!withdrawalGroup);
if (withdrawalGroup.status !== WithdrawalGroupStatus.PendingQueryingStatus) {
return TaskRunResult.backoff();
}
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)}`);
const transitionResult = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return undefined;
}
const txStateOld = computeWithdrawalTransactionStatus(wg);
wg.status = WithdrawalGroupStatus.PendingReady;
const txStateNew = computeWithdrawalTransactionStatus(wg);
wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
await tx.withdrawalGroups.put(wg);
return {
oldTxState: txStateOld,
newTxState: txStateNew,
};
},
);
notifyTransition(wex, transactionId, transitionResult);
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 WithdrawalGroupContext {
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 transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
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}`);
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
return undefined;
}
const txStatusOld = computeWithdrawalTransactionStatus(wg);
wg.status = WithdrawalGroupStatus.AbortedBank;
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
const txStatusNew = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState: txStatusOld,
newTxState: txStatusNew,
};
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.finished();
}
/**
* Store in the database that the KYC for a withdrawal is now
* satisfied.
*/
async function transitionKycSatisfied(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
});
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg2 = await tx.withdrawalGroups.get(
withdrawalGroup.withdrawalGroupId,
);
if (!wg2) {
return;
}
const oldTxState = computeWithdrawalTransactionStatus(wg2);
switch (wg2.status) {
case WithdrawalGroupStatus.PendingKyc: {
delete wg2.kycPending;
delete wg2.kycUrl;
wg2.status = WithdrawalGroupStatus.PendingReady;
await tx.withdrawalGroups.put(wg2);
const newTxState = computeWithdrawalTransactionStatus(wg2);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
},
);
notifyTransition(wex, transactionId, transitionInfo);
}
async function processWithdrawalGroupPendingKyc(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
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");
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
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 transitionKycSatisfied(wex, withdrawalGroup);
} 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 transitionKycUrlUpdate(wex, withdrawalGroupId, kycUrl);
}
} 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();
}
async function processWithdrawalGroupPendingReady(
wex: WalletExecutionContext,
withdrawalGroup: WithdrawalGroupRecord,
): Promise {
const { withdrawalGroupId } = withdrawalGroup;
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId,
});
await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl);
if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
logger.warn("Finishing empty withdrawal group (no denoms)");
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
return undefined;
}
const txStatusOld = computeWithdrawalTransactionStatus(wg);
wg.status = WithdrawalGroupStatus.Done;
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
const txStatusNew = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
oldTxState: txStatusOld,
newTxState: txStatusNew,
};
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.finished();
}
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
.map((x) => x.count)
.reduce((a, b) => a + b);
const wgContext: WithdrawalGroupContext = {
numPlanchets: numTotalCoins,
planchetsFinished: new Set(),
wgRecord: withdrawalGroup,
};
await wex.db.runReadOnlyTx(["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 numFinished = 0;
const errorsPerCoin: Record = {};
let numPlanchetErrors = 0;
const maxReportedErrors = 5;
const res = await wex.db.runReadWriteTx(
["coins", "coinAvailability", "withdrawalGroups", "planchets"],
async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
return;
}
await tx.planchets.indexes.byGroup
.iter(withdrawalGroupId)
.forEach((x) => {
if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
numFinished++;
}
if (x.lastError) {
numPlanchetErrors++;
if (numPlanchetErrors < maxReportedErrors) {
errorsPerCoin[x.coinIdx] = x.lastError;
}
}
});
const oldTxState = computeWithdrawalTransactionStatus(wg);
logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
wg.timestampFinish = timestampPreciseToDb(TalerPreciseTimestamp.now());
wg.status = WithdrawalGroupStatus.Done;
await makeCoinsVisible(wex, tx, transactionId);
}
const newTxState = computeWithdrawalTransactionStatus(wg);
await tx.withdrawalGroups.put(wg);
return {
kycInfo: wg.kycPending,
transitionInfo: {
oldTxState,
newTxState,
},
};
},
);
if (!res) {
throw Error("withdrawal group does not exist anymore");
}
notifyTransition(wex, transactionId, res.transitionInfo);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: 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(
["withdrawalGroups"],
async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
},
);
if (!withdrawalGroup) {
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
}
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.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:
// 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);
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,
},
CancellationToken.CONTINUE,
);
logger.trace("updating withdrawal denoms");
await updateWithdrawalDenoms(wex, exchangeBaseUrl);
logger.trace("getting candidate denoms");
const denoms = await getCandidateWithdrawalDenoms(
wex,
exchangeBaseUrl,
instructedAmount.currency,
);
logger.trace("selecting withdrawal denoms");
const selectedDenoms = selectWithdrawalDenominations(
instructedAmount,
denoms,
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 hasDenomWithAgeRestriction = false;
logger.trace("computing earliest deposit expiration");
let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
await wex.db.runReadOnlyTx(["denominations"], async (tx) => {
for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
const ds = selectedDenoms.selectedDenoms[i];
const denom = await getDenomInfo(
wex,
tx,
exchangeBaseUrl,
ds.denomPubHash,
);
checkDbInvariant(!!denom);
hasDenomWithAgeRestriction =
hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
const expireDeposit = denom.stampExpireDeposit;
if (!earliestDepositExpiration) {
earliestDepositExpiration = expireDeposit;
continue;
}
if (
AbsoluteTime.cmp(
AbsoluteTime.fromProtocolTimestamp(expireDeposit),
AbsoluteTime.fromProtocolTimestamp(earliestDepositExpiration),
) < 0
) {
earliestDepositExpiration = expireDeposit;
}
}
});
checkLogicInvariant(!!earliestDepositExpiration);
const possibleDenoms = await getCandidateWithdrawalDenoms(
wex,
exchangeBaseUrl,
instructedAmount.currency,
);
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,
exchangePaytoUris: paytoUris,
exchangeWireAccounts,
exchangeCreditAccountDetails: withdrawalAccountsList,
exchangeVersion: exchange.protocolVersionRange || "unknown",
numOfferedDenoms: possibleDenoms.length,
selectedDenoms,
// FIXME: delete this field / replace by something we can display to the user
trustedAuditorPubs: [],
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: hasDenomWithAgeRestriction
? AGE_MASK_GROUPS
: undefined,
scopeInfo: exchange.scopeInfo,
};
return ret;
}
export interface GetWithdrawalDetailsForUriOpts {
restrictAge?: number;
notifyChangeFromPendingTimeoutMs?: number;
}
type WithdrawalOperationMemoryMap = {
[uri: string]: boolean | undefined;
};
const ongoingChecks: WithdrawalOperationMemoryMap = {};
/**
* 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,
opts: GetWithdrawalDetailsForUriOpts = {},
): 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)
);
});
// FIXME: this should be removed after the extended version of
// withdrawal state machine. issue #8099
if (
info.status === "pending" &&
opts.notifyChangeFromPendingTimeoutMs !== undefined &&
!ongoingChecks[talerWithdrawUri]
) {
ongoingChecks[talerWithdrawUri] = true;
const bankApi = new TalerBankIntegrationHttpClient(
info.apiBaseUrl,
wex.http,
);
console.log(
`waiting operation (${info.operationId}) to change from pending`,
);
bankApi
.getWithdrawalOperationById(info.operationId, {
old_state: "pending",
timeoutMs: opts.notifyChangeFromPendingTimeoutMs,
})
.then((resp) => {
console.log(
`operation (${info.operationId}) to change to ${JSON.stringify(
resp,
undefined,
2,
)}`,
);
ongoingChecks[talerWithdrawUri] = false;
});
}
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 Withdrawal ${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);
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(["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(
["withdrawalGroups"],
async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
},
);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
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(),
);
const transitionInfo = await wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return undefined;
}
switch (r.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
r.wgInfo.bankInfo.timestampReserveInfoPosted = timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now()),
);
const oldTxState = computeWithdrawalTransactionStatus(r);
r.status = WithdrawalGroupStatus.PendingWaitConfirmBank;
r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
const newTxState = computeWithdrawalTransactionStatus(r);
await tx.withdrawalGroups.put(r);
return {
oldTxState,
newTxState,
};
},
);
notifyTransition(wex, transactionId, transitionInfo);
}
async function transitionBankAborted(
ctx: WithdrawTransactionContext,
): Promise {
logger.info("bank aborted the withdrawal");
const transitionInfo = await ctx.wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const r = await tx.withdrawalGroups.get(ctx.withdrawalGroupId);
if (!r) {
return;
}
switch (r.status) {
case WithdrawalGroupStatus.PendingRegisteringBank:
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
const now = AbsoluteTime.toPreciseTimestamp(AbsoluteTime.now());
const oldTxState = computeWithdrawalTransactionStatus(r);
r.wgInfo.bankInfo.timestampBankConfirmed = timestampPreciseToDb(now);
r.status = WithdrawalGroupStatus.FailedBankAborted;
const newTxState = computeWithdrawalTransactionStatus(r);
await tx.withdrawalGroups.put(r);
return {
oldTxState,
newTxState,
};
},
);
notifyTransition(ctx.wex, ctx.transactionId, transitionInfo);
return TaskRunResult.finished();
}
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 wex.db.runReadWriteTx(
["withdrawalGroups"],
async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return undefined;
}
// Re-check reserve status within transaction
switch (r.status) {
case WithdrawalGroupStatus.PendingWaitConfirmBank:
break;
default:
return undefined;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
const oldTxState = computeWithdrawalTransactionStatus(r);
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;
} else {
}
const newTxState = computeWithdrawalTransactionStatus(r);
await tx.withdrawalGroups.put(r);
return {
oldTxState,
newTxState,
};
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
if (transitionInfo) {
return TaskRunResult.progress();
} else {
return TaskRunResult.backoff();
}
}
export interface PrepareCreateWithdrawalGroupResult {
withdrawalGroup: WithdrawalGroupRecord;
transactionId: string;
creationInfo?: {
amount: AmountJson;
canonExchange: string;
};
}
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 canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
const amount = args.amount;
const currency = Amounts.currencyOf(amount);
let withdrawalGroupId;
if (args.forcedWithdrawalGroupId) {
withdrawalGroupId = args.forcedWithdrawalGroupId;
const wgId = withdrawalGroupId;
const existingWg = await wex.db.runReadOnlyTx(
["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));
}
await updateWithdrawalDenoms(wex, canonExchange);
const denoms = await getCandidateWithdrawalDenoms(
wex,
canonExchange,
currency,
);
let initialDenomSel: DenomSelectionState;
const denomSelUid = encodeCrock(getRandomBytes(16));
if (args.forcedDenomSel) {
logger.warn("using forced denom selection");
initialDenomSel = selectForcedWithdrawalDenominations(
amount,
denoms,
args.forcedDenomSel,
wex.ws.config.testing.denomselAllowLate,
);
} else {
initialDenomSel = selectWithdrawalDenominations(
amount,
denoms,
wex.ws.config.testing.denomselAllowLate,
);
}
const withdrawalGroup: WithdrawalGroupRecord = {
denomSelUid,
denomsSel: initialDenomSel,
exchangeBaseUrl: canonExchange,
instructedAmount: Amounts.stringify(amount),
timestampStart: timestampPreciseToDb(now),
rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
secretSeed,
reservePriv: reserveKeyPair.priv,
reservePub: reserveKeyPair.pub,
status: args.reserveStatus,
withdrawalGroupId,
restrictAge: args.restrictAge,
senderWire: undefined,
timestampFinish: undefined,
wgInfo: args.wgInfo,
};
await fetchFreshExchange(wex, canonExchange);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Withdrawal,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
});
return {
withdrawalGroup,
transactionId,
creationInfo: {
canonExchange,
amount,
},
};
}
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;
if (!prep.creationInfo) {
return {
withdrawalGroup,
transitionInfo: undefined,
exchangeNotif: undefined,
};
}
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,
});
const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
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.withdrawalGroup.exchangeBaseUrl,
);
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 res = await wex.db.runReadWriteTx(
["withdrawalGroups", "reserves", "exchanges", "exchangeDetails"],
async (tx) => {
return await internalPerformCreateWithdrawalGroup(wex, tx, prep);
},
);
if (res.exchangeNotif) {
wex.ws.notify(res.exchangeNotif);
}
notifyTransition(wex, transactionId, res.transitionInfo);
return res.withdrawalGroup;
}
/**
* 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.
*/
export async function acceptWithdrawalFromUri(
wex: WalletExecutionContext,
req: {
talerWithdrawUri: string;
selectedExchange: string;
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
},
): Promise {
const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
logger.info(
`accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
);
const existingWithdrawalGroup = await wex.db.runReadOnlyTx(
["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,
}),
};
}
await fetchFreshExchange(wex, selectedExchange);
const withdrawInfo = await getBankWithdrawalInfo(
wex.http,
req.talerWithdrawUri,
);
const exchangePaytoUri = await getExchangePaytoUri(
wex,
selectedExchange,
withdrawInfo.wireTypes,
);
const exchange = await fetchFreshExchange(wex, selectedExchange);
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(
["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: 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 Withdrawal ${reservePub}`,
});
}
const acctInfo: WithdrawalExchangeAccountDetails = {
status: "ok",
paytoUri,
transferAmount,
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);
}
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;
},
): 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",
);
}
const reserveKeyPair: EddsaKeypair = 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(
["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,
};
}