improved error reporting / towards a working recoup

This commit is contained in:
Florian Dold 2020-03-12 19:25:38 +05:30
parent 2ec6799c8c
commit b5b8f96cc9
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
16 changed files with 253 additions and 117 deletions

View File

@ -214,7 +214,7 @@ export class CryptoImplementation {
coin_blind_key_secret: coin.blindingKey, coin_blind_key_secret: coin.blindingKey,
coin_pub: coin.coinPub, coin_pub: coin.coinPub,
coin_sig: encodeCrock(coinSig), coin_sig: encodeCrock(coinSig),
denom_pub: coin.denomPub, denom_pub_hash: coin.denomPubHash,
denom_sig: coin.denomSig, denom_sig: coin.denomSig,
refreshed: (coin.coinSource.type === CoinSourceType.Refresh), refreshed: (coin.coinSource.type === CoinSourceType.Refresh),
}; };

View File

@ -217,9 +217,10 @@ walletCli
.subcommand("runPendingOpt", "run-pending", { .subcommand("runPendingOpt", "run-pending", {
help: "Run pending operations.", help: "Run pending operations.",
}) })
.flag("forceNow", ["-f", "--force-now"])
.action(async args => { .action(async args => {
await withWallet(args, async wallet => { await withWallet(args, async wallet => {
await wallet.runPending(); await wallet.runPending(args.runPendingOpt.forceNow);
}); });
}); });

View File

