full recoup, untested/unfinished first attempt

This commit is contained in:
Florian Dold 2020-03-12 00:44:28 +05:30
parent 6e2881fabf
commit 2c52046f0b
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
23 changed files with 667 additions and 272 deletions

View File

@ -30,12 +30,11 @@ import {
RefreshSessionRecord, RefreshSessionRecord,
TipPlanchet, TipPlanchet,
WireFee, WireFee,
WalletContractData,
} from "../../types/dbTypes"; } from "../../types/dbTypes";
import { CryptoWorker } from "./cryptoWorker"; import { CryptoWorker } from "./cryptoWorker";
import { ContractTerms, PaybackRequest, CoinDepositPermission } from "../../types/talerTypes"; import { RecoupRequest, CoinDepositPermission } from "../../types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
@ -409,8 +408,8 @@ export class CryptoApi {
return this.doRpc<boolean>("isValidWireAccount", 4, paytoUri, sig, masterPub); return this.doRpc<boolean>("isValidWireAccount", 4, paytoUri, sig, masterPub);
} }
createPaybackRequest(coin: CoinRecord): Promise<PaybackRequest> { createRecoupRequest(coin: CoinRecord): Promise<RecoupRequest> {
return this.doRpc<PaybackRequest>("createPaybackRequest", 1, coin); return this.doRpc<RecoupRequest>("createRecoupRequest", 1, coin);
} }
createRefreshSession( createRefreshSession(

View File

@ -31,9 +31,10 @@ import {
RefreshSessionRecord, RefreshSessionRecord,
TipPlanchet, TipPlanchet,
WireFee, WireFee,
CoinSourceType,
} from "../../types/dbTypes"; } from "../../types/dbTypes";
import { CoinDepositPermission, ContractTerms, PaybackRequest } from "../../types/talerTypes"; import { CoinDepositPermission, RecoupRequest } from "../../types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
PlanchetCreationResult, PlanchetCreationResult,
@ -73,7 +74,7 @@ enum SignaturePurpose {
WALLET_COIN_MELT = 1202, WALLET_COIN_MELT = 1202,
TEST = 4242, TEST = 4242,
MERCHANT_PAYMENT_OK = 1104, MERCHANT_PAYMENT_OK = 1104,
WALLET_COIN_PAYBACK = 1203, WALLET_COIN_RECOUP = 1203,
WALLET_COIN_LINK = 1204, WALLET_COIN_LINK = 1204,
} }
@ -198,10 +199,10 @@ export class CryptoImplementation {
} }
/** /**
* Create and sign a message to request payback for a coin. * Create and sign a message to recoup a coin.
*/ */
createPaybackRequest(coin: CoinRecord): PaybackRequest { createRecoupRequest(coin: CoinRecord): RecoupRequest {
const p = buildSigPS(SignaturePurpose.WALLET_COIN_PAYBACK) const p = buildSigPS(SignaturePurpose.WALLET_COIN_RECOUP)
.put(decodeCrock(coin.coinPub)) .put(decodeCrock(coin.coinPub))
.put(decodeCrock(coin.denomPubHash)) .put(decodeCrock(coin.denomPubHash))
.put(decodeCrock(coin.blindingKey)) .put(decodeCrock(coin.blindingKey))
@ -209,12 +210,13 @@ export class CryptoImplementation {
const coinPriv = decodeCrock(coin.coinPriv); const coinPriv = decodeCrock(coin.coinPriv);
const coinSig = eddsaSign(p, coinPriv); const coinSig = eddsaSign(p, coinPriv);
const paybackRequest: PaybackRequest = { const paybackRequest: RecoupRequest = {
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: coin.denomPub,
denom_sig: coin.denomSig, denom_sig: coin.denomSig,
refreshed: (coin.coinSource.type === CoinSourceType.Refresh),
}; };
return paybackRequest; return paybackRequest;
} }

View File

@ -365,6 +365,7 @@ advancedCli
console.log(`coin ${coin.coinPub}`); console.log(`coin ${coin.coinPub}`);
console.log(` status ${coin.status}`); console.log(` status ${coin.status}`);
console.log(` exchange ${coin.exchangeBaseUrl}`); console.log(` exchange ${coin.exchangeBaseUrl}`);
console.log(` denomPubHash ${coin.denomPubHash}`);
console.log( console.log(
` remaining amount ${Amounts.toString(coin.currentAmount)}`, ` remaining amount ${Amounts.toString(coin.currentAmount)}`,
); );

View File

@ -31,6 +31,7 @@ import {
WireFee, WireFee,
ExchangeUpdateReason, ExchangeUpdateReason,
ExchangeUpdatedEventRecord, ExchangeUpdatedEventRecord,
CoinStatus,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { canonicalizeBaseUrl } from "../util/helpers"; import { canonicalizeBaseUrl } from "../util/helpers";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
@ -45,6 +46,7 @@ import {
} from "./versions"; } from "./versions";
import { getTimestampNow } from "../util/time"; import { getTimestampNow } from "../util/time";
import { compare } from "../util/libtoolVersion"; import { compare } from "../util/libtoolVersion";
import { createRecoupGroup, processRecoupGroup } from "./recoup";
async function denominationRecordFromKeys( async function denominationRecordFromKeys(
ws: InternalWalletState, ws: InternalWalletState,
@ -61,6 +63,7 @@ async function denominationRecordFromKeys(
feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), feeRefund: Amounts.parseOrThrow(denomIn.fee_refund),
feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw),
isOffered: true, isOffered: true,
isRevoked: false,
masterSig: denomIn.master_sig, masterSig: denomIn.master_sig,
stampExpireDeposit: denomIn.stamp_expire_deposit, stampExpireDeposit: denomIn.stamp_expire_deposit,
stampExpireLegal: denomIn.stamp_expire_legal, stampExpireLegal: denomIn.stamp_expire_legal,
@ -189,6 +192,8 @@ async function updateExchangeWithKeys(
), ),
); );
let recoupGroupId: string | undefined = undefined;
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.exchanges, Stores.denominations], [Stores.exchanges, Stores.denominations],
async tx => { async tx => {
@ -222,8 +227,46 @@ async function updateExchangeWithKeys(
await tx.put(Stores.denominations, newDenom); await tx.put(Stores.denominations, newDenom);
} }
} }
// Handle recoup
const recoupDenomList = exchangeKeysJson.recoup ?? [];
const newlyRevokedCoinPubs: string[] = [];
for (const recoupDenomPubHash of recoupDenomList) {
const oldDenom = await tx.getIndexed(
Stores.denominations.denomPubHashIndex,
recoupDenomPubHash,
);
if (!oldDenom) {
// We never even knew about the revoked denomination, all good.
continue;
}
if (oldDenom.isRevoked) {
// We already marked the denomination as revoked,
// this implies we revoked all coins
continue;
}
oldDenom.isRevoked = true;
await tx.put(Stores.denominations, oldDenom);
const affectedCoins = await tx
.iterIndexed(Stores.coins.denomPubIndex)
.toArray();
for (const ac of affectedCoins) {
newlyRevokedCoinPubs.push(ac.coinPub);
}
}
if (newlyRevokedCoinPubs.length != 0) {
await createRecoupGroup(ws, tx, newlyRevokedCoinPubs);
}
}, },
); );
if (recoupGroupId) {
// Asynchronously start recoup. This doesn't need to finish
// for the exchange update to be considered finished.
processRecoupGroup(ws, recoupGroupId).catch((e) => {
console.log("error while recouping coins:", e);
});
}
} }
async function updateExchangeFinalize( async function updateExchangeFinalize(

View File

@ -181,6 +181,7 @@ export async function getHistory(
Stores.payEvents, Stores.payEvents,
Stores.refundEvents, Stores.refundEvents,
Stores.reserveUpdatedEvents, Stores.reserveUpdatedEvents,
Stores.recoupGroups,
], ],
async tx => { async tx => {
tx.iter(Stores.exchanges).forEach(exchange => { tx.iter(Stores.exchanges).forEach(exchange => {
@ -485,6 +486,16 @@ export async function getHistory(
amountRefundedInvalid: Amounts.toString(amountRefundedInvalid), amountRefundedInvalid: Amounts.toString(amountRefundedInvalid),
}); });
}); });
tx.iter(Stores.recoupGroups).forEach(rg => {
if (rg.timestampFinished) {
history.push({
type: HistoryEventType.FundsRecouped,
timestamp: rg.timestampFinished,
eventId: makeEventId(HistoryEventType.FundsRecouped, rg.recoupGroupId),
});
}
});
}, },
); );

