From ef0acf06bfb7820a21c4719dba0d659f600be3c7 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 2 Apr 2020 20:33:01 +0530 Subject: [PATCH] model reserve history in the exchange, improve reserve handling logic --- src/crypto/workers/cryptoApi.ts | 4 +- src/crypto/workers/cryptoImplementation.ts | 12 +- src/headless/helpers.ts | 6 +- src/headless/integrationtest.ts | 38 +- src/headless/taler-wallet-cli.ts | 32 +- src/operations/balance.ts | 4 +- src/operations/exchanges.ts | 2 +- src/operations/history.ts | 46 +-- src/operations/pending.ts | 7 +- src/operations/recoup.ts | 4 +- src/operations/refresh.ts | 5 +- src/operations/refund.ts | 6 +- src/operations/reserves.ts | 235 +++++++------ src/operations/tip.ts | 22 +- src/operations/withdraw.ts | 202 +++-------- src/types/dbTypes.ts | 70 ++-- src/types/history.ts | 16 +- src/types/notifications.ts | 28 +- src/types/pending.ts | 3 +- src/types/types-test.ts | 18 +- src/types/walletTypes.ts | 1 + src/util/amounts.ts | 20 +- src/util/reserveHistoryUtil-test.ts | 286 +++++++++++++++ src/util/reserveHistoryUtil.ts | 384 +++++++++++++++++++++ src/wallet.ts | 29 +- src/webex/pages/popup.tsx | 4 - src/webex/pages/return-coins.tsx | 4 +- 27 files changed, 1062 insertions(+), 426 deletions(-) create mode 100644 src/util/reserveHistoryUtil-test.ts create mode 100644 src/util/reserveHistoryUtil.ts diff --git a/src/crypto/workers/cryptoApi.ts b/src/crypto/workers/cryptoApi.ts index ab97e1274..d3b12e26d 100644 --- a/src/crypto/workers/cryptoApi.ts +++ b/src/crypto/workers/cryptoApi.ts @@ -359,8 +359,8 @@ export class CryptoApi { return this.doRpc("hashString", 1, str); } - hashDenomPub(denomPub: string): Promise { - return this.doRpc("hashDenomPub", 1, denomPub); + hashEncoded(encodedBytes: string): Promise { + return this.doRpc("hashEncoded", 1, encodedBytes); } isValidDenom(denom: DenominationRecord, masterPub: string): Promise { diff --git a/src/crypto/workers/cryptoImplementation.ts b/src/crypto/workers/cryptoImplementation.ts index 156c72ba0..eef8f5955 100644 --- a/src/crypto/workers/cryptoImplementation.ts +++ b/src/crypto/workers/cryptoImplementation.ts @@ -49,8 +49,7 @@ import { PlanchetCreationRequest, DepositInfo, } from "../../types/walletTypes"; -import { AmountJson } from "../../util/amounts"; -import * as Amounts from "../../util/amounts"; +import { AmountJson, Amounts } from "../../util/amounts"; import * as timer from "../../util/timer"; import { encodeCrock, @@ -199,6 +198,7 @@ export class CryptoImplementation { denomPubHash: encodeCrock(denomPubHash), reservePub: encodeCrock(reservePub), withdrawSig: encodeCrock(sig), + coinEvHash: encodeCrock(evHash), }; return planchet; } @@ -367,7 +367,7 @@ export class CryptoImplementation { const s: CoinDepositPermission = { coin_pub: depositInfo.coinPub, coin_sig: encodeCrock(coinSig), - contribution: Amounts.toString(depositInfo.spendAmount), + contribution: Amounts.stringify(depositInfo.spendAmount), denom_pub: depositInfo.denomPub, exchange_url: depositInfo.exchangeBaseUrl, ub_sig: depositInfo.denomSig, @@ -491,10 +491,10 @@ export class CryptoImplementation { } /** - * Hash a denomination public key. + * Hash a crockford encoded value. */ - hashDenomPub(denomPub: string): string { - return encodeCrock(hash(decodeCrock(denomPub))); + hashEncoded(encodedBytes: string): string { + return encodeCrock(hash(decodeCrock(encodedBytes))); } signCoinLink( diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index fb3d800d4..92452e78f 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -35,6 +35,7 @@ import { Database } from "../util/query"; import { NodeHttpLib } from "./NodeHttpLib"; import { Logger } from "../util/logging"; import { SynchronousCryptoWorkerFactory } from "../crypto/workers/synchronousWorker"; +import { WithdrawalSourceType } from "../types/dbTypes"; const logger = new Logger("helpers.ts"); @@ -165,8 +166,9 @@ export async function withdrawTestBalance( }); myWallet.addNotificationListener((n) => { if ( - n.type === NotificationType.ReserveDepleted && - n.reservePub === reservePub + n.type === NotificationType.WithdrawGroupFinished && + n.withdrawalSource.type === WithdrawalSourceType.Reserve && + n.withdrawalSource.reservePub === reservePub ) { resolve(); } diff --git a/src/headless/integrationtest.ts b/src/headless/integrationtest.ts index 191e48ff6..6e45b76e2 100644 --- a/src/headless/integrationtest.ts +++ b/src/headless/integrationtest.ts @@ -22,9 +22,9 @@ import { getDefaultNodeWallet, withdrawTestBalance } from "./helpers"; import { MerchantBackendConnection } from "./merchant"; import { Logger } from "../util/logging"; import { NodeHttpLib } from "./NodeHttpLib"; -import * as Amounts from "../util/amounts"; import { Wallet } from "../wallet"; import { Configuration } from "../util/talerconfig"; +import { Amounts, AmountJson } from "../util/amounts"; const logger = new Logger("integrationtest.ts"); @@ -127,31 +127,31 @@ export async function runIntegrationTest(args: IntegrationTestArgs) { await myWallet.runUntilDone(); console.log("withdrawing test balance for refund"); - const withdrawAmountTwo: Amounts.AmountJson = { + const withdrawAmountTwo: AmountJson = { currency, value: 18, fraction: 0, }; - const spendAmountTwo: Amounts.AmountJson = { + const spendAmountTwo: AmountJson = { currency, value: 7, fraction: 0, }; - const refundAmount: Amounts.AmountJson = { + const refundAmount: AmountJson = { currency, value: 6, fraction: 0, }; - const spendAmountThree: Amounts.AmountJson = { + const spendAmountThree: AmountJson = { currency, value: 3, fraction: 0, }; await withdrawTestBalance( myWallet, - Amounts.toString(withdrawAmountTwo), + Amounts.stringify(withdrawAmountTwo), args.bankBaseUrl, args.exchangeBaseUrl, ); @@ -162,14 +162,14 @@ export async function runIntegrationTest(args: IntegrationTestArgs) { let { orderId: refundOrderId } = await makePayment( myWallet, myMerchant, - Amounts.toString(spendAmountTwo), + Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await myMerchant.refund( refundOrderId, "test refund", - Amounts.toString(refundAmount), + Amounts.stringify(refundAmount), ); console.log("refund URI", refundUri); @@ -182,7 +182,7 @@ export async function runIntegrationTest(args: IntegrationTestArgs) { await makePayment( myWallet, myMerchant, - Amounts.toString(spendAmountThree), + Amounts.stringify(spendAmountThree), "payment after refund", ); @@ -240,7 +240,7 @@ export async function runIntegrationTestBasic(cfg: Configuration) { logger.info("withdrawing test balance"); await withdrawTestBalance( myWallet, - Amounts.toString(parsedWithdrawAmount), + Amounts.stringify(parsedWithdrawAmount), bankBaseUrl, exchangeBaseUrl, ); @@ -258,7 +258,7 @@ export async function runIntegrationTestBasic(cfg: Configuration) { await makePayment( myWallet, myMerchant, - Amounts.toString(parsedSpendAmount), + Amounts.stringify(parsedSpendAmount), "hello world", ); @@ -266,24 +266,24 @@ export async function runIntegrationTestBasic(cfg: Configuration) { await myWallet.runUntilDone(); console.log("withdrawing test balance for refund"); - const withdrawAmountTwo: Amounts.AmountJson = { + const withdrawAmountTwo: AmountJson = { currency, value: 18, fraction: 0, }; - const spendAmountTwo: Amounts.AmountJson = { + const spendAmountTwo: AmountJson = { currency, value: 7, fraction: 0, }; - const refundAmount: Amounts.AmountJson = { + const refundAmount: AmountJson = { currency, value: 6, fraction: 0, }; - const spendAmountThree: Amounts.AmountJson = { + const spendAmountThree: AmountJson = { currency, value: 3, fraction: 0, @@ -291,7 +291,7 @@ export async function runIntegrationTestBasic(cfg: Configuration) { await withdrawTestBalance( myWallet, - Amounts.toString(withdrawAmountTwo), + Amounts.stringify(withdrawAmountTwo), bankBaseUrl, exchangeBaseUrl, ); @@ -302,14 +302,14 @@ export async function runIntegrationTestBasic(cfg: Configuration) { let { orderId: refundOrderId } = await makePayment( myWallet, myMerchant, - Amounts.toString(spendAmountTwo), + Amounts.stringify(spendAmountTwo), "order that will be refunded", ); const refundUri = await myMerchant.refund( refundOrderId, "test refund", - Amounts.toString(refundAmount), + Amounts.stringify(refundAmount), ); console.log("refund URI", refundUri); @@ -322,7 +322,7 @@ export async function runIntegrationTestBasic(cfg: Configuration) { await makePayment( myWallet, myMerchant, - Amounts.toString(spendAmountThree), + Amounts.stringify(spendAmountThree), "payment after refund", ); diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 45ab819a7..d183ef316 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -24,7 +24,7 @@ import qrcodeGenerator = require("qrcode-generator"); import * as clk from "./clk"; import { BridgeIDBFactory, MemoryBackend } from "idb-bridge"; import { Logger } from "../util/logging"; -import * as Amounts from "../util/amounts"; +import { Amounts } from "../util/amounts"; import { decodeCrock } from "../crypto/talerCrypto"; import { OperationFailedAndReportedError } from "../operations/errors"; import { Bank } from "./bank"; @@ -190,7 +190,7 @@ walletCli } else { const currencies = Object.keys(balance.byCurrency).sort(); for (const c of currencies) { - console.log(Amounts.toString(balance.byCurrency[c].available)); + console.log(Amounts.stringify(balance.byCurrency[c].available)); } } }); @@ -356,6 +356,32 @@ advancedCli fs.writeFileSync(1, decodeCrock(enc.trim())); }); +const reservesCli = advancedCli.subcommand("reserves", "reserves", { + help: "Manage reserves.", +}); + +reservesCli + .subcommand("list", "list", { + help: "List reserves.", + }) + .action(async (args) => { + await withWallet(args, async (wallet) => { + const reserves = await wallet.getReserves(); + console.log(JSON.stringify(reserves, undefined, 2)); + }); + }); + +reservesCli + .subcommand("update", "update", { + help: "Update reserve status via exchange.", + }) + .requiredArgument("reservePub", clk.STRING) + .action(async (args) => { + await withWallet(args, async (wallet) => { + await wallet.updateReserve(args.update.reservePub); + }); + }); + advancedCli .subcommand("payPrepare", "pay-prepare", { help: "Claim an order but don't pay yet.", @@ -464,7 +490,7 @@ advancedCli console.log(` exchange ${coin.exchangeBaseUrl}`); console.log(` denomPubHash ${coin.denomPubHash}`); console.log( - ` remaining amount ${Amounts.toString(coin.currentAmount)}`, + ` remaining amount ${Amounts.stringify(coin.currentAmount)}`, ); } }); diff --git a/src/operations/balance.ts b/src/operations/balance.ts index 8858e8b43..7c2d0e3fe 100644 --- a/src/operations/balance.ts +++ b/src/operations/balance.ts @@ -106,7 +106,7 @@ export async function getBalancesInsideTransaction( } }); - await tx.iter(Stores.withdrawalSession).forEach((wds) => { + await tx.iter(Stores.withdrawalGroups).forEach((wds) => { let w = wds.totalCoinValue; for (let i = 0; i < wds.planchets.length; i++) { if (wds.withdrawn[i]) { @@ -150,7 +150,7 @@ export async function getBalances( Stores.refreshGroups, Stores.reserves, Stores.purchases, - Stores.withdrawalSession, + Stores.withdrawalGroups, ], async (tx) => { return getBalancesInsideTransaction(ws, tx); diff --git a/src/operations/exchanges.ts b/src/operations/exchanges.ts index 3aaf77468..b9806bb62 100644 --- a/src/operations/exchanges.ts +++ b/src/operations/exchanges.ts @@ -53,7 +53,7 @@ async function denominationRecordFromKeys( exchangeBaseUrl: string, denomIn: Denomination, ): Promise { - const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub); + const denomPubHash = await ws.cryptoApi.hashEncoded(denomIn.denom_pub); const d: DenominationRecord = { denomPub: denomIn.denom_pub, denomPubHash, diff --git a/src/operations/history.ts b/src/operations/history.ts index 1b4172526..848739334 100644 --- a/src/operations/history.ts +++ b/src/operations/history.ts @@ -26,7 +26,7 @@ import { PlanchetRecord, CoinRecord, } from "../types/dbTypes"; -import * as Amounts from "../util/amounts"; +import { Amounts } from "../util/amounts"; import { AmountJson } from "../util/amounts"; import { HistoryQuery, @@ -42,6 +42,7 @@ import { import { assertUnreachable } from "../util/assertUnreachable"; import { TransactionHandle, Store } from "../util/query"; import { timestampCmp } from "../util/time"; +import { summarizeReserveHistory } from "../util/reserveHistoryUtil"; /** * Create an event ID from the type and the primary key for the event. @@ -58,7 +59,7 @@ function getOrderShortInfo( return undefined; } return { - amount: Amounts.toString(download.contractData.amount), + amount: Amounts.stringify(download.contractData.amount), fulfillmentUrl: download.contractData.fulfillmentUrl, orderId: download.contractData.orderId, merchantBaseUrl: download.contractData.merchantBaseUrl, @@ -176,7 +177,7 @@ export async function getHistory( Stores.refreshGroups, Stores.reserves, Stores.tips, - Stores.withdrawalSession, + Stores.withdrawalGroups, Stores.payEvents, Stores.refundEvents, Stores.reserveUpdatedEvents, @@ -208,7 +209,7 @@ export async function getHistory( }); }); - tx.iter(Stores.withdrawalSession).forEach((wsr) => { + tx.iter(Stores.withdrawalGroups).forEach((wsr) => { if (wsr.timestampFinish) { const cs: PlanchetRecord[] = []; wsr.planchets.forEach((x) => { @@ -221,7 +222,7 @@ export async function getHistory( if (historyQuery?.extraDebug) { verboseDetails = { coins: cs.map((x) => ({ - value: Amounts.toString(x.coinValue), + value: Amounts.stringify(x.coinValue), denomPub: x.denomPub, })), }; @@ -229,13 +230,13 @@ export async function getHistory( history.push({ type: HistoryEventType.Withdrawn, - withdrawSessionId: wsr.withdrawSessionId, + withdrawalGroupId: wsr.withdrawalGroupId, eventId: makeEventId( HistoryEventType.Withdrawn, - wsr.withdrawSessionId, + wsr.withdrawalGroupId, ), - amountWithdrawnEffective: Amounts.toString(wsr.totalCoinValue), - amountWithdrawnRaw: Amounts.toString(wsr.rawWithdrawalAmount), + amountWithdrawnEffective: Amounts.stringify(wsr.totalCoinValue), + amountWithdrawnRaw: Amounts.stringify(wsr.rawWithdrawalAmount), exchangeBaseUrl: wsr.exchangeBaseUrl, timestamp: wsr.timestampFinish, withdrawalSource: wsr.source, @@ -283,7 +284,7 @@ export async function getHistory( coins.push({ contribution: x.contribution, denomPub: c.denomPub, - value: Amounts.toString(d.value), + value: Amounts.stringify(d.value), }); } verboseDetails = { coins }; @@ -301,7 +302,7 @@ export async function getHistory( sessionId: pe.sessionId, timestamp: pe.timestamp, numCoins: purchase.payReq.coins.length, - amountPaidWithFees: Amounts.toString(amountPaidWithFees), + amountPaidWithFees: Amounts.stringify(amountPaidWithFees), verboseDetails, }); }); @@ -364,7 +365,7 @@ export async function getHistory( } outputCoins.push({ denomPub: d.denomPub, - value: Amounts.toString(d.value), + value: Amounts.stringify(d.value), }); } } @@ -378,8 +379,8 @@ export async function getHistory( eventId: makeEventId(HistoryEventType.Refreshed, rg.refreshGroupId), timestamp: rg.timestampFinished, refreshReason: rg.reason, - amountRefreshedEffective: Amounts.toString(amountRefreshedEffective), - amountRefreshedRaw: Amounts.toString(amountRefreshedRaw), + amountRefreshedEffective: Amounts.stringify(amountRefreshedEffective), + amountRefreshedRaw: Amounts.stringify(amountRefreshedRaw), numInputCoins, numOutputCoins, numRefreshedInputCoins, @@ -403,21 +404,22 @@ export async function getHistory( type: ReserveType.Manual, }; } + const s = summarizeReserveHistory(reserve.reserveTransactions, reserve.currency); history.push({ type: HistoryEventType.ReserveBalanceUpdated, eventId: makeEventId( HistoryEventType.ReserveBalanceUpdated, ru.reserveUpdateId, ), - amountExpected: ru.amountExpected, - amountReserveBalance: ru.amountReserveBalance, timestamp: ru.timestamp, - newHistoryTransactions: ru.newHistoryTransactions, reserveShortInfo: { exchangeBaseUrl: reserve.exchangeBaseUrl, reserveCreationDetail, reservePub: reserve.reservePub, }, + reserveAwaitedAmount: Amounts.stringify(s.awaitedReserveAmount), + reserveBalance: Amounts.stringify(s.computedReserveBalance), + reserveUnclaimedAmount: Amounts.stringify(s.unclaimedReserveAmount), }); }); @@ -428,7 +430,7 @@ export async function getHistory( eventId: makeEventId(HistoryEventType.TipAccepted, tip.tipId), timestamp: tip.acceptedTimestamp, tipId: tip.tipId, - tipAmountRaw: Amounts.toString(tip.amount), + tipAmountRaw: Amounts.stringify(tip.amount), }); } }); @@ -488,9 +490,9 @@ export async function getHistory( refundGroupId: re.refundGroupId, orderShortInfo, timestamp: re.timestamp, - amountRefundedEffective: Amounts.toString(amountRefundedEffective), - amountRefundedRaw: Amounts.toString(amountRefundedRaw), - amountRefundedInvalid: Amounts.toString(amountRefundedInvalid), + amountRefundedEffective: Amounts.stringify(amountRefundedEffective), + amountRefundedRaw: Amounts.stringify(amountRefundedRaw), + amountRefundedInvalid: Amounts.stringify(amountRefundedInvalid), }); }); @@ -499,7 +501,7 @@ export async function getHistory( let verboseDetails: any = undefined; if (historyQuery?.extraDebug) { verboseDetails = { - oldAmountPerCoin: rg.oldAmountPerCoin.map(Amounts.toString), + oldAmountPerCoin: rg.oldAmountPerCoin.map(Amounts.stringify), }; } diff --git a/src/operations/pending.ts b/src/operations/pending.ts index adf47b151..b0bb9a7c3 100644 --- a/src/operations/pending.ts +++ b/src/operations/pending.ts @@ -243,7 +243,7 @@ async function gatherWithdrawalPending( resp: PendingOperationsResponse, onlyDue: boolean = false, ): Promise { - await tx.iter(Stores.withdrawalSession).forEach((wsr) => { + await tx.iter(Stores.withdrawalGroups).forEach((wsr) => { if (wsr.timestampFinish) { return; } @@ -266,7 +266,8 @@ async function gatherWithdrawalPending( numCoinsTotal, numCoinsWithdrawn, source: wsr.source, - withdrawSessionId: wsr.withdrawSessionId, + withdrawalGroupId: wsr.withdrawalGroupId, + lastError: wsr.lastError, }); }); } @@ -444,7 +445,7 @@ export async function getPendingOperations( Stores.reserves, Stores.refreshGroups, Stores.coins, - Stores.withdrawalSession, + Stores.withdrawalGroups, Stores.proposals, Stores.tips, Stores.purchases, diff --git a/src/operations/recoup.ts b/src/operations/recoup.ts index 4c6eaf3b6..e13ae3c1f 100644 --- a/src/operations/recoup.ts +++ b/src/operations/recoup.ts @@ -42,7 +42,7 @@ import { codecForRecoupConfirmation } from "../types/talerTypes"; import { NotificationType } from "../types/notifications"; import { forceQueryReserve } from "./reserves"; -import * as Amounts from "../util/amounts"; +import { Amounts } from "../util/amounts"; import { createRefreshGroup, processRefreshGroup } from "./refresh"; import { RefreshReason, OperationError } from "../types/walletTypes"; import { TransactionHandle } from "../util/query"; @@ -266,7 +266,7 @@ async function recoupRefreshCoin( ).amount; console.log( "recoup: setting old coin amount to", - Amounts.toString(oldCoin.currentAmount), + Amounts.stringify(oldCoin.currentAmount), ); recoupGroup.scheduleRefreshCoins.push(oldCoin.coinPub); await tx.put(Stores.coins, revokedCoin); diff --git a/src/operations/refresh.ts b/src/operations/refresh.ts index 5628263ec..be4f5c5af 100644 --- a/src/operations/refresh.ts +++ b/src/operations/refresh.ts @@ -14,8 +14,7 @@ GNU Taler; see the file COPYING. If not, see */ -import { AmountJson } from "../util/amounts"; -import * as Amounts from "../util/amounts"; +import { Amounts, AmountJson } from "../util/amounts"; import { DenominationRecord, Stores, @@ -239,7 +238,7 @@ async function refreshMelt( denom_pub_hash: coin.denomPubHash, denom_sig: coin.denomSig, rc: refreshSession.hash, - value_with_fee: Amounts.toString(refreshSession.amountRefreshInput), + value_with_fee: Amounts.stringify(refreshSession.amountRefreshInput), }; logger.trace(`melt request for coin:`, meltReq); const resp = await ws.http.postJson(reqUrl.href, meltReq); diff --git a/src/operations/refund.ts b/src/operations/refund.ts index 7552fc11c..f0fec4065 100644 --- a/src/operations/refund.ts +++ b/src/operations/refund.ts @@ -41,7 +41,7 @@ import { import { NotificationType } from "../types/notifications"; import { parseRefundUri } from "../util/taleruri"; import { createRefreshGroup, getTotalRefreshCost } from "./refresh"; -import * as Amounts from "../util/amounts"; +import { Amounts } from "../util/amounts"; import { MerchantRefundPermission, MerchantRefundResponse, @@ -476,7 +476,7 @@ async function processPurchaseApplyRefundImpl( `commiting refund ${perm.merchant_sig} to coin ${c.coinPub}`, ); logger.trace( - `coin amount before is ${Amounts.toString(c.currentAmount)}`, + `coin amount before is ${Amounts.stringify(c.currentAmount)}`, ); logger.trace(`refund amount (via merchant) is ${perm.refund_amount}`); logger.trace(`refund fee (via merchant) is ${perm.refund_fee}`); @@ -486,7 +486,7 @@ async function processPurchaseApplyRefundImpl( c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; logger.trace( - `coin amount after is ${Amounts.toString(c.currentAmount)}`, + `coin amount after is ${Amounts.stringify(c.currentAmount)}`, ); await tx.put(Stores.coins, c); }; diff --git a/src/operations/reserves.ts b/src/operations/reserves.ts index 5cf189d3b..2ef902ef2 100644 --- a/src/operations/reserves.ts +++ b/src/operations/reserves.ts @@ -28,14 +28,17 @@ import { ReserveRecord, CurrencyRecord, Stores, - WithdrawalSessionRecord, + WithdrawalGroupRecord, initRetryInfo, updateRetryInfoTimeout, ReserveUpdatedEventRecord, + WalletReserveHistoryItemType, + DenominationRecord, + PlanchetRecord, + WithdrawalSourceType, } from "../types/dbTypes"; -import { TransactionAbort } from "../util/query"; import { Logger } from "../util/logging"; -import * as Amounts from "../util/amounts"; +import { Amounts } from "../util/amounts"; import { updateExchangeFromUrl, getExchangeTrust, @@ -50,7 +53,7 @@ import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; import { randomBytes } from "../crypto/primitives/nacl-fast"; import { getVerifiedWithdrawDenomList, - processWithdrawSession, + processWithdrawGroup, getBankWithdrawalInfo, } from "./withdraw"; import { @@ -61,6 +64,10 @@ import { import { NotificationType } from "../types/notifications"; import { codecForReserveStatus } from "../types/ReserveStatus"; import { getTimestampNow } from "../util/time"; +import { + reconcileReserveHistory, + summarizeReserveHistory, +} from "../util/reserveHistoryUtil"; const logger = new Logger("reserves.ts"); @@ -98,11 +105,7 @@ export async function createReserve( const reserveRecord: ReserveRecord = { timestampCreated: now, - amountWithdrawAllocated: Amounts.getZero(currency), - amountWithdrawCompleted: Amounts.getZero(currency), - amountWithdrawRemaining: Amounts.getZero(currency), exchangeBaseUrl: canonExchange, - amountInitiallyRequested: req.amount, reservePriv: keypair.priv, reservePub: keypair.pub, senderWire: req.senderWire, @@ -115,8 +118,14 @@ export async function createReserve( retryInfo: initRetryInfo(), lastError: undefined, reserveTransactions: [], + currency: req.amount.currency, }; + reserveRecord.reserveTransactions.push({ + type: WalletReserveHistoryItemType.Credit, + expectedAmount: req.amount, + }); + const senderWire = req.senderWire; if (senderWire) { const rec = { @@ -460,6 +469,7 @@ async function updateReserve( const respJson = await resp.json(); const reserveInfo = codecForReserveStatus().decode(respJson); const balance = Amounts.parseOrThrow(reserveInfo.balance); + const currency = balance.currency; await ws.db.runWithWriteTransaction( [Stores.reserves, Stores.reserveUpdatedEvents], async (tx) => { @@ -477,60 +487,41 @@ async function updateReserve( const reserveUpdateId = encodeCrock(getRandomBytes(32)); - // FIXME: check / compare history! - if (!r.lastSuccessfulStatusQuery) { - // FIXME: check if this matches initial expectations - r.amountWithdrawRemaining = balance; + const reconciled = reconcileReserveHistory( + r.reserveTransactions, + reserveInfo.history, + ); + + console.log("reconciled history:", JSON.stringify(reconciled, undefined, 2)); + + const summary = summarizeReserveHistory( + reconciled.updatedLocalHistory, + currency, + ); + console.log("summary", summary); + + if ( + reconciled.newAddedItems.length + reconciled.newMatchedItems.length != + 0 + ) { const reserveUpdate: ReserveUpdatedEventRecord = { reservePub: r.reservePub, timestamp: getTimestampNow(), - amountReserveBalance: Amounts.toString(balance), - amountExpected: Amounts.toString(reserve.amountInitiallyRequested), + amountReserveBalance: Amounts.stringify(balance), + amountExpected: Amounts.stringify(summary.awaitedReserveAmount), newHistoryTransactions, reserveUpdateId, }; await tx.put(Stores.reserveUpdatedEvents, reserveUpdate); r.reserveStatus = ReserveRecordStatus.WITHDRAWING; + r.retryInfo = initRetryInfo(); } else { - const expectedBalance = Amounts.add( - r.amountWithdrawRemaining, - Amounts.sub(r.amountWithdrawAllocated, r.amountWithdrawCompleted) - .amount, - ); - const cmp = Amounts.cmp(balance, expectedBalance.amount); - if (cmp == 0) { - // Nothing changed, go back to sleep! - r.reserveStatus = ReserveRecordStatus.DORMANT; - } else if (cmp > 0) { - const extra = Amounts.sub(balance, expectedBalance.amount).amount; - r.amountWithdrawRemaining = Amounts.add( - r.amountWithdrawRemaining, - extra, - ).amount; - r.reserveStatus = ReserveRecordStatus.WITHDRAWING; - } else { - // We're missing some money. - r.reserveStatus = ReserveRecordStatus.DORMANT; - } - if (r.reserveStatus !== ReserveRecordStatus.DORMANT) { - const reserveUpdate: ReserveUpdatedEventRecord = { - reservePub: r.reservePub, - timestamp: getTimestampNow(), - amountReserveBalance: Amounts.toString(balance), - amountExpected: Amounts.toString(expectedBalance.amount), - newHistoryTransactions, - reserveUpdateId, - }; - await tx.put(Stores.reserveUpdatedEvents, reserveUpdate); - } + r.reserveStatus = ReserveRecordStatus.DORMANT; + r.retryInfo = initRetryInfo(false); } r.lastSuccessfulStatusQuery = getTimestampNow(); - if (r.reserveStatus == ReserveRecordStatus.DORMANT) { - r.retryInfo = initRetryInfo(false); - } else { - r.retryInfo = initRetryInfo(); - } - r.reserveTransactions = reserveInfo.history; + r.reserveTransactions = reconciled.updatedLocalHistory; + r.lastError = undefined; await tx.put(Stores.reserves, r); }, ); @@ -607,6 +598,33 @@ export async function confirmReserve( }); } +async function makePlanchet( + ws: InternalWalletState, + reserve: ReserveRecord, + denom: DenominationRecord, +): Promise { + const r = await ws.cryptoApi.createPlanchet({ + denomPub: denom.denomPub, + feeWithdraw: denom.feeWithdraw, + reservePriv: reserve.reservePriv, + reservePub: reserve.reservePub, + value: denom.value, + }); + return { + blindingKey: r.blindingKey, + coinEv: r.coinEv, + coinPriv: r.coinPriv, + coinPub: r.coinPub, + coinValue: r.coinValue, + denomPub: r.denomPub, + denomPubHash: r.denomPubHash, + isFromTip: false, + reservePub: r.reservePub, + withdrawSig: r.withdrawSig, + coinEvHash: r.coinEvHash, + }; +} + /** * Withdraw coins from a reserve until it is empty. * @@ -626,7 +644,12 @@ async function depleteReserve( } logger.trace(`depleting reserve ${reservePub}`); - const withdrawAmount = reserve.amountWithdrawRemaining; + const summary = summarizeReserveHistory( + reserve.reserveTransactions, + reserve.currency, + ); + + const withdrawAmount = summary.unclaimedReserveAmount; logger.trace(`getting denom list`); @@ -637,36 +660,47 @@ async function depleteReserve( ); logger.trace(`got denom list`); if (denomsForWithdraw.length === 0) { - const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; - const opErr = { - type: "internal", - message: m, - details: {}, - }; - await incrementReserveRetry(ws, reserve.reservePub, opErr); - console.log(m); - throw new OperationFailedAndReportedError(opErr); + // Only complain about inability to withdraw if we + // didn't withdraw before. + if (Amounts.isZero(summary.withdrawnAmount)) { + const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; + const opErr = { + type: "internal", + message: m, + details: {}, + }; + await incrementReserveRetry(ws, reserve.reservePub, opErr); + console.log(m); + throw new OperationFailedAndReportedError(opErr); + } + return; } logger.trace("selected denominations"); - const withdrawalSessionId = encodeCrock(randomBytes(32)); + const withdrawalGroupId = encodeCrock(randomBytes(32)); const totalCoinValue = Amounts.sum(denomsForWithdraw.map((x) => x.value)) .amount; - const withdrawalRecord: WithdrawalSessionRecord = { - withdrawSessionId: withdrawalSessionId, + const planchets: PlanchetRecord[] = []; + for (const d of denomsForWithdraw) { + const p = await makePlanchet(ws, reserve, d); + planchets.push(p); + } + + const withdrawalRecord: WithdrawalGroupRecord = { + withdrawalGroupId: withdrawalGroupId, exchangeBaseUrl: reserve.exchangeBaseUrl, source: { - type: "reserve", + type: WithdrawalSourceType.Reserve, reservePub: reserve.reservePub, }, rawWithdrawalAmount: withdrawAmount, timestampStart: getTimestampNow(), denoms: denomsForWithdraw.map((x) => x.denomPub), withdrawn: denomsForWithdraw.map((x) => false), - planchets: denomsForWithdraw.map((x) => undefined), + planchets, totalCoinValue, retryInfo: initRetryInfo(), lastErrorPerCoin: {}, @@ -679,53 +713,54 @@ async function depleteReserve( const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee) .amount; - function mutateReserve(r: ReserveRecord): ReserveRecord { - const remaining = Amounts.sub( - r.amountWithdrawRemaining, - totalWithdrawAmount, - ); - if (remaining.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - const allocated = Amounts.add( - r.amountWithdrawAllocated, - totalWithdrawAmount, - ); - if (allocated.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - r.amountWithdrawRemaining = remaining.amount; - r.amountWithdrawAllocated = allocated.amount; - r.reserveStatus = ReserveRecordStatus.DORMANT; - r.retryInfo = initRetryInfo(false); - return r; - } - const success = await ws.db.runWithWriteTransaction( - [Stores.withdrawalSession, Stores.reserves], + [Stores.withdrawalGroups, Stores.reserves], async (tx) => { - const myReserve = await tx.get(Stores.reserves, reservePub); - if (!myReserve) { + const newReserve = await tx.get(Stores.reserves, reservePub); + if (!newReserve) { return false; } - if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { + if (newReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { return false; } - await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); - await tx.put(Stores.withdrawalSession, withdrawalRecord); + const newSummary = summarizeReserveHistory( + newReserve.reserveTransactions, + newReserve.currency, + ); + if ( + Amounts.cmp(newSummary.unclaimedReserveAmount, totalWithdrawAmount) < 0 + ) { + // Something must have happened concurrently! + logger.error( + "aborting withdrawal session, likely concurrent withdrawal happened", + ); + return false; + } + for (let i = 0; i < planchets.length; i++) { + const amt = Amounts.add( + denomsForWithdraw[i].value, + denomsForWithdraw[i].feeWithdraw, + ).amount; + newReserve.reserveTransactions.push({ + type: WalletReserveHistoryItemType.Withdraw, + expectedAmount: amt, + }); + } + newReserve.reserveStatus = ReserveRecordStatus.DORMANT; + newReserve.retryInfo = initRetryInfo(false); + await tx.put(Stores.reserves, newReserve); + await tx.put(Stores.withdrawalGroups, withdrawalRecord); return true; }, ); if (success) { - console.log("processing new withdraw session"); + console.log("processing new withdraw group"); ws.notify({ - type: NotificationType.WithdrawSessionCreated, - withdrawSessionId: withdrawalSessionId, + type: NotificationType.WithdrawGroupCreated, + withdrawalGroupId: withdrawalGroupId, }); - await processWithdrawSession(ws, withdrawalSessionId); + await processWithdrawGroup(ws, withdrawalGroupId); } else { console.trace("withdraw session already existed"); } diff --git a/src/operations/tip.ts b/src/operations/tip.ts index 3636dd247..d3c98d288 100644 --- a/src/operations/tip.ts +++ b/src/operations/tip.ts @@ -28,14 +28,15 @@ import * as Amounts from "../util/amounts"; import { Stores, PlanchetRecord, - WithdrawalSessionRecord, + WithdrawalGroupRecord, initRetryInfo, updateRetryInfoTimeout, + WithdrawalSourceType, } from "../types/dbTypes"; import { getExchangeWithdrawalInfo, getVerifiedWithdrawDenomList, - processWithdrawSession, + processWithdrawGroup, } from "./withdraw"; import { updateExchangeFromUrl } from "./exchanges"; import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; @@ -246,8 +247,10 @@ async function processTipImpl( const planchets: PlanchetRecord[] = []; + for (let i = 0; i < tipRecord.planchets.length; i++) { const tipPlanchet = tipRecord.planchets[i]; + const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv); const planchet: PlanchetRecord = { blindingKey: tipPlanchet.blindingKey, coinEv: tipPlanchet.coinEv, @@ -259,22 +262,23 @@ async function processTipImpl( reservePub: response.reserve_pub, withdrawSig: response.reserve_sigs[i].reserve_sig, isFromTip: true, + coinEvHash, }; planchets.push(planchet); } - const withdrawalSessionId = encodeCrock(getRandomBytes(32)); + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); - const withdrawalSession: WithdrawalSessionRecord = { + const withdrawalGroup: WithdrawalGroupRecord = { denoms: planchets.map((x) => x.denomPub), exchangeBaseUrl: tipRecord.exchangeUrl, planchets: planchets, source: { - type: "tip", + type: WithdrawalSourceType.Tip, tipId: tipRecord.tipId, }, timestampStart: getTimestampNow(), - withdrawSessionId: withdrawalSessionId, + withdrawalGroupId: withdrawalGroupId, rawWithdrawalAmount: tipRecord.amount, withdrawn: planchets.map((x) => false), totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount, @@ -285,7 +289,7 @@ async function processTipImpl( }; await ws.db.runWithWriteTransaction( - [Stores.tips, Stores.withdrawalSession], + [Stores.tips, Stores.withdrawalGroups], async (tx) => { const tr = await tx.get(Stores.tips, tipId); if (!tr) { @@ -298,11 +302,11 @@ async function processTipImpl( tr.retryInfo = initRetryInfo(false); await tx.put(Stores.tips, tr); - await tx.put(Stores.withdrawalSession, withdrawalSession); + await tx.put(Stores.withdrawalGroups, withdrawalGroup); }, ); - await processWithdrawSession(ws, withdrawalSessionId); + await processWithdrawGroup(ws, withdrawalGroupId); return; } diff --git a/src/operations/withdraw.ts b/src/operations/withdraw.ts index 4d8af9fc0..48d70db20 100644 --- a/src/operations/withdraw.ts +++ b/src/operations/withdraw.ts @@ -52,6 +52,7 @@ import { timestampCmp, timestampSubtractDuraction, } from "../util/time"; +import { summarizeReserveHistory, ReserveHistorySummary } from "../util/reserveHistoryUtil"; const logger = new Logger("withdraw.ts"); @@ -158,29 +159,29 @@ async function getPossibleDenoms( */ async function processPlanchet( ws: InternalWalletState, - withdrawalSessionId: string, + withdrawalGroupId: string, coinIdx: number, ): Promise { - const withdrawalSession = await ws.db.get( - Stores.withdrawalSession, - withdrawalSessionId, + const withdrawalGroup = await ws.db.get( + Stores.withdrawalGroups, + withdrawalGroupId, ); - if (!withdrawalSession) { + if (!withdrawalGroup) { return; } - if (withdrawalSession.withdrawn[coinIdx]) { + if (withdrawalGroup.withdrawn[coinIdx]) { return; } - if (withdrawalSession.source.type === "reserve") { + if (withdrawalGroup.source.type === "reserve") { } - const planchet = withdrawalSession.planchets[coinIdx]; + const planchet = withdrawalGroup.planchets[coinIdx]; if (!planchet) { console.log("processPlanchet: planchet not found"); return; } const exchange = await ws.db.get( Stores.exchanges, - withdrawalSession.exchangeBaseUrl, + withdrawalGroup.exchangeBaseUrl, ); if (!exchange) { console.error("db inconsistent: exchange for planchet not found"); @@ -188,7 +189,7 @@ async function processPlanchet( } const denom = await ws.db.get(Stores.denominations, [ - withdrawalSession.exchangeBaseUrl, + withdrawalGroup.exchangeBaseUrl, planchet.denomPub, ]); @@ -232,24 +233,24 @@ async function processPlanchet( denomPub: planchet.denomPub, denomPubHash: planchet.denomPubHash, denomSig, - exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, + exchangeBaseUrl: withdrawalGroup.exchangeBaseUrl, status: CoinStatus.Fresh, coinSource: { type: CoinSourceType.Withdraw, coinIndex: coinIdx, reservePub: planchet.reservePub, - withdrawSessionId: withdrawalSessionId, + withdrawalGroupId: withdrawalGroupId, }, suspended: false, }; - let withdrawSessionFinished = false; - let reserveDepleted = false; + let withdrawalGroupFinished = false; + let summary: ReserveHistorySummary | undefined = undefined; const success = await ws.db.runWithWriteTransaction( - [Stores.coins, Stores.withdrawalSession, Stores.reserves], + [Stores.coins, Stores.withdrawalGroups, Stores.reserves], async (tx) => { - const ws = await tx.get(Stores.withdrawalSession, withdrawalSessionId); + const ws = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); if (!ws) { return false; } @@ -269,23 +270,13 @@ async function processPlanchet( ws.timestampFinish = getTimestampNow(); ws.lastError = undefined; ws.retryInfo = initRetryInfo(false); - withdrawSessionFinished = true; + withdrawalGroupFinished = true; } - await tx.put(Stores.withdrawalSession, ws); + await tx.put(Stores.withdrawalGroups, ws); if (!planchet.isFromTip) { const r = await tx.get(Stores.reserves, planchet.reservePub); if (r) { - r.amountWithdrawCompleted = Amounts.add( - r.amountWithdrawCompleted, - Amounts.add(denom.value, denom.feeWithdraw).amount, - ).amount; - if ( - Amounts.cmp(r.amountWithdrawCompleted, r.amountWithdrawAllocated) == - 0 - ) { - reserveDepleted = true; - } - await tx.put(Stores.reserves, r); + summary = summarizeReserveHistory(r.reserveTransactions, r.currency); } } await tx.add(Stores.coins, coin); @@ -299,17 +290,10 @@ async function processPlanchet( }); } - if (withdrawSessionFinished) { + if (withdrawalGroupFinished) { ws.notify({ - type: NotificationType.WithdrawSessionFinished, - withdrawSessionId: withdrawalSessionId, - }); - } - - if (reserveDepleted && withdrawalSession.source.type === "reserve") { - ws.notify({ - type: NotificationType.ReserveDepleted, - reservePub: withdrawalSession.source.reservePub, + type: NotificationType.WithdrawGroupFinished, + withdrawalSource: withdrawalGroup.source, }); } } @@ -383,113 +367,15 @@ export async function getVerifiedWithdrawDenomList( return selectedDenoms; } -async function makePlanchet( - ws: InternalWalletState, - withdrawalSessionId: string, - coinIndex: number, -): Promise { - const withdrawalSession = await ws.db.get( - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - return; - } - const src = withdrawalSession.source; - if (src.type !== "reserve") { - throw Error("invalid state"); - } - const reserve = await ws.db.get(Stores.reserves, src.reservePub); - if (!reserve) { - return; - } - const denom = await ws.db.get(Stores.denominations, [ - withdrawalSession.exchangeBaseUrl, - withdrawalSession.denoms[coinIndex], - ]); - if (!denom) { - return; - } - const r = await ws.cryptoApi.createPlanchet({ - denomPub: denom.denomPub, - feeWithdraw: denom.feeWithdraw, - reservePriv: reserve.reservePriv, - reservePub: reserve.reservePub, - value: denom.value, - }); - const newPlanchet: PlanchetRecord = { - blindingKey: r.blindingKey, - coinEv: r.coinEv, - coinPriv: r.coinPriv, - coinPub: r.coinPub, - coinValue: r.coinValue, - denomPub: r.denomPub, - denomPubHash: r.denomPubHash, - isFromTip: false, - reservePub: r.reservePub, - withdrawSig: r.withdrawSig, - }; - await ws.db.runWithWriteTransaction( - [Stores.withdrawalSession], - async (tx) => { - const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId); - if (!myWs) { - return; - } - if (myWs.planchets[coinIndex]) { - return; - } - myWs.planchets[coinIndex] = newPlanchet; - await tx.put(Stores.withdrawalSession, myWs); - }, - ); -} - -async function processWithdrawCoin( - ws: InternalWalletState, - withdrawalSessionId: string, - coinIndex: number, -) { - logger.trace("starting withdraw for coin", coinIndex); - const withdrawalSession = await ws.db.get( - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - console.log("ws doesn't exist"); - return; - } - - const planchet = withdrawalSession.planchets[coinIndex]; - - if (planchet) { - const coin = await ws.db.get(Stores.coins, planchet.coinPub); - - if (coin) { - console.log("coin already exists"); - return; - } - } - - if (!withdrawalSession.planchets[coinIndex]) { - const key = `${withdrawalSessionId}-${coinIndex}`; - await ws.memoMakePlanchet.memo(key, async () => { - logger.trace("creating planchet for coin", coinIndex); - return makePlanchet(ws, withdrawalSessionId, coinIndex); - }); - } - await processPlanchet(ws, withdrawalSessionId, coinIndex); -} - async function incrementWithdrawalRetry( ws: InternalWalletState, - withdrawalSessionId: string, + withdrawalGroupId: string, err: OperationError | undefined, ): Promise { await ws.db.runWithWriteTransaction( - [Stores.withdrawalSession], + [Stores.withdrawalGroups], async (tx) => { - const wsr = await tx.get(Stores.withdrawalSession, withdrawalSessionId); + const wsr = await tx.get(Stores.withdrawalGroups, withdrawalGroupId); if (!wsr) { return; } @@ -499,30 +385,30 @@ async function incrementWithdrawalRetry( wsr.retryInfo.retryCounter++; updateRetryInfoTimeout(wsr.retryInfo); wsr.lastError = err; - await tx.put(Stores.withdrawalSession, wsr); + await tx.put(Stores.withdrawalGroups, wsr); }, ); ws.notify({ type: NotificationType.WithdrawOperationError }); } -export async function processWithdrawSession( +export async function processWithdrawGroup( ws: InternalWalletState, - withdrawalSessionId: string, + withdrawalGroupId: string, forceNow: boolean = false, ): Promise { const onOpErr = (e: OperationError) => - incrementWithdrawalRetry(ws, withdrawalSessionId, e); + incrementWithdrawalRetry(ws, withdrawalGroupId, e); await guardOperationException( - () => processWithdrawSessionImpl(ws, withdrawalSessionId, forceNow), + () => processWithdrawGroupImpl(ws, withdrawalGroupId, forceNow), onOpErr, ); } -async function resetWithdrawSessionRetry( +async function resetWithdrawalGroupRetry( ws: InternalWalletState, - withdrawalSessionId: string, + withdrawalGroupId: string, ) { - await ws.db.mutate(Stores.withdrawalSession, withdrawalSessionId, (x) => { + await ws.db.mutate(Stores.withdrawalGroups, withdrawalGroupId, (x) => { if (x.retryInfo.active) { x.retryInfo = initRetryInfo(); } @@ -530,26 +416,26 @@ async function resetWithdrawSessionRetry( }); } -async function processWithdrawSessionImpl( +async function processWithdrawGroupImpl( ws: InternalWalletState, - withdrawalSessionId: string, + withdrawalGroupId: string, forceNow: boolean, ): Promise { - logger.trace("processing withdraw session", withdrawalSessionId); + logger.trace("processing withdraw group", withdrawalGroupId); if (forceNow) { - await resetWithdrawSessionRetry(ws, withdrawalSessionId); + await resetWithdrawalGroupRetry(ws, withdrawalGroupId); } - const withdrawalSession = await ws.db.get( - Stores.withdrawalSession, - withdrawalSessionId, + const withdrawalGroup = await ws.db.get( + Stores.withdrawalGroups, + withdrawalGroupId, ); - if (!withdrawalSession) { + if (!withdrawalGroup) { logger.trace("withdraw session doesn't exist"); return; } - const ps = withdrawalSession.denoms.map((d, i) => - processWithdrawCoin(ws, withdrawalSessionId, i), + const ps = withdrawalGroup.denoms.map((d, i) => + processPlanchet(ws, withdrawalGroupId, i), ); await Promise.all(ps); return; diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts index 9c2b3ca3e..b87ada115 100644 --- a/src/types/dbTypes.ts +++ b/src/types/dbTypes.ts @@ -151,7 +151,7 @@ export interface WalletReserveHistoryCreditItem { /** * Amount we expect to see credited. */ - expectedAmount?: string; + expectedAmount?: AmountJson; /** * Item from the reserve transaction history that this @@ -161,7 +161,15 @@ export interface WalletReserveHistoryCreditItem { } export interface WalletReserveHistoryWithdrawItem { - expectedAmount?: string; + expectedAmount?: AmountJson; + + /** + * Hash of the blinded coin. + * + * When this value is set, it indicates that a withdrawal is active + * in the wallet for the + */ + expectedCoinEvHash?: string; type: WalletReserveHistoryItemType.Withdraw; @@ -188,7 +196,7 @@ export interface WalletReserveHistoryRecoupItem { /** * Amount we expect to see recouped. */ - expectedAmount?: string; + expectedAmount?: AmountJson; /** * Item from the reserve transaction history that this @@ -222,6 +230,11 @@ export interface ReserveRecord { */ exchangeBaseUrl: string; + /** + * Currency of the reserve. + */ + currency: string; + /** * Time when the reserve was created. */ @@ -237,34 +250,13 @@ export interface ReserveRecord { timestampReserveInfoPosted: Timestamp | undefined; /** - * Time when the reserve was confirmed. + * Time when the reserve was confirmed, either manually by the user + * or by the bank. * - * Set to 0 if not confirmed yet. + * Set to undefined if not confirmed yet. */ timestampConfirmed: Timestamp | undefined; - /** - * Amount that's still available for withdrawing - * from this reserve. - */ - amountWithdrawRemaining: AmountJson; - - /** - * Amount allocated for withdrawing. - * The corresponding withdraw operation may or may not - * have been completed yet. - */ - amountWithdrawAllocated: AmountJson; - - amountWithdrawCompleted: AmountJson; - - /** - * Amount requested when the reserve was created. - * When a reserve is re-used (rare!) the current_amount can - * be higher than the requested_amount - */ - amountInitiallyRequested: AmountJson; - /** * Wire information (as payto URI) for the bank account that * transfered funds for this reserve. @@ -305,7 +297,7 @@ export interface ReserveRecord { */ lastError: OperationError | undefined; - reserveTransactions: ReserveTransaction[]; + reserveTransactions: WalletReserveHistoryItem[]; } /** @@ -627,6 +619,7 @@ export interface PlanchetRecord { blindingKey: string; withdrawSig: string; coinEv: string; + coinEvHash: string; coinValue: AmountJson; isFromTip: boolean; } @@ -675,7 +668,7 @@ export const enum CoinSourceType { export interface WithdrawCoinSource { type: CoinSourceType.Withdraw; - withdrawSessionId: string; + withdrawalGroupId: string; /** * Index of the coin in the withdrawal session. @@ -1362,20 +1355,25 @@ export interface CoinsReturnRecord { wire: any; } +export const enum WithdrawalSourceType { + Tip = "tip", + Reserve = "reserve", +} + export interface WithdrawalSourceTip { - type: "tip"; + type: WithdrawalSourceType.Tip; tipId: string; } export interface WithdrawalSourceReserve { - type: "reserve"; + type: WithdrawalSourceType.Reserve; reservePub: string; } export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve; -export interface WithdrawalSessionRecord { - withdrawSessionId: string; +export interface WithdrawalGroupRecord { + withdrawalGroupId: string; /** * Withdrawal source. Fields that don't apply to the respective @@ -1636,9 +1634,9 @@ export namespace Stores { } } - class WithdrawalSessionsStore extends Store { + class WithdrawalGroupsStore extends Store { constructor() { - super("withdrawals", { keyPath: "withdrawSessionId" }); + super("withdrawals", { keyPath: "withdrawalGroupId" }); } } @@ -1697,7 +1695,7 @@ export namespace Stores { export const purchases = new PurchasesStore(); export const tips = new TipsStore(); export const senderWires = new SenderWiresStore(); - export const withdrawalSession = new WithdrawalSessionsStore(); + export const withdrawalGroups = new WithdrawalGroupsStore(); export const bankWithdrawUris = new BankWithdrawUrisStore(); export const refundEvents = new RefundEventsStore(); export const payEvents = new PayEventsStore(); diff --git a/src/types/history.ts b/src/types/history.ts index f4f3872ca..8179f6261 100644 --- a/src/types/history.ts +++ b/src/types/history.ts @@ -119,8 +119,6 @@ export interface HistoryReserveBalanceUpdatedEvent { */ timestamp: Timestamp; - newHistoryTransactions: ReserveTransaction[]; - /** * Condensed information about the reserve. */ @@ -129,13 +127,17 @@ export interface HistoryReserveBalanceUpdatedEvent { /** * Amount currently left in the reserve. */ - amountReserveBalance: string; + reserveBalance: string; /** - * Amount we expected to be in the reserve at that time, - * considering ongoing withdrawals from that reserve. + * Amount we still expect to be added to the reserve. */ - amountExpected: string; + reserveAwaitedAmount: string; + + /** + * Amount that hasn't been withdrawn yet. + */ + reserveUnclaimedAmount: string; } /** @@ -612,7 +614,7 @@ export interface HistoryWithdrawnEvent { * Unique identifier for the withdrawal session, can be used to * query more detailed information from the wallet. */ - withdrawSessionId: string; + withdrawalGroupId: string; withdrawalSource: WithdrawalSource; diff --git a/src/types/notifications.ts b/src/types/notifications.ts index 39930dcca..05d3c273a 100644 --- a/src/types/notifications.ts +++ b/src/types/notifications.ts @@ -1,4 +1,5 @@ import { OperationError } from "./walletTypes"; +import { WithdrawCoinSource, WithdrawalSource } from "./dbTypes"; /* This file is part of GNU Taler @@ -34,10 +35,9 @@ export const enum NotificationType { RefreshUnwarranted = "refresh-unwarranted", ReserveUpdated = "reserve-updated", ReserveConfirmed = "reserve-confirmed", - ReserveDepleted = "reserve-depleted", ReserveCreated = "reserve-created", - WithdrawSessionCreated = "withdraw-session-created", - WithdrawSessionFinished = "withdraw-session-finished", + WithdrawGroupCreated = "withdraw-group-created", + WithdrawGroupFinished = "withdraw-group-finished", WaitingForRetry = "waiting-for-retry", RefundStarted = "refund-started", RefundQueried = "refund-queried", @@ -114,19 +114,14 @@ export interface ReserveConfirmedNotification { type: NotificationType.ReserveConfirmed; } -export interface WithdrawSessionCreatedNotification { - type: NotificationType.WithdrawSessionCreated; - withdrawSessionId: string; +export interface WithdrawalGroupCreatedNotification { + type: NotificationType.WithdrawGroupCreated; + withdrawalGroupId: string; } -export interface WithdrawSessionFinishedNotification { - type: NotificationType.WithdrawSessionFinished; - withdrawSessionId: string; -} - -export interface ReserveDepletedNotification { - type: NotificationType.ReserveDepleted; - reservePub: string; +export interface WithdrawalGroupFinishedNotification { + type: NotificationType.WithdrawGroupFinished; + withdrawalSource: WithdrawalSource; } export interface WaitingForRetryNotification { @@ -210,13 +205,12 @@ export type WalletNotification = | ReserveUpdatedNotification | ReserveCreatedNotification | ReserveConfirmedNotification - | WithdrawSessionFinishedNotification - | ReserveDepletedNotification + | WithdrawalGroupFinishedNotification | WaitingForRetryNotification | RefundStartedNotification | RefundFinishedNotification | RefundQueriedNotification - | WithdrawSessionCreatedNotification + | WithdrawalGroupCreatedNotification | CoinWithdrawnNotification | WildcardNotification | RecoupOperationErrorNotification; diff --git a/src/types/pending.ts b/src/types/pending.ts index d9d17a3b9..1471fa19a 100644 --- a/src/types/pending.ts +++ b/src/types/pending.ts @@ -214,7 +214,8 @@ export interface PendingRecoupOperation { export interface PendingWithdrawOperation { type: PendingOperationType.Withdraw; source: WithdrawalSource; - withdrawSessionId: string; + lastError: OperationError | undefined; + withdrawalGroupId: string; numCoinsWithdrawn: number; numCoinsTotal: number; } diff --git a/src/types/types-test.ts b/src/types/types-test.ts index 885371a1a..ce3092497 100644 --- a/src/types/types-test.ts +++ b/src/types/types-test.ts @@ -15,14 +15,14 @@ */ import test from "ava"; -import * as Amounts from "../util/amounts"; -import { ContractTerms, codecForContractTerms } from "./talerTypes"; +import { Amounts, AmountJson } from "../util/amounts"; +import { codecForContractTerms } from "./talerTypes"; const amt = ( value: number, fraction: number, currency: string, -): Amounts.AmountJson => ({ value, fraction, currency }); +): AmountJson => ({ value, fraction, currency }); test("amount addition (simple)", (t) => { const a1 = amt(1, 0, "EUR"); @@ -118,13 +118,13 @@ test("amount parsing", (t) => { }); test("amount stringification", (t) => { - t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); - t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); - t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); - t.is(Amounts.toString(amt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); - t.is(Amounts.toString(amt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); + t.is(Amounts.stringify(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); + t.is(Amounts.stringify(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); + t.is(Amounts.stringify(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); + t.is(Amounts.stringify(amt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); + t.is(Amounts.stringify(amt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); // denormalized - t.is(Amounts.toString(amt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); + t.is(Amounts.stringify(amt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); t.pass(); }); diff --git a/src/types/walletTypes.ts b/src/types/walletTypes.ts index 7b58ba500..5d28c5ae7 100644 --- a/src/types/walletTypes.ts +++ b/src/types/walletTypes.ts @@ -427,6 +427,7 @@ export interface PlanchetCreationResult { withdrawSig: string; coinEv: string; coinValue: AmountJson; + coinEvHash: string; } export interface PlanchetCreationRequest { diff --git a/src/util/amounts.ts b/src/util/amounts.ts index 8deeaeccc..aee7b12b5 100644 --- a/src/util/amounts.ts +++ b/src/util/amounts.ts @@ -299,7 +299,7 @@ export function fromFloat(floatVal: number, currency: string) { * Convert to standard human-readable string representation that's * also used in JSON formats. */ -export function toString(a: AmountJson): string { +export function stringify(a: AmountJson): string { const av = a.value + Math.floor(a.fraction / fractionalBase); const af = a.fraction % fractionalBase; let s = av.toString(); @@ -322,7 +322,7 @@ export function toString(a: AmountJson): string { /** * Check if the argument is a valid amount in string form. */ -export function check(a: any): boolean { +function check(a: any): boolean { if (typeof a !== "string") { return false; } @@ -333,3 +333,19 @@ export function check(a: any): boolean { return false; } } + +// Export all amount-related functions here for better IDE experience. +export const Amounts = { + stringify: stringify, + parse: parse, + parseOrThrow: parseOrThrow, + cmp: cmp, + add: add, + sum: sum, + sub: sub, + check: check, + getZero: getZero, + isZero: isZero, + maxAmountValue: maxAmountValue, + fromFloat: fromFloat, +}; \ No newline at end of file diff --git a/src/util/reserveHistoryUtil-test.ts b/src/util/reserveHistoryUtil-test.ts new file mode 100644 index 000000000..910d6a01a --- /dev/null +++ b/src/util/reserveHistoryUtil-test.ts @@ -0,0 +1,286 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import test from "ava"; +import { + reconcileReserveHistory, + summarizeReserveHistory, +} from "./reserveHistoryUtil"; +import { + WalletReserveHistoryItem, + WalletReserveHistoryItemType, +} from "../types/dbTypes"; +import { + ReserveTransaction, + ReserveTransactionType, +} from "../types/ReserveTransaction"; +import { Amounts } from "./amounts"; + +test("basics", (t) => { + const r = reconcileReserveHistory([], []); + t.deepEqual(r.updatedLocalHistory, []); +}); + +test("unmatched credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = []; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 1); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:100"); +}); + +test("unmatched credit #2", (t) => { + const localHistory: WalletReserveHistoryItem[] = []; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:150"); +}); + +test("matched credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:150"); +}); + +test("fulfilling credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); +}); + +test("unfulfilled credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:50", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC02", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:150"); +}); + +test("awaited credit", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:50"), + }, + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:50"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:100"); +}); + +test("withdrawal new match", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + { + type: WalletReserveHistoryItemType.Withdraw, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:5"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + { + type: ReserveTransactionType.Withdraw, + amount: "TESTKUDOS:5", + h_coin_envelope: "foobar", + h_denom_pub: "foobar", + reserve_sig: "foobar", + withdraw_fee: "TESTKUDOS:0.1", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + console.log(r); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:95"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:95"); +}); + +test("claimed but now arrived", (t) => { + const localHistory: WalletReserveHistoryItem[] = [ + { + type: WalletReserveHistoryItemType.Credit, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:100"), + matchedExchangeTransaction: { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + }, + { + type: WalletReserveHistoryItemType.Withdraw, + expectedAmount: Amounts.parseOrThrow("TESTKUDOS:5"), + }, + ]; + const remoteHistory: ReserveTransaction[] = [ + { + type: ReserveTransactionType.Credit, + amount: "TESTKUDOS:100", + sender_account_url: "payto://void/", + timestamp: { t_ms: 42 }, + wire_reference: "ABC01", + }, + ]; + const r = reconcileReserveHistory(localHistory, remoteHistory); + const s = summarizeReserveHistory(r.updatedLocalHistory, "TESTKUDOS"); + t.deepEqual(r.updatedLocalHistory.length, 2); + t.deepEqual(Amounts.stringify(s.computedReserveBalance), "TESTKUDOS:100"); + t.deepEqual(Amounts.stringify(s.awaitedReserveAmount), "TESTKUDOS:0"); + t.deepEqual(Amounts.stringify(s.unclaimedReserveAmount), "TESTKUDOS:95"); +}); diff --git a/src/util/reserveHistoryUtil.ts b/src/util/reserveHistoryUtil.ts new file mode 100644 index 000000000..95f58449e --- /dev/null +++ b/src/util/reserveHistoryUtil.ts @@ -0,0 +1,384 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Imports. + */ +import { + WalletReserveHistoryItem, + WalletReserveHistoryItemType, +} from "../types/dbTypes"; +import { + ReserveTransaction, + ReserveTransactionType, +} from "../types/ReserveTransaction"; +import * as Amounts from "../util/amounts"; +import { timestampCmp } from "./time"; +import { deepCopy } from "./helpers"; +import { AmountString } from "../types/talerTypes"; +import { AmountJson } from "../util/amounts"; + +/** + * Helpers for dealing with reserve histories. + * + * @author Florian Dold + */ + +export interface ReserveReconciliationResult { + /** + * The wallet's local history reconciled with the exchange's reserve history. + */ + updatedLocalHistory: WalletReserveHistoryItem[]; + + /** + * History items that were newly created, subset of the + * updatedLocalHistory items. + */ + newAddedItems: WalletReserveHistoryItem[]; + + /** + * History items that were newly matched, subset of the + * updatedLocalHistory items. + */ + newMatchedItems: WalletReserveHistoryItem[]; +} + +export interface ReserveHistorySummary { + /** + * Balance computed by the wallet, should match the balance + * computed by the reserve. + */ + computedReserveBalance: Amounts.AmountJson; + + /** + * Reserve balance that is still available for withdrawal. + */ + unclaimedReserveAmount: Amounts.AmountJson; + + /** + * Amount that we're still expecting to come into the reserve. + */ + awaitedReserveAmount: Amounts.AmountJson; + + /** + * Amount withdrawn from the reserve so far. Only counts + * finished withdrawals, not withdrawals in progress. + */ + withdrawnAmount: Amounts.AmountJson; +} + +export function isRemoteHistoryMatch( + t1: ReserveTransaction, + t2: ReserveTransaction, +): boolean { + switch (t1.type) { + case ReserveTransactionType.Closing: { + return t1.type === t2.type && t1.wtid == t2.wtid; + } + case ReserveTransactionType.Credit: { + return t1.type === t2.type && t1.wire_reference === t2.wire_reference; + } + case ReserveTransactionType.Recoup: { + return ( + t1.type === t2.type && + t1.coin_pub === t2.coin_pub && + timestampCmp(t1.timestamp, t2.timestamp) === 0 + ); + } + case ReserveTransactionType.Withdraw: { + return t1.type === t2.type && t1.h_coin_envelope === t2.h_coin_envelope; + } + } +} + +export function isLocalRemoteHistoryPreferredMatch( + t1: WalletReserveHistoryItem, + t2: ReserveTransaction, +): boolean { + switch (t1.type) { + case WalletReserveHistoryItemType.Credit: { + return ( + t2.type === ReserveTransactionType.Credit && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ); + } + case WalletReserveHistoryItemType.Withdraw: + return ( + t2.type === ReserveTransactionType.Withdraw && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ) + case WalletReserveHistoryItemType.Recoup: { + return ( + t2.type === ReserveTransactionType.Recoup && + !!t1.expectedAmount && + Amounts.cmp(t1.expectedAmount, Amounts.parseOrThrow(t2.amount)) === 0 + ); + } + } + return false; +} + +export function isLocalRemoteHistoryAcceptableMatch( + t1: WalletReserveHistoryItem, + t2: ReserveTransaction, +): boolean { + switch (t1.type) { + case WalletReserveHistoryItemType.Closing: + throw Error("invariant violated"); + case WalletReserveHistoryItemType.Credit: + return !t1.expectedAmount && t2.type == ReserveTransactionType.Credit; + case WalletReserveHistoryItemType.Recoup: + return !t1.expectedAmount && t2.type == ReserveTransactionType.Recoup; + case WalletReserveHistoryItemType.Withdraw: + return !t1.expectedAmount && t2.type == ReserveTransactionType.Withdraw; + } +} + +export function summarizeReserveHistory( + localHistory: WalletReserveHistoryItem[], + currency: string, +): ReserveHistorySummary { + const posAmounts: AmountJson[] = []; + const negAmounts: AmountJson[] = []; + const expectedPosAmounts: AmountJson[] = []; + const expectedNegAmounts: AmountJson[] = []; + const withdrawnAmounts: AmountJson[] = []; + + for (const item of localHistory) { + switch (item.type) { + case WalletReserveHistoryItemType.Credit: + if (item.matchedExchangeTransaction) { + posAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else if (item.expectedAmount) { + expectedPosAmounts.push(item.expectedAmount); + } + break; + case WalletReserveHistoryItemType.Recoup: + if (item.matchedExchangeTransaction) { + if (item.matchedExchangeTransaction) { + posAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else if (item.expectedAmount) { + expectedPosAmounts.push(item.expectedAmount); + } else { + throw Error("invariant failed"); + } + } + break; + case WalletReserveHistoryItemType.Closing: + if (item.matchedExchangeTransaction) { + negAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + } else { + throw Error("invariant failed"); + } + break; + case WalletReserveHistoryItemType.Withdraw: + if (item.matchedExchangeTransaction) { + negAmounts.push( + Amounts.parseOrThrow(item.matchedExchangeTransaction.amount), + ); + withdrawnAmounts.push(Amounts.parseOrThrow(item.matchedExchangeTransaction.amount)); + } else if (item.expectedAmount) { + expectedNegAmounts.push(item.expectedAmount); + } else { + throw Error("invariant failed"); + } + break; + } + } + + const z = Amounts.getZero(currency); + + const computedBalance = Amounts.sub( + Amounts.add(z, ...posAmounts).amount, + ...negAmounts, + ).amount; + + const unclaimedReserveAmount = Amounts.sub( + Amounts.add(z, ...posAmounts).amount, + ...negAmounts, + ...expectedNegAmounts, + ).amount; + + const awaitedReserveAmount = Amounts.sub( + Amounts.add(z, ...expectedPosAmounts).amount, + ...expectedNegAmounts, + ).amount; + + const withdrawnAmount = Amounts.add(z, ...withdrawnAmounts).amount; + + return { + computedReserveBalance: computedBalance, + unclaimedReserveAmount: unclaimedReserveAmount, + awaitedReserveAmount: awaitedReserveAmount, + withdrawnAmount, + }; +} + +export function reconcileReserveHistory( + localHistory: WalletReserveHistoryItem[], + remoteHistory: ReserveTransaction[], +): ReserveReconciliationResult { + const updatedLocalHistory: WalletReserveHistoryItem[] = deepCopy( + localHistory, + ); + const newMatchedItems: WalletReserveHistoryItem[] = []; + const newAddedItems: WalletReserveHistoryItem[] = []; + + const remoteMatched = remoteHistory.map(() => false); + const localMatched = localHistory.map(() => false); + + // Take care of deposits + + // First, see which pairs are already a definite match. + for (let remoteIndex = 0; remoteIndex < remoteHistory.length; remoteIndex++) { + const rhi = remoteHistory[remoteIndex]; + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + if (!lhi.matchedExchangeTransaction) { + continue; + } + if (isRemoteHistoryMatch(rhi, lhi.matchedExchangeTransaction)) { + localMatched[localIndex] = true; + remoteMatched[remoteIndex] = true; + break; + } + } + } + + // Check that all previously matched items are still matched + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + if (lhi.matchedExchangeTransaction) { + // Don't use for further matching + localMatched[localIndex] = true; + // FIXME: emit some error here! + throw Error("previously matched reserve history item now unmatched"); + } + } + + // Next, find out if there are any exact new matches between local and remote + // history items + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + for ( + let remoteIndex = 0; + remoteIndex < remoteHistory.length; + remoteIndex++ + ) { + const rhi = remoteHistory[remoteIndex]; + if (remoteMatched[remoteIndex]) { + continue; + } + if (isLocalRemoteHistoryPreferredMatch(lhi, rhi)) { + localMatched[localIndex] = true; + remoteMatched[remoteIndex] = true; + updatedLocalHistory[localIndex].matchedExchangeTransaction = rhi as any; + newMatchedItems.push(lhi); + break; + } + } + } + + // Next, find out if there are any acceptable new matches between local and remote + // history items + for (let localIndex = 0; localIndex < localHistory.length; localIndex++) { + if (localMatched[localIndex]) { + continue; + } + const lhi = localHistory[localIndex]; + for ( + let remoteIndex = 0; + remoteIndex < remoteHistory.length; + remoteIndex++ + ) { + const rhi = remoteHistory[remoteIndex]; + if (remoteMatched[remoteIndex]) { + continue; + } + if (isLocalRemoteHistoryAcceptableMatch(lhi, rhi)) { + localMatched[localIndex] = true; + remoteMatched[remoteIndex] = true; + updatedLocalHistory[localIndex].matchedExchangeTransaction = rhi as any; + newMatchedItems.push(lhi); + break; + } + } + } + + // Finally we add new history items + for (let remoteIndex = 0; remoteIndex < remoteHistory.length; remoteIndex++) { + if (remoteMatched[remoteIndex]) { + continue; + } + const rhi = remoteHistory[remoteIndex]; + let newItem: WalletReserveHistoryItem; + switch (rhi.type) { + case ReserveTransactionType.Closing: { + newItem = { + type: WalletReserveHistoryItemType.Closing, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Credit: { + newItem = { + type: WalletReserveHistoryItemType.Credit, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Recoup: { + newItem = { + type: WalletReserveHistoryItemType.Recoup, + matchedExchangeTransaction: rhi, + }; + break; + } + case ReserveTransactionType.Withdraw: { + newItem = { + type: WalletReserveHistoryItemType.Withdraw, + matchedExchangeTransaction: rhi, + }; + break; + } + } + updatedLocalHistory.push(newItem); + newAddedItems.push(newItem); + } + + return { + updatedLocalHistory, + newAddedItems, + newMatchedItems, + }; +} diff --git a/src/wallet.ts b/src/wallet.ts index 2560b0dc3..3171d0cea 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -26,8 +26,7 @@ import { CryptoWorkerFactory } from "./crypto/workers/cryptoApi"; import { HttpRequestLibrary } from "./util/http"; import { Database } from "./util/query"; -import { AmountJson } from "./util/amounts"; -import * as Amounts from "./util/amounts"; +import { Amounts, AmountJson } from "./util/amounts"; import { getWithdrawDetailsForUri, @@ -92,7 +91,7 @@ import { import { InternalWalletState } from "./operations/state"; import { createReserve, confirmReserve } from "./operations/reserves"; import { processRefreshGroup, createRefreshGroup } from "./operations/refresh"; -import { processWithdrawSession } from "./operations/withdraw"; +import { processWithdrawGroup } from "./operations/withdraw"; import { getHistory } from "./operations/history"; import { getPendingOperations } from "./operations/pending"; import { getBalances } from "./operations/balance"; @@ -193,9 +192,9 @@ export class Wallet { await processReserve(this.ws, pending.reservePub, forceNow); break; case PendingOperationType.Withdraw: - await processWithdrawSession( + await processWithdrawGroup( this.ws, - pending.withdrawSessionId, + pending.withdrawalGroupId, forceNow, ); break; @@ -574,10 +573,14 @@ export class Wallet { await this.db.put(Stores.currencies, currencyRecord); } - async getReserves(exchangeBaseUrl: string): Promise { - return await this.db - .iter(Stores.reserves) - .filter((r) => r.exchangeBaseUrl === exchangeBaseUrl); + async getReserves(exchangeBaseUrl?: string): Promise { + if (exchangeBaseUrl) { + return await this.db + .iter(Stores.reserves) + .filter((r) => r.exchangeBaseUrl === exchangeBaseUrl); + } else { + return await this.db.iter(Stores.reserves).toArray(); + } } async getCoinsForExchange(exchangeBaseUrl: string): Promise { @@ -807,8 +810,8 @@ export class Wallet { let withdrawalReservePub: string | undefined; if (cs.type == CoinSourceType.Withdraw) { const ws = await this.db.get( - Stores.withdrawalSession, - cs.withdrawSessionId, + Stores.withdrawalGroups, + cs.withdrawalGroupId, ); if (!ws) { console.error("no withdrawal session found for coin"); @@ -822,10 +825,10 @@ export class Wallet { coin_pub: c.coinPub, denom_pub: c.denomPub, denom_pub_hash: c.denomPubHash, - denom_value: Amounts.toString(denom.value), + denom_value: Amounts.stringify(denom.value), exchange_base_url: c.exchangeBaseUrl, refresh_parent_coin_pub: refreshParentCoinPub, - remaining_value: Amounts.toString(c.currentAmount), + remaining_value: Amounts.stringify(c.currentAmount), withdrawal_reserve_pub: withdrawalReservePub, coin_suspended: c.suspended, }); diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx index 7b20f2227..17880db58 100644 --- a/src/webex/pages/popup.tsx +++ b/src/webex/pages/popup.tsx @@ -565,10 +565,6 @@ function formatHistoryItem(historyItem: HistoryEvent) { ); } diff --git a/src/webex/pages/return-coins.tsx b/src/webex/pages/return-coins.tsx index fd9238ee2..3786697c6 100644 --- a/src/webex/pages/return-coins.tsx +++ b/src/webex/pages/return-coins.tsx @@ -25,7 +25,7 @@ */ import { AmountJson } from "../../util/amounts"; -import * as Amounts from "../../util/amounts"; +import { Amounts } from "../../util/amounts"; import { SenderWireInfos, WalletBalance } from "../../types/walletTypes"; @@ -70,7 +70,7 @@ class ReturnSelectionItem extends React.Component< ); this.state = { currency: props.balance.byExchange[props.exchangeUrl].available.currency, - selectedValue: Amounts.toString( + selectedValue: Amounts.stringify( props.balance.byExchange[props.exchangeUrl].available, ), selectedWire: "",