/* This file is part of GNU Taler (C) 2022 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ /** * Imports. */ import { AgeCommitmentProof, AmountJson, AmountString, Amounts, Codec, CoinPublicKeyString, CoinStatus, HttpStatusCode, Logger, NotificationType, PayPeerInsufficientBalanceDetails, TalerError, TalerErrorCode, TalerProtocolTimestamp, UnblindedSignature, buildCodecForObject, codecForAmountString, codecForTimestamp, codecOptional, j2s, strcmp, } from "@gnu-taler/taler-util"; import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; import { DenominationRecord, KycPendingInfo, KycUserType, PeerPushPaymentCoinSelection, ReserveRecord, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { getPeerPaymentBalanceDetailsInTx } from "./balance.js"; import { getTotalRefreshCost } from "./refresh.js"; const logger = new Logger("operations/peer-to-peer.ts"); interface SelectedPeerCoin { coinPub: string; coinPriv: string; contribution: AmountString; denomPubHash: string; denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; } interface PeerCoinSelectionDetails { exchangeBaseUrl: string; /** * Info of Coins that were selected. */ coins: SelectedPeerCoin[]; /** * How much of the deposit fees is the customer paying? */ depositFees: AmountJson; } /** * Information about a selected coin for peer to peer payments. */ interface CoinInfo { /** * Public key of the coin. */ coinPub: string; coinPriv: string; /** * Deposit fee for the coin. */ feeDeposit: AmountJson; value: AmountJson; denomPubHash: string; denomSig: UnblindedSignature; maxAge: number; ageCommitmentProof?: AgeCommitmentProof; } export type SelectPeerCoinsResult = | { type: "success"; result: PeerCoinSelectionDetails } | { type: "failure"; insufficientBalanceDetails: PayPeerInsufficientBalanceDetails; }; /** * Get information about the coin selected for signatures * @param ws * @param csel * @returns */ export async function queryCoinInfosForSelection( ws: InternalWalletState, csel: PeerPushPaymentCoinSelection, ): Promise { let infos: SpendCoinDetails[] = []; await ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { for (let i = 0; i < csel.coinPubs.length; i++) { const coin = await tx.coins.get(csel.coinPubs[i]); if (!coin) { throw Error("coin not found anymore"); } const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); if (!denom) { throw Error("denom for coin not found anymore"); } infos.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, contribution: csel.contributions[i], }); } }); return infos; } export interface PeerCoinRepair { exchangeBaseUrl: string; coinPubs: CoinPublicKeyString[]; contribs: AmountJson[]; } export interface PeerCoinSelectionRequest { instructedAmount: AmountJson; /** * Instruct the coin selection to repair this coin * selection instead of selecting completely new coins. */ repair?: PeerCoinRepair; } export async function selectPeerCoins( ws: InternalWalletState, req: PeerCoinSelectionRequest, ): Promise { const instructedAmount = req.instructedAmount; if (Amounts.isZero(instructedAmount)) { // Other parts of the code assume that we have at least // one coin to spend. throw new Error("amount of zero not allowed"); } return await ws.db .mktx((x) => [ x.exchanges, x.contractTerms, x.coins, x.coinAvailability, x.denominations, x.refreshGroups, x.peerPushPaymentInitiations, ]) .runReadWrite(async (tx) => { const exchanges = await tx.exchanges.iter().toArray(); const exchangeFeeGap: { [url: string]: AmountJson } = {}; const currency = Amounts.currencyOf(instructedAmount); for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } // FIXME: Can't we do this faster by using coinAvailability? const coins = ( await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl) ).filter((x) => x.status === CoinStatus.Fresh); const coinInfos: CoinInfo[] = []; for (const coin of coins) { const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); if (!denom) { throw Error("denom not found"); } coinInfos.push({ coinPub: coin.coinPub, feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), value: Amounts.parseOrThrow(denom.value), denomPubHash: denom.denomPubHash, coinPriv: coin.coinPriv, denomSig: coin.denomSig, maxAge: coin.maxAge, ageCommitmentProof: coin.ageCommitmentProof, }); } if (coinInfos.length === 0) { continue; } coinInfos.sort( (o1, o2) => -Amounts.cmp(o1.value, o2.value) || strcmp(o1.denomPubHash, o2.denomPubHash), ); let amountAcc = Amounts.zeroOfCurrency(currency); let depositFeesAcc = Amounts.zeroOfCurrency(currency); const resCoins: { coinPub: string; coinPriv: string; contribution: AmountString; denomPubHash: string; denomSig: UnblindedSignature; ageCommitmentProof: AgeCommitmentProof | undefined; }[] = []; let lastDepositFee = Amounts.zeroOfCurrency(currency); if (req.repair) { for (let i = 0; i < req.repair.coinPubs.length; i++) { const contrib = req.repair.contribs[i]; const coin = await tx.coins.get(req.repair.coinPubs[i]); if (!coin) { throw Error("repair not possible, coin not found"); } const denom = await ws.getDenomInfo( ws, tx, coin.exchangeBaseUrl, coin.denomPubHash, ); checkDbInvariant(!!denom); resCoins.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contribution: Amounts.stringify(contrib), denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, }); const depositFee = Amounts.parseOrThrow(denom.feeDeposit); lastDepositFee = depositFee; amountAcc = Amounts.add( amountAcc, Amounts.sub(contrib, depositFee).amount, ).amount; depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount; } } for (const coin of coinInfos) { if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { break; } const gap = Amounts.add( coin.feeDeposit, Amounts.sub(instructedAmount, amountAcc).amount, ).amount; const contrib = Amounts.min(gap, coin.value); amountAcc = Amounts.add( amountAcc, Amounts.sub(contrib, coin.feeDeposit).amount, ).amount; depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount; resCoins.push({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contribution: Amounts.stringify(contrib), denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, ageCommitmentProof: coin.ageCommitmentProof, }); lastDepositFee = coin.feeDeposit; } if (Amounts.cmp(amountAcc, instructedAmount) >= 0) { const res: PeerCoinSelectionDetails = { exchangeBaseUrl: exch.baseUrl, coins: resCoins, depositFees: depositFeesAcc, }; return { type: "success", result: res }; } const diff = Amounts.sub(instructedAmount, amountAcc).amount; exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount; continue; } // We were unable to select coins. // Now we need to produce error details. const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, { currency, }); const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {}; let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency); for (const exch of exchanges) { if (exch.detailsPointer?.currency !== currency) { continue; } const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, { currency, restrictExchangeTo: exch.baseUrl, }); let gap = exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency); if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) { // Show fee gap only if we should've been able to pay with the material amount gap = Amounts.zeroOfCurrency(currency); } perExchange[exch.baseUrl] = { balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable), balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial), feeGapEstimate: Amounts.stringify(gap), }; maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap); } const errDetails: PayPeerInsufficientBalanceDetails = { amountRequested: Amounts.stringify(instructedAmount), balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable), balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial), feeGapEstimate: Amounts.stringify(maxFeeGapEstimate), perExchange, }; return { type: "failure", insufficientBalanceDetails: errDetails }; }); } export async function getTotalPeerPaymentCost( ws: InternalWalletState, pcs: SelectedPeerCoin[], ): Promise { return ws.db .mktx((x) => [x.coins, x.denominations]) .runReadOnly(async (tx) => { const costs: AmountJson[] = []; for (let i = 0; i < pcs.length; i++) { const coin = await tx.coins.get(pcs[i].coinPub); if (!coin) { throw Error("can't calculate payment cost, coin not found"); } const denom = await tx.denominations.get([ coin.exchangeBaseUrl, coin.denomPubHash, ]); if (!denom) { throw Error( "can't calculate payment cost, denomination for coin not found", ); } const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl .iter(coin.exchangeBaseUrl) .filter((x) => Amounts.isSameCurrency( DenominationRecord.getValue(x), pcs[i].contribution, ), ); const amountLeft = Amounts.sub( DenominationRecord.getValue(denom), pcs[i].contribution, ).amount; const refreshCost = getTotalRefreshCost( allDenoms, DenominationRecord.toDenomInfo(denom), amountLeft, ws.config.testing.denomselAllowLate, ); costs.push(Amounts.parseOrThrow(pcs[i].contribution)); costs.push(refreshCost); } const zero = Amounts.zeroOfAmount(pcs[0].contribution); return Amounts.sum([zero, ...costs]).amount; }); } interface ExchangePurseStatus { balance: AmountString; deposit_timestamp?: TalerProtocolTimestamp; merge_timestamp?: TalerProtocolTimestamp; } export const codecForExchangePurseStatus = (): Codec => buildCodecForObject() .property("balance", codecForAmountString()) .property("deposit_timestamp", codecOptional(codecForTimestamp)) .property("merge_timestamp", codecOptional(codecForTimestamp)) .build("ExchangePurseStatus"); export function talerPaytoFromExchangeReserve( exchangeBaseUrl: string, reservePub: string, ): string { const url = new URL(exchangeBaseUrl); let proto: string; if (url.protocol === "http:") { proto = "taler-reserve-http"; } else if (url.protocol === "https:") { proto = "taler-reserve"; } else { throw Error(`unsupported exchange base URL protocol (${url.protocol})`); } let path = url.pathname; if (!path.endsWith("/")) { path = path + "/"; } return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; } export async function getMergeReserveInfo( ws: InternalWalletState, req: { exchangeBaseUrl: string; }, ): Promise { // We have to eagerly create the key pair outside of the transaction, // due to the async crypto API. const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); const mergeReserveRecord: ReserveRecord = await ws.db .mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups]) .runReadWrite(async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); checkDbInvariant(!!ex); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); checkDbInvariant(!!reserve); return reserve; } const reserve: ReserveRecord = { reservePriv: newReservePair.priv, reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); checkDbInvariant(typeof insertResp.key === "number"); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); return reserve; }); return mergeReserveRecord; }