View File

@ -405,6 +405,32 @@ async function gatherPurchasePending(
}); });
} }
async function gatherRecoupPending(
tx: TransactionHandle,
now: Timestamp,
resp: PendingOperationsResponse,
onlyDue: boolean = false,
): Promise<void> {
await tx.iter(Stores.recoupGroups).forEach(rg => {
if (rg.timestampFinished) {
return;
}
resp.nextRetryDelay = updateRetryDelay(
resp.nextRetryDelay,
now,
rg.retryInfo.nextRetry,
);
if (onlyDue && rg.retryInfo.nextRetry.t_ms > now.t_ms) {
return;
}
resp.pendingOperations.push({
type: PendingOperationType.Recoup,
givesLifeness: true,
recoupGroupId: rg.recoupGroupId,
});
});
}
export async function getPendingOperations( export async function getPendingOperations(
ws: InternalWalletState, ws: InternalWalletState,
{ onlyDue = false } = {}, { onlyDue = false } = {},
@ -420,6 +446,7 @@ export async function getPendingOperations(
Stores.proposals, Stores.proposals,
Stores.tips, Stores.tips,
Stores.purchases, Stores.purchases,
Stores.recoupGroups,
], ],
async tx => { async tx => {
const walletBalance = await getBalancesInsideTransaction(ws, tx); const walletBalance = await getBalancesInsideTransaction(ws, tx);
@ -436,6 +463,7 @@ export async function getPendingOperations(
await gatherProposalPending(tx, now, resp, onlyDue); await gatherProposalPending(tx, now, resp, onlyDue);
await gatherTipPending(tx, now, resp, onlyDue); await gatherTipPending(tx, now, resp, onlyDue);
await gatherPurchasePending(tx, now, resp, onlyDue); await gatherPurchasePending(tx, now, resp, onlyDue);
await gatherRecoupPending(tx, now, resp, onlyDue);
return resp; return resp;
}, },
); );

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-2010 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
@ -14,76 +14,358 @@
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/>
*/ */
/**
* Implementation of the recoup operation, which allows to recover the
* value of coins held in a revoked denomination.
*
* @author Florian Dold <dold@taler.net>
*/
/** /**
* Imports. * Imports.
*/ */
import {
Database
} from "../util/query";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { Stores, TipRecord, CoinStatus } from "../types/dbTypes"; import {
Stores,
CoinStatus,
CoinSourceType,
CoinRecord,
WithdrawCoinSource,
RefreshCoinSource,
ReserveRecordStatus,
RecoupGroupRecord,
initRetryInfo,
updateRetryInfoTimeout,
} from "../types/dbTypes";
import { Logger } from "../util/logging"; import { codecForRecoupConfirmation } from "../types/talerTypes";
import { RecoupConfirmation, codecForRecoupConfirmation } from "../types/talerTypes";
import { updateExchangeFromUrl } from "./exchanges";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { processReserve } from "./reserves";
const logger = new Logger("payback.ts"); import * as Amounts from "../util/amounts";
import { createRefreshGroup, processRefreshGroup } from "./refresh";
import { RefreshReason, OperationError } from "../types/walletTypes";
import { TransactionHandle } from "../util/query";
import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto";
import { getTimestampNow } from "../util/time";
import { guardOperationException } from "./errors";
export async function recoup( async function incrementRecoupRetry(
ws: InternalWalletState, ws: InternalWalletState,
coinPub: string, recoupGroupId: string,
err: OperationError | undefined,
): Promise<void> { ): Promise<void> {
await ws.db.runWithWriteTransaction([Stores.recoupGroups], async tx => {
const r = await tx.get(Stores.recoupGroups, recoupGroupId);
if (!r) {
return;
}
if (!r.retryInfo) {
return;
}
r.retryInfo.retryCounter++;
updateRetryInfoTimeout(r.retryInfo);
r.lastError = err;
await tx.put(Stores.recoupGroups, r);
});
ws.notify({ type: NotificationType.RecoupOperationError });
}
async function putGroupAsFinished(
tx: TransactionHandle,
recoupGroup: RecoupGroupRecord,
coinIdx: number,
): Promise<void> {
recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
let allFinished = true;
for (const b of recoupGroup.recoupFinishedPerCoin) {
if (!b) {
allFinished = false;
}
}
if (allFinished) {
recoupGroup.timestampFinished = getTimestampNow();
recoupGroup.retryInfo = initRetryInfo(false);
recoupGroup.lastError = undefined;
}
await tx.put(Stores.recoupGroups, recoupGroup);
}
async function recoupTipCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
): Promise<void> {
// We can't really recoup a coin we got via tipping.
// Thus we just put the coin to sleep.
// FIXME: somehow report this to the user
await ws.db.runWithWriteTransaction([Stores.recoupGroups], async tx => {
const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
await putGroupAsFinished(tx, recoupGroup, coinIdx);
});
}
async function recoupWithdrawCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
cs: WithdrawCoinSource,
): Promise<void> {
const reservePub = cs.reservePub;
const reserve = await ws.db.get(Stores.reserves, reservePub);
if (!reserve) {
// FIXME: We should at least emit some pending operation / warning for this?
return;
}
ws.notify({
type: NotificationType.RecoupStarted,
});
const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
if (resp.status !== 200) {
throw Error("recoup request failed");
}
const recoupConfirmation = codecForRecoupConfirmation().decode(
await resp.json(),
);
if (recoupConfirmation.reserve_pub !== reservePub) {
throw Error(`Coin's reserve doesn't match reserve on recoup`);
}
// FIXME: verify that our expectations about the amount match
await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.reserves, Stores.recoupGroups],
async tx => {
const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
const updatedCoin = await tx.get(Stores.coins, coin.coinPub);
if (!updatedCoin) {
return;
}
const updatedReserve = await tx.get(Stores.reserves, reserve.reservePub);
if (!updatedReserve) {
return;
}
updatedCoin.status = CoinStatus.Dormant;
const currency = updatedCoin.currentAmount.currency;
updatedCoin.currentAmount = Amounts.getZero(currency);
updatedReserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS;
await tx.put(Stores.coins, updatedCoin);
await tx.put(Stores.reserves, updatedReserve);
await putGroupAsFinished(tx, recoupGroup, coinIdx);
},
);
ws.notify({
type: NotificationType.RecoupFinished,
});
processReserve(ws, reserve.reservePub).catch(e => {
console.log("processing reserve after recoup failed:", e);
});
}
async function recoupRefreshCoin(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
coin: CoinRecord,
cs: RefreshCoinSource,
): Promise<void> {
ws.notify({
type: NotificationType.RecoupStarted,
});
const recoupRequest = await ws.cryptoApi.createRecoupRequest(coin);
const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl);
const resp = await ws.http.postJson(reqUrl.href, recoupRequest);
if (resp.status !== 200) {
throw Error("recoup request failed");
}
const recoupConfirmation = codecForRecoupConfirmation().decode(
await resp.json(),
);
if (recoupConfirmation.old_coin_pub != cs.oldCoinPub) {
throw Error(`Coin's oldCoinPub doesn't match reserve on recoup`);
}
const refreshGroupId = await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.reserves],
async tx => {
const recoupGroup = await tx.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
const oldCoin = await tx.get(Stores.coins, cs.oldCoinPub);
const updatedCoin = await tx.get(Stores.coins, coin.coinPub);
if (!updatedCoin) {
return;
}
if (!oldCoin) {
return;
}
updatedCoin.status = CoinStatus.Dormant;
oldCoin.currentAmount = Amounts.add(
oldCoin.currentAmount,
updatedCoin.currentAmount,
).amount;
await tx.put(Stores.coins, updatedCoin);
await putGroupAsFinished(tx, recoupGroup, coinIdx);
return await createRefreshGroup(
tx,
[{ coinPub: oldCoin.coinPub }],
RefreshReason.Recoup,
);
},
);
if (refreshGroupId) {
processRefreshGroup(ws, refreshGroupId.refreshGroupId).then(e => {
console.error("error while refreshing after recoup", e);
});
}
}
async function resetRecoupGroupRetry(
ws: InternalWalletState,
recoupGroupId: string,
) {
await ws.db.mutate(Stores.recoupGroups, recoupGroupId, x => {
if (x.retryInfo.active) {
x.retryInfo = initRetryInfo();
}
return x;
});
}
export async function processRecoupGroup(
ws: InternalWalletState,
recoupGroupId: string,
forceNow: boolean = false,
): Promise<void> {
await ws.memoProcessRecoup.memo(recoupGroupId, async () => {
const onOpErr = (e: OperationError) =>
incrementRecoupRetry(ws, recoupGroupId, e);
return await guardOperationException(
async () => await processRecoupGroupImpl(ws, recoupGroupId, forceNow),
onOpErr,
);
});
}
async function processRecoupGroupImpl(
ws: InternalWalletState,
recoupGroupId: string,
forceNow: boolean = false,
): Promise<void> {
if (forceNow) {
await resetRecoupGroupRetry(ws, recoupGroupId);
}
const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.timestampFinished) {
return;
}
const ps = recoupGroup.coinPubs.map((x, i) =>
processRecoup(ws, recoupGroupId, i),
);
await Promise.all(ps);
}
export async function createRecoupGroup(
ws: InternalWalletState,
tx: TransactionHandle,
coinPubs: string[],
): Promise<string> {
const recoupGroupId = encodeCrock(getRandomBytes(32));
const recoupGroup: RecoupGroupRecord = {
recoupGroupId,
coinPubs: coinPubs,
lastError: undefined,
timestampFinished: undefined,
timestampStarted: getTimestampNow(),
retryInfo: initRetryInfo(),
recoupFinishedPerCoin: coinPubs.map(() => false),
};
for (let coinIdx = 0; coinIdx < coinPubs.length; coinIdx++) {
const coinPub = coinPubs[coinIdx];
const coin = await tx.get(Stores.coins, coinPub);
if (!coin) {
recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
continue;
}
if (Amounts.isZero(coin.currentAmount)) {
recoupGroup.recoupFinishedPerCoin[coinIdx] = true;
continue;
}
coin.currentAmount = Amounts.getZero(coin.currentAmount.currency);
await tx.put(Stores.coins, coin);
}
await tx.put(Stores.recoupGroups, recoupGroup);
return recoupGroupId;
}
async function processRecoup(
ws: InternalWalletState,
recoupGroupId: string,
coinIdx: number,
): Promise<void> {
const recoupGroup = await ws.db.get(Stores.recoupGroups, recoupGroupId);
if (!recoupGroup) {
return;
}
if (recoupGroup.timestampFinished) {
return;
}
if (recoupGroup.recoupFinishedPerCoin[coinIdx]) {
return;
}
const coinPub = recoupGroup.coinPubs[coinIdx];
let coin = await ws.db.get(Stores.coins, coinPub); let coin = await ws.db.get(Stores.coins, coinPub);
if (!coin) { if (!coin) {
throw Error(`Coin ${coinPub} not found, can't request payback`); throw Error(`Coin ${coinPub} not found, can't request payback`);
} }
const reservePub = coin.reservePub;
if (!reservePub) {
throw Error(`Can't request payback for a refreshed coin`);
}
const reserve = await ws.db.get(Stores.reserves, reservePub);
if (!reserve) {
throw Error(`Reserve of coin ${coinPub} not found`);
}
switch (coin.status) {
case CoinStatus.Dormant:
throw Error(`Can't do payback for coin ${coinPub} since it's dormant`);
}
coin.status = CoinStatus.Dormant;
// Even if we didn't get the payback yet, we suspend withdrawal, since
// technically we might update reserve status before we get the response
// from the reserve for the payback request.
reserve.hasPayback = true;
await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.reserves],
async tx => {
await tx.put(Stores.coins, coin!!);
await tx.put(Stores.reserves, reserve);
},
);
ws.notify({
type: NotificationType.PaybackStarted,
});
const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); const cs = coin.coinSource;
const reqUrl = new URL("payback", coin.exchangeBaseUrl);
const resp = await ws.http.postJson(reqUrl.href, paybackRequest); switch (cs.type) {
if (resp.status !== 200) { case CoinSourceType.Tip:
throw Error(); return recoupTipCoin(ws, recoupGroupId, coinIdx, coin);
case CoinSourceType.Refresh:
return recoupRefreshCoin(ws, recoupGroupId, coinIdx, coin, cs);
case CoinSourceType.Withdraw:
return recoupWithdrawCoin(ws, recoupGroupId, coinIdx, coin, cs);
default:
throw Error("unknown coin source type");
} }
const paybackConfirmation = codecForRecoupConfirmation().decode(await resp.json());
if (paybackConfirmation.reserve_pub !== coin.reservePub) {
throw Error(`Coin's reserve doesn't match reserve on payback`);
}
coin = await ws.db.get(Stores.coins, coinPub);
if (!coin) {
throw Error(`Coin ${coinPub} not found, can't confirm payback`);
}
coin.status = CoinStatus.Dormant;
await ws.db.put(Stores.coins, coin);
ws.notify({
type: NotificationType.PaybackFinished,
});
await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true);
} }

