wallet-core/packages/taler-wallet-core/src/operations/withdraw.ts

1953 lines
56 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
(C) 2019-2021 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 <http://www.gnu.org/licenses/>
*/
/**
* Imports.
*/
import {
AbsoluteTime,
2022-08-09 15:00:45 +02:00
AcceptManualWithdrawalResult,
AcceptWithdrawalResponse,
addPaytoQueryParams,
AgeRestriction,
AmountJson,
2022-08-09 15:00:45 +02:00
AmountLike,
Amounts,
BankWithdrawDetails,
CancellationToken,
2022-08-09 15:00:45 +02:00
canonicalizeBaseUrl,
codecForBankWithdrawalOperationPostResponse,
codecForReserveStatus,
codecForTalerConfigResponse,
codecForWalletKycUuid,
codecForWithdrawBatchResponse,
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
CoinStatus,
DenomKeyType,
DenomSelectionState,
Duration,
durationFromSpec,
encodeCrock,
ExchangeListItem,
ExchangeWithdrawalDetails,
ExchangeWithdrawRequest,
ForcedDenomSel,
2022-08-09 15:00:45 +02:00
getRandomBytes,
2022-11-01 13:39:42 +01:00
HttpStatusCode,
2022-08-09 15:00:45 +02:00
j2s,
LibtoolVersion,
Logger,
NotificationType,
parseWithdrawUri,
TalerErrorCode,
TalerErrorDetail,
TalerProtocolTimestamp,
TransactionType,
UnblindedSignature,
URL,
ExchangeWithdrawBatchResponse,
ExchangeWithdrawResponse,
WithdrawUriInfoResponse,
ExchangeBatchWithdrawRequest,
WalletNotification,
} from "@gnu-taler/taler-util";
2022-08-09 15:00:45 +02:00
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
CoinRecord,
CoinSourceType,
DenominationRecord,
2021-08-24 15:43:06 +02:00
DenominationVerificationStatus,
KycPendingInfo,
KycUserType,
PlanchetRecord,
2022-10-14 21:00:13 +02:00
PlanchetStatus,
2022-08-09 15:00:45 +02:00
WalletStoresV1,
WgInfo,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
2021-06-14 16:08:58 +02:00
} from "../db.js";
2020-09-01 15:37:14 +02:00
import {
getErrorDetailFromException,
makeErrorDetail,
TalerError,
2023-02-15 23:32:42 +01:00
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
makeCoinAvailable,
makeExchangeListItem,
runLongpollAsync,
runOperationWithErrorReporting,
} from "../operations/common.js";
import {
HttpRequestLibrary,
HttpResponse,
2022-08-09 15:00:45 +02:00
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
2023-02-15 23:32:42 +01:00
} from "@gnu-taler/taler-util/http";
import {
checkDbInvariant,
checkLogicInvariant,
InvariantViolatedError,
} from "../util/invariants.js";
import { DbAccess, GetReadOnlyAccess } from "../util/query.js";
2022-09-16 19:27:24 +02:00
import {
OperationAttemptResult,
OperationAttemptResultType,
TaskIdentifiers,
2022-09-16 19:27:24 +02:00
} from "../util/retries.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
import { makeTransactionId } from "./common.js";
2022-08-09 15:00:45 +02:00
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl,
2022-08-09 15:00:45 +02:00
} from "./exchanges.js";
import {
selectForcedWithdrawalDenominations,
selectWithdrawalDenominations,
} from "../util/coinSelection.js";
import { isWithdrawableDenom } from "../index.js";
/**
* Logger for this file.
*/
2022-01-12 16:54:38 +01:00
const logger = new Logger("operations/withdraw.ts");
/**
* Get information about a withdrawal from
2019-12-09 19:59:08 +01:00
* a taler://withdraw URI by asking the bank.
2022-03-18 15:32:41 +01:00
*
* FIXME: Move into bank client.
*/
2019-12-16 16:59:09 +01:00
export async function getBankWithdrawalInfo(
http: HttpRequestLibrary,
talerWithdrawUri: string,
2019-12-09 19:59:08 +01:00
): Promise<BankWithdrawDetails> {
const uriResult = parseWithdrawUri(talerWithdrawUri);
if (!uriResult) {
2019-12-20 11:35:51 +01:00
throw Error(`can't parse URL ${talerWithdrawUri}`);
}
2020-12-14 16:45:15 +01:00
const configReqUrl = new URL("config", uriResult.bankIntegrationApiBaseUrl);
const configResp = await http.get(configReqUrl.href);
const config = await readSuccessResponseJsonOrThrow(
configResp,
codecForTalerConfigResponse(),
);
const versionRes = LibtoolVersion.compare(
2020-12-14 16:45:15 +01:00
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
config.version,
);
if (versionRes?.compatible != true) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
{
exchangeProtocolVersion: config.version,
walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
},
"bank integration protocol version not compatible with wallet",
);
}
2020-07-27 13:39:52 +02:00
const reqUrl = new URL(
`withdrawal-operation/${uriResult.withdrawalOperationId}`,
2020-07-27 13:39:52 +02:00
uriResult.bankIntegrationApiBaseUrl,
);
2022-08-25 23:35:29 +02:00
logger.info(`bank withdrawal status URL: ${reqUrl.href}}`);
const resp = await http.get(reqUrl.href);
const status = await readSuccessResponseJsonOrThrow(
resp,
codecForWithdrawOperationStatusResponse(),
);
logger.info(`bank withdrawal operation status: ${j2s(status)}`);
return {
amount: Amounts.parseOrThrow(status.amount),
confirmTransferUrl: status.confirm_transfer_url,
selectionDone: status.selection_done,
senderWire: status.sender_wire,
suggestedExchange: status.suggested_exchange,
transferDone: status.transfer_done,
wireTypes: status.wire_types,
};
}
/**
* Return denominations that can potentially used for a withdrawal.
*/
2021-01-14 18:00:00 +01:00
export async function getCandidateWithdrawalDenoms(
ws: InternalWalletState,
exchangeBaseUrl: string,
): Promise<DenominationRecord[]> {
return await ws.db
.mktx((x) => [x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
2021-08-24 15:43:06 +02:00
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
2023-04-19 17:42:47 +02:00
return allDenoms.filter((d) =>
isWithdrawableDenom(d, 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(
ws: InternalWalletState,
2022-01-12 16:54:38 +01:00
withdrawalGroup: WithdrawalGroupRecord,
coinIdx: number,
): Promise<void> {
2021-06-09 15:14:17 +02:00
let planchet = await ws.db
.mktx((x) => [x.planchets])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.planchets.indexes.byGroupAndIndex.get([
2022-01-12 16:54:38 +01:00
withdrawalGroup.withdrawalGroupId,
2021-06-09 15:14:17 +02:00
coinIdx,
]);
});
2022-01-12 16:54:38 +01:00
if (planchet) {
return;
}
let ci = 0;
2022-08-09 15:00:45 +02:00
let maybeDenomPubHash: string | undefined;
2022-01-12 16:54:38 +01:00
for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) {
const d = withdrawalGroup.denomsSel.selectedDenoms[di];
if (coinIdx >= ci && coinIdx < ci + d.count) {
2022-08-09 15:00:45 +02:00
maybeDenomPubHash = d.denomPubHash;
2022-01-12 16:54:38 +01:00
break;
}
2022-01-12 16:54:38 +01:00
ci += d.count;
}
2022-08-09 15:00:45 +02:00
if (!maybeDenomPubHash) {
2022-01-12 16:54:38 +01:00
throw Error("invariant violated");
}
2022-08-09 15:00:45 +02:00
const denomPubHash = maybeDenomPubHash;
2021-06-09 15:14:17 +02:00
2022-08-09 15:00:45 +02:00
const denom = await ws.db
.mktx((x) => [x.denominations])
2022-01-12 16:54:38 +01:00
.runReadOnly(async (tx) => {
2022-08-09 15:00:45 +02:00
return ws.getDenomInfo(
ws,
tx,
2022-01-12 16:54:38 +01:00
withdrawalGroup.exchangeBaseUrl,
2022-08-09 15:00:45 +02:00
denomPubHash,
);
2022-01-12 16:54:38 +01:00
});
2022-08-09 15:00:45 +02:00
checkDbInvariant(!!denom);
2022-01-12 16:54:38 +01:00
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
2022-11-02 17:42:14 +01:00
feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw),
2022-08-09 15:00:45 +02:00
reservePriv: withdrawalGroup.reservePriv,
reservePub: withdrawalGroup.reservePub,
2022-11-02 17:42:14 +01:00
value: Amounts.parseOrThrow(denom.value),
2022-01-12 16:54:38 +01:00
coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed,
2022-08-09 15:00:45 +02:00
restrictAge: withdrawalGroup.restrictAge,
2022-01-12 16:54:38 +01:00
});
const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey,
coinEv: r.coinEv,
coinEvHash: r.coinEvHash,
coinIdx,
coinPriv: r.coinPriv,
coinPub: r.coinPub,
denomPubHash: r.denomPubHash,
2022-10-14 21:00:13 +02:00
planchetStatus: PlanchetStatus.Pending,
2022-01-12 16:54:38 +01:00
withdrawSig: r.withdrawSig,
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
ageCommitmentProof: r.ageCommitmentProof,
2022-01-12 16:54:38 +01:00
lastError: undefined,
};
await ws.db
.mktx((x) => [x.planchets])
2022-01-12 16:54:38 +01:00
.runReadWrite(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 {
/**
* Use the batched request on the network level.
* Not supported by older exchanges.
*/
useBatchRequest: boolean;
coinStartIndex: number;
2021-06-09 15:14:17 +02:00
batchSize: number;
}
2021-06-09 15:14:17 +02:00
interface WithdrawalBatchResult {
coinIdxs: number[];
batchResp: ExchangeWithdrawBatchResponse;
}
/**
* 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(
ws: InternalWalletState,
wgContext: WithdrawalGroupContext,
args: WithdrawalRequestBatchArgs,
): Promise<WithdrawalBatchResult> {
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 ws.db
.mktx((x) => [
x.withdrawalGroups,
x.planchets,
x.exchanges,
x.denominations,
])
.runReadOnly(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;
}
2022-10-14 21:00:13 +02:00
if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
logger.warn("processPlanchet: planchet already withdrawn");
continue;
}
const denom = await ws.getDenomInfo(
ws,
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: [],
};
}
2023-02-10 16:53:27 +01:00
async function handleKycRequired(
resp: HttpResponse,
startIdx: number,
): Promise<void> {
logger.info("withdrawal requires KYC");
const respJson = await resp.json();
const uuidResp = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(uuidResp)}`);
await ws.db
.mktx((x) => [x.planchets, x.withdrawalGroups])
.runReadWrite(async (tx) => {
2023-02-10 19:25:04 +01:00
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;
}
wg2.kycPending = {
paytoHash: uuidResp.h_payto,
requirementRow: uuidResp.requirement_row,
};
await tx.withdrawalGroups.put(wg2);
});
return;
}
async function storeCoinError(e: any, coinIdx: number): Promise<void> {
const errDetail = getErrorDetailFromException(e);
logger.trace("withdrawal request failed", e);
logger.trace(String(e));
await ws.db
.mktx((x) => [x.planchets])
.runReadWrite(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!
if (args.useBatchRequest) {
const reqUrl = new URL(
`reserves/${withdrawalGroup.reservePub}/batch-withdraw`,
withdrawalGroup.exchangeBaseUrl,
).href;
try {
const resp = await ws.http.postJson(reqUrl, batchReq);
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
await handleKycRequired(resp, 0);
}
const r = await readSuccessResponseJsonOrThrow(
resp,
codecForWithdrawBatchResponse(),
);
return {
coinIdxs: requestCoinIdxs,
batchResp: r,
};
} catch (e) {
await storeCoinError(e, requestCoinIdxs[0]);
return {
batchResp: { ev_sigs: [] },
coinIdxs: [],
};
}
} else {
// We emulate the batch response here by making multiple individual requests
const responses: ExchangeWithdrawBatchResponse = {
ev_sigs: [],
};
const responseCoinIdxs: number[] = [];
for (let i = 0; i < batchReq.planchets.length; i++) {
try {
const p = batchReq.planchets[i];
const reqUrl = new URL(
`reserves/${withdrawalGroup.reservePub}/withdraw`,
withdrawalGroup.exchangeBaseUrl,
).href;
const resp = await ws.http.postJson(reqUrl, p);
if (resp.status === HttpStatusCode.UnavailableForLegalReasons) {
await handleKycRequired(resp, i);
// We still return blinded coins that we could actually withdraw.
return {
coinIdxs: responseCoinIdxs,
batchResp: responses,
};
}
const r = await readSuccessResponseJsonOrThrow(
resp,
codecForWithdrawResponse(),
);
responses.ev_sigs.push(r);
responseCoinIdxs.push(requestCoinIdxs[i]);
} catch (e) {
await storeCoinError(e, requestCoinIdxs[i]);
}
}
return {
coinIdxs: responseCoinIdxs,
batchResp: responses,
};
}
}
async function processPlanchetVerifyAndStoreCoin(
ws: InternalWalletState,
wgContext: WithdrawalGroupContext,
coinIdx: number,
resp: ExchangeWithdrawResponse,
): Promise<void> {
const withdrawalGroup = wgContext.wgRecord;
logger.info(`checking and storing planchet idx=${coinIdx}`);
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => [x.withdrawalGroups, x.planchets, x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
2022-01-12 16:54:38 +01:00
withdrawalGroup.withdrawalGroupId,
2021-06-09 15:14:17 +02:00
coinIdx,
]);
if (!planchet) {
return;
}
2022-10-14 21:00:13 +02:00
if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) {
2021-06-09 15:14:17 +02:00
logger.warn("processPlanchet: planchet already withdrawn");
return;
}
const denomInfo = await ws.getDenomInfo(
ws,
tx,
withdrawalGroup.exchangeBaseUrl,
planchet.denomPubHash,
);
if (!denomInfo) {
return;
}
return {
planchet,
denomInfo,
exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl,
};
2021-06-09 15:14:17 +02:00
});
if (!d) {
return;
}
const { planchet, denomInfo } = d;
2021-06-09 15:14:17 +02:00
const planchetDenomPub = denomInfo.denomPub;
if (planchetDenomPub.cipher !== DenomKeyType.Rsa) {
2021-11-27 20:56:58 +01:00
throw Error(`cipher (${planchetDenomPub.cipher}) not supported`);
2021-11-17 10:23:22 +01:00
}
2021-11-27 20:56:58 +01:00
let evSig = resp.ev_sig;
if (!(evSig.cipher === DenomKeyType.Rsa)) {
2021-11-17 10:23:22 +01:00
throw Error("unsupported cipher");
}
2022-03-23 21:24:23 +01:00
const denomSigRsa = await ws.cryptoApi.rsaUnblind({
bk: planchet.blindingKey,
blindedSig: evSig.blinded_rsa_signature,
pk: planchetDenomPub.rsa_public_key,
});
2022-03-23 21:24:23 +01:00
const isValid = await ws.cryptoApi.rsaVerify({
hm: planchet.coinPub,
pk: planchetDenomPub.rsa_public_key,
sig: denomSigRsa.sig,
});
2019-12-05 19:38:19 +01:00
if (!isValid) {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [x.planchets])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
2022-01-12 16:54:38 +01:00
withdrawalGroup.withdrawalGroupId,
2021-06-09 15:14:17 +02:00
coinIdx,
]);
if (!planchet) {
return;
}
planchet.lastError = makeErrorDetail(
2021-06-09 15:14:17 +02:00
TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
{},
"invalid signature from the exchange after unblinding",
2021-06-09 15:14:17 +02:00
);
await tx.planchets.put(planchet);
});
return;
2019-12-05 19:38:19 +01:00
}
2021-11-27 20:56:58 +01:00
let denomSig: UnblindedSignature;
if (planchetDenomPub.cipher === DenomKeyType.Rsa) {
2021-11-27 20:56:58 +01:00
denomSig = {
cipher: planchetDenomPub.cipher,
2022-03-23 21:24:23 +01:00
rsa_signature: denomSigRsa.sig,
2021-11-27 20:56:58 +01:00
};
} else {
throw Error("unsupported cipher");
}
const coin: CoinRecord = {
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
denomPubHash: planchet.denomPubHash,
2021-11-27 20:56:58 +01:00
denomSig,
coinEvHash: planchet.coinEvHash,
exchangeBaseUrl: d.exchangeBaseUrl,
status: CoinStatus.Fresh,
coinSource: {
type: CoinSourceType.Withdraw,
coinIndex: coinIdx,
2022-10-14 23:01:41 +02:00
reservePub: withdrawalGroup.reservePub,
2022-01-12 16:54:38 +01:00
withdrawalGroupId: withdrawalGroup.withdrawalGroupId,
},
2022-10-15 16:25:44 +02:00
maxAge: withdrawalGroup.restrictAge ?? AgeRestriction.AGE_UNRESTRICTED,
ageCommitmentProof: planchet.ageCommitmentProof,
spendAllocation: undefined,
};
const planchetCoinPub = planchet.coinPub;
wgContext.planchetsFinished.add(planchet.coinPub);
// We create the notification here, as the async transaction below
// allows other planchet withdrawals to change wgContext.planchetsFinished
const notification: WalletNotification = {
type: NotificationType.CoinWithdrawn,
numTotal: wgContext.numPlanchets,
numWithdrawn: wgContext.planchetsFinished.size,
2023-02-10 16:53:27 +01:00
};
2022-08-09 15:00:45 +02:00
// Check if this is the first time that the whole
// withdrawal succeeded. If so, mark the withdrawal
// group as finished.
2021-06-09 15:14:17 +02:00
const firstSuccess = await ws.db
.mktx((x) => [
x.coins,
x.denominations,
x.coinAvailability,
x.withdrawalGroups,
x.planchets,
])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const p = await tx.planchets.get(planchetCoinPub);
2022-10-14 21:00:13 +02:00
if (!p || p.planchetStatus === PlanchetStatus.WithdrawalDone) {
return false;
}
2022-10-14 21:00:13 +02:00
p.planchetStatus = PlanchetStatus.WithdrawalDone;
2021-06-09 15:14:17 +02:00
await tx.planchets.put(p);
await makeCoinAvailable(ws, tx, coin);
return true;
2021-06-09 15:14:17 +02:00
});
2019-12-05 19:38:19 +01:00
if (firstSuccess) {
ws.notify(notification);
}
}
/**
* 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(
ws: InternalWalletState,
exchangeBaseUrl: string,
): Promise<void> {
2021-08-06 16:27:18 +02:00
logger.trace(
`updating denominations used for withdrawal for ${exchangeBaseUrl}`,
);
2021-06-09 15:14:17 +02:00
const exchangeDetails = await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return ws.exchangeOps.getExchangeDetails(tx, exchangeBaseUrl);
2021-06-09 15:14:17 +02:00
});
if (!exchangeDetails) {
2020-07-20 09:16:32 +02:00
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.
2021-08-06 16:27:18 +02:00
logger.trace("getting candidate denominations");
2021-01-14 18:00:00 +01:00
const denominations = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
2021-08-06 16:27:18 +02:00
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++
) {
2021-08-06 16:27:18 +02:00
const denom = denominations[current];
2021-11-17 10:23:22 +01:00
if (
denom.verificationStatus === DenominationVerificationStatus.Unverified
) {
2021-08-06 16:27:18 +02:00
logger.trace(
2022-09-21 01:14:57 +02:00
`Validating denomination (${current + 1}/${
denominations.length
2021-08-06 16:27:18 +02:00
}) signature of ${denom.denomPubHash}`,
);
let valid = false;
2023-04-19 17:42:47 +02:00
if (ws.config.testing.insecureTrustExchange) {
valid = true;
} else {
2022-03-23 21:24:23 +01:00
const res = await ws.cryptoApi.isValidDenom({
denom,
2022-03-23 21:24:23 +01:00
masterPub: exchangeDetails.masterPublicKey,
});
valid = res.valid;
}
2021-08-06 16:27:18 +02:00
logger.trace(`Done validating ${denom.denomPubHash}`);
if (!valid) {
logger.warn(
`Signature check for denomination h=${denom.denomPubHash} failed`,
);
2021-08-24 15:43:06 +02:00
denom.verificationStatus = DenominationVerificationStatus.VerifiedBad;
2021-08-06 16:27:18 +02:00
} else {
2021-11-17 10:23:22 +01:00
denom.verificationStatus =
DenominationVerificationStatus.VerifiedGood;
2021-08-06 16:27:18 +02:00
}
updatedDenominations.push(denom);
}
2021-08-06 16:27:18 +02:00
}
if (updatedDenominations.length > 0) {
logger.trace("writing denomination batch to db");
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [x.denominations])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
2021-08-06 16:27:18 +02:00
for (let i = 0; i < updatedDenominations.length; i++) {
const denom = updatedDenominations[i];
await tx.denominations.put(denom);
}
2021-06-09 15:14:17 +02:00
});
2021-08-06 16:27:18 +02:00
logger.trace("done with DB write");
}
2020-07-16 13:52:03 +02:00
}
}
2022-08-09 15:00:45 +02:00
/**
* Update the information about a reserve that is stored in the wallet
* by querying the reserve's exchange.
*
* If the reserve have funds that are not allocated in a withdrawal group yet
* and are big enough to withdraw with available denominations,
* create a new withdrawal group for the remaining amount.
*/
async function queryReserve(
ws: InternalWalletState,
withdrawalGroupId: string,
cancellationToken: CancellationToken,
2022-08-09 15:00:45 +02:00
): Promise<{ ready: boolean }> {
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
checkDbInvariant(!!withdrawalGroup);
if (withdrawalGroup.status !== WithdrawalGroupStatus.QueryingStatus) {
2022-08-09 15:00:45 +02:00
return { ready: true };
}
const reservePub = withdrawalGroup.reservePub;
const reserveUrl = new URL(
`reserves/${reservePub}`,
withdrawalGroup.exchangeBaseUrl,
);
reserveUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying reserve status via ${reserveUrl.href}`);
2022-08-09 15:00:45 +02:00
const resp = await ws.http.get(reserveUrl.href, {
timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken,
2022-08-09 15:00:45 +02:00
});
2023-01-18 20:08:16 +01:00
logger.info(`reserve status code: HTTP ${resp.status}`);
2022-08-09 15:00:45 +02:00
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForReserveStatus(),
);
if (result.isError) {
2023-01-18 20:08:16 +01:00
logger.info(
`got reserve status error, EC=${result.talerErrorResponse.code}`,
);
2022-08-09 15:00:45 +02:00
if (
resp.status === 404 &&
result.talerErrorResponse.code ===
2022-09-21 01:14:57 +02:00
TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN
2022-08-09 15:00:45 +02:00
) {
ws.notify({
type: NotificationType.ReserveNotYetFound,
reservePub,
});
return { ready: false };
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
logger.trace(`got reserve status ${j2s(result.response)}`);
await ws.db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
logger.warn(`withdrawal group ${withdrawalGroupId} not found`);
return;
}
wg.status = WithdrawalGroupStatus.Ready;
2022-11-02 17:42:14 +01:00
wg.reserveBalanceAmount = Amounts.stringify(result.response.balance);
2022-08-09 15:00:45 +02:00
await tx.withdrawalGroups.put(wg);
});
ws.notify({
type: NotificationType.WithdrawalGroupReserveReady,
transactionId: makeTransactionId(
TransactionType.Withdrawal,
withdrawalGroupId,
),
});
2022-08-09 15:00:45 +02:00
return { ready: true };
}
enum BankStatusResultCode {
Done = "done",
Waiting = "waiting",
Aborted = "aborted",
}
/**
* Withdrawal context that is kept in-memory.
*
* Used to store some cached info during a withdrawal operation.
*/
export interface WithdrawalGroupContext {
numPlanchets: number;
planchetsFinished: Set<string>;
/**
* Cached withdrawal group record from the database.
*/
wgRecord: WithdrawalGroupRecord;
}
2022-08-09 15:00:45 +02:00
export async function processWithdrawalGroup(
ws: InternalWalletState,
withdrawalGroupId: string,
options: {} = {},
2022-09-05 18:12:30 +02:00
): Promise<OperationAttemptResult> {
logger.trace("processing withdrawal group", withdrawalGroupId);
2021-06-09 15:14:17 +02:00
const withdrawalGroup = await ws.db
.mktx((x) => [x.withdrawalGroups])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
});
2022-08-09 15:00:45 +02:00
if (!withdrawalGroup) {
2022-08-09 15:00:45 +02:00
throw Error(`withdrawal group ${withdrawalGroupId} not found`);
}
const retryTag = TaskIdentifiers.forWithdrawal(withdrawalGroup);
// We're already running!
if (ws.activeLongpoll[retryTag]) {
logger.info("withdrawal group already in long-polling, returning!");
return {
type: OperationAttemptResultType.Longpoll,
};
}
switch (withdrawalGroup.status) {
case WithdrawalGroupStatus.RegisteringBank:
2022-08-09 15:00:45 +02:00
await processReserveBankStatus(ws, withdrawalGroupId);
2022-09-05 18:12:30 +02:00
return await processWithdrawalGroup(ws, withdrawalGroupId, {
2022-08-09 15:00:45 +02:00
forceNow: true,
});
case WithdrawalGroupStatus.QueryingStatus: {
runLongpollAsync(ws, retryTag, (ct) => {
return queryReserve(ws, withdrawalGroupId, ct);
});
logger.trace(
"returning early from withdrawal for long-polling in background",
);
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Longpoll,
2022-09-05 18:12:30 +02:00
};
}
case WithdrawalGroupStatus.WaitConfirmBank: {
2022-09-05 18:12:30 +02:00
const res = await processReserveBankStatus(ws, withdrawalGroupId);
switch (res.status) {
case BankStatusResultCode.Aborted:
case BankStatusResultCode.Done:
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
case BankStatusResultCode.Waiting: {
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
}
}
break;
}
case WithdrawalGroupStatus.BankAborted: {
2022-08-09 15:00:45 +02:00
// FIXME
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
}
case WithdrawalGroupStatus.Finished:
2022-08-09 15:00:45 +02:00
// We can try to withdraw, nothing needs to be done with the reserve.
break;
case WithdrawalGroupStatus.Ready:
// Continue with the actual withdrawal!
break;
2022-08-09 15:00:45 +02:00
default:
throw new InvariantViolatedError(
`unknown reserve record status: ${withdrawalGroup.status}`,
2022-08-09 15:00:45 +02:00
);
}
await ws.exchangeOps.updateExchangeFromUrl(
ws,
withdrawalGroup.exchangeBaseUrl,
);
2020-09-03 13:59:09 +02:00
if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) {
logger.warn("Finishing empty withdrawal group (no denoms)");
await ws.db
.mktx((x) => [x.withdrawalGroups])
.runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wg) {
return;
}
wg.status = WithdrawalGroupStatus.Finished;
wg.timestampFinish = TalerProtocolTimestamp.now();
await tx.withdrawalGroups.put(wg);
});
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
.map((x) => x.count)
.reduce((a, b) => a + b);
const wgContext: WithdrawalGroupContext = {
numPlanchets: numTotalCoins,
planchetsFinished: new Set<string>(),
wgRecord: withdrawalGroup,
};
await ws.db
.mktx((x) => [x.planchets])
.runReadOnly(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(ws, withdrawalGroup, i);
}
const maxBatchSize = 100;
for (let i = 0; i < numTotalCoins; i += maxBatchSize) {
const resp = await processPlanchetExchangeBatchRequest(ws, wgContext, {
batchSize: maxBatchSize,
coinStartIndex: i,
2023-04-19 17:42:47 +02:00
useBatchRequest: ws.config.features.batchWithdrawal,
});
let work: Promise<void>[] = [];
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
2023-02-10 16:53:27 +01:00
continue;
}
work.push(
processPlanchetVerifyAndStoreCoin(
ws,
wgContext,
resp.coinIdxs[j],
resp.batchResp.ev_sigs[j],
),
);
}
await Promise.all(work);
}
let numFinished = 0;
2022-11-01 13:39:42 +01:00
let numKycRequired = 0;
let finishedForFirstTime = false;
const errorsPerCoin: Record<number, TalerErrorDetail> = {};
const res = await ws.db
.mktx((x) => [x.coins, x.withdrawalGroups, x.planchets])
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const wg = await tx.withdrawalGroups.get(withdrawalGroupId);
2020-09-01 14:30:46 +02:00
if (!wg) {
return;
}
2021-06-09 15:14:17 +02:00
await tx.planchets.indexes.byGroup
.iter(withdrawalGroupId)
.forEach((x) => {
2022-10-14 21:00:13 +02:00
if (x.planchetStatus === PlanchetStatus.WithdrawalDone) {
numFinished++;
}
2022-11-01 13:39:42 +01:00
if (x.planchetStatus === PlanchetStatus.KycRequired) {
numKycRequired++;
}
2020-09-01 14:30:46 +02:00
if (x.lastError) {
errorsPerCoin[x.coinIdx] = x.lastError;
}
});
logger.info(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
2020-09-01 14:30:46 +02:00
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
finishedForFirstTime = true;
2022-03-18 15:32:41 +01:00
wg.timestampFinish = TalerProtocolTimestamp.now();
wg.status = WithdrawalGroupStatus.Finished;
}
2020-09-01 14:30:46 +02:00
2021-06-09 15:14:17 +02:00
await tx.withdrawalGroups.put(wg);
return {
kycInfo: wg.kycPending,
};
2021-06-09 15:14:17 +02:00
});
if (!res) {
throw Error("withdrawal group does not exist anymore");
}
const { kycInfo } = res;
2022-11-01 13:39:42 +01:00
if (numKycRequired > 0) {
if (kycInfo) {
const txId = makeTransactionId(
TransactionType.Withdrawal,
withdrawalGroup.withdrawalGroupId,
);
await checkWithdrawalKycStatus(
ws,
withdrawalGroup.exchangeBaseUrl,
txId,
kycInfo,
"individual",
);
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
} else {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
{
//FIXME we can't rise KYC error here since we don't have the url
} as any,
`KYC check required for withdrawal (not yet implemented in wallet-core)`,
);
}
2022-11-01 13:39:42 +01:00
}
if (numFinished != numTotalCoins) {
throw TalerError.fromDetail(
2020-09-01 14:30:46 +02:00
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
{
errorsPerCoin,
},
`withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
);
}
if (finishedForFirstTime) {
ws.notify({
type: NotificationType.WithdrawGroupFinished,
2020-09-08 15:57:08 +02:00
reservePub: withdrawalGroup.reservePub,
});
}
2022-09-05 18:12:30 +02:00
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
export async function checkWithdrawalKycStatus(
ws: InternalWalletState,
exchangeUrl: string,
txId: string,
kycInfo: KycPendingInfo,
userType: KycUserType,
): Promise<void> {
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await ws.http.fetch(url.href, {
method: "GET",
});
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)}`);
ws.notify({
type: NotificationType.KycRequested,
kycUrl: kycStatus.kyc_url,
transactionId: txId,
});
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED, //FIXME: another error code or rename for merge
{
kycUrl: kycStatus.kyc_url,
},
`KYC check required for transfer`,
);
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
}
const AGE_MASK_GROUPS = "8:10:12:14:16:18"
.split(":")
.map((n) => parseInt(n, 10));
2019-12-09 19:59:08 +01:00
export async function getExchangeWithdrawalInfo(
ws: InternalWalletState,
2022-01-13 12:08:31 +01:00
exchangeBaseUrl: string,
instructedAmount: AmountJson,
ageRestricted: number | undefined,
): Promise<ExchangeWithdrawalDetails> {
2022-01-10 01:19:19 +01:00
const { exchange, exchangeDetails } =
2022-01-13 12:08:31 +01:00
await ws.exchangeOps.updateExchangeFromUrl(ws, exchangeBaseUrl);
await updateWithdrawalDenoms(ws, exchangeBaseUrl);
const denoms = await getCandidateWithdrawalDenoms(ws, exchangeBaseUrl);
const selectedDenoms = selectWithdrawalDenominations(
instructedAmount,
denoms,
2023-04-19 17:42:47 +02:00
ws.config.testing.denomselAllowLate,
);
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 exchangeDetails.wireInfo.accounts) {
2020-01-19 19:02:47 +01:00
exchangeWireAccounts.push(account.payto_uri);
}
const { isTrusted, isAudited } = await ws.exchangeOps.getExchangeTrust(
ws,
exchange,
);
let hasDenomWithAgeRestriction = false;
let earliestDepositExpiration: TalerProtocolTimestamp | undefined;
2022-04-18 22:00:26 +02:00
for (let i = 0; i < selectedDenoms.selectedDenoms.length; i++) {
const ds = selectedDenoms.selectedDenoms[i];
// FIXME: Do in one transaction!
const denom = await ws.db
.mktx((x) => [x.denominations])
.runReadOnly(async (tx) => {
return ws.getDenomInfo(ws, tx, exchangeBaseUrl, ds.denomPubHash);
});
checkDbInvariant(!!denom);
2022-09-21 01:14:46 +02:00
hasDenomWithAgeRestriction =
hasDenomWithAgeRestriction || denom.denomPub.age_mask > 0;
const expireDeposit = denom.stampExpireDeposit;
if (!earliestDepositExpiration) {
earliestDepositExpiration = expireDeposit;
continue;
}
2022-03-18 15:32:41 +01:00
if (
AbsoluteTime.cmp(
AbsoluteTime.fromTimestamp(expireDeposit),
AbsoluteTime.fromTimestamp(earliestDepositExpiration),
) < 0
) {
earliestDepositExpiration = expireDeposit;
}
}
checkLogicInvariant(!!earliestDepositExpiration);
const possibleDenoms = await ws.db
.mktx((x) => [x.denominations])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
2022-01-13 12:08:31 +01:00
const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
return ds.filter((x) => x.isOffered);
2021-06-09 15:14:17 +02:00
});
let versionMatch;
2022-10-14 21:00:13 +02:00
if (exchangeDetails.protocolVersionRange) {
versionMatch = LibtoolVersion.compare(
2019-12-16 16:59:09 +01:00
WALLET_EXCHANGE_PROTOCOL_VERSION,
2022-10-14 21:00:13 +02:00
exchangeDetails.protocolVersionRange,
);
if (
versionMatch &&
!versionMatch.compatible &&
versionMatch.currentCmp === -1
) {
2022-08-09 15:00:45 +02:00
logger.warn(
2019-12-16 16:59:09 +01:00
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
2022-10-14 21:00:13 +02:00
`(exchange has ${exchangeDetails.protocolVersionRange}), checking for updates`,
);
}
}
2019-12-09 19:59:08 +01:00
let tosAccepted = false;
if (exchangeDetails.tosAccepted?.timestamp) {
if (exchangeDetails.tosAccepted.etag === exchangeDetails.tosCurrentEtag) {
2019-12-09 19:59:08 +01:00
tosAccepted = true;
}
}
const paytoUris = exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri);
if (!paytoUris) {
throw Error("exchange is in invalid state");
}
const ret: ExchangeWithdrawalDetails = {
earliestDepositExpiration,
exchangePaytoUris: paytoUris,
exchangeWireAccounts,
2022-10-14 21:00:13 +02:00
exchangeVersion: exchangeDetails.protocolVersionRange || "unknown",
isAudited,
isTrusted,
numOfferedDenoms: possibleDenoms.length,
selectedDenoms,
// FIXME: delete this field / replace by something we can display to the user
trustedAuditorPubs: [],
versionMatch,
2019-12-16 16:59:09 +01:00
walletVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
2019-12-09 19:59:08 +01:00
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,
};
return ret;
}
export interface GetWithdrawalDetailsForUriOpts {
restrictAge?: number;
}
2022-01-12 16:54:38 +01:00
/**
* 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 permanently added
* to the wallet's list of known exchanges.
*/
export async function getWithdrawalDetailsForUri(
ws: InternalWalletState,
talerWithdrawUri: string,
opts: GetWithdrawalDetailsForUriOpts = {},
): Promise<WithdrawUriInfoResponse> {
logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
const info = await getBankWithdrawalInfo(ws.http, talerWithdrawUri);
logger.trace(`got bank info`);
if (info.suggestedExchange) {
// FIXME: right now the exchange gets permanently added,
// we might want to only temporarily add it.
try {
await ws.exchangeOps.updateExchangeFromUrl(ws, 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`,
);
}
}
2022-01-12 16:54:38 +01:00
// Extract information about possible exchanges for the withdrawal
// operation from the database.
const exchanges: ExchangeListItem[] = [];
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => [
x.exchanges,
x.exchangeDetails,
x.exchangeTos,
x.denominations,
x.operationRetries,
])
2021-06-09 15:14:17 +02:00
.runReadOnly(async (tx) => {
const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) {
2022-10-15 16:25:44 +02:00
const exchangeDetails = await ws.exchangeOps.getExchangeDetails(
tx,
r.baseUrl,
);
const denominations = await tx.denominations.indexes.byExchangeBaseUrl
.iter(r.baseUrl)
.toArray();
const retryRecord = await tx.operationRetries.get(
TaskIdentifiers.forExchangeUpdate(r),
);
if (exchangeDetails && denominations) {
exchanges.push(
makeExchangeListItem(r, exchangeDetails, retryRecord?.lastError),
);
2021-06-09 15:14:17 +02:00
}
}
});
return {
amount: Amounts.stringify(info.amount),
defaultExchangeBaseUrl: info.suggestedExchange,
possibleExchanges: exchanges,
};
}
2022-08-09 15:00:45 +02:00
export async function getFundingPaytoUrisTx(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<string[]> {
return await ws.db
.mktx((x) => [x.exchanges, x.exchangeDetails, x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId));
}
export function augmentPaytoUrisForWithdrawal(
plainPaytoUris: string[],
reservePub: string,
2022-11-02 17:42:14 +01:00
instructedAmount: AmountLike,
): string[] {
return plainPaytoUris.map((x) =>
addPaytoQueryParams(x, {
amount: Amounts.stringify(instructedAmount),
message: `Taler Withdrawal ${reservePub}`,
}),
);
}
2022-08-09 15:00:45 +02:00
/**
* Get payto URIs that can be used to fund a withdrawal operation.
*/
export async function getFundingPaytoUris(
tx: GetReadOnlyAccess<{
withdrawalGroups: typeof WalletStoresV1.withdrawalGroups;
exchanges: typeof WalletStoresV1.exchanges;
exchangeDetails: typeof WalletStoresV1.exchangeDetails;
}>,
withdrawalGroupId: string,
): Promise<string[]> {
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
checkDbInvariant(!!withdrawalGroup);
const exchangeDetails = await getExchangeDetails(
tx,
withdrawalGroup.exchangeBaseUrl,
);
if (!exchangeDetails) {
logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`);
return [];
}
const plainPaytoUris =
exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? [];
if (!plainPaytoUris) {
logger.error(
`exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`,
);
return [];
}
return augmentPaytoUrisForWithdrawal(
plainPaytoUris,
withdrawalGroup.reservePub,
withdrawalGroup.instructedAmount,
2022-08-09 15:00:45 +02:00
);
}
async function getWithdrawalGroupRecordTx(
db: DbAccess<typeof WalletStoresV1>,
req: {
withdrawalGroupId: string;
},
): Promise<WithdrawalGroupRecord | undefined> {
return await db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(req.withdrawalGroupId);
});
}
export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration {
2022-09-05 18:12:30 +02:00
return { d_ms: 60000 };
2022-08-09 15:00:45 +02:00
}
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;
}
2022-08-09 15:00:45 +02:00
async function registerReserveWithBank(
ws: InternalWalletState,
withdrawalGroupId: string,
): Promise<void> {
const withdrawalGroup = await ws.db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadOnly(async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
});
switch (withdrawalGroup?.status) {
case WithdrawalGroupStatus.WaitConfirmBank:
case WithdrawalGroupStatus.RegisteringBank:
2022-08-09 15:00:45 +02:00
break;
default:
return;
}
if (
withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
) {
throw Error();
}
const bankInfo = withdrawalGroup.wgInfo.bankInfo;
2022-08-09 15:00:45 +02:00
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 ws.http.postJson(bankStatusUrl, reqBody, {
timeout: getReserveRequestTimeout(withdrawalGroup),
});
2022-08-09 15:00:45 +02:00
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBankWithdrawalOperationPostResponse(),
);
await ws.db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
switch (r.status) {
case WithdrawalGroupStatus.RegisteringBank:
case WithdrawalGroupStatus.WaitConfirmBank:
2022-08-09 15:00:45 +02:00
break;
default:
return;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
2022-08-09 15:00:45 +02:00
throw Error("invariant failed");
}
r.wgInfo.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp(
2022-08-09 15:00:45 +02:00
AbsoluteTime.now(),
);
r.status = WithdrawalGroupStatus.WaitConfirmBank;
2022-08-09 15:00:45 +02:00
await tx.withdrawalGroups.put(r);
});
ws.notify({ type: NotificationType.ReserveRegisteredWithBank });
2022-09-05 18:12:30 +02:00
}
interface BankStatusResult {
status: BankStatusResultCode;
2022-08-09 15:00:45 +02:00
}
async function processReserveBankStatus(
ws: InternalWalletState,
withdrawalGroupId: string,
2022-09-05 18:12:30 +02:00
): Promise<BankStatusResult> {
2022-08-09 15:00:45 +02:00
const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
switch (withdrawalGroup?.status) {
case WithdrawalGroupStatus.WaitConfirmBank:
case WithdrawalGroupStatus.RegisteringBank:
2022-08-09 15:00:45 +02:00
break;
default:
2022-09-05 18:12:30 +02:00
return {
status: BankStatusResultCode.Done,
};
2022-08-09 15:00:45 +02:00
}
if (
withdrawalGroup.wgInfo.withdrawalType != WithdrawalRecordType.BankIntegrated
) {
2022-09-05 18:12:30 +02:00
throw Error("wrong withdrawal record type");
}
const bankInfo = withdrawalGroup.wgInfo.bankInfo;
if (!bankInfo) {
2022-09-05 18:12:30 +02:00
return {
status: BankStatusResultCode.Done,
};
2022-08-09 15:00:45 +02:00
}
const bankStatusUrl = getBankStatusUrl(bankInfo.talerWithdrawUri);
2022-08-09 15:00:45 +02:00
const statusResp = await ws.http.get(bankStatusUrl, {
timeout: getReserveRequestTimeout(withdrawalGroup),
});
const status = await readSuccessResponseJsonOrThrow(
statusResp,
codecForWithdrawOperationStatusResponse(),
);
if (status.aborted) {
logger.info("bank aborted the withdrawal");
await ws.db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
switch (r.status) {
case WithdrawalGroupStatus.RegisteringBank:
case WithdrawalGroupStatus.WaitConfirmBank:
2022-08-09 15:00:45 +02:00
break;
default:
return;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
2022-08-09 15:00:45 +02:00
throw Error("invariant failed");
}
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.wgInfo.bankInfo.timestampBankConfirmed = now;
r.status = WithdrawalGroupStatus.BankAborted;
2022-08-09 15:00:45 +02:00
await tx.withdrawalGroups.put(r);
});
2022-09-05 18:12:30 +02:00
return {
status: BankStatusResultCode.Aborted,
};
2022-08-09 15:00:45 +02:00
}
// Bank still needs to know our reserve info
if (!status.selection_done) {
await registerReserveWithBank(ws, withdrawalGroupId);
return await processReserveBankStatus(ws, withdrawalGroupId);
}
// FIXME: Why do we do this?!
if (withdrawalGroup.status === WithdrawalGroupStatus.RegisteringBank) {
2022-08-09 15:00:45 +02:00
await registerReserveWithBank(ws, withdrawalGroupId);
return await processReserveBankStatus(ws, withdrawalGroupId);
}
await ws.db
.mktx((x) => [x.withdrawalGroups])
2022-08-09 15:00:45 +02:00
.runReadWrite(async (tx) => {
const r = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!r) {
return;
}
// Re-check reserve status within transaction
switch (r.status) {
case WithdrawalGroupStatus.RegisteringBank:
case WithdrawalGroupStatus.WaitConfirmBank:
2022-08-09 15:00:45 +02:00
break;
default:
return;
}
if (r.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) {
throw Error("invariant failed");
}
2022-08-09 15:00:45 +02:00
if (status.transfer_done) {
logger.info("withdrawal: transfer confirmed by bank.");
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
r.wgInfo.bankInfo.timestampBankConfirmed = now;
r.status = WithdrawalGroupStatus.QueryingStatus;
ws.notify({
type: NotificationType.WithdrawalGroupBankConfirmed,
transactionId: makeTransactionId(
TransactionType.Withdrawal,
r.withdrawalGroupId,
),
});
2022-08-09 15:00:45 +02:00
} else {
logger.info("withdrawal: transfer not yet confirmed by bank");
r.wgInfo.bankInfo.confirmUrl = status.confirm_transfer_url;
r.senderWire = status.sender_wire;
2022-08-09 15:00:45 +02:00
}
await tx.withdrawalGroups.put(r);
});
2022-09-05 18:12:30 +02:00
if (status.transfer_done) {
return {
status: BankStatusResultCode.Done,
};
} else {
return {
status: BankStatusResultCode.Waiting,
};
}
2022-08-09 15:00:45 +02:00
}
/**
* 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.
*/
2022-08-09 15:00:45 +02:00
export async function internalCreateWithdrawalGroup(
ws: InternalWalletState,
args: {
reserveStatus: WithdrawalGroupStatus;
2022-08-09 15:00:45 +02:00
amount: AmountJson;
exchangeBaseUrl: string;
forcedWithdrawalGroupId?: string;
2022-08-09 15:00:45 +02:00
forcedDenomSel?: ForcedDenomSel;
reserveKeyPair?: EddsaKeypair;
restrictAge?: number;
wgInfo: WgInfo;
2022-08-09 15:00:45 +02:00
},
): Promise<WithdrawalGroupRecord> {
const reserveKeyPair =
args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({}));
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
const secretSeed = encodeCrock(getRandomBytes(32));
const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl);
const amount = args.amount;
let withdrawalGroupId;
if (args.forcedWithdrawalGroupId) {
withdrawalGroupId = args.forcedWithdrawalGroupId;
const wgId = withdrawalGroupId;
const existingWg = await ws.db
.mktx((x) => [x.withdrawalGroups])
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(wgId);
});
if (existingWg) {
return existingWg;
}
} else {
withdrawalGroupId = encodeCrock(getRandomBytes(32));
}
2022-08-09 15:00:45 +02:00
await updateWithdrawalDenoms(ws, canonExchange);
const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange);
let initialDenomSel: DenomSelectionState;
const denomSelUid = encodeCrock(getRandomBytes(16));
if (args.forcedDenomSel) {
logger.warn("using forced denom selection");
initialDenomSel = selectForcedWithdrawalDenominations(
amount,
denoms,
args.forcedDenomSel,
2023-04-19 17:42:47 +02:00
ws.config.testing.denomselAllowLate,
2022-08-09 15:00:45 +02:00
);
} else {
2023-04-19 17:42:47 +02:00
initialDenomSel = selectWithdrawalDenominations(
amount,
denoms,
ws.config.testing.denomselAllowLate,
);
2022-08-09 15:00:45 +02:00
}
const withdrawalGroup: WithdrawalGroupRecord = {
denomSelUid,
denomsSel: initialDenomSel,
exchangeBaseUrl: canonExchange,
2022-11-02 17:42:14 +01:00
instructedAmount: Amounts.stringify(amount),
2022-08-09 15:00:45 +02:00
timestampStart: now,
rawWithdrawalAmount: initialDenomSel.totalWithdrawCost,
effectiveWithdrawalAmount: initialDenomSel.totalCoinValue,
2022-08-09 15:00:45 +02:00
secretSeed,
reservePriv: reserveKeyPair.priv,
reservePub: reserveKeyPair.pub,
status: args.reserveStatus,
2022-08-09 15:00:45 +02:00
withdrawalGroupId,
restrictAge: args.restrictAge,
senderWire: undefined,
timestampFinish: undefined,
wgInfo: args.wgInfo,
2022-08-09 15:00:45 +02:00
};
const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange);
const exchangeDetails = exchangeInfo.exchangeDetails;
if (!exchangeDetails) {
logger.trace(exchangeDetails);
throw Error("exchange not updated");
}
const { isAudited, isTrusted } = await getExchangeTrust(
ws,
exchangeInfo.exchange,
);
await ws.db
.mktx((x) => [
x.withdrawalGroups,
x.reserves,
x.exchanges,
x.exchangeDetails,
x.exchangeTrust,
])
2022-08-09 15:00:45 +02:00
.runReadWrite(async (tx) => {
await tx.withdrawalGroups.add(withdrawalGroup);
await tx.reserves.put({
reservePub: withdrawalGroup.reservePub,
reservePriv: withdrawalGroup.reservePriv,
});
2022-08-09 15:00:45 +02:00
2023-02-19 23:13:44 +01:00
const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
if (exchange) {
exchange.lastWithdrawal = TalerProtocolTimestamp.now();
await tx.exchanges.put(exchange);
}
2022-08-09 15:00:45 +02:00
if (!isAudited && !isTrusted) {
await tx.exchangeTrust.put({
currency: amount.currency,
exchangeBaseUrl: canonExchange,
exchangeMasterPub: exchangeDetails.masterPublicKey,
uids: [encodeCrock(getRandomBytes(32))],
});
}
});
return withdrawalGroup;
}
export async function acceptWithdrawalFromUri(
ws: InternalWalletState,
req: {
talerWithdrawUri: string;
selectedExchange: string;
forcedDenomSel?: ForcedDenomSel;
restrictAge?: number;
},
): Promise<AcceptWithdrawalResponse> {
const selectedExchange = canonicalizeBaseUrl(req.selectedExchange);
logger.info(
`accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`,
);
const existingWithdrawalGroup = await ws.db
.mktx((x) => [x.withdrawalGroups])
.runReadOnly(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: makeTransactionId(
TransactionType.Withdrawal,
existingWithdrawalGroup.withdrawalGroupId,
2022-09-16 19:27:24 +02:00
),
};
}
await updateExchangeFromUrl(ws, selectedExchange);
2022-08-09 15:00:45 +02:00
const withdrawInfo = await getBankWithdrawalInfo(
ws.http,
req.talerWithdrawUri,
);
const exchangePaytoUri = await getExchangePaytoUri(
ws,
selectedExchange,
2022-08-09 15:00:45 +02:00
withdrawInfo.wireTypes,
);
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
amount: withdrawInfo.amount,
exchangeBaseUrl: req.selectedExchange,
wgInfo: {
withdrawalType: WithdrawalRecordType.BankIntegrated,
bankInfo: {
exchangePaytoUri,
talerWithdrawUri: req.talerWithdrawUri,
confirmUrl: withdrawInfo.confirmTransferUrl,
2022-09-21 21:13:31 +02:00
timestampBankConfirmed: undefined,
timestampReserveInfoPosted: undefined,
},
},
restrictAge: req.restrictAge,
2022-08-09 15:00:45 +02:00
forcedDenomSel: req.forcedDenomSel,
reserveStatus: WithdrawalGroupStatus.RegisteringBank,
2022-08-09 15:00:45 +02:00
});
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
// We do this here, as the reserve should be registered before we return,
// so that we can redirect the user to the bank's status page.
await processReserveBankStatus(ws, withdrawalGroupId);
const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, {
withdrawalGroupId,
});
if (processedWithdrawalGroup?.status === WithdrawalGroupStatus.BankAborted) {
2022-08-09 15:00:45 +02:00
throw TalerError.fromDetail(
TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK,
{},
);
}
// Start withdrawal in the background
processWithdrawalGroup(ws, withdrawalGroupId, {
forceNow: true,
}).catch((err) => {
logger.error("Processing withdrawal (after creation) failed:", err);
});
2022-08-09 15:00:45 +02:00
return {
reservePub: withdrawalGroup.reservePub,
confirmTransferUrl: withdrawInfo.confirmTransferUrl,
2022-10-14 23:01:41 +02:00
transactionId: makeTransactionId(
TransactionType.Withdrawal,
withdrawalGroupId,
),
2022-08-09 15:00:45 +02:00
};
}
/**
* Create a manual withdrawal operation.
*
* Adds the corresponding exchange as a trusted exchange if it is neither
* audited nor trusted already.
*
* Asynchronously starts the withdrawal.
*/
export async function createManualWithdrawal(
ws: InternalWalletState,
req: {
exchangeBaseUrl: string;
amount: AmountLike;
restrictAge?: number;
forcedDenomSel?: ForcedDenomSel;
},
): Promise<AcceptManualWithdrawalResult> {
const withdrawalGroup = await internalCreateWithdrawalGroup(ws, {
amount: Amounts.jsonifyAmount(req.amount),
wgInfo: {
withdrawalType: WithdrawalRecordType.BankManual,
},
2022-08-09 15:00:45 +02:00
exchangeBaseUrl: req.exchangeBaseUrl,
forcedDenomSel: req.forcedDenomSel,
restrictAge: req.restrictAge,
reserveStatus: WithdrawalGroupStatus.QueryingStatus,
2022-08-09 15:00:45 +02:00
});
const withdrawalGroupId = withdrawalGroup.withdrawalGroupId;
const exchangePaytoUris = await ws.db
.mktx((x) => [
x.withdrawalGroups,
x.exchanges,
x.exchangeDetails,
x.exchangeTrust,
])
2023-02-10 19:47:59 +01:00
.runReadOnly(async (tx) => {
2022-08-09 15:00:45 +02:00
return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId);
});
// Start withdrawal in the background (do not await!)
// FIXME: We could also interrupt the task look if it is waiting and
// rely on retry handling to re-process the withdrawal group.
runOperationWithErrorReporting(
ws,
TaskIdentifiers.forWithdrawal(withdrawalGroup),
async () => {
return await processWithdrawalGroup(ws, withdrawalGroupId, {
forceNow: true,
});
2022-08-09 15:00:45 +02:00
},
);
return {
reservePub: withdrawalGroup.reservePub,
exchangePaytoUris: exchangePaytoUris,
2022-10-14 23:01:41 +02:00
transactionId: makeTransactionId(
TransactionType.Withdrawal,
withdrawalGroupId,
),
2022-08-09 15:00:45 +02:00
};
}