exchange selection: timeline done
This commit is contained in:
parent
30e8fd83c2
commit
eef2d47020
@ -669,8 +669,17 @@ const codecForDenominationInfo = (): Codec<DenominationInfo> =>
|
||||
.build("codecForDenominationInfo");
|
||||
|
||||
export interface DenominationInfo {
|
||||
/**
|
||||
* Value of one coin of the denomination.
|
||||
*/
|
||||
value: AmountJson;
|
||||
|
||||
/**
|
||||
* Hash of the denomination public key.
|
||||
* Stored in the database for faster lookups.
|
||||
*/
|
||||
denomPubHash: string;
|
||||
|
||||
/**
|
||||
* Fee for withdrawing.
|
||||
*/
|
||||
|
@ -36,6 +36,7 @@ import {
|
||||
DenominationPubKey,
|
||||
TalerProtocolTimestamp,
|
||||
CancellationToken,
|
||||
DenominationInfo,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
|
||||
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
|
||||
@ -124,64 +125,14 @@ export interface RecoupOperations {
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DenomInfo {
|
||||
/**
|
||||
* Value of one coin of the denomination.
|
||||
*/
|
||||
value: AmountJson;
|
||||
|
||||
export type DenomInfo = DenominationInfo & {
|
||||
/**
|
||||
* The denomination public key.
|
||||
*/
|
||||
denomPub: DenominationPubKey;
|
||||
|
||||
/**
|
||||
* Hash of the denomination public key.
|
||||
* Stored in the database for faster lookups.
|
||||
*/
|
||||
denomPubHash: string;
|
||||
|
||||
/**
|
||||
* Fee for withdrawing.
|
||||
*/
|
||||
feeWithdraw: AmountJson;
|
||||
|
||||
/**
|
||||
* Fee for depositing.
|
||||
*/
|
||||
feeDeposit: AmountJson;
|
||||
|
||||
/**
|
||||
* Fee for refreshing.
|
||||
*/
|
||||
feeRefresh: AmountJson;
|
||||
|
||||
/**
|
||||
* Fee for refunding.
|
||||
*/
|
||||
feeRefund: AmountJson;
|
||||
|
||||
/**
|
||||
* Validity start date of the denomination.
|
||||
*/
|
||||
stampStart: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* Date after which the currency can't be withdrawn anymore.
|
||||
*/
|
||||
stampExpireWithdraw: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* Date after the denomination officially doesn't exist anymore.
|
||||
*/
|
||||
stampExpireLegal: TalerProtocolTimestamp;
|
||||
|
||||
/**
|
||||
* Data after which coins of this denomination can't be deposited anymore.
|
||||
*/
|
||||
stampExpireDeposit: TalerProtocolTimestamp;
|
||||
}
|
||||
|
||||
|
||||
export type NotificationListener = (n: WalletNotification) => void;
|
||||
|
||||
/**
|
||||
|
@ -97,7 +97,7 @@ export const WalletBox = styled.div<{ noPadding?: boolean }>`
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
& > * {
|
||||
width: 600px;
|
||||
width: 800px;
|
||||
}
|
||||
& > section {
|
||||
padding: ${({ noPadding }) => (noPadding ? "0px" : "8px")};
|
||||
@ -862,8 +862,8 @@ export const SvgIcon = styled.div<SvgIconProps>`
|
||||
fill: ${({ color }) => color};
|
||||
transform: ${({ transform }) => (transform ? transform : "")};
|
||||
}
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* width: 24px;
|
||||
height: 24px; */
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
display: inline;
|
||||
|
@ -58,7 +58,7 @@ import {
|
||||
DestinationSelectionSendCash,
|
||||
} from "./DestinationSelection.js";
|
||||
import { Amounts } from "@gnu-taler/taler-util";
|
||||
import { ExchangeSelection } from "./ExchangeSelection.js";
|
||||
import { ExchangeSelectionPage } from "./ExchangeSelection/index.js";
|
||||
|
||||
export function Application(): VNode {
|
||||
const [globalNotification, setGlobalNotification] = useState<
|
||||
@ -142,7 +142,7 @@ export function Application(): VNode {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path={Pages.exchanges} component={ExchangeSelection} />
|
||||
<Route path={Pages.exchanges} component={ExchangeSelectionPage} />
|
||||
<Route
|
||||
path={Pages.sendCash.pattern}
|
||||
component={DestinationSelectionSendCash}
|
||||
|
@ -1,85 +0,0 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Javier Marchano (sebasjm)
|
||||
*/
|
||||
|
||||
import { TalerProtocolTimestamp } from "@gnu-taler/taler-util";
|
||||
import { createExample } from "../test-utils.js";
|
||||
import { ExchangeSelectionView } from "./ExchangeSelection.js";
|
||||
|
||||
export default {
|
||||
title: "wallet/select exchange",
|
||||
};
|
||||
|
||||
const exchangeList = [
|
||||
{
|
||||
currency: "KUDOS",
|
||||
exchangeBaseUrl: "https://exchange.demo.taler.net",
|
||||
paytoUris: [],
|
||||
tos: {},
|
||||
auditors: [
|
||||
{
|
||||
auditor_pub: "pubpubpubpubpub",
|
||||
auditor_url: "https://audotor.taler.net",
|
||||
denomination_keys: [],
|
||||
},
|
||||
],
|
||||
denominations: [
|
||||
{
|
||||
stampStart: TalerProtocolTimestamp.never(),
|
||||
stampExpireWithdraw: TalerProtocolTimestamp.never(),
|
||||
stampExpireLegal: TalerProtocolTimestamp.never(),
|
||||
stampExpireDeposit: TalerProtocolTimestamp.never(),
|
||||
},
|
||||
],
|
||||
wireInfo: {
|
||||
accounts: [],
|
||||
feesForType: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
currency: "ARS",
|
||||
exchangeBaseUrl: "https://exchange.taler.ar",
|
||||
paytoUris: [],
|
||||
tos: {},
|
||||
auditors: [
|
||||
{
|
||||
auditor_pub: "pubpubpubpubpub",
|
||||
auditor_url: "https://audotor.taler.net",
|
||||
denomination_keys: [],
|
||||
},
|
||||
],
|
||||
denominations: [
|
||||
{
|
||||
stampStart: TalerProtocolTimestamp.never(),
|
||||
stampExpireWithdraw: TalerProtocolTimestamp.never(),
|
||||
stampExpireLegal: TalerProtocolTimestamp.never(),
|
||||
stampExpireDeposit: TalerProtocolTimestamp.never(),
|
||||
} as any,
|
||||
],
|
||||
wireInfo: {
|
||||
accounts: [],
|
||||
feesForType: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Listing = createExample(ExchangeSelectionView, {
|
||||
exchanges: exchangeList,
|
||||
});
|
@ -1,282 +0,0 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
import {
|
||||
AbsoluteTime,
|
||||
ExchangeListItem,
|
||||
TalerProtocolTimestamp,
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { styled } from "@linaria/react";
|
||||
import { Fragment, h, VNode } from "preact";
|
||||
import { useState } from "preact/hooks";
|
||||
import { Loading } from "../components/Loading.js";
|
||||
import { LoadingError } from "../components/LoadingError.js";
|
||||
import { SelectList } from "../components/SelectList.js";
|
||||
import { Input, LinkPrimary } from "../components/styled/index.js";
|
||||
import { Time } from "../components/Time.js";
|
||||
import { useTranslationContext } from "../context/translation.js";
|
||||
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
|
||||
import { Button } from "../mui/Button.js";
|
||||
import * as wxApi from "../wxApi.js";
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& > * {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
initialValue?: number;
|
||||
exchanges: ExchangeListItem[];
|
||||
onSelected: (exchange: string) => void;
|
||||
}
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
& > button {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function ExchangeSelection(): VNode {
|
||||
const hook = useAsyncAsHook(wxApi.listExchanges);
|
||||
const { i18n } = useTranslationContext();
|
||||
if (!hook) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (hook.hasError) {
|
||||
return (
|
||||
<LoadingError
|
||||
error={hook}
|
||||
title={<i18n.Translate>Could not load list of exchange</i18n.Translate>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ExchangeSelectionView
|
||||
exchanges={hook.response.exchanges}
|
||||
onSelected={(exchange) => alert(`ok, selected: ${exchange}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExchangeSelectionView({
|
||||
initialValue,
|
||||
exchanges,
|
||||
onSelected,
|
||||
}: Props): VNode {
|
||||
const list: Record<string, string> = {};
|
||||
exchanges.forEach((e, i) => (list[String(i)] = e.exchangeBaseUrl));
|
||||
|
||||
const [value, setValue] = useState(String(initialValue || 0));
|
||||
const { i18n } = useTranslationContext();
|
||||
|
||||
if (!exchanges.length) {
|
||||
return <div>no exchanges for listing, please add one</div>;
|
||||
}
|
||||
|
||||
const current = exchanges[Number(value)];
|
||||
|
||||
const hasChange = value !== current.exchangeBaseUrl;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let nextFeeUpdate = TalerProtocolTimestamp.never();
|
||||
|
||||
nextFeeUpdate = Object.values(current.wireInfo.feesForType).reduce(
|
||||
(prev, cur) => {
|
||||
return cur.reduce((p, c) => nearestTimestamp(p, c.endStamp), prev);
|
||||
},
|
||||
nextFeeUpdate,
|
||||
);
|
||||
|
||||
nextFeeUpdate = current.denominations.reduce((prev, cur) => {
|
||||
return [
|
||||
cur.stampExpireWithdraw,
|
||||
cur.stampExpireLegal,
|
||||
cur.stampExpireDeposit,
|
||||
].reduce(nearestTimestamp, prev);
|
||||
}, nextFeeUpdate);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h2>
|
||||
<i18n.Translate>Service fee description</i18n.Translate>
|
||||
</h2>
|
||||
|
||||
<section>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<Input>
|
||||
<SelectList
|
||||
label={<i18n.Translate>Known exchanges</i18n.Translate>}
|
||||
list={list}
|
||||
name="lang"
|
||||
value={value}
|
||||
onChange={(v) => setValue(v)}
|
||||
/>
|
||||
</Input>
|
||||
</p>
|
||||
{hasChange ? (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={async () => {
|
||||
setValue(current.exchangeBaseUrl);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={async () => {
|
||||
onSelected(value);
|
||||
}}
|
||||
>
|
||||
Use this exchange
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={async () => {
|
||||
null;
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<dl>
|
||||
<dt>Auditors</dt>
|
||||
{current.auditors.map((a) => {
|
||||
<dd>{a.auditor_url}</dd>;
|
||||
})}
|
||||
</dl>
|
||||
<table>
|
||||
<tr>
|
||||
<td>currency</td>
|
||||
<td>{current.currency}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>next fee update</td>
|
||||
<td>
|
||||
{
|
||||
<Time
|
||||
timestamp={AbsoluteTime.fromTimestamp(nextFeeUpdate)}
|
||||
format="dd MMMM yyyy, HH:mm"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Denomination operations</td>
|
||||
<td>Current fee</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={2}>deposit (i)</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>* 10</td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>* 5</td>
|
||||
<td>0.05</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>* 1</td>
|
||||
<td>0.01</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Wallet operations</td>
|
||||
<td>Current fee</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>history(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>kyc (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>account (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>purse (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wire SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>closing SEPA(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wad SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<ButtonGroup>
|
||||
<LinkPrimary>Privacy policy</LinkPrimary>
|
||||
<LinkPrimary>Terms of service</LinkPrimary>
|
||||
</ButtonGroup>
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
File diff suppressed because one or more lines are too long
@ -0,0 +1,105 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
import { AbsoluteTime, AmountJson, ExchangeListItem } from "@gnu-taler/taler-util";
|
||||
import { Loading } from "../../components/Loading.js";
|
||||
import { HookError } from "../../hooks/useAsyncAsHook.js";
|
||||
import { ButtonHandler, SelectFieldHandler } from "../../mui/handlers.js";
|
||||
import { compose, StateViewMap } from "../../utils/index.js";
|
||||
import * as wxApi from "../../wxApi.js";
|
||||
import { useComponentState } from "./state.js";
|
||||
import { ComparingView, LoadingUriView, NoExchangesView, ReadyView } from "./views.js";
|
||||
|
||||
|
||||
|
||||
export interface Props {
|
||||
currency?: string;
|
||||
onCancel: () => Promise<void>;
|
||||
onSelection: (exchange: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type State =
|
||||
| State.Loading
|
||||
| State.LoadingUriError
|
||||
| State.Ready
|
||||
| State.Comparing
|
||||
| State.NoExchanges;
|
||||
|
||||
export namespace State {
|
||||
|
||||
export interface Loading {
|
||||
status: "loading";
|
||||
error: undefined;
|
||||
}
|
||||
|
||||
export interface LoadingUriError {
|
||||
status: "loading-uri";
|
||||
error: HookError;
|
||||
}
|
||||
|
||||
export interface BaseInfo {
|
||||
exchanges: SelectFieldHandler;
|
||||
selected: ExchangeListItem;
|
||||
nextFeeUpdate: AbsoluteTime;
|
||||
error: undefined;
|
||||
}
|
||||
|
||||
export interface NoExchanges {
|
||||
status: "no-exchanges";
|
||||
error: undefined;
|
||||
}
|
||||
|
||||
export interface Ready extends BaseInfo {
|
||||
status: "ready";
|
||||
timeline: OperationMap<FeeDescription[]>;
|
||||
onClose: ButtonHandler;
|
||||
}
|
||||
|
||||
export interface Comparing extends BaseInfo {
|
||||
status: "comparing";
|
||||
pairTimeline: OperationMap<FeeDescriptionPair[]>;
|
||||
onReset: ButtonHandler;
|
||||
onSelect: ButtonHandler;
|
||||
}
|
||||
}
|
||||
|
||||
export type Operation = "deposit" | "withdraw" | "refresh" | "refund";
|
||||
export type OperationMap<T> = { [op in Operation]: T };
|
||||
|
||||
|
||||
const viewMapping: StateViewMap<State> = {
|
||||
loading: Loading,
|
||||
"loading-uri": LoadingUriView,
|
||||
"comparing": ComparingView,
|
||||
"no-exchanges": NoExchangesView,
|
||||
"ready": ReadyView,
|
||||
};
|
||||
|
||||
export const ExchangeSelectionPage = compose("Tip", (p: Props) => useComponentState(p, wxApi), viewMapping)
|
||||
|
||||
export interface FeeDescription {
|
||||
value: AmountJson;
|
||||
from: AbsoluteTime;
|
||||
until: AbsoluteTime;
|
||||
fee?: AmountJson;
|
||||
}
|
||||
export interface FeeDescriptionPair {
|
||||
value: AmountJson;
|
||||
from: AbsoluteTime;
|
||||
until: AbsoluteTime;
|
||||
left?: AmountJson;
|
||||
right?: AmountJson;
|
||||
}
|
@ -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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
|
||||
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<FeeDescription[]> = {
|
||||
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<string, string>)
|
||||
|
||||
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<FeeDescription[]> = {
|
||||
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<FeeDescription[]> = {
|
||||
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<T extends object, F> = 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<DenominationInfo, TalerProtocolTimestamp>,
|
||||
feeProp: PropsWithReturnType<DenominationInfo, AmountJson>,
|
||||
): 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[]);
|
||||
}
|
@ -0,0 +1,173 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Javier Marchano (sebasjm)
|
||||
*/
|
||||
|
||||
import { ExchangeListItem } from "@gnu-taler/taler-util";
|
||||
import { createExample } from "../../test-utils.js";
|
||||
import { bitcoinExchanges, kudosExchanges } from "./example.js";
|
||||
import { FeeDescription, FeeDescriptionPair, OperationMap } from "./index.js";
|
||||
import {
|
||||
createDenominationPairTimeline,
|
||||
createDenominationTimeline,
|
||||
} from "./state.js";
|
||||
import { ReadyView, ComparingView } from "./views.js";
|
||||
|
||||
export default {
|
||||
title: "wallet/select exchange",
|
||||
};
|
||||
|
||||
function timelineForExchange(
|
||||
ex: ExchangeListItem,
|
||||
): OperationMap<FeeDescription[]> {
|
||||
return {
|
||||
deposit: createDenominationTimeline(
|
||||
ex.denominations,
|
||||
"stampExpireDeposit",
|
||||
"feeDeposit",
|
||||
),
|
||||
refresh: createDenominationTimeline(
|
||||
ex.denominations,
|
||||
"stampExpireWithdraw",
|
||||
"feeRefresh",
|
||||
),
|
||||
refund: createDenominationTimeline(
|
||||
ex.denominations,
|
||||
"stampExpireWithdraw",
|
||||
"feeRefund",
|
||||
),
|
||||
withdraw: createDenominationTimeline(
|
||||
ex.denominations,
|
||||
"stampExpireWithdraw",
|
||||
"feeWithdraw",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function timelinePairForExchange(
|
||||
ex1: ExchangeListItem,
|
||||
ex2: ExchangeListItem,
|
||||
): OperationMap<FeeDescriptionPair[]> {
|
||||
const om1 = timelineForExchange(ex1);
|
||||
const om2 = timelineForExchange(ex2);
|
||||
return {
|
||||
deposit: createDenominationPairTimeline(om1.deposit, om2.deposit),
|
||||
refresh: createDenominationPairTimeline(om1.refresh, om2.refresh),
|
||||
refund: createDenominationPairTimeline(om1.refund, om2.refund),
|
||||
withdraw: createDenominationPairTimeline(om1.withdraw, om2.withdraw),
|
||||
};
|
||||
}
|
||||
|
||||
export const Bitcoin1 = createExample(ReadyView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "BITCOINBTC",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onClose: {},
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
timeline: timelineForExchange(bitcoinExchanges[0]),
|
||||
});
|
||||
export const Bitcoin2 = createExample(ReadyView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "BITCOINBTC",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onClose: {},
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
timeline: timelineForExchange(bitcoinExchanges[1]),
|
||||
});
|
||||
export const Kudos1 = createExample(ReadyView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "BITCOINBTC",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onClose: {},
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
timeline: timelineForExchange(kudosExchanges[0]),
|
||||
});
|
||||
export const Kudos2 = createExample(ReadyView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "BITCOINBTC",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onClose: {},
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
timeline: timelineForExchange(kudosExchanges[1]),
|
||||
});
|
||||
export const ComparingBitcoin = createExample(ComparingView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "BITCOINBTC",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onReset: {},
|
||||
onSelect: {},
|
||||
error: undefined,
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
pairTimeline: timelinePairForExchange(
|
||||
bitcoinExchanges[0],
|
||||
bitcoinExchanges[1],
|
||||
),
|
||||
});
|
||||
export const ComparingKudos = createExample(ComparingView, {
|
||||
exchanges: {
|
||||
list: { "http://exchange": "http://exchange" },
|
||||
value: "http://exchange",
|
||||
},
|
||||
selected: {
|
||||
currency: "KUDOS",
|
||||
auditors: [],
|
||||
} as any,
|
||||
onReset: {},
|
||||
onSelect: {},
|
||||
error: undefined,
|
||||
nextFeeUpdate: {
|
||||
t_ms: 1,
|
||||
},
|
||||
pairTimeline: timelinePairForExchange(kudosExchanges[0], kudosExchanges[1]),
|
||||
});
|
@ -0,0 +1,700 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Javier Marchano (sebasjm)
|
||||
*/
|
||||
|
||||
import {
|
||||
AbsoluteTime,
|
||||
Amounts, DenominationInfo
|
||||
} from "@gnu-taler/taler-util";
|
||||
import { expect } from "chai";
|
||||
import { bitcoinExchanges } from "./example.js";
|
||||
import { FeeDescription, FeeDescriptionPair } from "./index.js";
|
||||
import { createDenominationPairTimeline, createDenominationTimeline } from "./state.js";
|
||||
|
||||
const values = Array.from({ length: 10 }).map((undef, t) => Amounts.parseOrThrow(`USD:${t}`))
|
||||
const timestamps = Array.from({ length: 20 }).map((undef, t_s) => ({ t_s }))
|
||||
const moments = timestamps.map(m => AbsoluteTime.fromTimestamp(m))
|
||||
|
||||
function normalize(list: DenominationInfo[]): DenominationInfo[] {
|
||||
return list.map((e, idx) => ({ ...e, denomPubHash: `id${idx}` }))
|
||||
}
|
||||
|
||||
describe("Denomination timeline creation", () => {
|
||||
describe("single value example", () => {
|
||||
|
||||
it("should have one row with start and exp", () => {
|
||||
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[2],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[1],
|
||||
} as FeeDescription])
|
||||
});
|
||||
|
||||
it("should have two rows with the second denom in the middle if second is better", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[3],
|
||||
until: moments[4],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should have two rows with the first denom in the middle if second is worse", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should add a gap when there no fee", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[2],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[3],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[3],
|
||||
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[3],
|
||||
until: moments[4],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should have three rows when first denom is between second and second is worse", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[3],
|
||||
until: moments[4],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should have one row when first denom is between second and second is better", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[4],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should only add the best1", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should only add the best2", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[5],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[5],
|
||||
stampExpireDeposit: timestamps[6],
|
||||
feeDeposit: values[3]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[5],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[5],
|
||||
until: moments[6],
|
||||
fee: values[3],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should only add the best3", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[5],
|
||||
feeDeposit: values[3]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[5],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[5],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[5],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[])
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiple value example", () => {
|
||||
|
||||
//TODO: test the same start but different value
|
||||
|
||||
it("should not merge when there is different value", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[2],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[2],
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it("should not merge when there is different value (with duplicates)", () => {
|
||||
const timeline = createDenominationTimeline(normalize([
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[2],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[1],
|
||||
stampStart: timestamps[1],
|
||||
stampExpireDeposit: timestamps[3],
|
||||
feeDeposit: values[1]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
{
|
||||
value: values[2],
|
||||
stampStart: timestamps[2],
|
||||
stampExpireDeposit: timestamps[4],
|
||||
feeDeposit: values[2]
|
||||
} as Partial<DenominationInfo> as DenominationInfo,
|
||||
]), "stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[2],
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[])
|
||||
|
||||
});
|
||||
|
||||
it.skip("real world example: bitcoin exchange", () => {
|
||||
const timeline = createDenominationTimeline(
|
||||
bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
|
||||
"stampExpireDeposit", "feeDeposit");
|
||||
|
||||
expect(timeline).deep.equal([{
|
||||
fee: Amounts.parseOrThrow('BITCOINBTC:0.00000001'),
|
||||
from: { t_ms: 1652978648000 },
|
||||
until: { t_ms: 1699633748000 },
|
||||
value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
|
||||
}, {
|
||||
fee: Amounts.parseOrThrow('BITCOINBTC:0.00000003'),
|
||||
from: { t_ms: 1699633748000 },
|
||||
until: { t_ms: 1707409448000 },
|
||||
value: Amounts.parseOrThrow('BITCOINBTC:0.01048576'),
|
||||
}] as FeeDescription[])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe("Denomination timeline pair creation", () => {
|
||||
|
||||
describe("single value example", () => {
|
||||
|
||||
it("should return empty", () => {
|
||||
|
||||
const left = [] as FeeDescription[];
|
||||
const right = [] as FeeDescription[];
|
||||
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
|
||||
expect(pairs).deep.equals([])
|
||||
});
|
||||
|
||||
it("should return first element", () => {
|
||||
|
||||
const left = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[];
|
||||
|
||||
const right = [] as FeeDescription[];
|
||||
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: undefined,
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(right, left)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
right: values[1],
|
||||
left: undefined,
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
it("should add both to the same row", () => {
|
||||
|
||||
const left = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[];
|
||||
|
||||
const right = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[];
|
||||
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: values[2],
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(right, left)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: values[2],
|
||||
right: values[1],
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
});
|
||||
|
||||
it("should repeat the first and change the second", () => {
|
||||
|
||||
const left = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[5],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[];
|
||||
|
||||
const right = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[2],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[3],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[3],
|
||||
until: moments[4],
|
||||
fee: values[3],
|
||||
}] as FeeDescription[];
|
||||
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: values[2],
|
||||
}, {
|
||||
from: moments[2],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: undefined,
|
||||
}, {
|
||||
from: moments[3],
|
||||
until: moments[4],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: values[3],
|
||||
}, {
|
||||
from: moments[4],
|
||||
until: moments[5],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: undefined,
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
describe("multiple value example", () => {
|
||||
|
||||
it("should separate denominations of different value", () => {
|
||||
|
||||
const left = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[1],
|
||||
}] as FeeDescription[];
|
||||
|
||||
const right = [{
|
||||
value: values[2],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[];
|
||||
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: undefined,
|
||||
}, {
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[2],
|
||||
left: undefined,
|
||||
right: values[2],
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(right, left)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[1],
|
||||
left: undefined,
|
||||
right: values[1],
|
||||
}, {
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[2],
|
||||
left: values[2],
|
||||
right: undefined,
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
});
|
||||
|
||||
it("should separate denominations of different value2", () => {
|
||||
|
||||
const left = [{
|
||||
value: values[1],
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
fee: values[1],
|
||||
}, {
|
||||
value: values[1],
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[];
|
||||
|
||||
const right = [{
|
||||
value: values[2],
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
fee: values[2],
|
||||
}] as FeeDescription[];
|
||||
|
||||
{
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
expect(pairs).deep.equals([{
|
||||
from: moments[1],
|
||||
until: moments[2],
|
||||
value: values[1],
|
||||
left: values[1],
|
||||
right: undefined,
|
||||
}, {
|
||||
from: moments[2],
|
||||
until: moments[4],
|
||||
value: values[1],
|
||||
left: values[2],
|
||||
right: undefined,
|
||||
}, {
|
||||
from: moments[1],
|
||||
until: moments[3],
|
||||
value: values[2],
|
||||
left: undefined,
|
||||
right: values[2],
|
||||
}] as FeeDescriptionPair[])
|
||||
}
|
||||
// {
|
||||
// const pairs = createDenominationPairTimeline(right, left)
|
||||
// expect(pairs).deep.equals([{
|
||||
// from: moments[1],
|
||||
// until: moments[3],
|
||||
// value: values[1],
|
||||
// left: undefined,
|
||||
// right: values[1],
|
||||
// }, {
|
||||
// from: moments[1],
|
||||
// until: moments[3],
|
||||
// value: values[2],
|
||||
// left: values[2],
|
||||
// right: undefined,
|
||||
// }] as FeeDescriptionPair[])
|
||||
// }
|
||||
});
|
||||
it.skip("should render real world", () => {
|
||||
const left = createDenominationTimeline(
|
||||
bitcoinExchanges[0].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
|
||||
"stampExpireDeposit", "feeDeposit");
|
||||
const right = createDenominationTimeline(
|
||||
bitcoinExchanges[1].denominations.filter(d => Amounts.cmp(d.value, Amounts.parseOrThrow('BITCOINBTC:0.01048576'))),
|
||||
"stampExpireDeposit", "feeDeposit");
|
||||
|
||||
|
||||
const pairs = createDenominationPairTimeline(left, right)
|
||||
|
||||
console.log(JSON.stringify(pairs, undefined, 2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -0,0 +1,651 @@
|
||||
/*
|
||||
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 <http://www.gnu.org/licenses/>
|
||||
*/
|
||||
|
||||
import { Fragment, h, VNode } from "preact";
|
||||
import { LoadingError } from "../../components/LoadingError.js";
|
||||
import { LogoHeader } from "../../components/LogoHeader.js";
|
||||
import {
|
||||
Input,
|
||||
LinkPrimary,
|
||||
SubTitle,
|
||||
SvgIcon,
|
||||
WalletAction,
|
||||
} from "../../components/styled/index.js";
|
||||
import { useTranslationContext } from "../../context/translation.js";
|
||||
import { FeeDescription, FeeDescriptionPair, State } from "./index.js";
|
||||
import { styled } from "@linaria/react";
|
||||
import arrowDown from "../../svg/chevron-down.svg";
|
||||
import { Amount } from "../../components/Amount.js";
|
||||
import { Time } from "../../components/Time.js";
|
||||
import { AbsoluteTime, AmountJson, Amounts } from "@gnu-taler/taler-util";
|
||||
import { Button } from "../../mui/Button.js";
|
||||
import { SelectList } from "../../components/SelectList.js";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
& > button {
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FeeDescriptionTable = styled.table`
|
||||
& {
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td {
|
||||
padding: 8px;
|
||||
}
|
||||
td.fee {
|
||||
text-align: center;
|
||||
}
|
||||
th.fee {
|
||||
text-align: center;
|
||||
}
|
||||
td.value {
|
||||
text-align: right;
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
td.icon {
|
||||
width: 24px;
|
||||
}
|
||||
td.icon > div {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0px;
|
||||
}
|
||||
td.expiration {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr[data-main="true"] {
|
||||
background-color: #add8e662;
|
||||
}
|
||||
tr[data-main="true"] > td.value,
|
||||
tr[data-main="true"] > td.expiration,
|
||||
tr[data-main="true"] > td.fee {
|
||||
border-bottom: lightgray solid 1px;
|
||||
}
|
||||
tr[data-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
tbody > tr.value[data-hasMore="true"],
|
||||
tbody > tr.value[data-hasMore="true"] > td {
|
||||
cursor: pointer;
|
||||
}
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: white;
|
||||
}
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
& > * {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
export function LoadingUriView({ error }: State.LoadingUriError): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
|
||||
return (
|
||||
<LoadingError
|
||||
title={<i18n.Translate>Could not load tip status</i18n.Translate>}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoExchangesView(state: State.NoExchanges): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
return <div>no exchanges</div>;
|
||||
}
|
||||
|
||||
export function ComparingView({
|
||||
exchanges,
|
||||
selected,
|
||||
nextFeeUpdate,
|
||||
onReset,
|
||||
onSelect,
|
||||
pairTimeline,
|
||||
}: State.Comparing): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
return (
|
||||
<Container>
|
||||
<h2>
|
||||
<i18n.Translate>Service fee description</i18n.Translate>
|
||||
</h2>
|
||||
|
||||
<section>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<Input>
|
||||
<SelectList
|
||||
label={
|
||||
<i18n.Translate>
|
||||
Select {selected.currency} exchange
|
||||
</i18n.Translate>
|
||||
}
|
||||
list={exchanges.list}
|
||||
name="lang"
|
||||
value={exchanges.value}
|
||||
onChange={exchanges.onChange}
|
||||
/>
|
||||
</Input>
|
||||
</p>
|
||||
<ButtonGroup>
|
||||
<Button variant="outlined" onClick={onReset.onClick}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onSelect.onClick}>
|
||||
Use this exchange
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<dl>
|
||||
<dt>Auditors</dt>
|
||||
{selected.auditors.length === 0 ? (
|
||||
<dd style={{ color: "red" }}>Doesn't have auditors</dd>
|
||||
) : (
|
||||
selected.auditors.map((a) => {
|
||||
<dd>{a.auditor_url}</dd>;
|
||||
})
|
||||
)}
|
||||
</dl>
|
||||
<table>
|
||||
<tr>
|
||||
<td>currency</td>
|
||||
<td>{selected.currency}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>next fee update</td>
|
||||
<td>
|
||||
{<Time timestamp={nextFeeUpdate} format="dd MMMM yyyy, HH:mm" />}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Operations</h2>
|
||||
<p>Deposits</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeePairByValue list={pairTimeline.deposit} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>
|
||||
<p>Withdrawals</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeePairByValue list={pairTimeline.withdraw} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>
|
||||
<p>Refunds</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeePairByValue list={pairTimeline.refund} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>{" "}
|
||||
<p>Refresh</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeePairByValue list={pairTimeline.refresh} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>{" "}
|
||||
</section>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Wallet operations</td>
|
||||
<td>Fee</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>history(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>kyc (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>account (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>purse (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wire SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>closing SEPA(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wad SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<ButtonGroup>
|
||||
<LinkPrimary>Privacy policy</LinkPrimary>
|
||||
<LinkPrimary>Terms of service</LinkPrimary>
|
||||
</ButtonGroup>
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadyView({
|
||||
exchanges,
|
||||
selected,
|
||||
nextFeeUpdate,
|
||||
onClose,
|
||||
timeline,
|
||||
}: State.Ready): VNode {
|
||||
const { i18n } = useTranslationContext();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h2>
|
||||
<i18n.Translate>Service fee description</i18n.Translate>
|
||||
</h2>
|
||||
|
||||
<section>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
<Input>
|
||||
<SelectList
|
||||
label={
|
||||
<i18n.Translate>
|
||||
Select {selected.currency} exchange
|
||||
</i18n.Translate>
|
||||
}
|
||||
list={exchanges.list}
|
||||
name="lang"
|
||||
value={exchanges.value}
|
||||
onChange={exchanges.onChange}
|
||||
/>
|
||||
</Input>
|
||||
</p>
|
||||
<Button variant="outlined" onClick={onClose.onClick}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<dl>
|
||||
<dt>Auditors</dt>
|
||||
{selected.auditors.length === 0 ? (
|
||||
<dd style={{ color: "red" }}>Doesn't have auditors</dd>
|
||||
) : (
|
||||
selected.auditors.map((a) => {
|
||||
<dd>{a.auditor_url}</dd>;
|
||||
})
|
||||
)}
|
||||
</dl>
|
||||
<table>
|
||||
<tr>
|
||||
<td>currency</td>
|
||||
<td>{selected.currency}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>next fee update</td>
|
||||
<td>
|
||||
{<Time timestamp={nextFeeUpdate} format="dd MMMM yyyy, HH:mm" />}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Operations</h2>
|
||||
<p>Deposits</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeeDescriptionByValue first={timeline.deposit} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>
|
||||
<p>Withdrawals</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeeDescriptionByValue first={timeline.withdraw} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>
|
||||
<p>Refunds</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeeDescriptionByValue first={timeline.refund} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>{" "}
|
||||
<p>Refresh</p>
|
||||
<FeeDescriptionTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th> </th>
|
||||
<th>Denomination</th>
|
||||
<th class="fee">Fee</th>
|
||||
<th>Until</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<RenderFeeDescriptionByValue first={timeline.refresh} />
|
||||
</tbody>
|
||||
</FeeDescriptionTable>{" "}
|
||||
</section>
|
||||
<section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Wallet operations</td>
|
||||
<td>Fee</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>history(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>kyc (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>account (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>purse (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wire SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>closing SEPA(i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>wad SEPA (i) </td>
|
||||
<td>0.1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section>
|
||||
<ButtonGroup>
|
||||
<LinkPrimary>Privacy policy</LinkPrimary>
|
||||
<LinkPrimary>Terms of service</LinkPrimary>
|
||||
</ButtonGroup>
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function FeeDescriptionRowsGroup({
|
||||
infos,
|
||||
}: {
|
||||
infos: FeeDescription[];
|
||||
}): VNode {
|
||||
const [expanded, setExpand] = useState(false);
|
||||
const hasMoreInfo = infos.length > 1;
|
||||
return (
|
||||
<Fragment>
|
||||
{infos.map((info, idx) => {
|
||||
const main = idx === 0;
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
class="value"
|
||||
data-hasMore={!hasMoreInfo}
|
||||
data-main={main}
|
||||
data-hidden={!main && !expanded}
|
||||
onClick={() => setExpand((p) => !p)}
|
||||
>
|
||||
<td class="icon">
|
||||
{hasMoreInfo && main ? (
|
||||
<SvgIcon
|
||||
title="Select this contact"
|
||||
dangerouslySetInnerHTML={{ __html: arrowDown }}
|
||||
color="currentColor"
|
||||
transform={expanded ? "" : "rotate(-90deg)"}
|
||||
/>
|
||||
) : undefined}
|
||||
</td>
|
||||
<td class="value">
|
||||
{main ? <Amount value={info.value} hideCurrency /> : ""}
|
||||
</td>
|
||||
{info.fee ? (
|
||||
<td class="fee">{<Amount value={info.fee} hideCurrency />}</td>
|
||||
) : undefined}
|
||||
<td class="expiration">
|
||||
<Time timestamp={info.until} format="dd-MMM-yyyy" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function FeePairRowsGroup({ infos }: { infos: FeeDescriptionPair[] }): VNode {
|
||||
const [expanded, setExpand] = useState(false);
|
||||
const hasMoreInfo = infos.length > 1;
|
||||
return (
|
||||
<Fragment>
|
||||
{infos.map((info, idx) => {
|
||||
const main = idx === 0;
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
class="value"
|
||||
data-hasMore={!hasMoreInfo}
|
||||
data-main={main}
|
||||
data-hidden={!main && !expanded}
|
||||
onClick={() => setExpand((p) => !p)}
|
||||
>
|
||||
<td class="icon">
|
||||
{hasMoreInfo && main ? (
|
||||
<SvgIcon
|
||||
title="Select this contact"
|
||||
dangerouslySetInnerHTML={{ __html: arrowDown }}
|
||||
color="currentColor"
|
||||
transform={expanded ? "" : "rotate(-90deg)"}
|
||||
/>
|
||||
) : undefined}
|
||||
</td>
|
||||
<td class="value">
|
||||
{main ? <Amount value={info.value} hideCurrency /> : ""}
|
||||
</td>
|
||||
{info.left ? (
|
||||
<td class="fee">{<Amount value={info.left} hideCurrency />}</td>
|
||||
) : (
|
||||
<td class="fee"> --- </td>
|
||||
)}
|
||||
{info.right ? (
|
||||
<td class="fee">{<Amount value={info.right} hideCurrency />}</td>
|
||||
) : (
|
||||
<td class="fee"> --- </td>
|
||||
)}
|
||||
<td class="expiration">
|
||||
<Time timestamp={info.until} format="dd-MMM-yyyy" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group by value and then render using FeePairRowsGroup
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
function RenderFeePairByValue({ list }: { list: FeeDescriptionPair[] }): VNode {
|
||||
return (
|
||||
<Fragment>
|
||||
{
|
||||
list.reduce(
|
||||
(prev, info, idx) => {
|
||||
const next = idx >= list.length - 1 ? undefined : list[idx + 1];
|
||||
|
||||
const nextIsMoreInfo =
|
||||
next !== undefined && Amounts.cmp(next.value, info.value) === 0;
|
||||
|
||||
prev.rows.push(info);
|
||||
|
||||
if (nextIsMoreInfo) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
prev.rows = [];
|
||||
prev.views.push(<FeePairRowsGroup infos={prev.rows} />);
|
||||
return prev;
|
||||
},
|
||||
{ rows: [], views: [] } as {
|
||||
rows: FeeDescriptionPair[];
|
||||
views: h.JSX.Element[];
|
||||
},
|
||||
).views
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
/**
|
||||
*
|
||||
* Group by value and then render using FeeDescriptionRowsGroup
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
function RenderFeeDescriptionByValue({
|
||||
first,
|
||||
}: {
|
||||
first: FeeDescription[];
|
||||
}): VNode {
|
||||
return (
|
||||
<Fragment>
|
||||
{
|
||||
first.reduce(
|
||||
(prev, info, idx) => {
|
||||
const next = idx >= first.length - 1 ? undefined : first[idx + 1];
|
||||
|
||||
const nextIsMoreInfo =
|
||||
next !== undefined && Amounts.cmp(next.value, info.value) === 0;
|
||||
|
||||
prev.rows.push(info);
|
||||
|
||||
if (nextIsMoreInfo) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
prev.rows = [];
|
||||
prev.views.push(<FeeDescriptionRowsGroup infos={prev.rows} />);
|
||||
return prev;
|
||||
},
|
||||
{ rows: [], views: [] } as {
|
||||
rows: FeeDescription[];
|
||||
views: h.JSX.Element[];
|
||||
},
|
||||
).views
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
@ -36,7 +36,7 @@ import * as a15 from "./AddNewActionView.stories.js";
|
||||
import * as a16 from "./DeveloperPage.stories.js";
|
||||
import * as a17 from "./QrReader.stories.js";
|
||||
import * as a18 from "./DestinationSelection.stories.js";
|
||||
import * as a19 from "./ExchangeSelection.stories.js";
|
||||
import * as a19 from "./ExchangeSelection/stories.js";
|
||||
|
||||
export default [
|
||||
a1,
|
||||
|
@ -19,6 +19,7 @@
|
||||
"strict": true,
|
||||
"incremental": true,
|
||||
"sourceMap": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"importHelpers": true,
|
||||
"rootDir": "./src",
|
||||
|
Loading…
Reference in New Issue
Block a user