From eef2d4702019b9de64efc01fff097b437e65ce39 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 26 Aug 2022 01:08:51 -0300 Subject: exchange selection: timeline done --- .../src/wallet/ExchangeSelection/state.ts | 525 +++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts (limited to 'packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts') diff --git a/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts new file mode 100644 index 000000000..f077f45a4 --- /dev/null +++ b/packages/taler-wallet-webextension/src/wallet/ExchangeSelection/state.ts @@ -0,0 +1,525 @@ +/* + This file is part of GNU Taler + (C) 2022 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 + */ + + +import { AbsoluteTime, AmountJson, Amounts, DenominationInfo, TalerProtocolTimestamp } from "@gnu-taler/taler-util"; +import { useState } from "preact/hooks"; +import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; +import * as wxApi from "../../wxApi.js"; +import { FeeDescription, FeeDescriptionPair, OperationMap, Props, State } from "./index.js"; + +export function useComponentState( + { onCancel, onSelection, currency }: Props, + api: typeof wxApi, +): State { + const hook = useAsyncAsHook(api.listExchanges); + + const initialValue = 0 + const [value, setValue] = useState(String(initialValue)); + + if (!hook) { + return { + status: "loading", + error: undefined, + } + } + if (hook.hasError) { + return { + status: "loading-uri", + error: hook, + }; + } + + const exchanges = hook.response.exchanges; + + if (exchanges.length === 0) { + return { + status: "no-exchanges", + error: undefined + } + } + + const original = exchanges[initialValue]; + const selected = exchanges[Number(value)]; + + let nextFeeUpdate = TalerProtocolTimestamp.never(); + + nextFeeUpdate = Object.values(selected.wireInfo.feesForType).reduce( + (prev, cur) => { + return cur.reduce((p, c) => nearestTimestamp(p, c.endStamp), prev); + }, + nextFeeUpdate, + ); + + nextFeeUpdate = selected.denominations.reduce((prev, cur) => { + return [ + cur.stampExpireWithdraw, + cur.stampExpireLegal, + cur.stampExpireDeposit, + ].reduce(nearestTimestamp, prev); + }, nextFeeUpdate); + + const timeline: OperationMap = { + deposit: createDenominationTimeline( + selected.denominations, + "stampExpireDeposit", + "feeDeposit", + ), + refresh: createDenominationTimeline( + selected.denominations, + "stampExpireWithdraw", + "feeRefresh", + ), + refund: createDenominationTimeline( + selected.denominations, + "stampExpireWithdraw", + "feeRefund", + ), + withdraw: createDenominationTimeline( + selected.denominations, + "stampExpireWithdraw", + "feeWithdraw", + ), + }; + + const exchangeMap = exchanges.reduce((prev, cur, idx) => ({ ...prev, [cur.exchangeBaseUrl]: String(idx) }), {} as Record) + + if (original === selected) { + return { + status: "ready", + exchanges: { + list: exchangeMap, + value: value, + onChange: async (v) => { + setValue(v) + } + }, + error: undefined, + nextFeeUpdate: AbsoluteTime.fromTimestamp(nextFeeUpdate), + onClose: { + onClick: onCancel + }, + selected, + timeline + } + } + + const originalTimeline: OperationMap = { + deposit: createDenominationTimeline( + original.denominations, + "stampExpireDeposit", + "feeDeposit", + ), + refresh: createDenominationTimeline( + original.denominations, + "stampExpireWithdraw", + "feeRefresh", + ), + refund: createDenominationTimeline( + original.denominations, + "stampExpireWithdraw", + "feeRefund", + ), + withdraw: createDenominationTimeline( + original.denominations, + "stampExpireWithdraw", + "feeWithdraw", + ), + }; + const pairTimeline: OperationMap = { + deposit: createDenominationPairTimeline(timeline.deposit, originalTimeline.deposit), + refresh: createDenominationPairTimeline(timeline.refresh, originalTimeline.refresh), + refund: createDenominationPairTimeline(timeline.refund, originalTimeline.refund), + withdraw: createDenominationPairTimeline(timeline.withdraw, originalTimeline.withdraw), + } + + return { + status: "comparing", + exchanges: { + list: exchangeMap, + value: value, + onChange: async (v) => { + setValue(v) + } + }, + error: undefined, + nextFeeUpdate: AbsoluteTime.fromTimestamp(nextFeeUpdate), + onReset: { + onClick: async () => { + setValue(String(initialValue)) + } + }, + onSelect: { + onClick: async () => { + onSelection(selected.exchangeBaseUrl) + } + }, + selected, + pairTimeline, + } + +} + +function nearestTimestamp( + first: TalerProtocolTimestamp, + second: TalerProtocolTimestamp, +): TalerProtocolTimestamp { + const f = AbsoluteTime.fromTimestamp(first); + const s = AbsoluteTime.fromTimestamp(second); + const a = AbsoluteTime.min(f, s); + return AbsoluteTime.toTimestamp(a); +} + + + +export interface TimePoint { + type: "start" | "end"; + moment: AbsoluteTime; + denom: DenominationInfo; +} + +/** + * Given a list of denominations with the same value and same period of time: + * return the one that will be used. + * The best denomination is the one that will minimize the fee cost. + * + * @param list denominations of same value + * @returns + */ +function selectBestForOverlappingDenominations( + list: DenominationInfo[], +): DenominationInfo | undefined { + let minDeposit: DenominationInfo | undefined = undefined; + list.forEach((e) => { + if (minDeposit === undefined) { + minDeposit = e; + return; + } + if (Amounts.cmp(minDeposit.feeDeposit, e.feeDeposit) > -1) { + minDeposit = e; + } + }); + return minDeposit; +} + +type PropsWithReturnType = Exclude< + { + [K in keyof T]: T[K] extends F ? K : never; + }[keyof T], + undefined +>; + +/** + * Takes two list and create one with one timeline. + * For any element in the position "p" on the left or right "list", then + * list[p].until should be equal to list[p+1].from + * + * @see {createDenominationTimeline} + * + * @param left list denominations @type {FeeDescription} + * @param right list denominations @type {FeeDescription} + * @returns list of pairs for the same time + */ +export function createDenominationPairTimeline(left: FeeDescription[], right: FeeDescription[]): FeeDescriptionPair[] { + //both list empty, discarded + if (left.length === 0 && right.length === 0) return []; + + const pairList: FeeDescriptionPair[] = []; + + let li = 0; + let ri = 0; + + while (li < left.length && ri < right.length) { + const currentValue = Amounts.cmp(left[li].value, right[ri].value) < 0 ? left[li].value : right[ri].value; + + let ll = 0 //left length (until next value) + while (li + ll < left.length && Amounts.cmp(left[li + ll].value, currentValue) === 0) { + ll++ + } + let rl = 0 //right length (until next value) + while (ri + rl < right.length && Amounts.cmp(right[ri + rl].value, currentValue) === 0) { + rl++ + } + const leftIsEmpty = ll === 0 + const rightIsEmpty = rl === 0 + //check which start after, add gap so both list starts at the same time + // one list may be empty + const leftStarts: AbsoluteTime = + leftIsEmpty ? { t_ms: "never" } : left[li].from; + const rightStarts: AbsoluteTime = + rightIsEmpty ? { t_ms: "never" } : right[ri].from; + + //first time cut is the smallest time + let timeCut: AbsoluteTime = leftStarts; + + if (AbsoluteTime.cmp(leftStarts, rightStarts) < 0) { + const ends = + rightIsEmpty ? left[li + ll - 1].until : right[0].from; + + right.splice(ri, 0, { + from: leftStarts, + until: ends, + value: left[li].value, + }); + rl++; + + timeCut = leftStarts + } + if (AbsoluteTime.cmp(leftStarts, rightStarts) > 0) { + const ends = + leftIsEmpty ? right[ri + rl - 1].until : left[0].from; + + left.splice(li, 0, { + from: rightStarts, + until: ends, + value: right[ri].value, + }); + ll++; + + timeCut = rightStarts + } + + //check which ends sooner, add gap so both list ends at the same time + // here both list are non empty + const leftEnds: AbsoluteTime = left[li + ll - 1].until; + const rightEnds: AbsoluteTime = right[ri + rl - 1].until; + + if (AbsoluteTime.cmp(leftEnds, rightEnds) > 0) { + right.splice(ri + rl, 0, { + from: rightEnds, + until: leftEnds, + value: left[0].value, + }); + rl++; + + } + if (AbsoluteTime.cmp(leftEnds, rightEnds) < 0) { + left.splice(li + ll, 0, { + from: leftEnds, + until: rightEnds, + value: right[0].value, + }); + ll++; + } + + //now both lists are non empty and (starts,ends) at the same time + while (li < left.length && ri < right.length && Amounts.cmp(left[li].value, right[ri].value) === 0) { + // console.log('start', li, ri, left[li], right[ri]) + + if (AbsoluteTime.cmp(left[li].from, timeCut) !== 0 && AbsoluteTime.cmp(right[ri].from, timeCut) !== 0) { + // timeCut comes from the latest "until" (expiration from the previous) + // and this value comes from the latest left or right + // it should be the same as the "from" from one of the latest left or right + // otherwise it means that there is missing a gap object in the middle + // the list is not complete and the behavior is undefined + console.log(li, ri, timeCut) + throw Error('one of the list is not completed: list[i].until !== list[i+1].from') + } + + pairList.push({ + left: left[li].fee, + right: right[ri].fee, + from: timeCut, + until: AbsoluteTime.never(), + value: currentValue, + }); + + if (left[li].until.t_ms === right[ri].until.t_ms) { + timeCut = left[li].until; + ri++; + li++; + } else if (left[li].until.t_ms < right[ri].until.t_ms) { + timeCut = left[li].until; + li++; + } else if (left[li].until.t_ms > right[ri].until.t_ms) { + timeCut = right[ri].until; + ri++; + } + pairList[pairList.length - 1].until = timeCut + + if (li < left.length && Amounts.cmp(left[li].value, pairList[pairList.length - 1].value) !== 0) { + //value changed, should break + //this if will catch when both (left and right) change at the same time + //if just one side changed it will catch in the while condition + break; + } + + } + + } + //one of the list left or right can still have elements + if (li < left.length) { + let timeCut = pairList.length > 0 && Amounts.cmp(pairList[pairList.length - 1].value, left[li].value) === 0 ? pairList[pairList.length - 1].until : left[li].from; + while (li < left.length) { + pairList.push({ + left: left[li].fee, + right: undefined, + from: timeCut, + until: left[li].until, + value: left[li].value, + }) + timeCut = left[li].until + li++; + } + } + if (ri < right.length) { + let timeCut = pairList.length > 0 && Amounts.cmp(pairList[pairList.length - 1].value, right[ri].value) === 0 ? pairList[pairList.length - 1].until : right[ri].from; + while (ri < right.length) { + pairList.push({ + right: right[ri].fee, + left: undefined, + from: timeCut, + until: right[ri].until, + value: right[ri].value, + }) + timeCut = right[ri].until + ri++; + } + } + return pairList +} + +/** + * Create a usage timeline with the denominations given. + * + * If there are multiple denominations that can be used, the list will + * contain the one that minimize the fee cost. @see selectBestForOverlappingDenominations + * + * @param list list of denominations + * @param periodProp property of element of the list that will be used as end of the usage period + * @param feeProp property of the element of the list that will be used as fee reference + * @returns list of @type {FeeDescription} sorted by usage period + */ +export function createDenominationTimeline( + list: DenominationInfo[], + periodProp: PropsWithReturnType, + feeProp: PropsWithReturnType, +): FeeDescription[] { + const points = list + .reduce((ps, denom) => { + //exclude denoms with bad configuration + if (denom.stampStart.t_s >= denom[periodProp].t_s) { + throw Error(`denom ${denom.denomPubHash} has start after the end`); + // return ps; + } + ps.push({ + type: "start", + moment: AbsoluteTime.fromTimestamp(denom.stampStart), + denom, + }); + ps.push({ + type: "end", + moment: AbsoluteTime.fromTimestamp(denom[periodProp]), + denom, + }); + return ps; + }, [] as TimePoint[]) + .sort((a, b) => { + const v = Amounts.cmp(a.denom.value, b.denom.value); + if (v != 0) return v; + const t = AbsoluteTime.cmp(a.moment, b.moment); + if (t != 0) return t; + if (a.type === b.type) return 0; + return a.type === "start" ? 1 : -1; + }); + + const activeAtTheSameTime: DenominationInfo[] = []; + return points.reduce((result, cursor, idx) => { + const hash = cursor.denom.denomPubHash; + if (!hash) + throw Error( + `denomination without hash ${JSON.stringify( + cursor.denom, + undefined, + 2, + )}`, + ); + + let prev = result.length > 0 ? result[result.length - 1] : undefined; + const prevHasSameValue = + prev && Amounts.cmp(prev.value, cursor.denom.value) === 0; + if (prev) { + if (prevHasSameValue) { + prev.until = cursor.moment; + + if (prev.from.t_ms === prev.until.t_ms) { + result.pop(); + prev = result[result.length - 1]; + } + } else { + // the last end adds a gap that we have to remove + result.pop(); + } + } + + //update the activeAtTheSameTime list + if (cursor.type === "end") { + const loc = activeAtTheSameTime.findIndex((v) => v.denomPubHash === hash); + if (loc === -1) { + throw Error(`denomination ${hash} has an end but no start`); + } + activeAtTheSameTime.splice(loc, 1); + } else if (cursor.type === "start") { + activeAtTheSameTime.push(cursor.denom); + } else { + const exhaustiveCheck: never = cursor.type; + throw new Error(`not TimePoint defined for type: ${exhaustiveCheck}`); + } + + if (idx == points.length - 1) { + //this is the last element in the list, prevent adding + //a gap in the end + if (cursor.type !== "end") { + throw Error( + `denomination ${hash} starts after ending or doesn't have an ending`, + ); + } + if (activeAtTheSameTime.length > 0) { + throw Error( + `there are ${activeAtTheSameTime.length} denominations without ending`, + ); + } + return result; + } + + const current = selectBestForOverlappingDenominations(activeAtTheSameTime); + + if (current) { + if ( + prev === undefined || //is the first + !prev.fee || //is a gap + Amounts.cmp(prev.fee, current[feeProp]) !== 0 // prev has the same fee + ) { + result.push({ + value: cursor.denom.value, + from: cursor.moment, + until: AbsoluteTime.never(), //not yet known + fee: current[feeProp], + }); + } else { + prev.until = cursor.moment; + } + } else { + result.push({ + value: cursor.denom.value, + from: cursor.moment, + until: AbsoluteTime.never(), //not yet known + }); + } + + return result; + }, [] as FeeDescription[]); +} -- cgit v1.2.3