diff options
author | Florian Dold <florian@dold.me> | 2023-06-05 11:45:16 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2023-06-05 11:45:16 +0200 |
commit | fda5a0ed87a6473a6b34bd1ac07d5f1d45dfbc19 (patch) | |
tree | c8b7b09ca441d2a01e340dd3f569e075d3ef278e /packages/taler-wallet-core/src/operations/pay-peer-common.ts | |
parent | f3d4ff4e3a44141ad387ef68a9083b01bf1c818a (diff) | |
download | wallet-core-fda5a0ed87a6473a6b34bd1ac07d5f1d45dfbc19.tar.xz |
wallet-core: restructure p2p implv0.9.3-dev.14
Diffstat (limited to 'packages/taler-wallet-core/src/operations/pay-peer-common.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/pay-peer-common.ts | 463 |
1 files changed, 463 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/operations/pay-peer-common.ts b/packages/taler-wallet-core/src/operations/pay-peer-common.ts new file mode 100644 index 000000000..4b1dd31a5 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/pay-peer-common.ts @@ -0,0 +1,463 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { + AgeCommitmentProof, + AmountJson, + AmountString, + Amounts, + Codec, + CoinPublicKeyString, + CoinStatus, + Logger, + PayPeerInsufficientBalanceDetails, + TalerProtocolTimestamp, + UnblindedSignature, + buildCodecForObject, + codecForAmountString, + codecForTimestamp, + codecOptional, + strcmp, +} from "@gnu-taler/taler-util"; +import { SpendCoinDetails } from "../crypto/cryptoImplementation.js"; +import { + DenominationRecord, + 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; + }; + +export async function queryCoinInfosForSelection( + ws: InternalWalletState, + csel: PeerPushPaymentCoinSelection, +): Promise<SpendCoinDetails[]> { + 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 PeerCoinSelectionRequest { + instructedAmount: AmountJson; + + /** + * Instruct the coin selection to repair this coin + * selection instead of selecting completely new coins. + */ + repair?: { + exchangeBaseUrl: string; + coinPubs: CoinPublicKeyString[]; + contribs: AmountJson[]; + }; +} + +export async function selectPeerCoins( + ws: InternalWalletState, + req: PeerCoinSelectionRequest, +): Promise<SelectPeerCoinsResult> { + 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<AmountJson> { + 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<ExchangePurseStatus> => + buildCodecForObject<ExchangePurseStatus>() + .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<ReserveRecord> { + // 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; +} |