/* This file is part of GNU Taler (C) 2021 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 { AbsoluteTime, AmountJson, Amounts, AmountString, DenominationInfo, Duration, FeeDescription, FeeDescriptionPair, TalerProtocolTimestamp, TimePoint, } from "@gnu-taler/taler-util"; import { DenominationRecord, timestampProtocolFromDb } from "./db.js"; /** * 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 */ export function selectBestForOverlappingDenominations< T extends DenominationInfo, >(list: T[]): T | undefined { let minDeposit: DenominationInfo | undefined = undefined; //TODO: improve denomination selection, this is a trivial implementation list.forEach((e) => { if (minDeposit === undefined) { minDeposit = e; return; } if (Amounts.cmp(minDeposit.feeDeposit, e.feeDeposit) > -1) { minDeposit = e; } }); return minDeposit; } export function selectMinimumFee( list: T[], ): T | undefined { let minFee: T | undefined = undefined; //TODO: improve denomination selection, this is a trivial implementation list.forEach((e) => { if (minFee === undefined) { minFee = e; return; } if (Amounts.cmp(minFee.fee, e.fee) > -1) { minFee = e; } }); return minFee; } type PropsWithReturnType = Exclude< { [K in keyof T]: T[K] extends F ? K : never; }[keyof T], undefined >; /** * Takes two timelines and create one to compare them. * * For both lists the next condition should be true: * for any element in the position "idx" then * list[idx].until === list[idx+1].from * * @see {createTimeline} * * @param left list denominations @type {FeeDescription} * @param right list denominations @type {FeeDescription} * @returns list of pairs for the same time */ export function createPairTimeline( left: FeeDescription[], right: FeeDescription[], ): FeeDescriptionPair[] { //FIXME: we need to create a copy of the array because //this algorithm is using splice, remove splice and //remove this array duplication left = [...left]; right = [...right]; //both list empty, discarded if (left.length === 0 && right.length === 0) return []; const pairList: FeeDescriptionPair[] = []; let li = 0; //left list index let ri = 0; //right list index while (li < left.length && ri < right.length) { const currentGroup = Number.parseFloat(left[li].group) < Number.parseFloat(right[ri].group) ? left[li].group : right[ri].group; const lgs = li; //left group start index const rgs = ri; //right group start index let lgl = 0; //left group length (until next value) while (li + lgl < left.length && left[li + lgl].group === currentGroup) { lgl++; } let rgl = 0; //right group length (until next value) while (ri + rgl < right.length && right[ri + rgl].group === currentGroup) { rgl++; } const leftGroupIsEmpty = lgl === 0; const rightGroupIsEmpty = rgl === 0; //check which start after, add gap so both list starts at the same time // one list may be empty const leftStartTime: AbsoluteTime = leftGroupIsEmpty ? AbsoluteTime.never() : left[li].from; const rightStartTime: AbsoluteTime = rightGroupIsEmpty ? AbsoluteTime.never() : right[ri].from; //first time cut is the smallest time let timeCut: AbsoluteTime = leftStartTime; if (AbsoluteTime.cmp(leftStartTime, rightStartTime) < 0) { const ends = rightGroupIsEmpty ? left[li + lgl - 1].until : right[0].from; right.splice(ri, 0, { from: leftStartTime, until: ends, group: left[li].group, }); rgl++; timeCut = leftStartTime; } if (AbsoluteTime.cmp(leftStartTime, rightStartTime) > 0) { const ends = leftGroupIsEmpty ? right[ri + rgl - 1].until : left[0].from; left.splice(li, 0, { from: rightStartTime, until: ends, group: right[ri].group, }); lgl++; timeCut = rightStartTime; } //check which ends sooner, add gap so both list ends at the same time // here both list are non empty const leftEndTime: AbsoluteTime = left[li + lgl - 1].until; const rightEndTime: AbsoluteTime = right[ri + rgl - 1].until; if (AbsoluteTime.cmp(leftEndTime, rightEndTime) > 0) { right.splice(ri + rgl, 0, { from: rightEndTime, until: leftEndTime, group: left[0].group, }); rgl++; } if (AbsoluteTime.cmp(leftEndTime, rightEndTime) < 0) { left.splice(li + lgl, 0, { from: leftEndTime, until: rightEndTime, group: right[0].group, }); lgl++; } //now both lists are non empty and (starts,ends) at the same time while (li < lgs + lgl && ri < rgs + rgl) { 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 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(), group: currentGroup, }); 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 && left[li].group !== currentGroup) || // (ri < right.length && right[ri].group !== currentGroup) // ) { // //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 && pairList[pairList.length - 1].group === left[li].group ? 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, group: left[li].group, }); timeCut = left[li].until; li++; } } if (ri < right.length) { let timeCut = pairList.length > 0 && pairList[pairList.length - 1].group === right[ri].group ? 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, group: right[ri].group, }); timeCut = right[ri].until; ri++; } } return pairList; } /** * Create a usage timeline with the entity given. * * If there are multiple entities that can be used in the same period, * the list will contain the one that minimize the fee cost. * @see selectBestForOverlappingDenominations * * @param list list of entities * @param idProp property used for identification * @param periodStartProp property of element of the list that will be used as start of the usage period * @param periodEndProp 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 * @param groupProp property of the element of the list that will be used for grouping * @returns list of @type {FeeDescription} sorted by usage period */ export function createTimeline( list: Type[], idProp: PropsWithReturnType, periodStartProp: PropsWithReturnType, periodEndProp: PropsWithReturnType, feeProp: PropsWithReturnType, groupProp: PropsWithReturnType | undefined, selectBestForOverlapping: (l: Type[]) => Type | undefined, ): FeeDescription[] { /** * First we create a list with with point in the timeline sorted * by time and categorized by starting or ending. */ const sortedPointsInTime = list .reduce((ps, denom) => { //exclude denoms with bad configuration const id = denom[idProp] as string; const stampStart = denom[periodStartProp] as TalerProtocolTimestamp; const stampEnd = denom[periodEndProp] as TalerProtocolTimestamp; const fee = denom[feeProp] as AmountJson; const group = !groupProp ? "" : (denom[groupProp] as string); if (!id) { throw Error( `denomination without hash ${JSON.stringify(denom, undefined, 2)}`, ); } if (stampStart.t_s >= stampEnd.t_s) { throw Error(`denom ${id} has start after the end`); } ps.push({ type: "start", fee: Amounts.stringify(fee), group, id, moment: AbsoluteTime.fromProtocolTimestamp(stampStart), denom, }); ps.push({ type: "end", fee: Amounts.stringify(fee), group, id, moment: AbsoluteTime.fromProtocolTimestamp(stampEnd), denom, }); return ps; }, [] as TimePoint[]) .sort((a, b) => { const v = a.group == b.group ? 0 : a.group > b.group ? 1 : -1; 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: Type[] = []; return sortedPointsInTime.reduce((result, cursor, idx) => { /** * Now that we have move one step forward, we should * update the previous element ending period with the * current start time. */ let prev = result.length > 0 ? result[result.length - 1] : undefined; const prevHasSameValue = prev && prev.group == cursor.group; 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(); } } /** * With the current moment in the iteration we * should keep updated which entities are current * active in this period of time. */ if (cursor.type === "end") { const loc = activeAtTheSameTime.findIndex((v) => v[idProp] === cursor.id); if (loc === -1) { throw Error(`denomination ${cursor.id} 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 == sortedPointsInTime.length - 1) { /** * This is the last element in the list, if we continue * a gap will normally be added which is not necessary. * Also, the last element should be ending and the list of active * element should be empty */ if (cursor.type !== "end") { throw Error( `denomination ${cursor.id} 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 = selectBestForOverlapping(activeAtTheSameTime); if (current) { /** * We have a candidate to add in the list, check that we are * not adding a duplicate. * Next element in the list will defined the ending. */ const currentFee = current[feeProp] as AmountJson; if ( prev === undefined || //is the first !prev.fee || //is a gap Amounts.cmp(prev.fee, currentFee) !== 0 // prev has different fee ) { result.push({ group: cursor.group, from: cursor.moment, until: AbsoluteTime.never(), //not yet known fee: Amounts.stringify(currentFee), }); } else { prev.until = cursor.moment; } } else { /** * No active element in this period of time, so we add a gap (no fee) * Next element in the list will defined the ending. */ result.push({ group: cursor.group, from: cursor.moment, until: AbsoluteTime.never(), //not yet known }); } return result; }, [] as FeeDescription[]); } /** * Check if a denom is withdrawable based on the expiration time, * revocation and offered state. */ export function isWithdrawableDenom( d: DenominationRecord, denomselAllowLate?: boolean, ): boolean { const now = AbsoluteTime.now(); const start = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(d.stampStart), ); const withdrawExpire = AbsoluteTime.fromProtocolTimestamp( timestampProtocolFromDb(d.stampExpireWithdraw), ); const started = AbsoluteTime.cmp(now, start) >= 0; let lastPossibleWithdraw: AbsoluteTime; if (denomselAllowLate) { lastPossibleWithdraw = start; } else { lastPossibleWithdraw = AbsoluteTime.subtractDuraction( withdrawExpire, Duration.fromSpec({ minutes: 5 }), ); } const remaining = Duration.getRemaining(lastPossibleWithdraw, now); const stillOkay = remaining.d_ms !== 0; return started && stillOkay && !d.isRevoked && d.isOffered && !d.isLost; }