/* 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 */ import { AbsoluteTime, AmountJson, Amounts, DenominationInfo, FeeDescription, FeeDescriptionPair, TalerProtocolTimestamp, TimePoint, } from "@gnu-taler/taler-util"; /** * 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; //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; } 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 ) { 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(), 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[]); }