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

1144 lines
31 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 {
AmountJson,
Amounts,
BankWithdrawDetails,
codecForTalerConfigResponse,
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
durationFromSpec,
ExchangeListItem,
getDurationRemaining,
getTimestampNow,
Logger,
NotificationType,
parseWithdrawUri,
TalerErrorCode,
TalerErrorDetails,
Timestamp,
timestampCmp,
timestampSubtractDuraction,
WithdrawResponse,
URL,
WithdrawUriInfoResponse,
VersionMatchResult,
2021-11-17 10:23:22 +01:00
DenomKeyType,
LibtoolVersion,
2021-11-27 20:56:58 +01:00
UnblindedSignature,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
CoinSourceType,
CoinStatus,
DenominationRecord,
2021-08-24 15:43:06 +02:00
DenominationVerificationStatus,
DenomSelectionState,
ExchangeDetailsRecord,
ExchangeRecord,
PlanchetRecord,
2021-06-14 16:08:58 +02:00
} from "../db.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
2020-09-01 15:37:14 +02:00
import {
guardOperationException,
makeErrorDetails,
OperationFailedError,
} from "../errors.js";
import { InternalWalletState } from "../common.js";
import {
WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
/**
* Logger for this file.
*/
const logger = new Logger("withdraw.ts");
2021-08-24 15:43:06 +02:00
/**
* FIXME: Eliminate this in favor of DenomSelectionState.
*/
interface DenominationSelectionInfo {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;
selectedDenoms: {
/**
* How many times do we withdraw this denomination?
*/
count: number;
denom: DenominationRecord;
}[];
}
2021-03-17 17:56:37 +01:00
/**
* Information about what will happen when creating a reserve.
*
* Sent to the wallet frontend to be rendered and shown to the user.
*/
export interface ExchangeWithdrawDetails {
2021-03-17 17:56:37 +01:00
/**
* Exchange that the reserve will be created at.
*/
exchangeInfo: ExchangeRecord;
exchangeDetails: ExchangeDetailsRecord;
2021-03-17 17:56:37 +01:00
/**
* Filtered wire info to send to the bank.
*/
exchangeWireAccounts: string[];
/**
* Selected denominations for withdraw.
*/
selectedDenoms: DenominationSelectionInfo;
/**
* Fees for withdraw.
*/
withdrawFee: AmountJson;
/**
* Remaining balance that is too small to be withdrawn.
*/
overhead: AmountJson;
/**
* Does the wallet know about an auditor for
* the exchange that the reserve.
*/
isAudited: boolean;
/**
* Did the user already accept the current terms of service for the exchange?
*/
termsOfServiceAccepted: boolean;
/**
* The exchange is trusted directly.
*/
isTrusted: boolean;
/**
* The earliest deposit expiration of the selected coins.
*/
earliestDepositExpiration: Timestamp;
/**
* Number of currently offered denominations.
*/
numOfferedDenoms: number;
/**
* Public keys of trusted auditors for the currency we're withdrawing.
*/
trustedAuditorPubs: string[];
/**
* Result of checking the wallet's version
* against the exchange's version.
*
* Older exchanges don't return version information.
*/
versionMatch: VersionMatchResult | undefined;
2021-03-17 17:56:37 +01:00
/**
* Libtool-style version string for the exchange or "unknown"
* for older exchanges.
*/
exchangeVersion: string;
/**
* Libtool-style version string for the wallet.
*/
walletVersion: string;
}
/**
* Check if a denom is withdrawable based on the expiration time,
* revocation and offered state.
*/
export function isWithdrawableDenom(d: DenominationRecord): boolean {
const now = getTimestampNow();
const started = timestampCmp(now, d.stampStart) >= 0;
let lastPossibleWithdraw: Timestamp;
if (walletCoreDebugFlags.denomselAllowLate) {
lastPossibleWithdraw = d.stampExpireWithdraw;
} else {
lastPossibleWithdraw = timestampSubtractDuraction(
d.stampExpireWithdraw,
durationFromSpec({ minutes: 5 }),
);
}
const remaining = getDurationRemaining(lastPossibleWithdraw, now);
const stillOkay = remaining.d_ms !== 0;
return started && stillOkay && !d.isRevoked && d.isOffered;
}
/**
* Get a list of denominations (with repetitions possible)
* whose total value is as close as possible to the available
* amount, but never larger.
*/
export function selectWithdrawalDenominations(
amountAvailable: AmountJson,
denoms: DenominationRecord[],
): DenominationSelectionInfo {
let remaining = Amounts.copy(amountAvailable);
const selectedDenoms: {
count: number;
denom: DenominationRecord;
}[] = [];
let totalCoinValue = Amounts.getZero(amountAvailable.currency);
let totalWithdrawCost = Amounts.getZero(amountAvailable.currency);
denoms = denoms.filter(isWithdrawableDenom);
denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value));
for (const d of denoms) {
let count = 0;
const cost = Amounts.add(d.value, d.feeWithdraw).amount;
for (;;) {
if (Amounts.cmp(remaining, cost) < 0) {
break;
}
remaining = Amounts.sub(remaining, cost).amount;
count++;
}
if (count > 0) {
totalCoinValue = Amounts.add(
totalCoinValue,
Amounts.mult(d.value, count).amount,
).amount;
2020-05-12 21:50:40 +02:00
totalWithdrawCost = Amounts.add(
totalWithdrawCost,
Amounts.mult(cost, count).amount,
).amount;
selectedDenoms.push({
count,
denom: d,
});
}
if (Amounts.isZero(remaining)) {
break;
}
}
if (logger.shouldLogTrace()) {
2021-08-24 15:43:06 +02:00
logger.trace(
`selected withdrawal denoms for ${Amounts.stringify(totalCoinValue)}`,
);
for (const sd of selectedDenoms) {
2021-08-24 15:43:06 +02:00
logger.trace(
`denom_pub_hash=${sd.denom.denomPubHash}, count=${sd.count}`,
);
}
logger.trace("(end of withdrawal denom list)");
}
return {
selectedDenoms,
totalCoinValue,
totalWithdrawCost,
};
}
/**
* Get information about a withdrawal from
2019-12-09 19:59:08 +01:00
* a taler://withdraw URI by asking the bank.
*/
2019-12-16 16:59:09 +01:00
export async function getBankWithdrawalInfo(
ws: InternalWalletState,
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 ws.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) {
const opErr = makeErrorDetails(
TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE,
"bank integration protocol version not compatible with wallet",
{
exchangeProtocolVersion: config.version,
walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION,
},
);
throw new OperationFailedError(opErr);
}
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,
);
const resp = await ws.http.get(reqUrl.href);
const status = await readSuccessResponseJsonOrThrow(
resp,
codecForWithdrawOperationStatusResponse(),
);
return {
amount: Amounts.parseOrThrow(status.amount),
confirmTransferUrl: status.confirm_transfer_url,
extractedStatusUrl: reqUrl.href,
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
2021-06-09 15:14:17 +02:00
.mktx((x) => ({ denominations: x.denominations }))
.runReadOnly(async (tx) => {
2021-08-24 15:43:06 +02:00
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
return allDenoms.filter(isWithdrawableDenom);
});
}
/**
* 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,
withdrawalGroupId: string,
coinIdx: number,
): Promise<void> {
2021-06-09 15:14:17 +02:00
const withdrawalGroup = await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadOnly(async (tx) => {
return await tx.withdrawalGroups.get(withdrawalGroupId);
});
if (!withdrawalGroup) {
return;
}
2021-06-09 15:14:17 +02:00
let planchet = await ws.db
.mktx((x) => ({
planchets: x.planchets,
}))
.runReadOnly(async (tx) => {
return tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
});
if (!planchet) {
let ci = 0;
let denomPubHash: string | undefined;
2020-05-12 21:50:40 +02:00
for (
let di = 0;
di < withdrawalGroup.denomsSel.selectedDenoms.length;
di++
) {
const d = withdrawalGroup.denomsSel.selectedDenoms[di];
if (coinIdx >= ci && coinIdx < ci + d.count) {
denomPubHash = d.denomPubHash;
break;
}
ci += d.count;
}
if (!denomPubHash) {
throw Error("invariant violated");
}
2021-06-09 15:14:17 +02:00
const { denom, reserve } = await ws.db
.mktx((x) => ({
reserves: x.reserves,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const denom = await tx.denominations.get([
withdrawalGroup.exchangeBaseUrl,
denomPubHash!,
]);
if (!denom) {
throw Error("invariant violated");
}
const reserve = await tx.reserves.get(withdrawalGroup.reservePub);
if (!reserve) {
throw Error("invariant violated");
}
return { denom, reserve };
});
const r = await ws.cryptoApi.createPlanchet({
denomPub: denom.denomPub,
feeWithdraw: denom.feeWithdraw,
reservePriv: reserve.reservePriv,
reservePub: reserve.reservePub,
value: denom.value,
coinIndex: coinIdx,
secretSeed: withdrawalGroup.secretSeed,
});
const newPlanchet: PlanchetRecord = {
blindingKey: r.blindingKey,
coinEv: r.coinEv,
coinEvHash: r.coinEvHash,
coinIdx,
coinPriv: r.coinPriv,
coinPub: r.coinPub,
coinValue: r.coinValue,
denomPub: r.denomPub,
denomPubHash: r.denomPubHash,
isFromTip: false,
reservePub: r.reservePub,
withdrawalDone: false,
withdrawSig: r.withdrawSig,
withdrawalGroupId: withdrawalGroupId,
lastError: undefined,
};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ planchets: x.planchets }))
.runReadWrite(async (tx) => {
const p = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
if (p) {
planchet = p;
return;
}
await tx.planchets.put(newPlanchet);
planchet = newPlanchet;
});
}
}
/**
* Send the withdrawal request for a generated planchet to the exchange.
*
* The verification of the response is done asynchronously to enable parallelism.
*/
async function processPlanchetExchangeRequest(
ws: InternalWalletState,
withdrawalGroupId: string,
coinIdx: number,
): Promise<WithdrawResponse | undefined> {
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
planchets: x.planchets,
exchanges: x.exchanges,
denominations: x.denominations,
}))
.runReadOnly(async (tx) => {
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!withdrawalGroup) {
return;
}
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
if (planchet.withdrawalDone) {
logger.warn("processPlanchet: planchet already withdrawn");
return;
}
const exchange = await tx.exchanges.get(withdrawalGroup.exchangeBaseUrl);
if (!exchange) {
logger.error("db inconsistent: exchange for planchet not found");
return;
}
2021-06-09 15:14:17 +02:00
const denom = await tx.denominations.get([
withdrawalGroup.exchangeBaseUrl,
planchet.denomPubHash,
]);
2021-06-09 15:14:17 +02:00
if (!denom) {
2021-11-17 10:23:22 +01:00
logger.error("db inconsistent: denom for planchet not found");
2021-06-09 15:14:17 +02:00
return;
}
2021-06-09 15:14:17 +02:00
logger.trace(
`processing planchet #${coinIdx} in withdrawal ${withdrawalGroupId}`,
);
2021-06-09 15:14:17 +02:00
const reqBody: any = {
denom_pub_hash: planchet.denomPubHash,
reserve_pub: planchet.reservePub,
reserve_sig: planchet.withdrawSig,
coin_ev: planchet.coinEv,
};
const reqUrl = new URL(
`reserves/${planchet.reservePub}/withdraw`,
exchange.baseUrl,
).href;
return { reqUrl, reqBody };
});
if (!d) {
return;
}
const { reqUrl, reqBody } = d;
try {
2021-06-09 15:14:17 +02:00
const resp = await ws.http.postJson(reqUrl, reqBody);
const r = await readSuccessResponseJsonOrThrow(
resp,
codecForWithdrawResponse(),
);
return r;
} catch (e) {
2020-08-28 18:35:35 +02:00
logger.trace("withdrawal request failed", e);
logger.trace(e);
if (!(e instanceof OperationFailedError)) {
throw e;
}
const errDetails = e.operationError;
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ planchets: x.planchets }))
.runReadWrite(async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
planchet.lastError = errDetails;
await tx.planchets.put(planchet);
});
return;
}
}
async function processPlanchetVerifyAndStoreCoin(
ws: InternalWalletState,
withdrawalGroupId: string,
coinIdx: number,
resp: WithdrawResponse,
): Promise<void> {
2021-06-09 15:14:17 +02:00
const d = await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
planchets: x.planchets,
}))
.runReadOnly(async (tx) => {
const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!withdrawalGroup) {
return;
}
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
if (planchet.withdrawalDone) {
logger.warn("processPlanchet: planchet already withdrawn");
return;
}
return { planchet, exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl };
});
if (!d) {
return;
}
2021-06-09 15:14:17 +02:00
const { planchet, exchangeBaseUrl } = d;
2021-11-17 10:23:22 +01:00
const planchetDenomPub = planchet.denomPub;
2021-11-27 20:56:58 +01:00
if (
planchetDenomPub.cipher !== DenomKeyType.Rsa &&
planchetDenomPub.cipher !== DenomKeyType.LegacyRsa
) {
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 (typeof resp.ev_sig === "string") {
evSig = {
cipher: DenomKeyType.LegacyRsa,
blinded_rsa_signature: resp.ev_sig,
};
} else {
evSig = resp.ev_sig;
}
if (
!(
evSig.cipher === DenomKeyType.Rsa ||
evSig.cipher === DenomKeyType.LegacyRsa
)
) {
2021-11-17 10:23:22 +01:00
throw Error("unsupported cipher");
}
const denomSigRsa = await ws.cryptoApi.rsaUnblind(
evSig.blinded_rsa_signature,
planchet.blindingKey,
2021-11-17 10:23:22 +01:00
planchetDenomPub.rsa_public_key,
);
const isValid = await ws.cryptoApi.rsaVerify(
planchet.coinPub,
2021-11-17 10:23:22 +01:00
denomSigRsa,
planchetDenomPub.rsa_public_key,
);
2019-12-05 19:38:19 +01:00
if (!isValid) {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ planchets: x.planchets }))
.runReadWrite(async (tx) => {
let planchet = await tx.planchets.indexes.byGroupAndIndex.get([
withdrawalGroupId,
coinIdx,
]);
if (!planchet) {
return;
}
planchet.lastError = makeErrorDetails(
TalerErrorCode.WALLET_EXCHANGE_COIN_SIGNATURE_INVALID,
"invalid signature from the exchange after unblinding",
{},
);
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 (
planchet.denomPub.cipher === DenomKeyType.LegacyRsa ||
planchet.denomPub.cipher === DenomKeyType.Rsa
) {
denomSig = {
cipher: planchet.denomPub.cipher,
rsa_signature: denomSigRsa,
};
} else {
throw Error("unsupported cipher");
}
const coin: CoinRecord = {
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
currentAmount: planchet.coinValue,
denomPub: planchet.denomPub,
denomPubHash: planchet.denomPubHash,
2021-11-27 20:56:58 +01:00
denomSig,
coinEvHash: planchet.coinEvHash,
2021-06-09 15:14:17 +02:00
exchangeBaseUrl: exchangeBaseUrl,
status: CoinStatus.Fresh,
coinSource: {
type: CoinSourceType.Withdraw,
coinIndex: coinIdx,
reservePub: planchet.reservePub,
withdrawalGroupId: withdrawalGroupId,
},
2020-03-24 10:55:04 +01:00
suspended: false,
};
const planchetCoinPub = planchet.coinPub;
2021-06-09 15:14:17 +02:00
const firstSuccess = await ws.db
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets,
}))
.runReadWrite(async (tx) => {
const ws = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!ws) {
return false;
}
2021-06-09 15:14:17 +02:00
const p = await tx.planchets.get(planchetCoinPub);
if (!p || p.withdrawalDone) {
return false;
}
p.withdrawalDone = true;
2021-06-09 15:14:17 +02:00
await tx.planchets.put(p);
await tx.coins.add(coin);
return true;
2021-06-09 15:14:17 +02:00
});
2019-12-05 19:38:19 +01:00
if (firstSuccess) {
ws.notify({
type: NotificationType.CoinWithdrawn,
});
}
}
export function denomSelectionInfoToState(
dsi: DenominationSelectionInfo,
): DenomSelectionState {
return {
selectedDenoms: dsi.selectedDenoms.map((x) => {
return {
count: x.count,
denomPubHash: x.denom.denomPubHash,
};
}),
totalCoinValue: dsi.totalCoinValue,
totalWithdrawCost: dsi.totalWithdrawCost,
};
}
/**
* 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) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.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(
`Validating denomination (${current + 1}/${
denominations.length
2021-08-06 16:27:18 +02:00
}) signature of ${denom.denomPubHash}`,
);
let valid: boolean = false;
if (ws.insecureTrustExchange) {
valid = true;
} else {
valid = await ws.cryptoApi.isValidDenom(
denom,
exchangeDetails.masterPublicKey,
);
}
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) => ({ denominations: x.denominations }))
.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
}
}
2019-12-05 19:38:19 +01:00
async function incrementWithdrawalRetry(
ws: InternalWalletState,
withdrawalGroupId: string,
2020-09-01 14:57:22 +02:00
err: TalerErrorDetails | undefined,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadWrite(async (tx) => {
const wsr = await tx.withdrawalGroups.get(withdrawalGroupId);
if (!wsr) {
return;
}
wsr.retryInfo.retryCounter++;
updateRetryInfoTimeout(wsr.retryInfo);
wsr.lastError = err;
await tx.withdrawalGroups.put(wsr);
});
2020-07-20 12:50:32 +02:00
if (err) {
ws.notify({ type: NotificationType.WithdrawOperationError, error: err });
}
}
export async function processWithdrawGroup(
ws: InternalWalletState,
withdrawalGroupId: string,
2020-04-06 17:45:41 +02:00
forceNow = false,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2020-09-01 14:57:22 +02:00
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
incrementWithdrawalRetry(ws, withdrawalGroupId, e);
2019-12-05 19:38:19 +01:00
await guardOperationException(
() => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow),
2019-12-05 19:38:19 +01:00
onOpErr,
);
}
async function resetWithdrawalGroupRetry(
2019-12-05 19:38:19 +01:00
ws: InternalWalletState,
withdrawalGroupId: string,
2020-04-07 10:07:32 +02:00
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
}))
2021-06-09 15:14:17 +02:00
.runReadWrite(async (tx) => {
const x = await tx.withdrawalGroups.get(withdrawalGroupId);
2021-06-11 11:15:08 +02:00
if (x) {
2021-06-09 15:14:17 +02:00
x.retryInfo = initRetryInfo();
await tx.withdrawalGroups.put(x);
}
});
}
async function processWithdrawGroupImpl(
ws: InternalWalletState,
withdrawalGroupId: string,
forceNow: boolean,
): Promise<void> {
logger.trace("processing withdraw group", withdrawalGroupId);
if (forceNow) {
await resetWithdrawalGroupRetry(ws, withdrawalGroupId);
}
2021-06-09 15:14:17 +02:00
const withdrawalGroup = await ws.db
.mktx((x) => ({ withdrawalGroups: x.withdrawalGroups }))
.runReadOnly(async (tx) => {
return tx.withdrawalGroups.get(withdrawalGroupId);
});
if (!withdrawalGroup) {
// Withdrawal group doesn't exist yet, but reserve might exist
// (and reference the yet to be created withdrawal group)
const reservePub = await ws.db
.mktx((x) => ({ reserves: x.reserves }))
.runReadOnly(async (tx) => {
const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get(
withdrawalGroupId,
);
2021-12-13 11:28:57 +01:00
return r?.reservePub;
});
if (!reservePub) {
logger.warn(
"withdrawal group doesn't exist (and reserve doesn't exist either)",
);
return;
}
return await ws.reserveOps.processReserve(ws, reservePub, forceNow);
}
await ws.exchangeOps.updateExchangeFromUrl(
ws,
withdrawalGroup.exchangeBaseUrl,
);
2020-09-03 13:59:09 +02:00
const numTotalCoins = withdrawalGroup.denomsSel.selectedDenoms
.map((x) => x.count)
.reduce((a, b) => a + b);
let work: Promise<void>[] = [];
for (let i = 0; i < numTotalCoins; i++) {
work.push(processPlanchetGenerate(ws, withdrawalGroupId, i));
}
// Generate coins concurrently (parallelism only happens in the crypto API workers)
await Promise.all(work);
work = [];
for (let coinIdx = 0; coinIdx < numTotalCoins; coinIdx++) {
const resp = await processPlanchetExchangeRequest(
ws,
withdrawalGroupId,
coinIdx,
);
if (!resp) {
continue;
}
work.push(
processPlanchetVerifyAndStoreCoin(ws, withdrawalGroupId, coinIdx, resp),
);
}
await Promise.all(work);
let numFinished = 0;
let finishedForFirstTime = false;
2020-09-01 14:57:22 +02:00
let errorsPerCoin: Record<number, TalerErrorDetails> = {};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
coins: x.coins,
withdrawalGroups: x.withdrawalGroups,
reserves: x.reserves,
planchets: x.planchets,
}))
.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) => {
if (x.withdrawalDone) {
numFinished++;
}
2020-09-01 14:30:46 +02:00
if (x.lastError) {
errorsPerCoin[x.coinIdx] = x.lastError;
}
});
2020-09-01 14:30:46 +02:00
logger.trace(`now withdrawn ${numFinished} of ${numTotalCoins} coins`);
if (wg.timestampFinish === undefined && numFinished === numTotalCoins) {
finishedForFirstTime = true;
2020-09-01 14:30:46 +02:00
wg.timestampFinish = getTimestampNow();
wg.lastError = undefined;
wg.retryInfo = initRetryInfo();
}
2020-09-01 14:30:46 +02:00
2021-06-09 15:14:17 +02:00
await tx.withdrawalGroups.put(wg);
});
if (numFinished != numTotalCoins) {
2020-09-01 14:30:46 +02:00
throw OperationFailedError.fromCode(
TalerErrorCode.WALLET_WITHDRAWAL_GROUP_INCOMPLETE,
`withdrawal did not finish (${numFinished} / ${numTotalCoins} coins withdrawn)`,
2020-09-01 14:30:46 +02:00
{
errorsPerCoin,
},
);
}
if (finishedForFirstTime) {
ws.notify({
type: NotificationType.WithdrawGroupFinished,
2020-09-08 15:57:08 +02:00
reservePub: withdrawalGroup.reservePub,
});
}
}
2019-12-09 19:59:08 +01:00
export async function getExchangeWithdrawalInfo(
ws: InternalWalletState,
baseUrl: string,
amount: AmountJson,
2019-12-09 19:59:08 +01:00
): Promise<ExchangeWithdrawDetails> {
2022-01-10 01:19:19 +01:00
const { exchange, exchangeDetails } =
await ws.exchangeOps.updateExchangeFromUrl(ws, baseUrl);
await updateWithdrawalDenoms(ws, baseUrl);
2021-01-14 18:00:00 +01:00
const denoms = await getCandidateWithdrawalDenoms(ws, baseUrl);
const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
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 earliestDepositExpiration =
selectedDenoms.selectedDenoms[0].denom.stampExpireDeposit;
for (let i = 1; i < selectedDenoms.selectedDenoms.length; i++) {
const expireDeposit =
selectedDenoms.selectedDenoms[i].denom.stampExpireDeposit;
if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) {
earliestDepositExpiration = expireDeposit;
}
}
const possibleDenoms = await ws.db
2021-06-09 15:14:17 +02:00
.mktx((x) => ({ denominations: x.denominations }))
.runReadOnly(async (tx) => {
return tx.denominations.indexes.byExchangeBaseUrl
.iter()
.filter((d) => d.isOffered);
});
let versionMatch;
if (exchangeDetails.protocolVersion) {
versionMatch = LibtoolVersion.compare(
2019-12-16 16:59:09 +01:00
WALLET_EXCHANGE_PROTOCOL_VERSION,
exchangeDetails.protocolVersion,
);
if (
versionMatch &&
!versionMatch.compatible &&
versionMatch.currentCmp === -1
) {
console.warn(
2019-12-16 16:59:09 +01:00
`wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` +
`(exchange has ${exchangeDetails.protocolVersion}), checking for updates`,
);
}
}
2019-12-09 19:59:08 +01:00
let tosAccepted = false;
if (exchangeDetails.termsOfServiceLastEtag) {
if (
exchangeDetails.termsOfServiceAcceptedEtag ===
exchangeDetails.termsOfServiceLastEtag
) {
2019-12-09 19:59:08 +01:00
tosAccepted = true;
}
}
const withdrawFee = Amounts.sub(
selectedDenoms.totalWithdrawCost,
selectedDenoms.totalCoinValue,
).amount;
2019-12-09 19:59:08 +01:00
const ret: ExchangeWithdrawDetails = {
earliestDepositExpiration,
exchangeInfo: exchange,
exchangeDetails,
exchangeWireAccounts,
exchangeVersion: exchangeDetails.protocolVersion || "unknown",
isAudited,
isTrusted,
numOfferedDenoms: possibleDenoms.length,
overhead: Amounts.sub(amount, selectedDenoms.totalWithdrawCost).amount,
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,
withdrawFee,
2019-12-09 19:59:08 +01:00
termsOfServiceAccepted: tosAccepted,
};
return ret;
}
export async function getWithdrawalDetailsForUri(
ws: InternalWalletState,
talerWithdrawUri: string,
): Promise<WithdrawUriInfoResponse> {
logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`);
2019-12-09 19:59:08 +01:00
const info = await getBankWithdrawalInfo(ws, 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`,
);
}
}
const exchanges: ExchangeListItem[] = [];
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
const exchangeRecords = await tx.exchanges.iter().toArray();
for (const r of exchangeRecords) {
const details = await ws.exchangeOps.getExchangeDetails(tx, r.baseUrl);
2021-06-09 15:14:17 +02:00
if (details) {
exchanges.push({
exchangeBaseUrl: details.exchangeBaseUrl,
currency: details.currency,
tos: {
acceptedVersion: details.termsOfServiceAcceptedEtag,
currentVersion: details.termsOfServiceLastEtag,
contentType: details.termsOfServiceContentType,
content: details.termsOfServiceText,
},
2021-06-09 15:14:17 +02:00
paytoUris: details.wireInfo.accounts.map((x) => x.payto_uri),
});
}
}
});
return {
amount: Amounts.stringify(info.amount),
defaultExchangeBaseUrl: info.suggestedExchange,
possibleExchanges: exchanges,
};
}