/* 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 { 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[]; } /** * Various totals computed from the wallet's view * on the reserve history. */ 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; } /** * Check if two reserve history items (exchange's version) match. */ 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; } } } /** * Check a local reserve history item and a remote history item are a match. */ export function isLocalRemoteHistoryMatch( 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; } /** * Compute totals for the wallet's view of the reserve history. */ 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, }; } /** * Reconcile the wallet's local model of the reserve history * with the reserve history of the exchange. */ 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 (isLocalRemoteHistoryMatch(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, }; }