deposit from wallet webex: wip

This commit is contained in:
Sebastian 2021-12-23 15:17:36 -03:00
parent b8200de6f6
commit 2e71117f59
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
18 changed files with 758 additions and 119 deletions

View File

@ -54,6 +54,7 @@ import {
} from "./talerTypes.js"; } from "./talerTypes.js";
import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js"; import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js";
import { BackupRecovery } from "./backupTypes.js"; import { BackupRecovery } from "./backupTypes.js";
import { PaytoUri } from "./payto.js";
/** /**
* Response for the create reserve request to the wallet. * Response for the create reserve request to the wallet.
@ -525,6 +526,10 @@ export interface ExchangesListRespose {
exchanges: ExchangeListItem[]; exchanges: ExchangeListItem[];
} }
export interface KnownBankAccounts {
accounts: PaytoUri[];
}
export interface ExchangeTos { export interface ExchangeTos {
acceptedVersion?: string; acceptedVersion?: string;
currentVersion?: string; currentVersion?: string;
@ -737,12 +742,19 @@ export const codecForApplyRefundRequest = (): Codec<ApplyRefundRequest> =>
export interface GetWithdrawalDetailsForUriRequest { export interface GetWithdrawalDetailsForUriRequest {
talerWithdrawUri: string; talerWithdrawUri: string;
} }
export const codecForGetWithdrawalDetailsForUri = (): Codec<GetWithdrawalDetailsForUriRequest> => export const codecForGetWithdrawalDetailsForUri = (): Codec<GetWithdrawalDetailsForUriRequest> =>
buildCodecForObject<GetWithdrawalDetailsForUriRequest>() buildCodecForObject<GetWithdrawalDetailsForUriRequest>()
.property("talerWithdrawUri", codecForString()) .property("talerWithdrawUri", codecForString())
.build("GetWithdrawalDetailsForUriRequest"); .build("GetWithdrawalDetailsForUriRequest");
export interface ListKnownBankAccountsRequest {
currency?: string;
}
export const codecForListKnownBankAccounts = (): Codec<ListKnownBankAccountsRequest> =>
buildCodecForObject<ListKnownBankAccountsRequest>()
.property("currency", codecOptional(codecForString()))
.build("ListKnownBankAccountsRequest");
export interface GetExchangeWithdrawalInfo { export interface GetExchangeWithdrawalInfo {
exchangeBaseUrl: string; exchangeBaseUrl: string;
amount: AmountJson; amount: AmountJson;
@ -965,11 +977,23 @@ export const codecForAbortPayWithRefundRequest = (): Codec<AbortPayWithRefundReq
.property("proposalId", codecForString()) .property("proposalId", codecForString())
.build("AbortPayWithRefundRequest"); .build("AbortPayWithRefundRequest");
export interface GetFeeForDepositRequest {
depositPaytoUri: string;
amount: AmountString;
}
export interface CreateDepositGroupRequest { export interface CreateDepositGroupRequest {
depositPaytoUri: string; depositPaytoUri: string;
amount: string; amount: AmountString;
} }
export const codecForGetFeeForDeposit = (): Codec<GetFeeForDepositRequest> =>
buildCodecForObject<GetFeeForDepositRequest>()
.property("amount", codecForAmountString())
.property("depositPaytoUri", codecForString())
.build("GetFeeForDepositRequest");
export const codecForCreateDepositGroupRequest = (): Codec<CreateDepositGroupRequest> => export const codecForCreateDepositGroupRequest = (): Codec<CreateDepositGroupRequest> =>
buildCodecForObject<CreateDepositGroupRequest>() buildCodecForObject<CreateDepositGroupRequest>()
.property("amount", codecForAmountString()) .property("amount", codecForAmountString())

View File

@ -369,7 +369,7 @@ export class CryptoImplementation {
sig: string, sig: string,
masterPub: string, masterPub: string,
): boolean { ): boolean {
if (versionCurrent === 10) { if (versionCurrent === 10 || versionCurrent === 11) {
const paytoHash = hash(stringToBytes(paytoUri + "\0")); const paytoHash = hash(stringToBytes(paytoUri + "\0"));
const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS) const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS)
.put(paytoHash) .put(paytoHash)

View File

@ -15,6 +15,7 @@
*/ */
import { import {
AmountJson,
Amounts, Amounts,
buildCodecForObject, buildCodecForObject,
canonicalJson, canonicalJson,
@ -28,6 +29,7 @@ import {
decodeCrock, decodeCrock,
DenomKeyType, DenomKeyType,
durationFromSpec, durationFromSpec,
GetFeeForDepositRequest,
getTimestampNow, getTimestampNow,
Logger, Logger,
NotificationType, NotificationType,
@ -35,6 +37,7 @@ import {
TalerErrorDetails, TalerErrorDetails,
Timestamp, Timestamp,
timestampAddDuration, timestampAddDuration,
timestampIsBetween,
timestampTruncateToSecond, timestampTruncateToSecond,
TrackDepositGroupRequest, TrackDepositGroupRequest,
TrackDepositGroupResponse, TrackDepositGroupResponse,
@ -49,7 +52,7 @@ import {
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { DepositGroupRecord } from "../db.js"; import { DepositGroupRecord } from "../db.js";
import { guardOperationException } from "../errors.js"; import { guardOperationException } from "../errors.js";
import { selectPayCoins } from "../util/coinSelection.js"; import { PayCoinSelection, selectPayCoins } from "../util/coinSelection.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
@ -58,11 +61,11 @@ import {
extractContractData, extractContractData,
generateDepositPermissions, generateDepositPermissions,
getCandidatePayCoins, getCandidatePayCoins,
getEffectiveDepositAmount,
getTotalPaymentCost, getTotalPaymentCost,
hashWire, hashWire,
hashWireLegacy, hashWireLegacy,
} from "./pay.js"; } from "./pay.js";
import { getTotalRefreshCost } from "./refresh.js";
/** /**
* Logger. * Logger.
@ -342,6 +345,100 @@ export async function trackDepositGroup(
}; };
} }
export async function getFeeForDeposit(
ws: InternalWalletState,
req: GetFeeForDepositRequest,
): Promise<DepositFee> {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
const exchangeInfos: { url: string; master_pub: string }[] = [];
await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
const details = await getExchangeDetails(tx, e.baseUrl);
if (!details) {
continue;
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
url: e.baseUrl,
});
}
});
const timestamp = getTimestampNow();
const timestampRound = timestampTruncateToSecond(timestamp);
// const noncePair = await ws.cryptoApi.createEddsaKeypair();
// const merchantPair = await ws.cryptoApi.createEddsaKeypair();
// const wireSalt = encodeCrock(getRandomBytes(16));
// const wireHash = hashWire(req.depositPaytoUri, wireSalt);
// const wireHashLegacy = hashWireLegacy(req.depositPaytoUri, wireSalt);
const contractTerms: ContractTerms = {
auditors: [],
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
max_wire_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: timestampRound,
merchant_base_url: "",
summary: "",
nonce: "",
wire_transfer_deadline: timestampRound,
order_id: "",
h_wire: "",
pay_deadline: timestampAddDuration(
timestampRound,
durationFromSpec({ hours: 1 }),
),
merchant: {
name: "",
},
merchant_pub: "",
refund_deadline: { t_ms: 0 },
};
const contractData = extractContractData(
contractTerms,
"",
"",
);
const candidates = await getCandidatePayCoins(ws, contractData);
const payCoinSel = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
});
if (!payCoinSel) {
throw Error("insufficient funds");
}
return await getTotalFeeForDepositAmount(
ws,
p.targetType,
amount,
payCoinSel,
);
}
export async function createDepositGroup( export async function createDepositGroup(
ws: InternalWalletState, ws: InternalWalletState,
req: CreateDepositGroupRequest, req: CreateDepositGroupRequest,
@ -495,3 +592,152 @@ export async function createDepositGroup(
return { depositGroupId }; return { depositGroupId };
} }
/**
* Get the amount that will be deposited on the merchant's bank
* account, not considering aggregation.
*/
export async function getEffectiveDepositAmount(
ws: InternalWalletState,
wireType: string,
pcs: PayCoinSelection,
): Promise<AmountJson> {
const amt: AmountJson[] = [];
const fees: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
await ws.db
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await tx.coins.get(pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate deposit amount, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
amt.push(pcs.coinContributions[i]);
fees.push(denom.feeDeposit);
exchangeSet.add(coin.exchangeBaseUrl);
}
for (const exchangeUrl of exchangeSet.values()) {
const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
if (!exchangeDetails) {
continue;
}
// FIXME/NOTE: the line below _likely_ throws exception
// about "find method not found on undefined" when the wireType
// is not supported by the Exchange.
const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
return timestampIsBetween(
getTimestampNow(),
x.startStamp,
x.endStamp,
);
})?.wireFee;
if (fee) {
fees.push(fee);
}
}
});
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}
export interface DepositFee {
coin: AmountJson;
wire: AmountJson;
refresh: AmountJson;
}
/**
* Get the fee amount that will be charged when trying to deposit the
* specified amount using the selected coins and the wire method.
*/
export async function getTotalFeeForDepositAmount(
ws: InternalWalletState,
wireType: string,
total: AmountJson,
pcs: PayCoinSelection,
): Promise<DepositFee> {
const wireFee: AmountJson[] = [];
const coinFee: AmountJson[] = [];
const refreshFee: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
// let acc: AmountJson = Amounts.getZero(total.currency);
await ws.db
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await tx.coins.get(pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate deposit amount, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
// const cc = pcs.coinContributions[i]
// acc = Amounts.add(acc, cc).amount
coinFee.push(denom.feeDeposit);
exchangeSet.add(coin.exchangeBaseUrl);
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.filter((x) =>
Amounts.isSameCurrency(x.value, pcs.coinContributions[i]),
);
const amountLeft = Amounts.sub(denom.value, pcs.coinContributions[i])
.amount;
const refreshCost = getTotalRefreshCost(allDenoms, denom, amountLeft);
refreshFee.push(refreshCost);
}
for (const exchangeUrl of exchangeSet.values()) {
const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
if (!exchangeDetails) {
continue;
}
// FIXME/NOTE: the line below _likely_ throws exception
// about "find method not found on undefined" when the wireType
// is not supported by the Exchange.
const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
return timestampIsBetween(
getTimestampNow(),
x.startStamp,
x.endStamp,
);
})?.wireFee;
if (fee) {
wireFee.push(fee);
}
}
});
return {
coin: coinFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(coinFee).amount,
wire: wireFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(wireFee).amount,
refresh: refreshFee.length === 0 ? Amounts.getZero(total.currency) : Amounts.sum(refreshFee).amount
};
}