@ -1,8 +1,6 @@
import { OperationError } from "../types/walletTypes";
/* /*
This file is part of GNU Taler This file is part of GNU Taler
(C) 2019 GNUnet e.V. (C) 2019-2020 Taler Systems SA
GNU Taler is free software; you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -16,13 +14,26 @@ import { OperationError } from "../types/walletTypes";
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
/**
* Classes and helpers for error handling specific to wallet operations.
*
* @author Florian Dold <dold@taler.net>
*/
/**
* Imports.
*/
import { OperationError } from "../types/walletTypes";
import { HttpResponse } from "../util/http";
import { Codec } from "../util/codec";
/** /**
* This exception is there to let the caller know that an error happened, * This exception is there to let the caller know that an error happened,
* but the error has already been reported by writing it to the database. * but the error has already been reported by writing it to the database.
*/ */
export class OperationFailedAndReportedError extends Error { export class OperationFailedAndReportedError extends Error {
constructor(message: string) { constructor(public operationError: OperationError) {
super(message); super(operationError.message);
// Set the prototype explicitly. // Set the prototype explicitly.
Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype); Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype);
@ -34,14 +45,73 @@ export class OperationFailedAndReportedError extends Error {
* responsible for recording the failure in the database. * responsible for recording the failure in the database.
*/ */
export class OperationFailedError extends Error { export class OperationFailedError extends Error {
constructor(message: string, public err: OperationError) { constructor(public operationError: OperationError) {
super(message); super(operationError.message);
// Set the prototype explicitly. // Set the prototype explicitly.
Object.setPrototypeOf(this, OperationFailedError.prototype); Object.setPrototypeOf(this, OperationFailedError.prototype);
} }
} }
/**
* Process an HTTP response that we expect to contain Taler-specific JSON.
*
* Depending on the status code, we throw an exception. This function
* will try to extract Taler-specific error information from the HTTP response
* if possible.
*/
export async function scrutinizeTalerJsonResponse<T>(
resp: HttpResponse,
codec: Codec<T>,
): Promise<T> {
// FIXME: We should distinguish between different types of error status
// to react differently (throttle, report permanent failure)
// FIXME: Make sure that when we receive an error message,
// it looks like a Taler error message
if (resp.status !== 200) {
let exc: OperationFailedError | undefined = undefined;
try {
const errorJson = await resp.json();
const m = `received error response (status ${resp.status})`;
exc = new OperationFailedError({
type: "protocol",
message: m,
details: {
httpStatusCode: resp.status,
errorResponse: errorJson,
}
});
} catch (e) {
const m = "could not parse response JSON";
exc = new OperationFailedError({
type: "network",
message: m,
details: {
status: resp.status,
}
});
}
throw exc;
}
let json: any;
try {
json = await resp.json();
} catch (e) {
const m = "could not parse response JSON";
throw new OperationFailedError({
type: "network",
message: m,
details: {
status: resp.status,
}
});
}
return codec.decode(json);
}
/** /**
* Run an operation and call the onOpError callback * Run an operation and call the onOpError callback
* when there was an exception or operation error that must be reported. * when there was an exception or operation error that must be reported.
@ -59,26 +129,28 @@ export async function guardOperationException<T>(
throw e; throw e;
} }
if (e instanceof OperationFailedError) { if (e instanceof OperationFailedError) {
await onOpError(e.err); await onOpError(e.operationError);
throw new OperationFailedAndReportedError(e.message); throw new OperationFailedAndReportedError(e.operationError);
} }
if (e instanceof Error) { if (e instanceof Error) {
console.log("guard: caught Error"); console.log("guard: caught Error");
await onOpError({ const opErr = {
type: "exception", type: "exception",
message: e.message, message: e.message,
details: {}, details: {},
}); }
throw new OperationFailedAndReportedError(e.message); await onOpError(opErr);
throw new OperationFailedAndReportedError(opErr);
} }
console.log("guard: caught something else"); console.log("guard: caught something else");
await onOpError({ const opErr = {
type: "exception", type: "exception",
message: "non-error exception thrown", message: "non-error exception thrown",
details: { details: {
value: e.toString(), value: e.toString(),
}, },
}); };
throw new OperationFailedAndReportedError(e.message); await onOpError(opErr);
throw new OperationFailedAndReportedError(opErr);
} }
} }

View File

@ -115,72 +115,78 @@ async function updateExchangeWithKeys(
keysResp = await r.json(); keysResp = await r.json();
} catch (e) { } catch (e) {
const m = `Fetching keys failed: ${e.message}`; const m = `Fetching keys failed: ${e.message}`;
await setExchangeError(ws, baseUrl, { const opErr = {
type: "network", type: "network",
details: { details: {
requestUrl: e.config?.url, requestUrl: e.config?.url,
}, },
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
let exchangeKeysJson: ExchangeKeysJson; let exchangeKeysJson: ExchangeKeysJson;
try { try {
exchangeKeysJson = codecForExchangeKeysJson().decode(keysResp); exchangeKeysJson = codecForExchangeKeysJson().decode(keysResp);
} catch (e) { } catch (e) {
const m = `Parsing /keys response failed: ${e.message}`; const m = `Parsing /keys response failed: ${e.message}`;
await setExchangeError(ws, baseUrl, { const opErr = {
type: "protocol-violation", type: "protocol-violation",
details: {}, details: {},
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const lastUpdateTimestamp = exchangeKeysJson.list_issue_date; const lastUpdateTimestamp = exchangeKeysJson.list_issue_date;
if (!lastUpdateTimestamp) { if (!lastUpdateTimestamp) {
const m = `Parsing /keys response failed: invalid list_issue_date.`; const m = `Parsing /keys response failed: invalid list_issue_date.`;
await setExchangeError(ws, baseUrl, { const opErr = {
type: "protocol-violation", type: "protocol-violation",
details: {}, details: {},
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
if (exchangeKeysJson.denoms.length === 0) { if (exchangeKeysJson.denoms.length === 0) {
const m = "exchange doesn't offer any denominations"; const m = "exchange doesn't offer any denominations";
await setExchangeError(ws, baseUrl, { const opErr = {
type: "protocol-violation", type: "protocol-violation",
details: {}, details: {},
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const protocolVersion = exchangeKeysJson.version; const protocolVersion = exchangeKeysJson.version;
if (!protocolVersion) { if (!protocolVersion) {
const m = "outdate exchange, no version in /keys response"; const m = "outdate exchange, no version in /keys response";
await setExchangeError(ws, baseUrl, { const opErr = {
type: "protocol-violation", type: "protocol-violation",
details: {}, details: {},
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion); const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion);
if (versionRes?.compatible != true) { if (versionRes?.compatible != true) {
const m = "exchange protocol version not compatible with wallet"; const m = "exchange protocol version not compatible with wallet";
await setExchangeError(ws, baseUrl, { const opErr = {
type: "protocol-incompatible", type: "protocol-incompatible",
details: { details: {
exchangeProtocolVersion: protocolVersion, exchangeProtocolVersion: protocolVersion,
walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION,
}, },
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await setExchangeError(ws, baseUrl, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value)
@ -195,7 +201,7 @@ async function updateExchangeWithKeys(
let recoupGroupId: string | undefined = undefined; let recoupGroupId: string | undefined = undefined;
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.exchanges, Stores.denominations], [Stores.exchanges, Stores.denominations, Stores.recoupGroups, Stores.coins],
async tx => { async tx => {
const r = await tx.get(Stores.exchanges, baseUrl); const r = await tx.get(Stores.exchanges, baseUrl);
if (!r) { if (!r) {
@ -231,10 +237,11 @@ async function updateExchangeWithKeys(
// Handle recoup // Handle recoup
const recoupDenomList = exchangeKeysJson.recoup ?? []; const recoupDenomList = exchangeKeysJson.recoup ?? [];
const newlyRevokedCoinPubs: string[] = []; const newlyRevokedCoinPubs: string[] = [];
for (const recoupDenomPubHash of recoupDenomList) { console.log("recoup list from exchange", recoupDenomList);
for (const recoupInfo of recoupDenomList) {
const oldDenom = await tx.getIndexed( const oldDenom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex, Stores.denominations.denomPubHashIndex,
recoupDenomPubHash, recoupInfo.h_denom_pub,
); );
if (!oldDenom) { if (!oldDenom) {
// We never even knew about the revoked denomination, all good. // We never even knew about the revoked denomination, all good.
@ -243,18 +250,21 @@ async function updateExchangeWithKeys(
if (oldDenom.isRevoked) { if (oldDenom.isRevoked) {
// We already marked the denomination as revoked, // We already marked the denomination as revoked,
// this implies we revoked all coins // this implies we revoked all coins
console.log("denom already revoked");
continue; continue;
} }
console.log("revoking denom", recoupInfo.h_denom_pub);
oldDenom.isRevoked = true; oldDenom.isRevoked = true;
await tx.put(Stores.denominations, oldDenom); await tx.put(Stores.denominations, oldDenom);
const affectedCoins = await tx const affectedCoins = await tx
.iterIndexed(Stores.coins.denomPubIndex) .iterIndexed(Stores.coins.denomPubHashIndex, recoupInfo.h_denom_pub)
.toArray(); .toArray();
for (const ac of affectedCoins) { for (const ac of affectedCoins) {
newlyRevokedCoinPubs.push(ac.coinPub); newlyRevokedCoinPubs.push(ac.coinPub);
} }
} }
if (newlyRevokedCoinPubs.length != 0) { if (newlyRevokedCoinPubs.length != 0) {
console.log("recouping coins", newlyRevokedCoinPubs);
await createRecoupGroup(ws, tx, newlyRevokedCoinPubs); await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
} }
}, },
@ -263,7 +273,7 @@ async function updateExchangeWithKeys(
if (recoupGroupId) { if (recoupGroupId) {
// Asynchronously start recoup. This doesn't need to finish // Asynchronously start recoup. This doesn't need to finish
// for the exchange update to be considered finished. // for the exchange update to be considered finished.
processRecoupGroup(ws, recoupGroupId).catch((e) => { processRecoupGroup(ws, recoupGroupId).catch(e => {
console.log("error while recouping coins:", e); console.log("error while recouping coins:", e);
}); });
} }

View File

@ -41,7 +41,6 @@ import {
} from "../types/history"; } from "../types/history";
import { assertUnreachable } from "../util/assertUnreachable"; import { assertUnreachable } from "../util/assertUnreachable";
import { TransactionHandle, Store } from "../util/query"; import { TransactionHandle, Store } from "../util/query";
import { ReserveTransactionType } from "../types/ReserveTransaction";
import { timestampCmp } from "../util/time"; import { timestampCmp } from "../util/time";
/** /**

View File

@ -427,6 +427,8 @@ async function gatherRecoupPending(
type: PendingOperationType.Recoup, type: PendingOperationType.Recoup,
givesLifeness: true, givesLifeness: true,
recoupGroupId: rg.recoupGroupId, recoupGroupId: rg.recoupGroupId,
retryInfo: rg.retryInfo,
lastError: rg.lastError,
}); });
}); });
} }

View File

@ -40,7 +40,7 @@ import {
import { codecForRecoupConfirmation } from "../types/talerTypes"; import { codecForRecoupConfirmation } from "../types/talerTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { processReserve } from "./reserves"; import { forceQueryReserve } from "./reserves";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
import { createRefreshGroup, processRefreshGroup } from "./refresh"; import { createRefreshGroup, processRefreshGroup } from "./refresh";
@ -48,7 +48,7 @@ import { RefreshReason, OperationError } from "../types/walletTypes";
import { TransactionHandle } from "../util/query"; import { TransactionHandle } from "../util/query";
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { guardOperationException } from "./errors"; import { guardOperationException, scrutinizeTalerJsonResponse } from "./errors";
async function incrementRecoupRetry( async function incrementRecoupRetry(
ws: InternalWalletState, ws: InternalWalletState,
@ -133,17 +133,17 @@ async function recoupWithdrawCoin(
const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin); const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
const resp = await ws.http.postJson(reqUrl.href, recoupRequest); const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
if (resp.status !== 200) { const recoupConfirmation = await scrutinizeTalerJsonResponse(
throw Error("recoup request failed"); resp,
} codecForRecoupConfirmation(),
const recoupConfirmation = codecForRecoupConfirmation().decode(
await resp.json(),
); );
if (recoupConfirmation.reserve_pub !== reservePub) { if (recoupConfirmation.reserve_pub !== reservePub) {
throw Error(`Coin's reserve doesn't match reserve on recoup`); throw Error(`Coin's reserve doesn't match reserve on recoup`);
} }
// FIXME: verify signature
// FIXME: verify that our expectations about the amount match // FIXME: verify that our expectations about the amount match
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
@ -178,8 +178,8 @@ async function recoupWithdrawCoin(
type: NotificationType.RecoupFinished, type: NotificationType.RecoupFinished,
}); });
processReserve(ws, reserve.reservePub).catch(e => { forceQueryReserve(ws, reserve.reservePub).catch(e => {
console.log("processing reserve after recoup failed:", e); console.log("re-querying reserve after recoup failed:", e);
}); });
} }
@ -196,12 +196,11 @@ async function recoupRefreshCoin(
const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin); const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
console.log("making recoup request");
const resp = await ws.http.postJson(reqUrl.href, recoupRequest); const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
if (resp.status !== 200) { const recoupConfirmation = await scrutinizeTalerJsonResponse(
throw Error("recoup request failed"); resp,
} codecForRecoupConfirmation(),
const recoupConfirmation = codecForRecoupConfirmation().decode(
await resp.json(),
); );
if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) { if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
@ -283,11 +282,14 @@ async function processRecoupGroupImpl(
if (forceNow) { if (forceNow) {
await resetRecoupGroupRetry(ws, recoupGroupId); await resetRecoupGroupRetry(ws, recoupGroupId);
} }
console.log("in processRecoupGroupImpl");
const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId); const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) { if (!recoupGroup) {
return; return;
} }
console.log(recoupGroup);
if (recoupGroup.timestampFinished) { if (recoupGroup.timestampFinished) {
console.log("recoup group finished");
return; return;
} }
const ps = recoupGroup.coinPubs.map((x, i) => const ps = recoupGroup.coinPubs.map((x, i) =>
@ -317,11 +319,11 @@ export async function createRecoupGroup(
const coinPub = coinPubs[coinIdx]; const coinPub = coinPubs[coinIdx];
const coin = await tx.get(Stores.coins, coinPub); const coin = await tx.get(Stores.coins, coinPub);
if (!coin) { if (!coin) {
recoupGroup.recoupFinishedPerCoin[coinIdx] = true; await putGroupAsFinished(tx, recoupGroup, coinIdx);
continue; continue;
} }
if (Amounts.isZero(coin.currentAmount)) { if (Amounts.isZero(coin.currentAmount)) {
recoupGroup.recoupFinishedPerCoin[coinIdx] = true; await putGroupAsFinished(tx, recoupGroup, coinIdx);
continue; continue;
} }
coin.currentAmount = Amounts.getZero(coin.currentAmount.currency); coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);

View File

@ -440,7 +440,7 @@ async function processPurchaseApplyRefundImpl(
body = await resp.json(); body = await resp.json();
} catch {} } catch {}
const m = "refund request (at exchange) failed"; const m = "refund request (at exchange) failed";
throw new OperationFailedError(m, { throw new OperationFailedError({
message: m, message: m,
type: "network", type: "network",
details: { details: {

View File

@ -202,6 +202,35 @@ export async function createReserve(
return resp; return resp;
} }
/**
* Re-query the status of a reserve.
*/
export async function forceQueryReserve(
ws: InternalWalletState,
reservePub: string,
): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.reserves], async (tx) => {
const reserve = await tx.get(Stores.reserves, reservePub);
if (!reserve) {
return;
}
// Only force status query where it makes sense
switch (reserve.reserveStatus) {
case ReserveRecordStatus.DORMANT:
case ReserveRecordStatus.WITHDRAWING:
case ReserveRecordStatus.QUERYING_STATUS:
break;
default:
return;
}
reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
reserve.retryInfo = initRetryInfo();
await tx.put(Stores.reserves, reserve);
});
await processReserve(ws, reservePub);
}
/** /**
* First fetch information requred to withdraw from the reserve, * First fetch information requred to withdraw from the reserve,
* then deplete the reserve, withdrawing coins until it is empty. * then deplete the reserve, withdrawing coins until it is empty.
@ -408,7 +437,7 @@ async function updateReserve(
console.log("got reserves/${RESERVE_PUB} response", await resp.json()); console.log("got reserves/${RESERVE_PUB} response", await resp.json());
if (resp.status === 404) { if (resp.status === 404) {
const m = "reserve not known to the exchange yet" const m = "reserve not known to the exchange yet"
throw new OperationFailedError(m, { throw new OperationFailedError({
type: "waiting", type: "waiting",
message: m, message: m,
details: {}, details: {},
@ -420,12 +449,13 @@ async function updateReserve(
} catch (e) { } catch (e) {
logger.trace("caught exception for reserve/status"); logger.trace("caught exception for reserve/status");
const m = e.message; const m = e.message;
await incrementReserveRetry(ws, reservePub, { const opErr = {
type: "network", type: "network",
details: {}, details: {},
message: m, message: m,
}); };
throw new OperationFailedAndReportedError(m); await incrementReserveRetry(ws, reservePub, opErr);
throw new OperationFailedAndReportedError(opErr);
} }
const respJson = await resp.json(); const respJson = await resp.json();
const reserveInfo = codecForReserveStatus().decode(respJson); const reserveInfo = codecForReserveStatus().decode(respJson);
@ -600,13 +630,14 @@ async function depleteReserve(
logger.trace(`got denom list`); logger.trace(`got denom list`);
if (denomsForWithdraw.length === 0) { if (denomsForWithdraw.length === 0) {
const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`;
await incrementReserveRetry(ws, reserve.reservePub, { const opErr = {
type: "internal", type: "internal",
message: m, message: m,
details: {}, details: {},
}); };
await incrementReserveRetry(ws, reserve.reservePub, opErr);
console.log(m); console.log(m);
throw new OperationFailedAndReportedError(m); throw new OperationFailedAndReportedError(opErr);
} }
logger.trace("selected denominations"); logger.trace("selected denominations");

View File

@ -1,6 +1,6 @@
/* /*
This file is part of GNU Taler This file is part of GNU Taler
(C) 2019 GNUnet e.V. (C) 2019-2029 Taler Systems SA
GNU Taler is free software; you can redistribute it and/or modify it under the 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 terms of the GNU General Public License as published by the Free Software
@ -33,7 +33,10 @@ import {
WithdrawDetails, WithdrawDetails,
OperationError, OperationError,
} from "../types/walletTypes"; } from "../types/walletTypes";
import { WithdrawOperationStatusResponse, codecForWithdrawOperationStatusResponse } from "../types/talerTypes"; import {
codecForWithdrawOperationStatusResponse,
codecForWithdrawResponse,
} from "../types/talerTypes";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { parseWithdrawUri } from "../util/taleruri"; import { parseWithdrawUri } from "../util/taleruri";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
@ -41,7 +44,7 @@ import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions"; import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions";
import * as LibtoolVersion from "../util/libtoolVersion"; import * as LibtoolVersion from "../util/libtoolVersion";
import { guardOperationException } from "./errors"; import { guardOperationException, scrutinizeTalerJsonResponse } from "./errors";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { import {
getTimestampNow, getTimestampNow,
@ -49,7 +52,6 @@ import {
timestampCmp, timestampCmp,
timestampSubtractDuraction, timestampSubtractDuraction,
} from "../util/time"; } from "../util/time";
import { Store } from "../util/query";
const logger = new Logger("withdraw.ts"); const logger = new Logger("withdraw.ts");
@ -62,7 +64,7 @@ function isWithdrawableDenom(d: DenominationRecord) {
); );
const remaining = getDurationRemaining(lastPossibleWithdraw, now); const remaining = getDurationRemaining(lastPossibleWithdraw, now);
const stillOkay = remaining.d_ms !== 0; const stillOkay = remaining.d_ms !== 0;
return started && stillOkay; return started && stillOkay && !d.isRevoked;
} }
/** /**
@ -144,8 +146,9 @@ async function getPossibleDenoms(
.iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl) .iterIndex(Stores.denominations.exchangeBaseUrlIndex, exchangeBaseUrl)
.filter(d => { .filter(d => {
return ( return (
d.status === DenominationStatus.Unverified || (d.status === DenominationStatus.Unverified ||
d.status === DenominationStatus.VerifiedGood d.status === DenominationStatus.VerifiedGood) &&
!d.isRevoked
); );
}); });
} }
@ -199,13 +202,12 @@ async function processPlanchet(
wd.reserve_pub = planchet.reservePub; wd.reserve_pub = planchet.reservePub;
wd.reserve_sig = planchet.withdrawSig; wd.reserve_sig = planchet.withdrawSig;
wd.coin_ev = planchet.coinEv; wd.coin_ev = planchet.coinEv;
const reqUrl = new URL(`reserves/${planchet.reservePub}/withdraw`, exchange.baseUrl).href; const reqUrl = new URL(
`reserves/${planchet.reservePub}/withdraw`,
exchange.baseUrl,
).href;
const resp = await ws.http.postJson(reqUrl, wd); const resp = await ws.http.postJson(reqUrl, wd);
if (resp.status !== 200) { const r = await scrutinizeTalerJsonResponse(resp, codecForWithdrawResponse());
throw Error(`unexpected status ${resp.status} for withdraw`);
}
const r = await resp.json();
const denomSig = await ws.cryptoApi.rsaUnblind( const denomSig = await ws.cryptoApi.rsaUnblind(
r.ev_sig, r.ev_sig,
@ -236,8 +238,8 @@ async function processPlanchet(
type: CoinSourceType.Withdraw, type: CoinSourceType.Withdraw,
coinIndex: coinIdx, coinIndex: coinIdx,
reservePub: planchet.reservePub, reservePub: planchet.reservePub,
withdrawSessionId: withdrawalSessionId withdrawSessionId: withdrawalSessionId,
} },
}; };
let withdrawSessionFinished = false; let withdrawSessionFinished = false;

View File

@ -40,7 +40,7 @@ import { Timestamp, codecForTimestamp } from "../util/time";
export const enum ReserveTransactionType { export const enum ReserveTransactionType {
Withdraw = "WITHDRAW", Withdraw = "WITHDRAW",
Deposit = "DEPOSIT", Deposit = "DEPOSIT",
Payback = "PAYBACK", Recoup = "RECOUP",
Closing = "CLOSING", Closing = "CLOSING",
} }
@ -139,24 +139,14 @@ export interface ReserveClosingTransaction {
timestamp: Timestamp; timestamp: Timestamp;
} }
export interface ReservePaybackTransaction { export interface ReserveRecoupTransaction {
type: ReserveTransactionType.Payback; type: ReserveTransactionType.Recoup;
/** /**
* Amount paid back. * Amount paid back.
*/ */
amount: AmountString; amount: AmountString;
/**
* Receiver account details.
*/
receiver_account_details: any;
/**
* Wire transfer identifier.
*/
wire_transfer: any;
/** /**
* This is a signature over * This is a signature over
* a struct TALER_PaybackConfirmationPS with purpose * a struct TALER_PaybackConfirmationPS with purpose
@ -187,7 +177,7 @@ export type ReserveTransaction =
| ReserveWithdrawTransaction | ReserveWithdrawTransaction
| ReserveDepositTransaction | ReserveDepositTransaction
| ReserveClosingTransaction | ReserveClosingTransaction
| ReservePaybackTransaction; | ReserveRecoupTransaction;
export const codecForReserveWithdrawTransaction = () => export const codecForReserveWithdrawTransaction = () =>
typecheckedCodec<ReserveWithdrawTransaction>( typecheckedCodec<ReserveWithdrawTransaction>(
@ -229,18 +219,16 @@ export const codecForReserveClosingTransaction = () =>
.build("ReserveClosingTransaction"), .build("ReserveClosingTransaction"),
); );
export const codecForReservePaybackTransaction = () => export const codecForReserveRecoupTransaction = () =>
typecheckedCodec<ReservePaybackTransaction>( typecheckedCodec<ReserveRecoupTransaction>(
makeCodecForObject<ReservePaybackTransaction>() makeCodecForObject<ReserveRecoupTransaction>()
.property("amount", codecForString) .property("amount", codecForString)
.property("coin_pub", codecForString) .property("coin_pub", codecForString)
.property("exchange_pub", codecForString) .property("exchange_pub", codecForString)
.property("exchange_sig", codecForString) .property("exchange_sig", codecForString)
.property("receiver_account_details", codecForString)
.property("timestamp", codecForTimestamp) .property("timestamp", codecForTimestamp)
.property("type", makeCodecForConstString(ReserveTransactionType.Payback)) .property("type", makeCodecForConstString(ReserveTransactionType.Recoup))
.property("wire_transfer", codecForString) .build("ReserveRecoupTransaction"),
.build("ReservePaybackTransaction"),
); );
export const codecForReserveTransaction = () => export const codecForReserveTransaction = () =>
@ -256,8 +244,8 @@ export const codecForReserveTransaction = () =>
codecForReserveClosingTransaction(), codecForReserveClosingTransaction(),
) )
.alternative( .alternative(
ReserveTransactionType.Payback, ReserveTransactionType.Recoup,
codecForReservePaybackTransaction(), codecForReserveRecoupTransaction(),
) )
.alternative( .alternative(
ReserveTransactionType.Deposit, ReserveTransactionType.Deposit,

View File

@ -1457,6 +1457,11 @@ export namespace Stores {
"denomPubIndex", "denomPubIndex",
"denomPub", "denomPub",
); );
denomPubHashIndex = new Index<string, CoinRecord>(
this,
"denomPubHashIndex",
"denomPubHash",
);
} }
class ProposalsStore extends Store<ProposalRecord> { class ProposalsStore extends Store<ProposalRecord> {

View File

@ -26,8 +26,8 @@ export const enum NotificationType {
ProposalAccepted = "proposal-accepted", ProposalAccepted = "proposal-accepted",
ProposalDownloaded = "proposal-downloaded", ProposalDownloaded = "proposal-downloaded",
RefundsSubmitted = "refunds-submitted", RefundsSubmitted = "refunds-submitted",
RecoupStarted = "payback-started", RecoupStarted = "recoup-started",
RecoupFinished = "payback-finished", RecoupFinished = "recoup-finished",
RefreshRevealed = "refresh-revealed", RefreshRevealed = "refresh-revealed",
RefreshMelted = "refresh-melted", RefreshMelted = "refresh-melted",
RefreshStarted = "refresh-started", RefreshStarted = "refresh-started",
@ -44,7 +44,7 @@ export const enum NotificationType {
RefundFinished = "refund-finished", RefundFinished = "refund-finished",
ExchangeOperationError = "exchange-operation-error", ExchangeOperationError = "exchange-operation-error",
RefreshOperationError = "refresh-operation-error", RefreshOperationError = "refresh-operation-error",
RecoupOperationError = "refresh-operation-error", RecoupOperationError = "recoup-operation-error",
RefundApplyOperationError = "refund-apply-error", RefundApplyOperationError = "refund-apply-error",
RefundStatusOperationError = "refund-status-error", RefundStatusOperationError = "refund-status-error",
ProposalOperationError = "proposal-error", ProposalOperationError = "proposal-error",
@ -82,11 +82,11 @@ export interface RefundsSubmittedNotification {
proposalId: string; proposalId: string;
} }
export interface PaybackStartedNotification { export interface RecoupStartedNotification {
type: NotificationType.RecoupStarted; type: NotificationType.RecoupStarted;
} }
export interface PaybackFinishedNotification { export interface RecoupFinishedNotification {
type: NotificationType.RecoupFinished; type: NotificationType.RecoupFinished;
} }
@ -171,6 +171,10 @@ export interface WithdrawOperationErrorNotification {
type: NotificationType.WithdrawOperationError; type: NotificationType.WithdrawOperationError;
} }
export interface RecoupOperationErrorNotification {
type: NotificationType.RecoupOperationError;
}
export interface ReserveOperationErrorNotification { export interface ReserveOperationErrorNotification {
type: NotificationType.ReserveOperationError; type: NotificationType.ReserveOperationError;
operationError: OperationError; operationError: OperationError;
@ -197,8 +201,8 @@ export type WalletNotification =
| ProposalAcceptedNotification | ProposalAcceptedNotification
| ProposalDownloadedNotification | ProposalDownloadedNotification
| RefundsSubmittedNotification | RefundsSubmittedNotification
| PaybackStartedNotification | RecoupStartedNotification
| PaybackFinishedNotification | RecoupFinishedNotification
| RefreshMeltedNotification | RefreshMeltedNotification
| RefreshRevealedNotification | RefreshRevealedNotification
| RefreshStartedNotification | RefreshStartedNotification
@ -214,4 +218,5 @@ export type WalletNotification =
| RefundQueriedNotification | RefundQueriedNotification
| WithdrawSessionCreatedNotification | WithdrawSessionCreatedNotification
| CoinWithdrawnNotification | CoinWithdrawnNotification
| WildcardNotification; | WildcardNotification
| RecoupOperationErrorNotification;

View File

@ -204,6 +204,8 @@ export interface PendingRefundApplyOperation {
export interface PendingRecoupOperation { export interface PendingRecoupOperation {
type: PendingOperationType.Recoup; type: PendingOperationType.Recoup;
recoupGroupId: string; recoupGroupId: string;
retryInfo: RetryInfo;
lastError: OperationError | undefined;
} }
/** /**

View File

@ -148,10 +148,10 @@ export class Auditor {
*/ */
export interface RecoupRequest { export interface RecoupRequest {
/** /**
* Denomination public key of the coin we want to get * Hashed enomination public key of the coin we want to get
* paid back. * paid back.
*/ */
denom_pub: string; denom_pub_hash: string;
/** /**
* Signature over the coin public key by the denomination. * Signature over the coin public key by the denomination.
@ -744,6 +744,10 @@ export class TipPickupGetResponse {
stamp_created: Timestamp; stamp_created: Timestamp;
} }
export class WithdrawResponse {
ev_sig: string;
}
export type AmountString = string; export type AmountString = string;
export type Base32String = string; export type Base32String = string;
export type EddsaSignatureString = string; export type EddsaSignatureString = string;
@ -976,3 +980,11 @@ export const codecForRecoupConfirmation = () =>
.property("exchange_pub", codecForString) .property("exchange_pub", codecForString)
.build("RecoupConfirmation"), .build("RecoupConfirmation"),
); );
export const codecForWithdrawResponse = () =>
typecheckedCodec<WithdrawResponse>(
makeCodecForObject<WithdrawResponse>()
.property("ev_sig", codecForString)
.build("WithdrawResponse"),
);

View File

@ -113,6 +113,7 @@ import {
} from "./operations/refund"; } from "./operations/refund";
import { durationMin, Duration } from "./util/time"; import { durationMin, Duration } from "./util/time";
import { processRecoupGroup } from "./operations/recoup"; import { processRecoupGroup } from "./operations/recoup";
import { OperationFailedAndReportedError } from "./operations/errors";
const builtinCurrencies: CurrencyRecord[] = [ const builtinCurrencies: CurrencyRecord[] = [
{ {
@ -235,10 +236,14 @@ export class Wallet {
try { try {
await this.processOnePendingOperation(p, forceNow); await this.processOnePendingOperation(p, forceNow);
} catch (e) { } catch (e) {
if (e instanceof OperationFailedAndReportedError) {
console.error("Operation failed:", JSON.stringify(e.operationError, undefined, 2));
} else {
console.error(e); console.error(e);
} }
} }
} }
}
/** /**
* Run the wallet until there are no more pending operations that give * Run the wallet until there are no more pending operations that give