View File

@ -26,6 +26,7 @@ import {
initRetryInfo, initRetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
RefreshGroupRecord, RefreshGroupRecord,
CoinSourceType,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { amountToPretty } from "../util/helpers"; import { amountToPretty } from "../util/helpers";
import { Database, TransactionHandle } from "../util/query"; import { Database, TransactionHandle } from "../util/query";
@ -407,10 +408,11 @@ async function refreshReveal(
denomPubHash: denom.denomPubHash, denomPubHash: denom.denomPubHash,
denomSig, denomSig,
exchangeBaseUrl: refreshSession.exchangeBaseUrl, exchangeBaseUrl: refreshSession.exchangeBaseUrl,
reservePub: undefined,
status: CoinStatus.Fresh, status: CoinStatus.Fresh,
coinIndex: -1, coinSource: {
withdrawSessionId: "", type: CoinSourceType.Refresh,
oldCoinPub: refreshSession.meltCoinPub,
}
}; };
coins.push(coin); coins.push(coin);

View File

@ -103,7 +103,6 @@ export async function createReserve(
amountWithdrawCompleted: Amounts.getZero(currency), amountWithdrawCompleted: Amounts.getZero(currency),
amountWithdrawRemaining: Amounts.getZero(currency), amountWithdrawRemaining: Amounts.getZero(currency),
exchangeBaseUrl: canonExchange, exchangeBaseUrl: canonExchange,
hasPayback: false,
amountInitiallyRequested: req.amount, amountInitiallyRequested: req.amount,
reservePriv: keypair.priv, reservePriv: keypair.priv,
reservePub: keypair.pub, reservePub: keypair.pub,

View File

@ -39,6 +39,7 @@ export class InternalWalletState {
> = new AsyncOpMemoSingle(); > = new AsyncOpMemoSingle();
memoGetBalance: AsyncOpMemoSingle<WalletBalance> = new AsyncOpMemoSingle(); memoGetBalance: AsyncOpMemoSingle<WalletBalance> = new AsyncOpMemoSingle();
memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
cryptoApi: CryptoApi; cryptoApi: CryptoApi;
listeners: NotificationListener[] = []; listeners: NotificationListener[] = [];

View File

@ -24,6 +24,7 @@ import {
PlanchetRecord, PlanchetRecord,
initRetryInfo, initRetryInfo,
updateRetryInfoTimeout, updateRetryInfoTimeout,
CoinSourceType,
} from "../types/dbTypes"; } from "../types/dbTypes";
import * as Amounts from "../util/amounts"; import * as Amounts from "../util/amounts";
import { import {
@ -48,6 +49,7 @@ 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");
@ -229,10 +231,13 @@ async function processPlanchet(
denomPubHash: planchet.denomPubHash, denomPubHash: planchet.denomPubHash,
denomSig, denomSig,
exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, exchangeBaseUrl: withdrawalSession.exchangeBaseUrl,
reservePub: planchet.reservePub,
status: CoinStatus.Fresh, status: CoinStatus.Fresh,
coinIndex: coinIdx, coinSource: {
withdrawSessionId: withdrawalSessionId, type: CoinSourceType.Withdraw,
coinIndex: coinIdx,
reservePub: planchet.reservePub,
withdrawSessionId: withdrawalSessionId
}
}; };
let withdrawSessionFinished = false; let withdrawSessionFinished = false;
@ -449,14 +454,15 @@ async function processWithdrawCoin(
return; return;
} }
const coin = await ws.db.getIndexed(Stores.coins.byWithdrawalWithIdx, [ const planchet = withdrawalSession.planchets[coinIndex];
withdrawalSessionId,
coinIndex,
]);
if (coin) { if (planchet) {
console.log("coin already exists"); const coin = await ws.db.get(Stores.coins, planchet.coinPub);
return;
if (coin) {
console.log("coin already exists");
return;
}
} }
if (!withdrawalSession.planchets[coinIndex]) { if (!withdrawalSession.planchets[coinIndex]) {

View File

@ -33,10 +33,7 @@ import {
} from "./talerTypes"; } from "./talerTypes";
import { Index, Store } from "../util/query"; import { Index, Store } from "../util/query";
import { import { OperationError, RefreshReason } from "./walletTypes";
OperationError,
RefreshReason,
} from "./walletTypes";
import { ReserveTransaction } from "./ReserveTransaction"; import { ReserveTransaction } from "./ReserveTransaction";
import { Timestamp, Duration, getTimestampNow } from "../util/time"; import { Timestamp, Duration, getTimestampNow } from "../util/time";
@ -133,7 +130,6 @@ export function initRetryInfo(
return info; return info;
} }
/** /**
* A reserve record as stored in the wallet's database. * A reserve record as stored in the wallet's database.
*/ */
@ -196,12 +192,6 @@ export interface ReserveRecord {
*/ */
amountInitiallyRequested: AmountJson; amountInitiallyRequested: AmountJson;
/**
* We got some payback to this reserve. We'll cease to automatically
* withdraw money from it.
*/
hasPayback: boolean;
/** /**
* Wire information (as payto URI) for the bank account that * Wire information (as payto URI) for the bank account that
* transfered funds for this reserve. * transfered funds for this reserve.
@ -386,6 +376,8 @@ export interface DenominationRecord {
/** /**
* Did we verify the signature on the denomination? * Did we verify the signature on the denomination?
*
* FIXME: Rename to "verificationStatus"?
*/ */
status: DenominationStatus; status: DenominationStatus;
@ -396,6 +388,13 @@ export interface DenominationRecord {
*/ */
isOffered: boolean; isOffered: boolean;
/**
* Did the exchange revoke the denomination?
* When this field is set to true in the database, the same transaction
* should also mark all affected coins as revoked.
*/
isRevoked: boolean;
/** /**
* Base URL of the exchange. * Base URL of the exchange.
*/ */
@ -577,7 +576,7 @@ export interface RefreshPlanchetRecord {
/** /**
* Status of a coin. * Status of a coin.
*/ */
export enum CoinStatus { export const enum CoinStatus {
/** /**
* Withdrawn and never shown to anybody. * Withdrawn and never shown to anybody.
*/ */
@ -588,26 +587,47 @@ export enum CoinStatus {
Dormant = "dormant", Dormant = "dormant",
} }
export enum CoinSource { export const enum CoinSourceType {
Withdraw = "withdraw", Withdraw = "withdraw",
Refresh = "refresh", Refresh = "refresh",
Tip = "tip", Tip = "tip",
} }
export interface WithdrawCoinSource {
type: CoinSourceType.Withdraw;
withdrawSessionId: string;
/**
* Index of the coin in the withdrawal session.
*/
coinIndex: number;
/**
* Reserve public key for the reserve we got this coin from.
*/
reservePub: string;
}
export interface RefreshCoinSource {
type: CoinSourceType.Refresh;
oldCoinPub: string;
}
export interface TipCoinSource {
type: CoinSourceType.Tip;
}
export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource;
/** /**
* CoinRecord as stored in the "coins" data store * CoinRecord as stored in the "coins" data store
* of the wallet database. * of the wallet database.
*/ */
export interface CoinRecord { export interface CoinRecord {
/** /**
* Withdraw session ID, or "" (empty string) if withdrawn via refresh. * Where did the coin come from? Used for recouping coins.
*/ */
withdrawSessionId: string; coinSource: CoinSource;
/**
* Index of the coin in the withdrawal session.
*/
coinIndex: number;
/** /**
* Public key of the coin. * Public key of the coin.
@ -658,12 +678,6 @@ export interface CoinRecord {
*/ */
blindingKey: string; blindingKey: string;
/**
* Reserve public key for the reserve we got this coin from,
* or zero when we got the coin from refresh.
*/
reservePub: string | undefined;
/** /**
* Status of the coin. * Status of the coin.
*/ */
@ -992,10 +1006,10 @@ export interface WireFee {
/** /**
* Record to store information about a refund event. * Record to store information about a refund event.
* *
* All information about a refund is stored with the purchase, * All information about a refund is stored with the purchase,
* this event is just for the history. * this event is just for the history.
* *
* The event is only present for completed refunds. * The event is only present for completed refunds.
*/ */
export interface RefundEventRecord { export interface RefundEventRecord {
@ -1285,6 +1299,11 @@ export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve;
export interface WithdrawalSessionRecord { export interface WithdrawalSessionRecord {
withdrawSessionId: string; withdrawSessionId: string;
/**
* Withdrawal source. Fields that don't apply to the respective
* withdrawal source type must be null (i.e. can't be absent),
* otherwise the IndexedDB indexing won't like us.
*/
source: WithdrawalSource; source: WithdrawalSource;
exchangeBaseUrl: string; exchangeBaseUrl: string;
@ -1343,6 +1362,46 @@ export interface BankWithdrawUriRecord {
reservePub: string; reservePub: string;
} }
/**
* Status of recoup operations that were grouped together.
*
* The remaining amount of involved coins should be set to zero
* in the same transaction that inserts the RecoupGroupRecord.
*/
export interface RecoupGroupRecord {
/**
* Unique identifier for the recoup group record.
*/
recoupGroupId: string;
timestampStarted: Timestamp;
timestampFinished: Timestamp | undefined;
/**
* Public keys that identify the coins being recouped
* as part of this session.
*
* (Structured like this to enable multiEntry indexing in IndexedDB.)
*/
coinPubs: string[];
/**
* Array of flags to indicate whether the recoup finished on each individual coin.
*/
recoupFinishedPerCoin: boolean[];
/**
* Retry info.
*/
retryInfo: RetryInfo;
/**
* Last error that occured, if any.
*/
lastError: OperationError | undefined;
}
export const enum ImportPayloadType { export const enum ImportPayloadType {
CoreSchema = "core-schema", CoreSchema = "core-schema",
} }
@ -1398,11 +1457,6 @@ export namespace Stores {
"denomPubIndex", "denomPubIndex",
"denomPub", "denomPub",
); );
byWithdrawalWithIdx = new Index<any, CoinRecord>(
this,
"planchetsByWithdrawalWithIdxIndex",
["withdrawSessionId", "coinIndex"],
);
} }
class ProposalsStore extends Store<ProposalRecord> { class ProposalsStore extends Store<ProposalRecord> {
@ -1540,6 +1594,9 @@ export namespace Stores {
export const refreshGroups = new Store<RefreshGroupRecord>("refreshGroups", { export const refreshGroups = new Store<RefreshGroupRecord>("refreshGroups", {
keyPath: "refreshGroupId", keyPath: "refreshGroupId",
}); });
export const recoupGroups = new Store<RecoupGroupRecord>("recoupGroups", {
keyPath: "recoupGroupId",
});
export const reserves = new ReservesStore(); export const reserves = new ReservesStore();
export const purchases = new PurchasesStore(); export const purchases = new PurchasesStore();
export const tips = new TipsStore(); export const tips = new TipsStore();

View File

@ -348,19 +348,7 @@ export interface HistoryFundsDepositedToSelfEvent {
* converted funds in these denominations to new funds. * converted funds in these denominations to new funds.
*/ */
export interface HistoryFundsRecoupedEvent { export interface HistoryFundsRecoupedEvent {
type: HistoryEventType.FundsDepositedToSelf; type: HistoryEventType.FundsRecouped;
exchangeBaseUrl: string;
/**
* Amount that the wallet managed to recover.
*/
amountRecouped: string;
/**
* Amount that was lost due to fees.
*/
amountLost: string;
} }
/** /**

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",
PaybackStarted = "payback-started", RecoupStarted = "payback-started",
PaybackFinished = "payback-finished", RecoupFinished = "payback-finished",
RefreshRevealed = "refresh-revealed", RefreshRevealed = "refresh-revealed",
RefreshMelted = "refresh-melted", RefreshMelted = "refresh-melted",
RefreshStarted = "refresh-started", RefreshStarted = "refresh-started",
@ -44,6 +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",
RefundApplyOperationError = "refund-apply-error", RefundApplyOperationError = "refund-apply-error",
RefundStatusOperationError = "refund-status-error", RefundStatusOperationError = "refund-status-error",
ProposalOperationError = "proposal-error", ProposalOperationError = "proposal-error",
@ -82,11 +83,11 @@ export interface RefundsSubmittedNotification {
} }
export interface PaybackStartedNotification { export interface PaybackStartedNotification {
type: NotificationType.PaybackStarted; type: NotificationType.RecoupStarted;
} }
export interface PaybackFinishedNotification { export interface PaybackFinishedNotification {
type: NotificationType.PaybackFinished; type: NotificationType.RecoupFinished;
} }
export interface RefreshMeltedNotification { export interface RefreshMeltedNotification {

View File

@ -58,6 +58,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon &
| PendingTipChoiceOperation | PendingTipChoiceOperation
| PendingTipPickupOperation | PendingTipPickupOperation
| PendingWithdrawOperation | PendingWithdrawOperation
| PendingRecoupOperation
); );
/** /**
@ -200,6 +201,11 @@ export interface PendingRefundApplyOperation {
numRefundsDone: number; numRefundsDone: number;
} }
export interface PendingRecoupOperation {
type: PendingOperationType.Recoup;
recoupGroupId: string;
}
/** /**
* Status of an ongoing withdrawal operation. * Status of an ongoing withdrawal operation.
*/ */

View File

@ -38,7 +38,12 @@ import {
codecForBoolean, codecForBoolean,
makeCodecForMap, makeCodecForMap,
} from "../util/codec"; } from "../util/codec";
import { Timestamp, codecForTimestamp, Duration, codecForDuration } from "../util/time"; import {
Timestamp,
codecForTimestamp,
Duration,
codecForDuration,
} from "../util/time";
/** /**
* Denomination as found in the /keys response from the exchange. * Denomination as found in the /keys response from the exchange.
@ -141,7 +146,7 @@ export class Auditor {
/** /**
* Request that we send to the exchange to get a payback. * Request that we send to the exchange to get a payback.
*/ */
export interface PaybackRequest { export interface RecoupRequest {
/** /**
* Denomination public key of the coin we want to get * Denomination public key of the coin we want to get
* paid back. * paid back.
@ -168,6 +173,11 @@ export interface PaybackRequest {
* Signature made by the coin, authorizing the payback. * Signature made by the coin, authorizing the payback.
*/ */
coin_sig: string; coin_sig: string;
/**
* Was the coin refreshed (and thus the recoup should go to the old coin)?
*/
refreshed: boolean;
} }
/** /**
@ -175,9 +185,15 @@ export interface PaybackRequest {
*/ */
export class RecoupConfirmation { export class RecoupConfirmation {
/** /**
* public key of the reserve that will receive the payback. * Public key of the reserve that will receive the payback.
*/ */
reserve_pub: string; reserve_pub?: string;
/**
* Public key of the old coin that will receive the recoup,
* provided if refreshed was true.
*/
old_coin_pub?: string;
/** /**
* How much will the exchange pay back (needed by wallet in * How much will the exchange pay back (needed by wallet in
@ -575,7 +591,7 @@ export class TipResponse {
* Element of the payback list that the * Element of the payback list that the
* exchange gives us in /keys. * exchange gives us in /keys.
*/ */
export class Payback { export class Recoup {
/** /**
* The hash of the denomination public key for which the payback is offered. * The hash of the denomination public key for which the payback is offered.
*/ */
@ -607,9 +623,9 @@ export class ExchangeKeysJson {
list_issue_date: Timestamp; list_issue_date: Timestamp;
/** /**
* List of paybacks for compromised denominations. * List of revoked denominations.
*/ */
payback?: Payback[]; recoup?: Recoup[];
/** /**
* Short-lived signing keys used to sign online * Short-lived signing keys used to sign online
@ -764,7 +780,10 @@ export const codecForAuditor = () =>
makeCodecForObject<Auditor>() makeCodecForObject<Auditor>()
.property("auditor_pub", codecForString) .property("auditor_pub", codecForString)
.property("auditor_url", codecForString) .property("auditor_url", codecForString)
.property("denomination_keys", makeCodecForList(codecForAuditorDenomSig())) .property(
"denomination_keys",
makeCodecForList(codecForAuditorDenomSig()),
)
.build("Auditor"), .build("Auditor"),
); );
@ -779,7 +798,7 @@ export const codecForExchangeHandle = () =>
export const codecForAuditorHandle = () => export const codecForAuditorHandle = () =>
typecheckedCodec<AuditorHandle>( typecheckedCodec<AuditorHandle>(
makeCodecForObject<AuditorHandle>() makeCodecForObject<AuditorHandle>()
.property("name", codecForString) .property("name", codecForString)
.property("master_pub", codecForString) .property("master_pub", codecForString)
.property("url", codecForString) .property("url", codecForString)
.build("AuditorHandle"), .build("AuditorHandle"),
@ -851,9 +870,9 @@ export const codecForTipResponse = () =>
.build("TipResponse"), .build("TipResponse"),
); );
export const codecForPayback = () => export const codecForRecoup = () =>
typecheckedCodec<Payback>( typecheckedCodec<Recoup>(
makeCodecForObject<Payback>() makeCodecForObject<Recoup>()
.property("h_denom_pub", codecForString) .property("h_denom_pub", codecForString)
.build("Payback"), .build("Payback"),
); );
@ -865,13 +884,12 @@ export const codecForExchangeKeysJson = () =>
.property("master_public_key", codecForString) .property("master_public_key", codecForString)
.property("auditors", makeCodecForList(codecForAuditor())) .property("auditors", makeCodecForList(codecForAuditor()))
.property("list_issue_date", codecForTimestamp) .property("list_issue_date", codecForTimestamp)
.property("payback", makeCodecOptional(makeCodecForList(codecForPayback()))) .property("recoup", makeCodecOptional(makeCodecForList(codecForRecoup())))
.property("signkeys", codecForAny) .property("signkeys", codecForAny)
.property("version", codecForString) .property("version", codecForString)
.build("KeysJson"), .build("KeysJson"),
); );
export const codecForWireFeesJson = () => export const codecForWireFeesJson = () =>
typecheckedCodec<WireFeesJson>( typecheckedCodec<WireFeesJson>(
makeCodecForObject<WireFeesJson>() makeCodecForObject<WireFeesJson>()
@ -895,7 +913,10 @@ export const codecForExchangeWireJson = () =>
typecheckedCodec<ExchangeWireJson>( typecheckedCodec<ExchangeWireJson>(
makeCodecForObject<ExchangeWireJson>() makeCodecForObject<ExchangeWireJson>()
.property("accounts", makeCodecForList(codecForAccountInfo())) .property("accounts", makeCodecForList(codecForAccountInfo()))
.property("fees", makeCodecForMap(makeCodecForList(codecForWireFeesJson()))) .property(
"fees",
makeCodecForMap(makeCodecForList(codecForWireFeesJson())),
)
.build("ExchangeWireJson"), .build("ExchangeWireJson"),
); );
@ -919,13 +940,12 @@ export const codecForCheckPaymentResponse = () =>
.build("CheckPaymentResponse"), .build("CheckPaymentResponse"),
); );
export const codecForWithdrawOperationStatusResponse = () => export const codecForWithdrawOperationStatusResponse = () =>
typecheckedCodec<WithdrawOperationStatusResponse>( typecheckedCodec<WithdrawOperationStatusResponse>(
makeCodecForObject<WithdrawOperationStatusResponse>() makeCodecForObject<WithdrawOperationStatusResponse>()
.property("selection_done", codecForBoolean) .property("selection_done", codecForBoolean)
.property("transfer_done", codecForBoolean) .property("transfer_done", codecForBoolean)
.property("amount",codecForString) .property("amount", codecForString)
.property("sender_wire", makeCodecOptional(codecForString)) .property("sender_wire", makeCodecOptional(codecForString))
.property("suggested_exchange", makeCodecOptional(codecForString)) .property("suggested_exchange", makeCodecOptional(codecForString))
.property("confirm_transfer_url", makeCodecOptional(codecForString)) .property("confirm_transfer_url", makeCodecOptional(codecForString))
@ -945,11 +965,11 @@ export const codecForTipPickupGetResponse = () =>
.build("TipPickupGetResponse"), .build("TipPickupGetResponse"),
); );
export const codecForRecoupConfirmation = () => export const codecForRecoupConfirmation = () =>
typecheckedCodec<RecoupConfirmation>( typecheckedCodec<RecoupConfirmation>(
makeCodecForObject<RecoupConfirmation>() makeCodecForObject<RecoupConfirmation>()
.property("reserve_pub", codecForString) .property("reserve_pub", makeCodecOptional(codecForString))
.property("old_coin_pub", makeCodecOptional(codecForString))
.property("amount", codecForString) .property("amount", codecForString)
.property("timestamp", codecForTimestamp) .property("timestamp", codecForTimestamp)
.property("exchange_sig", codecForString) .property("exchange_sig", codecForString)

View File

@ -1,6 +1,6 @@
/* /*
This file is part of TALER This file is part of GNU Taler
(C) 2015-2017 GNUnet e.V. and INRIA (C) 2015-2020 Taler Systems SA
TALER is free software; you can redistribute it and/or modify it under the 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
@ -20,6 +20,8 @@
* These types are defined in a separate file make tree shaking easier, since * These types are defined in a separate file make tree shaking easier, since
* some components use these types (via RPC) but do not depend on the wallet * some components use these types (via RPC) but do not depend on the wallet
* code directly. * code directly.
*
* @author Florian Dold <dold@taler.net>
*/ */
/** /**

View File

@ -271,6 +271,14 @@ export class TransactionHandle {
return new ResultStream<T>(req); return new ResultStream<T>(req);
} }
iterIndexed<S extends IDBValidKey,T>(
index: Index<S, T>,
key?: any,
): ResultStream<T> {
const req = this.tx.objectStore(index.storeName).index(index.indexName).openCursor(key);
return new ResultStream<T>(req);
}
delete<T>(store: Store<T>, key: any): Promise<void> { delete<T>(store: Store<T>, key: any): Promise<void> {
const req = this.tx.objectStore(store.name).delete(key); const req = this.tx.objectStore(store.name).delete(key);
return requestToPromise(req); return requestToPromise(req);

View File

@ -95,7 +95,6 @@ import { getHistory } from "./operations/history";
import { getPendingOperations } from "./operations/pending"; import { getPendingOperations } from "./operations/pending";
import { getBalances } from "./operations/balance"; import { getBalances } from "./operations/balance";
import { acceptTip, getTipStatus, processTip } from "./operations/tip"; import { acceptTip, getTipStatus, processTip } from "./operations/tip";
import { recoup } from "./operations/recoup";
import { TimerGroup } from "./util/timer"; import { TimerGroup } from "./util/timer";
import { AsyncCondition } from "./util/promiseUtils"; import { AsyncCondition } from "./util/promiseUtils";
import { AsyncOpMemoSingle } from "./util/asyncMemo"; import { AsyncOpMemoSingle } from "./util/asyncMemo";
@ -113,6 +112,7 @@ import {
applyRefund, applyRefund,
} from "./operations/refund"; } from "./operations/refund";
import { durationMin, Duration } from "./util/time"; import { durationMin, Duration } from "./util/time";
import { processRecoupGroup } from "./operations/recoup";
const builtinCurrencies: CurrencyRecord[] = [ const builtinCurrencies: CurrencyRecord[] = [
{ {
@ -217,6 +217,9 @@ export class Wallet {
case PendingOperationType.RefundApply: case PendingOperationType.RefundApply:
await processPurchaseApplyRefund(this.ws, pending.proposalId, forceNow); await processPurchaseApplyRefund(this.ws, pending.proposalId, forceNow);
break; break;
case PendingOperationType.Recoup:
await processRecoupGroup(this.ws, pending.recoupGroupId, forceNow);
break;
default: default:
assertUnreachable(pending); assertUnreachable(pending);
} }
@ -577,10 +580,6 @@ export class Wallet {
return await this.db.iter(Stores.coins).toArray(); return await this.db.iter(Stores.coins).toArray();
} }
async getPaybackReserves(): Promise<ReserveRecord[]> {
return await this.db.iter(Stores.reserves).filter(r => r.hasPayback);
}
/** /**
* Stop ongoing processing. * Stop ongoing processing.
*/ */

View File

@ -106,14 +106,6 @@ export interface MessageMap {
request: { exchangeBaseUrl: string }; request: { exchangeBaseUrl: string };
response: dbTypes.ReserveRecord[]; response: dbTypes.ReserveRecord[];
}; };
"get-payback-reserves": {
request: {};
response: dbTypes.ReserveRecord[];
};
"withdraw-payback-reserve": {
request: { reservePub: string };
response: dbTypes.ReserveRecord[];
};
"get-denoms": { "get-denoms": {
request: { exchangeBaseUrl: string }; request: { exchangeBaseUrl: string };
response: dbTypes.DenominationRecord[]; response: dbTypes.DenominationRecord[];

View File

@ -25,49 +25,11 @@
*/ */
import { ReserveRecord } from "../../types/dbTypes"; import { ReserveRecord } from "../../types/dbTypes";
import { renderAmount, registerMountPage } from "../renderHtml"; import { renderAmount, registerMountPage } from "../renderHtml";
import { getPaybackReserves, withdrawPaybackReserve } from "../wxApi";
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useState } from "react";
function Payback() { function Payback() {
const [reserves, setReserves] = useState<ReserveRecord[] | null>(null); return <div>not implemented</div>;
useState(() => {
const update = async () => {
const r = await getPaybackReserves();
setReserves(r);
};
const port = chrome.runtime.connect();
port.onMessage.addListener((msg: any) => {
if (msg.notify) {
console.log("got notified");
update();
}
});
});
if (!reserves) {
return <span>loading ...</span>;
}
if (reserves.length === 0) {
return <span>No reserves with payback available.</span>;
}
return (
<div>
{reserves.map(r => (
<div>
<h2>Reserve for ${renderAmount(r.amountWithdrawRemaining)}</h2>
<ul>
<li>Exchange: ${r.exchangeBaseUrl}</li>
</ul>
<button onClick={() => withdrawPaybackReserve(r.reservePub)}>
Withdraw again
</button>
</div>
))}
</div>
);
} }
registerMountPage(() => <Payback />); registerMountPage(() => <Payback />);

View File

@ -18,7 +18,6 @@
* Interface to the wallet through WebExtension messaging. * Interface to the wallet through WebExtension messaging.
*/ */
/** /**
* Imports. * Imports.
*/ */
@ -28,7 +27,6 @@ import {
CurrencyRecord, CurrencyRecord,
DenominationRecord, DenominationRecord,
ExchangeRecord, ExchangeRecord,
PlanchetRecord,
ReserveRecord, ReserveRecord,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { import {
@ -44,7 +42,6 @@ import {
import { MessageMap, MessageType } from "./messages"; import { MessageMap, MessageType } from "./messages";
/** /**
* Response with information about available version upgrades. * Response with information about available version upgrades.
*/ */
@ -66,7 +63,6 @@ export interface UpgradeResponse {
oldDbVersion: string; oldDbVersion: string;
} }
/** /**
* Error thrown when the function from the backend (via RPC) threw an error. * Error thrown when the function from the backend (via RPC) threw an error.
*/ */
@ -78,19 +74,22 @@ export class WalletApiError extends Error {
} }
} }
async function callBackend<T extends MessageType>( async function callBackend<T extends MessageType>(
type: T, type: T,
detail: MessageMap[T]["request"], detail: MessageMap[T]["request"],
): Promise<MessageMap[T]["response"]> { ): Promise<MessageMap[T]["response"]> {
return new Promise<MessageMap[T]["response"]>((resolve, reject) => { return new Promise<MessageMap[T]["response"]>((resolve, reject) => {
chrome.runtime.sendMessage({ type, detail }, (resp) => { chrome.runtime.sendMessage({ type, detail }, resp => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
console.log("Error calling backend"); console.log("Error calling backend");
reject(new Error(`Error contacting backend: chrome.runtime.lastError.message`)); reject(
new Error(
`Error contacting backend: chrome.runtime.lastError.message`,
),
);
} }
if (typeof resp === "object" && resp && resp.error) { if (typeof resp === "object" && resp && resp.error) {
console.warn("response error:", resp) console.warn("response error:", resp);
const e = new WalletApiError(resp.error.message, resp.error); const e = new WalletApiError(resp.error.message, resp.error);
reject(e); reject(e);
} else { } else {
@ -100,42 +99,38 @@ async function callBackend<T extends MessageType>(
}); });
} }
/** /**
* Query the wallet for the coins that would be used to withdraw * Query the wallet for the coins that would be used to withdraw
* from a given reserve. * from a given reserve.
*/ */
export function getReserveCreationInfo(baseUrl: string, export function getReserveCreationInfo(
amount: AmountJson): Promise<ExchangeWithdrawDetails> { baseUrl: string,
amount: AmountJson,
): Promise<ExchangeWithdrawDetails> {
return callBackend("reserve-creation-info", { baseUrl, amount }); return callBackend("reserve-creation-info", { baseUrl, amount });
} }
/** /**
* Get all exchanges the wallet knows about. * Get all exchanges the wallet knows about.
*/ */
export function getExchanges(): Promise<ExchangeRecord[]> { export function getExchanges(): Promise<ExchangeRecord[]> {
return callBackend("get-exchanges", { }); return callBackend("get-exchanges", {});
} }
/** /**
* Get all currencies the exchange knows about. * Get all currencies the exchange knows about.
*/ */
export function getCurrencies(): Promise<CurrencyRecord[]> { export function getCurrencies(): Promise<CurrencyRecord[]> {
return callBackend("get-currencies", { }); return callBackend("get-currencies", {});
} }
/** /**
* Get information about a specific exchange. * Get information about a specific exchange.
*/ */
export function getExchangeInfo(baseUrl: string): Promise<ExchangeRecord> { export function getExchangeInfo(baseUrl: string): Promise<ExchangeRecord> {
return callBackend("exchange-info", {baseUrl}); return callBackend("exchange-info", { baseUrl });
} }
/** /**
* Replace an existing currency record with the one given. The currency to * Replace an existing currency record with the one given. The currency to
* replace is specified inside the currency record. * replace is specified inside the currency record.
@ -144,7 +139,6 @@ export function updateCurrency(currencyRecord: CurrencyRecord): Promise<void> {
return callBackend("update-currency", { currencyRecord }); return callBackend("update-currency", { currencyRecord });
} }
/** /**
* Get all reserves the wallet has at an exchange. * Get all reserves the wallet has at an exchange.
*/ */
@ -152,23 +146,6 @@ export function getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> {
return callBackend("get-reserves", { exchangeBaseUrl }); return callBackend("get-reserves", { exchangeBaseUrl });
} }
/**
* Get all reserves for which a payback is available.
*/
export function getPaybackReserves(): Promise<ReserveRecord[]> {
return callBackend("get-payback-reserves", { });
}
/**
* Withdraw the payback that is available for a reserve.
*/
export function withdrawPaybackReserve(reservePub: string): Promise<ReserveRecord[]> {
return callBackend("withdraw-payback-reserve", { reservePub });
}
/** /**
* Get all coins withdrawn from the given exchange. * Get all coins withdrawn from the given exchange.
*/ */
@ -176,15 +153,15 @@ export function getCoins(exchangeBaseUrl: string): Promise<CoinRecord[]> {
return callBackend("get-coins", { exchangeBaseUrl }); return callBackend("get-coins", { exchangeBaseUrl });
} }
/** /**
* Get all denoms offered by the given exchange. * Get all denoms offered by the given exchange.
*/ */
export function getDenoms(exchangeBaseUrl: string): Promise<DenominationRecord[]> { export function getDenoms(
exchangeBaseUrl: string,
): Promise<DenominationRecord[]> {
return callBackend("get-denoms", { exchangeBaseUrl }); return callBackend("get-denoms", { exchangeBaseUrl });
} }
/** /**
* Start refreshing a coin. * Start refreshing a coin.
*/ */
@ -192,15 +169,16 @@ export function refresh(coinPub: string): Promise<void> {
return callBackend("refresh-coin", { coinPub }); return callBackend("refresh-coin", { coinPub });
} }
/** /**
* Pay for a proposal. * Pay for a proposal.
*/ */
export function confirmPay(proposalId: string, sessionId: string | undefined): Promise<ConfirmPayResult> { export function confirmPay(
proposalId: string,
sessionId: string | undefined,
): Promise<ConfirmPayResult> {
return callBackend("confirm-pay", { proposalId, sessionId }); return callBackend("confirm-pay", { proposalId, sessionId });
} }
/** /**
* Mark a reserve as confirmed. * Mark a reserve as confirmed.
*/ */
@ -212,13 +190,17 @@ export function confirmReserve(reservePub: string): Promise<void> {
* Check upgrade information * Check upgrade information
*/ */
export function checkUpgrade(): Promise<UpgradeResponse> { export function checkUpgrade(): Promise<UpgradeResponse> {
return callBackend("check-upgrade", { }); return callBackend("check-upgrade", {});
} }
/** /**
* Create a reserve. * Create a reserve.
*/ */
export function createReserve(args: { amount: AmountJson, exchange: string, senderWire?: string }): Promise<any> { export function createReserve(args: {
amount: AmountJson;
exchange: string;
senderWire?: string;
}): Promise<any> {
return callBackend("create-reserve", args); return callBackend("create-reserve", args);
} }
@ -226,42 +208,45 @@ export function createReserve(args: { amount: AmountJson, exchange: string, send
* Reset database * Reset database
*/ */
export function resetDb(): Promise<void> { export function resetDb(): Promise<void> {
return callBackend("reset-db", { }); return callBackend("reset-db", {});
} }
/** /**
* Get balances for all currencies/exchanges. * Get balances for all currencies/exchanges.
*/ */
export function getBalance(): Promise<WalletBalance> { export function getBalance(): Promise<WalletBalance> {
return callBackend("balances", { }); return callBackend("balances", {});
} }
/** /**
* Get possible sender wire infos for getting money * Get possible sender wire infos for getting money
* wired from an exchange. * wired from an exchange.
*/ */
export function getSenderWireInfos(): Promise<SenderWireInfos> { export function getSenderWireInfos(): Promise<SenderWireInfos> {
return callBackend("get-sender-wire-infos", { }); return callBackend("get-sender-wire-infos", {});
} }
/** /**
* Return coins to a bank account. * Return coins to a bank account.
*/ */
export function returnCoins(args: { amount: AmountJson, exchange: string, senderWire: object }): Promise<void> { export function returnCoins(args: {
amount: AmountJson;
exchange: string;
senderWire: object;
}): Promise<void> {
return callBackend("return-coins", args); return callBackend("return-coins", args);
} }
/** /**
* Look up a purchase in the wallet database from * Look up a purchase in the wallet database from
* the contract terms hash. * the contract terms hash.
*/ */
export function getPurchaseDetails(contractTermsHash: string): Promise<PurchaseDetails> { export function getPurchaseDetails(
contractTermsHash: string,
): Promise<PurchaseDetails> {
return callBackend("get-purchase-details", { contractTermsHash }); return callBackend("get-purchase-details", { contractTermsHash });
} }
/** /**
* Get the status of processing a tip. * Get the status of processing a tip.
*/ */
@ -276,7 +261,6 @@ export function acceptTip(talerTipUri: string): Promise<void> {
return callBackend("accept-tip", { talerTipUri }); return callBackend("accept-tip", { talerTipUri });
} }
/** /**
* Download a refund and accept it. * Download a refund and accept it.
*/ */
@ -291,7 +275,6 @@ export function abortFailedPayment(contractTermsHash: string) {
return callBackend("abort-failed-payment", { contractTermsHash }); return callBackend("abort-failed-payment", { contractTermsHash });
} }
/** /**
* Abort a failed payment and try to get a refund. * Abort a failed payment and try to get a refund.
*/ */
@ -302,8 +285,14 @@ export function benchmarkCrypto(repetitions: number): Promise<BenchmarkResult> {
/** /**
* Get details about a withdraw operation. * Get details about a withdraw operation.
*/ */
export function getWithdrawDetails(talerWithdrawUri: string, maybeSelectedExchange: string | undefined) { export function getWithdrawDetails(
return callBackend("get-withdraw-details", { talerWithdrawUri, maybeSelectedExchange }); talerWithdrawUri: string,
maybeSelectedExchange: string | undefined,
) {
return callBackend("get-withdraw-details", {
talerWithdrawUri,
maybeSelectedExchange,
});
} }
/** /**
@ -316,8 +305,14 @@ export function preparePay(talerPayUri: string) {
/** /**
* Get details about a withdraw operation. * Get details about a withdraw operation.
*/ */
export function acceptWithdrawal(talerWithdrawUri: string, selectedExchange: string) { export function acceptWithdrawal(
return callBackend("accept-withdrawal", { talerWithdrawUri, selectedExchange }); talerWithdrawUri: string,
selectedExchange: string,
) {
return callBackend("accept-withdrawal", {
talerWithdrawUri,
selectedExchange,
});
} }
/** /**

View File

@ -148,15 +148,6 @@ async function handleMessage(
} }
return needsWallet().getReserves(detail.exchangeBaseUrl); return needsWallet().getReserves(detail.exchangeBaseUrl);
} }
case "get-payback-reserves": {
return needsWallet().getPaybackReserves();
}
case "withdraw-payback-reserve": {
if (typeof detail.reservePub !== "string") {
return Promise.reject(Error("reservePub missing"));
}
throw Error("not implemented");
}
case "get-coins": { case "get-coins": {
if (typeof detail.exchangeBaseUrl !== "string") { if (typeof detail.exchangeBaseUrl !== "string") {
return Promise.reject(Error("exchangBaseUrl missing")); return Promise.reject(Error("exchangBaseUrl missing"));