View File

@ -177,66 +177,6 @@ export async function getTotalPaymentCost(
}); });
} }
/**
* Get the amount that will be deposited on the merchant's bank
* account, not considering aggregation.
*/
export async function getEffectiveDepositAmount(
ws: InternalWalletState,
wireType: string,
pcs: PayCoinSelection,
): Promise<AmountJson> {
const amt: AmountJson[] = [];
const fees: AmountJson[] = [];
const exchangeSet: Set<string> = new Set();
await ws.db
.mktx((x) => ({
coins: x.coins,
denominations: x.denominations,
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
for (let i = 0; i < pcs.coinPubs.length; i++) {
const coin = await tx.coins.get(pcs.coinPubs[i]);
if (!coin) {
throw Error("can't calculate deposit amount, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
amt.push(pcs.coinContributions[i]);
fees.push(denom.feeDeposit);
exchangeSet.add(coin.exchangeBaseUrl);
}
for (const exchangeUrl of exchangeSet.values()) {
const exchangeDetails = await getExchangeDetails(tx, exchangeUrl);
if (!exchangeDetails) {
continue;
}
// FIXME/NOTE: the line below _likely_ throws exception
// about "find method not found on undefined" when the wireType
// is not supported by the Exchange.
const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
return timestampIsBetween(
getTimestampNow(),
x.startStamp,
x.endStamp,
);
})?.wireFee;
if (fee) {
fees.push(fee);
}
}
});
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}
function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean { function isSpendableCoin(coin: CoinRecord, denom: DenominationRecord): boolean {
if (coin.suspended) { if (coin.suspended) {
return false; return false;
@ -585,8 +525,7 @@ async function incrementPurchasePayRetry(
pr.payRetryInfo.retryCounter++; pr.payRetryInfo.retryCounter++;
updateRetryInfoTimeout(pr.payRetryInfo); updateRetryInfoTimeout(pr.payRetryInfo);
logger.trace( logger.trace(
`retrying pay in ${ `retrying pay in ${getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms
getDurationRemaining(pr.payRetryInfo.nextRetry).d_ms
} ms`, } ms`,
); );
pr.lastPayError = err; pr.lastPayError = err;

View File

@ -83,6 +83,7 @@ export enum WalletApiOperation {
AddExchange = "addExchange", AddExchange = "addExchange",
GetTransactions = "getTransactions", GetTransactions = "getTransactions",
ListExchanges = "listExchanges", ListExchanges = "listExchanges",
ListKnownBankAccounts = "listKnownBankAccounts",
GetWithdrawalDetailsForUri = "getWithdrawalDetailsForUri", GetWithdrawalDetailsForUri = "getWithdrawalDetailsForUri",
GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount", GetWithdrawalDetailsForAmount = "getWithdrawalDetailsForAmount",
AcceptManualWithdrawal = "acceptManualWithdrawal", AcceptManualWithdrawal = "acceptManualWithdrawal",

View File

@ -41,6 +41,10 @@ import {
codecForWithdrawFakebankRequest, codecForWithdrawFakebankRequest,
URL, URL,
parsePaytoUri, parsePaytoUri,
KnownBankAccounts,
PaytoUri,
codecForGetFeeForDeposit,
codecForListKnownBankAccounts,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
addBackupProvider, addBackupProvider,
@ -58,6 +62,7 @@ import { exportBackup } from "./operations/backup/export.js";
import { getBalances } from "./operations/balance.js"; import { getBalances } from "./operations/balance.js";
import { import {
createDepositGroup, createDepositGroup,
getFeeForDeposit,
processDepositGroup, processDepositGroup,
trackDepositGroup, trackDepositGroup,
} from "./operations/deposits.js"; } from "./operations/deposits.js";
@ -495,6 +500,30 @@ async function getExchangeTos(
}; };
} }
async function listKnownBankAccounts(
ws: InternalWalletState,
currency?: string,
): Promise<KnownBankAccounts> {
const accounts: PaytoUri[] = []
await ws.db
.mktx((x) => ({
reserves: x.reserves,
}))
.runReadOnly(async (tx) => {
const reservesRecords = await tx.reserves.iter().toArray()
for (const r of reservesRecords) {
if (currency && currency !== r.currency) {
continue
}
const payto = r.senderWire ? parsePaytoUri(r.senderWire) : undefined
if (payto) {
accounts.push(payto)
}
}
})
return { accounts }
}
async function getExchanges( async function getExchanges(
ws: InternalWalletState, ws: InternalWalletState,
): Promise<ExchangesListRespose> { ): Promise<ExchangesListRespose> {
@ -728,6 +757,10 @@ async function dispatchRequestInternal(
case "listExchanges": { case "listExchanges": {
return await getExchanges(ws); return await getExchanges(ws);
} }
case "listKnownBankAccounts": {
const req = codecForListKnownBankAccounts().decode(payload);
return await listKnownBankAccounts(ws, req.currency);
}
case "getWithdrawalDetailsForUri": { case "getWithdrawalDetailsForUri": {
const req = codecForGetWithdrawalDetailsForUri().decode(payload); const req = codecForGetWithdrawalDetailsForUri().decode(payload);
return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri); return await getWithdrawalDetailsForUri(ws, req.talerWithdrawUri);
@ -881,6 +914,10 @@ async function dispatchRequestInternal(
const resp = await getBackupInfo(ws); const resp = await getBackupInfo(ws);
return resp; return resp;
} }
case "getFeeForDeposit": {
const req = codecForGetFeeForDeposit().decode(payload);
return await getFeeForDeposit(ws, req);
}
case "createDepositGroup": { case "createDepositGroup": {
const req = codecForCreateDepositGroupRequest().decode(payload); const req = codecForCreateDepositGroupRequest().decode(payload);
return await createDepositGroup(ws, req); return await createDepositGroup(ws, req);

View File

@ -34,6 +34,7 @@ export enum Pages {
welcome = "/welcome", welcome = "/welcome",
balance = "/balance", balance = "/balance",
manual_withdraw = "/manual-withdraw", manual_withdraw = "/manual-withdraw",
deposit = "/deposit/:currency",
settings = "/settings", settings = "/settings",
dev = "/dev", dev = "/dev",
cta = "/cta", cta = "/cta",

View File

@ -16,9 +16,18 @@
import { amountFractionalBase, Amounts, Balance } from "@gnu-taler/taler-util"; import { amountFractionalBase, Amounts, Balance } from "@gnu-taler/taler-util";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { TableWithRoundRows as TableWithRoundedRows } from "./styled/index"; import {
ButtonPrimary,
TableWithRoundRows as TableWithRoundedRows,
} from "./styled/index";
export function BalanceTable({ balances }: { balances: Balance[] }): VNode { export function BalanceTable({
balances,
goToWalletDeposit,
}: {
balances: Balance[];
goToWalletDeposit: (currency: string) => void;
}): VNode {
const currencyFormatter = new Intl.NumberFormat("en-US"); const currencyFormatter = new Intl.NumberFormat("en-US");
return ( return (
<TableWithRoundedRows> <TableWithRoundedRows>
@ -40,6 +49,11 @@ export function BalanceTable({ balances }: { balances: Balance[] }): VNode {
> >
{v} {v}
</td> </td>
<td>
<ButtonPrimary onClick={() => goToWalletDeposit(av.currency)}>
Deposit
</ButtonPrimary>
</td>
</tr> </tr>
); );
})} })}

View File

@ -716,6 +716,10 @@ export const InputWithLabel = styled.div<{ invalid?: boolean }>`
} }
`; `;
export const ErrorText = styled.div`
color: red;
`;
export const ErrorBox = styled.div` export const ErrorBox = styled.div`
border: 2px solid #f5c6cb; border: 2px solid #f5c6cb;
border-radius: 0.25em; border-radius: 0.25em;

View File

@ -21,18 +21,21 @@ import { ButtonPrimary, ErrorBox } from "../components/styled/index";
import { HookResponse, useAsyncAsHook } from "../hooks/useAsyncAsHook"; import { HookResponse, useAsyncAsHook } from "../hooks/useAsyncAsHook";
import { PageLink } from "../renderHtml"; import { PageLink } from "../renderHtml";
import * as wxApi from "../wxApi"; import * as wxApi from "../wxApi";
interface Props {
goToWalletDeposit: (currency: string) => void;
goToWalletManualWithdraw: () => void;
}
export function BalancePage({ export function BalancePage({
goToWalletManualWithdraw, goToWalletManualWithdraw,
}: { goToWalletDeposit,
goToWalletManualWithdraw: () => void; }: Props): VNode {
}): VNode {
const state = useAsyncAsHook(wxApi.getBalance); const state = useAsyncAsHook(wxApi.getBalance);
return ( return (
<BalanceView <BalanceView
balance={state} balance={state}
Linker={PageLink} Linker={PageLink}
goToWalletManualWithdraw={goToWalletManualWithdraw} goToWalletManualWithdraw={goToWalletManualWithdraw}
goToWalletDeposit={goToWalletDeposit}
/> />
); );
} }
@ -40,12 +43,14 @@ export interface BalanceViewProps {
balance: HookResponse<BalancesResponse>; balance: HookResponse<BalancesResponse>;
Linker: typeof PageLink; Linker: typeof PageLink;
goToWalletManualWithdraw: () => void; goToWalletManualWithdraw: () => void;
goToWalletDeposit: (currency: string) => void;
} }
export function BalanceView({ export function BalanceView({
balance, balance,
Linker, Linker,
goToWalletManualWithdraw, goToWalletManualWithdraw,
goToWalletDeposit,
}: BalanceViewProps): VNode { }: BalanceViewProps): VNode {
if (!balance) { if (!balance) {
return <div>Loading...</div>; return <div>Loading...</div>;
@ -71,7 +76,8 @@ export function BalanceView({
<Linker pageName="/welcome">help</Linker> getting started? <Linker pageName="/welcome">help</Linker> getting started?
</i18n.Translate> </i18n.Translate>
</p> </p>
<footer style={{ justifyContent: "space-around" }}> <footer style={{ justifyContent: "space-between" }}>
<div />
<ButtonPrimary onClick={goToWalletManualWithdraw}> <ButtonPrimary onClick={goToWalletManualWithdraw}>
Withdraw Withdraw
</ButtonPrimary> </ButtonPrimary>
@ -83,9 +89,13 @@ export function BalanceView({
return ( return (
<Fragment> <Fragment>
<section> <section>
<BalanceTable balances={balance.response.balances} /> <BalanceTable
balances={balance.response.balances}
goToWalletDeposit={goToWalletDeposit}
/>
</section> </section>
<footer style={{ justifyContent: "space-around" }}> <footer style={{ justifyContent: "space-between" }}>
<div />
<ButtonPrimary onClick={goToWalletManualWithdraw}> <ButtonPrimary onClick={goToWalletManualWithdraw}>
Withdraw Withdraw
</ButtonPrimary> </ButtonPrimary>

View File

@ -43,14 +43,17 @@ export function DeveloperPage(): VNode {
? [] ? []
: operationsResponse.response.pendingOperations; : operationsResponse.response.pendingOperations;
return <View status={status} return (
<View
status={status}
timedOut={timedOut} timedOut={timedOut}
operations={operations} operations={operations}
onDownloadDatabase={async () => { onDownloadDatabase={async () => {
const db = await wxApi.exportDB() const db = await wxApi.exportDB();
return JSON.stringify(db) return JSON.stringify(db);
}} }}
/>; />
);
} }
export interface Props { export interface Props {
@ -64,14 +67,21 @@ function hashObjectId(o: any): string {
return JSON.stringify(o); return JSON.stringify(o);
} }
export function View({ status, timedOut, operations, onDownloadDatabase }: Props): VNode { export function View({
const [downloadedDatabase, setDownloadedDatabase] = useState<{time: Date; content: string}|undefined>(undefined) status,
timedOut,
operations,
onDownloadDatabase,
}: Props): VNode {
const [downloadedDatabase, setDownloadedDatabase] = useState<
{ time: Date; content: string } | undefined
>(undefined);
async function onExportDatabase(): Promise<void> { async function onExportDatabase(): Promise<void> {
const content = await onDownloadDatabase() const content = await onDownloadDatabase();
setDownloadedDatabase({ setDownloadedDatabase({
time: new Date(), time: new Date(),
content content,
}) });
} }
return ( return (
<div> <div>
@ -83,9 +93,27 @@ export function View({ status, timedOut, operations, onDownloadDatabase }: Props
<button onClick={confirmReset}>reset</button> <button onClick={confirmReset}>reset</button>
<br /> <br />
<button onClick={onExportDatabase}>export database</button> <button onClick={onExportDatabase}>export database</button>
{downloadedDatabase && <div> {downloadedDatabase && (
Database exported at <Time timestamp={{t_ms: downloadedDatabase.time.getTime()}} format="yyyy/MM/dd HH:mm:ss" /> <a href={`data:text/plain;charset=utf-8;base64,${btoa(downloadedDatabase.content)}`} download={`taler-wallet-database-${format(downloadedDatabase.time, 'yyyy/MM/dd_HH:mm')}.json`}>click here</a> to download <div>
</div>} Database exported at
<Time
timestamp={{ t_ms: downloadedDatabase.time.getTime() }}
format="yyyy/MM/dd HH:mm:ss"
/>
<a
href={`data:text/plain;charset=utf-8;base64,${toBase64(
downloadedDatabase.content,
)}`}
download={`taler-wallet-database-${format(
downloadedDatabase.time,
"yyyy/MM/dd_HH:mm",
)}.json`}
>
click here
</a>
to download
</div>
)}
<br /> <br />
<Diagnostics diagnostics={status} timedOut={timedOut} /> <Diagnostics diagnostics={status} timedOut={timedOut} />
{operations && operations.length > 0 && ( {operations && operations.length > 0 && (
@ -109,6 +137,14 @@ export function View({ status, timedOut, operations, onDownloadDatabase }: Props
); );
} }
function toBase64(str: string): string {
return btoa(
encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
return String.fromCharCode(parseInt(p1, 16));
}),
);
}
export function reload(): void { export function reload(): void {
try { try {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef

View File

@ -84,6 +84,9 @@ function Application() {
goToWalletManualWithdraw={() => goToWalletManualWithdraw={() =>
goToWalletPage(Pages.manual_withdraw) goToWalletPage(Pages.manual_withdraw)
} }
goToWalletDeposit={(currency: string) =>
goToWalletPage(Pages.deposit.replace(":currency", currency))
}
/> />
<Route path={Pages.settings} component={SettingsPage} /> <Route path={Pages.settings} component={SettingsPage} />
<Route <Route
@ -107,6 +110,7 @@ function Application() {
/> />
<Route path={Pages.history} component={HistoryPage} /> <Route path={Pages.history} component={HistoryPage} />
<Route <Route
path={Pages.backup} path={Pages.backup}
component={BackupPage} component={BackupPage}

View File

@ -24,7 +24,9 @@ import * as wxApi from "../wxApi";
export function BalancePage({ export function BalancePage({
goToWalletManualWithdraw, goToWalletManualWithdraw,
goToWalletDeposit,
}: { }: {
goToWalletDeposit: (currency: string) => void;
goToWalletManualWithdraw: () => void; goToWalletManualWithdraw: () => void;
}): VNode { }): VNode {
const state = useAsyncAsHook(wxApi.getBalance); const state = useAsyncAsHook(wxApi.getBalance);
@ -33,6 +35,7 @@ export function BalancePage({
balance={state} balance={state}
Linker={PageLink} Linker={PageLink}
goToWalletManualWithdraw={goToWalletManualWithdraw} goToWalletManualWithdraw={goToWalletManualWithdraw}
goToWalletDeposit={goToWalletDeposit}
/> />
); );
} }
@ -41,12 +44,14 @@ export interface BalanceViewProps {
balance: HookResponse<BalancesResponse>; balance: HookResponse<BalancesResponse>;
Linker: typeof PageLink; Linker: typeof PageLink;
goToWalletManualWithdraw: () => void; goToWalletManualWithdraw: () => void;
goToWalletDeposit: (currency: string) => void;
} }
export function BalanceView({ export function BalanceView({
balance, balance,
Linker, Linker,
goToWalletManualWithdraw, goToWalletManualWithdraw,
goToWalletDeposit,
}: BalanceViewProps): VNode { }: BalanceViewProps): VNode {
if (!balance) { if (!balance) {
return <div>Loading...</div>; return <div>Loading...</div>;
@ -65,28 +70,35 @@ export function BalanceView({
} }
if (balance.response.balances.length === 0) { if (balance.response.balances.length === 0) {
return ( return (
<Fragment>
<p> <p>
<Centered style={{ marginTop: 100 }}> <Centered style={{ marginTop: 100 }}>
<i18n.Translate> <i18n.Translate>
You have no balance to show. Need some{" "} You have no balance to show. Need some{" "}
<Linker pageName="/welcome">help</Linker> getting started? <Linker pageName="/welcome">help</Linker> getting started?
</i18n.Translate> </i18n.Translate>
<div>
<ButtonPrimary onClick={goToWalletManualWithdraw}>
Withdraw
</ButtonPrimary>
</div>
</Centered> </Centered>
</p> </p>
); <footer style={{ justifyContent: "space-between" }}>
} <div />
<ButtonPrimary onClick={goToWalletManualWithdraw}>
return ( Withdraw
<Fragment> </ButtonPrimary>
<section> </footer>
<BalanceTable balances={balance.response.balances} /> </Fragment>
</section> );
<footer style={{ justifyContent: "space-around" }}> }
return (
<Fragment>
<section>
<BalanceTable
balances={balance.response.balances}
goToWalletDeposit={goToWalletDeposit}
/>
</section>
<footer style={{ justifyContent: "space-between" }}>
<div />
<ButtonPrimary onClick={goToWalletManualWithdraw}> <ButtonPrimary onClick={goToWalletManualWithdraw}>
Withdraw Withdraw
</ButtonPrimary> </ButtonPrimary>

View File

@ -0,0 +1,52 @@
/*
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 <http://www.gnu.org/licenses/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { AmountJson, Amounts, parsePaytoUri } from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { createExample } from "../test-utils";
import { View as TestedComponent } from "./DepositPage";
export default {
title: "wallet/deposit",
component: TestedComponent,
argTypes: {},
};
async function alwaysReturnFeeToOne(): Promise<DepositFee> {
const fee = {
currency: "EUR",
value: 1,
fraction: 0,
};
return { coin: fee, refresh: fee, wire: fee };
}
export const WithEmptyAccountList = createExample(TestedComponent, {
knownBankAccounts: [],
balance: Amounts.parseOrThrow("USD:10"),
onCalculateFee: alwaysReturnFeeToOne,
});
export const WithSomeBankAccounts = createExample(TestedComponent, {
knownBankAccounts: [parsePaytoUri("payto://iban/ES8877998399652238")!],
balance: Amounts.parseOrThrow("EUR:10"),
onCalculateFee: alwaysReturnFeeToOne,
});

View File

@ -0,0 +1,234 @@
/*
This file is part of TALER
(C) 2016 GNUnet e.V.
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.
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
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
AmountJson,
Amounts,
AmountString,
PaytoUri,
} from "@gnu-taler/taler-util";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { Fragment, h, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
import { Part } from "../components/Part";
import { SelectList } from "../components/SelectList";
import {
ButtonPrimary,
ErrorText,
Input,
InputWithLabel,
} from "../components/styled";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook";
import * as wxApi from "../wxApi";
interface Props {
currency: string;
}
export function DepositPage({ currency }: Props): VNode {
const [success, setSuccess] = useState(false);
const state = useAsyncAsHook(async () => {
const balance = await wxApi.getBalance();
const bs = balance.balances.filter((b) => b.available.startsWith(currency));
const currencyBalance =
bs.length === 0
? Amounts.getZero(currency)
: Amounts.parseOrThrow(bs[0].available);
const knownAccounts = await wxApi.listKnownBankAccounts(currency);
return { accounts: knownAccounts.accounts, currencyBalance };
});
const accounts =
state === undefined ? [] : state.hasError ? [] : state.response.accounts;
const currencyBalance =
state === undefined
? Amounts.getZero(currency)
: state.hasError
? Amounts.getZero(currency)
: state.response.currencyBalance;
async function doSend(account: string, amount: AmountString): Promise<void> {
await wxApi.createDepositGroup(account, amount);
setSuccess(true);
}
async function getFeeForAmount(
account: string,
amount: AmountString,
): Promise<DepositFee> {
return await wxApi.getFeeForDeposit(account, amount);
}
if (accounts.length === 0) return <div>loading..</div>;
if (success) return <div>deposit created</div>;
return (
<View
knownBankAccounts={accounts}
balance={currencyBalance}
onSend={doSend}
onCalculateFee={getFeeForAmount}
/>
);
}
interface ViewProps {
knownBankAccounts: Array<PaytoUri>;
balance: AmountJson;
onSend: (account: string, amount: AmountString) => Promise<void>;
onCalculateFee: (
account: string,
amount: AmountString,
) => Promise<DepositFee>;
}
export function View({
knownBankAccounts,
balance,
onSend,
onCalculateFee,
}: ViewProps): VNode {
const accountMap = createLabelsForBankAccount(knownBankAccounts);
const [accountIdx, setAccountIdx] = useState(0);
const [amount, setAmount] = useState<number | undefined>(undefined);
const [fee, setFee] = useState<DepositFee | undefined>(undefined);
const currency = balance.currency;
const amountStr: AmountString = `${currency}:${amount}`;
const account = knownBankAccounts[accountIdx];
const accountURI = `payto://${account.targetType}/${account.targetPath}`;
useEffect(() => {
if (amount === undefined) return;
onCalculateFee(accountURI, amountStr).then((result) => {
setFee(result);
});
}, [amount]);
if (!balance) {
return <div>no balance</div>;
}
if (!knownBankAccounts || !knownBankAccounts.length) {
return <div>there is no known bank account to send money to</div>;
}
const parsedAmount =
amount === undefined ? undefined : Amounts.parse(amountStr);
const isDirty = amount !== 0;
const error = !isDirty
? undefined
: !parsedAmount
? "Invalid amount"
: Amounts.cmp(balance, parsedAmount) === -1
? `To much, your current balance is ${balance.value}`
: undefined;
return (
<Fragment>
<h2>Send {currency} to your account</h2>
<section>
<Input>
<SelectList
label="Bank account IBAN number"
list={accountMap}
name="account"
value={String(accountIdx)}
onChange={(s) => setAccountIdx(parseInt(s, 10))}
/>
</Input>
<InputWithLabel invalid={!!error}>
<label>Amount to send</label>
<div>
<span>{currency}</span>
<input
type="number"
value={amount}
onInput={(e) => {
const num = parseFloat(e.currentTarget.value);
console.log(num);
if (!Number.isNaN(num)) {
setAmount(num);
} else {
setAmount(undefined);
setFee(undefined);
}
}}
/>
</div>
{error && <ErrorText>{error}</ErrorText>}
</InputWithLabel>
{!error && fee && (
<div style={{ textAlign: "center" }}>
<Part
title="Exchange fee"
text={Amounts.stringify(Amounts.sum([fee.wire, fee.coin]).amount)}
kind="negative"
/>
<Part
title="Change cost"
text={Amounts.stringify(fee.refresh)}
kind="negative"
/>
{parsedAmount && (
<Part
title="Total received"
text={Amounts.stringify(
Amounts.sub(
parsedAmount,
Amounts.sum([fee.wire, fee.coin]).amount,
).amount,
)}
kind="positive"
/>
)}
</div>
)}
</section>
<footer>
<div />
<ButtonPrimary
disabled={!parsedAmount}
onClick={() => onSend(accountURI, amountStr)}
>
Send
</ButtonPrimary>
</footer>
</Fragment>
);
}
function createLabelsForBankAccount(knownBankAccounts: Array<PaytoUri>): {
[label: number]: string;
} {
if (!knownBankAccounts) return {};
return knownBankAccounts.reduce((prev, cur, i) => {
let label = cur.targetPath;
if (cur.isKnown) {
switch (cur.targetType) {
case "x-taler-bank": {
label = cur.account;
break;
}
case "iban": {
label = cur.iban;
break;
}
}
}
return {
...prev,
[i]: label,
};
}, {} as { [label: number]: string });
}

View File

@ -369,8 +369,8 @@ export function TransactionView({
if (transaction.type === TransactionType.Deposit) { if (transaction.type === TransactionType.Deposit) {
const fee = Amounts.sub( const fee = Amounts.sub(
Amounts.parseOrThrow(transaction.amountRaw),
Amounts.parseOrThrow(transaction.amountEffective), Amounts.parseOrThrow(transaction.amountEffective),
Amounts.parseOrThrow(transaction.amountRaw),
).amount; ).amount;
return ( return (
<TransactionTemplate> <TransactionTemplate>
@ -379,15 +379,15 @@ export function TransactionView({
<br /> <br />
<Part <Part
big big
title="Total deposit" title="Total send"
text={amountToString(transaction.amountEffective)} text={amountToString(transaction.amountEffective)}
kind="negative" kind="neutral"
/> />
<Part <Part
big big
title="Purchase amount" title="Deposit amount"
text={amountToString(transaction.amountRaw)} text={amountToString(transaction.amountRaw)}
kind="neutral" kind="positive"
/> />
<Part big title="Fee" text={amountToString(fee)} kind="negative" /> <Part big title="Fee" text={amountToString(fee)} kind="negative" />
</TransactionTemplate> </TransactionTemplate>

View File

@ -45,6 +45,7 @@ import { WalletBox } from "./components/styled";
import { ProviderDetailPage } from "./wallet/ProviderDetailPage"; import { ProviderDetailPage } from "./wallet/ProviderDetailPage";
import { ProviderAddPage } from "./wallet/ProviderAddPage"; import { ProviderAddPage } from "./wallet/ProviderAddPage";
import { ExchangeAddPage } from "./wallet/ExchangeAddPage"; import { ExchangeAddPage } from "./wallet/ExchangeAddPage";
import { DepositPage } from "./wallet/DepositPage";
function main(): void { function main(): void {
try { try {
@ -105,6 +106,9 @@ function Application(): VNode {
path={Pages.balance} path={Pages.balance}
component={withLogoAndNavBar(BalancePage)} component={withLogoAndNavBar(BalancePage)}
goToWalletManualWithdraw={() => route(Pages.manual_withdraw)} goToWalletManualWithdraw={() => route(Pages.manual_withdraw)}
goToWalletDeposit={(currency: string) =>
route(Pages.deposit.replace(":currency", currency))
}
/> />
<Route <Route
path={Pages.settings} path={Pages.settings}
@ -145,6 +149,10 @@ function Application(): VNode {
component={withLogoAndNavBar(ManualWithdrawPage)} component={withLogoAndNavBar(ManualWithdrawPage)}
/> />
<Route
path={Pages.deposit}
component={withLogoAndNavBar(DepositPage)}
/>
<Route <Route
path={Pages.reset_required} path={Pages.reset_required}
component={() => <div>no yet implemented</div>} component={() => <div>no yet implemented</div>}

View File

@ -24,10 +24,11 @@
import { import {
AcceptExchangeTosRequest, AcceptExchangeTosRequest,
AcceptManualWithdrawalResult, AcceptTipRequest, AcceptWithdrawalResponse, AcceptManualWithdrawalResult, AcceptTipRequest, AcceptWithdrawalResponse,
AddExchangeRequest, ApplyRefundResponse, BalancesResponse, ConfirmPayResult, AddExchangeRequest, AmountJson, AmountString, ApplyRefundResponse, BalancesResponse, ConfirmPayResult,
CoreApiResponse, DeleteTransactionRequest, ExchangesListRespose, CoreApiResponse, CreateDepositGroupRequest, CreateDepositGroupResponse, DeleteTransactionRequest, ExchangesListRespose,
GetExchangeTosResult, GetExchangeWithdrawalInfo, GetExchangeTosResult, GetExchangeWithdrawalInfo,
GetWithdrawalDetailsForUriRequest, NotificationType, PreparePayResult, PrepareTipRequest, GetFeeForDepositRequest,
GetWithdrawalDetailsForUriRequest, KnownBankAccounts, NotificationType, PreparePayResult, PrepareTipRequest,
PrepareTipResult, RetryTransactionRequest, PrepareTipResult, RetryTransactionRequest,
SetWalletDeviceIdRequest, TransactionsResponse, WalletDiagnostics, WithdrawUriInfoResponse SetWalletDeviceIdRequest, TransactionsResponse, WalletDiagnostics, WithdrawUriInfoResponse
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
@ -36,6 +37,7 @@ import {
PendingOperationsResponse, PendingOperationsResponse,
RemoveBackupProviderRequest RemoveBackupProviderRequest
} from "@gnu-taler/taler-wallet-core"; } from "@gnu-taler/taler-wallet-core";
import { DepositFee } from "@gnu-taler/taler-wallet-core/src/operations/deposits";
import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw"; import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw";
import { MessageFromBackend } from "./wxBackend.js"; import { MessageFromBackend } from "./wxBackend.js";
@ -119,6 +121,18 @@ export function resetDb(): Promise<void> {
return callBackend("reset-db", {}); return callBackend("reset-db", {});
} }
export function getFeeForDeposit(depositPaytoUri: string, amount: AmountString): Promise<DepositFee> {
return callBackend("getFeeForDeposit", {
depositPaytoUri, amount
} as GetFeeForDepositRequest);
}
export function createDepositGroup(depositPaytoUri: string, amount: AmountString): Promise<CreateDepositGroupResponse> {
return callBackend("createDepositGroup", {
depositPaytoUri, amount
} as CreateDepositGroupRequest);
}
/** /**
* Get balances for all currencies/exchanges. * Get balances for all currencies/exchanges.
*/ */
@ -170,6 +184,9 @@ export function listKnownCurrencies(): Promise<ListOfKnownCurrencies> {
export function listExchanges(): Promise<ExchangesListRespose> { export function listExchanges(): Promise<ExchangesListRespose> {
return callBackend("listExchanges", {}); return callBackend("listExchanges", {});
} }
export function listKnownBankAccounts(currency?: string): Promise<KnownBankAccounts> {
return callBackend("listKnownBankAccounts", { currency });
}
/** /**
* Get information about the current state of wallet backups. * Get information about the current state of wallet backups.