/* 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 */ /** * Helper functions to run wallet functionality (withdrawal, deposit, refresh) * without a database or retry loop. * * Used for benchmarking, where we want to benchmark the exchange, but the * normal wallet would be too sluggish. */ /** * Imports. */ import { AbsoluteTime, AgeRestriction, AmountJson, AmountString, Amounts, DenominationPubKey, ExchangeBatchDepositRequest, ExchangeBatchWithdrawRequest, ExchangeMeltRequest, ExchangeProtocolVersion, Logger, TalerCorebankApiClient, UnblindedSignature, codecForAny, codecForBankWithdrawalOperationPostResponse, codecForBatchDepositSuccess, codecForExchangeMeltResponse, codecForExchangeRevealResponse, codecForExchangeWithdrawBatchResponse, encodeCrock, getRandomBytes, hashWire, parsePaytoUri, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { DenominationRecord } from "./db.js"; import { isWithdrawableDenom } from "./denominations.js"; import { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; import { assembleRefreshRevealRequest } from "./refresh.js"; import { getBankStatusUrl, getBankWithdrawalInfo } from "./withdraw.js"; export { downloadExchangeInfo }; const logger = new Logger("dbless.ts"); export interface ReserveKeypair { reservePub: string; reservePriv: string; } /** * Denormalized info about a coin. */ export interface CoinInfo { coinPub: string; coinPriv: string; exchangeBaseUrl: string; denomSig: UnblindedSignature; denomPub: DenominationPubKey; denomPubHash: string; feeDeposit: string; feeRefresh: string; maxAge: number; } export interface TopupReserveWithBankArgs { http: HttpRequestLibrary; reservePub: string; corebankApiBaseUrl: string; exchangeInfo: ExchangeInfo; amount: AmountString; } export async function topupReserveWithBank(args: TopupReserveWithBankArgs) { const { http, corebankApiBaseUrl, amount, exchangeInfo, reservePub } = args; const bankClient = new TalerCorebankApiClient(corebankApiBaseUrl); const bankUser = await bankClient.createRandomBankUser(); const wopi = await bankClient.createWithdrawalOperation( bankUser.username, amount, ); const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri); const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri); if (!bankInfo.exchange) { throw Error("no suggested exchange"); } const plainPaytoUris = exchangeInfo.keys.accounts.map((x) => x.payto_uri) ?? []; if (plainPaytoUris.length <= 0) { throw new Error(); } const httpResp = await http.fetch(bankStatusUrl, { method: "POST", body: { reserve_pub: reservePub, selected_exchange: plainPaytoUris[0], }, }); await readSuccessResponseJsonOrThrow( httpResp, codecForBankWithdrawalOperationPostResponse(), ); await bankClient.confirmWithdrawalOperation(bankUser.username, { withdrawalOperationId: wopi.withdrawal_id, }); } export async function withdrawCoin(args: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; reserveKeyPair: ReserveKeypair; denom: DenominationRecord; exchangeBaseUrl: string; }): Promise { const { http, cryptoApi, reserveKeyPair, denom, exchangeBaseUrl } = args; const planchet = await cryptoApi.createPlanchet({ coinIndex: 0, denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.fees.feeWithdraw), reservePriv: reserveKeyPair.reservePriv, reservePub: reserveKeyPair.reservePub, secretSeed: encodeCrock(getRandomBytes(32)), value: Amounts.parseOrThrow(denom.value), }); const reqBody: ExchangeBatchWithdrawRequest = { planchets: [ { denom_pub_hash: planchet.denomPubHash, reserve_sig: planchet.withdrawSig, coin_ev: planchet.coinEv, }, ], }; const reqUrl = new URL( `reserves/${planchet.reservePub}/batch-withdraw`, exchangeBaseUrl, ).href; const resp = await http.fetch(reqUrl, { method: "POST", body: reqBody }); const rBatch = await readSuccessResponseJsonOrThrow( resp, codecForExchangeWithdrawBatchResponse(), ); const ubSig = await cryptoApi.unblindDenominationSignature({ planchet, evSig: rBatch.ev_sigs[0].ev_sig, }); return { coinPriv: planchet.coinPriv, coinPub: planchet.coinPub, denomSig: ubSig, denomPub: denom.denomPub, denomPubHash: denom.denomPubHash, feeDeposit: Amounts.stringify(denom.fees.feeDeposit), feeRefresh: Amounts.stringify(denom.fees.feeRefresh), exchangeBaseUrl: args.exchangeBaseUrl, maxAge: AgeRestriction.AGE_UNRESTRICTED, }; } export interface FindDenomOptions { denomselAllowLate?: boolean; } export function findDenomOrThrow( exchangeInfo: ExchangeInfo, amount: AmountString, options: FindDenomOptions = {}, ): DenominationRecord { const denomselAllowLate = options.denomselAllowLate ?? false; for (const d of exchangeInfo.keys.currentDenominations) { const value: AmountJson = Amounts.parseOrThrow(d.value); if ( Amounts.cmp(value, amount) === 0 && isWithdrawableDenom(d, denomselAllowLate) ) { return d; } } throw new Error("no matching denomination found"); } export async function depositCoin(args: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; exchangeBaseUrl: string; coin: CoinInfo; amount: AmountString; depositPayto?: string; merchantPub: string; merchantPriv: string; contractTermsHash?: string; // 16 bytes, crockford encoded wireSalt?: string; }): Promise { const { coin, http, cryptoApi } = args; const depositPayto = args.depositPayto ?? "payto://x-taler-bank/localhost/foo?receiver-name=foo"; const wireSalt = args.wireSalt ?? encodeCrock(getRandomBytes(16)); const timestampNow = AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()); const contractTermsHash = args.contractTermsHash ?? encodeCrock(getRandomBytes(64)); const depositTimestamp = timestampNow; const refundDeadline = timestampNow; const wireTransferDeadline = timestampNow; const merchantPub = args.merchantPub ?? encodeCrock(getRandomBytes(32)); const dp = await cryptoApi.signDepositPermission({ coinPriv: coin.coinPriv, coinPub: coin.coinPub, contractTermsHash, denomKeyType: coin.denomPub.cipher, denomPubHash: coin.denomPubHash, denomSig: coin.denomSig, exchangeBaseUrl: args.exchangeBaseUrl, feeDeposit: Amounts.parseOrThrow(coin.feeDeposit), merchantPub, spendAmount: Amounts.parseOrThrow(args.amount), timestamp: depositTimestamp, refundDeadline: refundDeadline, wireInfoHash: hashWire(depositPayto, wireSalt), }); const merchantContractSigResp = await cryptoApi.signContractTermsHash({ contractTermsHash, merchantPriv: merchantPub, }); const requestBody: ExchangeBatchDepositRequest = { coins: [ { contribution: Amounts.stringify(dp.contribution), coin_pub: dp.coin_pub, coin_sig: dp.coin_sig, denom_pub_hash: dp.h_denom, ub_sig: dp.ub_sig, }, ], merchant_sig: merchantContractSigResp.sig, merchant_payto_uri: depositPayto, wire_salt: wireSalt, h_contract_terms: contractTermsHash, timestamp: depositTimestamp, wire_transfer_deadline: wireTransferDeadline, refund_deadline: refundDeadline, merchant_pub: merchantPub, }; const url = new URL(`batch-deposit`, dp.exchange_url); const httpResp = await http.fetch(url.href, { method: "POST", body: requestBody, }); await readSuccessResponseJsonOrThrow(httpResp, codecForBatchDepositSuccess()); } export async function refreshCoin(req: { http: HttpRequestLibrary; cryptoApi: TalerCryptoInterface; oldCoin: CoinInfo; newDenoms: DenominationRecord[]; }): Promise { const { cryptoApi, oldCoin, http } = req; const refreshSessionSeed = encodeCrock(getRandomBytes(32)); const session = await cryptoApi.deriveRefreshSession({ exchangeProtocolVersion: ExchangeProtocolVersion.V12, feeRefresh: Amounts.parseOrThrow(oldCoin.feeRefresh), kappa: 3, meltCoinDenomPubHash: oldCoin.denomPubHash, meltCoinPriv: oldCoin.coinPriv, meltCoinPub: oldCoin.coinPub, sessionSecretSeed: refreshSessionSeed, newCoinDenoms: req.newDenoms.map((x) => ({ count: 1, denomPub: x.denomPub, denomPubHash: x.denomPubHash, feeWithdraw: x.fees.feeWithdraw, value: x.value, })), meltCoinMaxAge: oldCoin.maxAge, }); const meltReqBody: ExchangeMeltRequest = { coin_pub: oldCoin.coinPub, confirm_sig: session.confirmSig, denom_pub_hash: oldCoin.denomPubHash, denom_sig: oldCoin.denomSig, rc: session.hash, value_with_fee: Amounts.stringify(session.meltValueWithFee), }; logger.info("requesting melt"); const meltReqUrl = new URL( `coins/${oldCoin.coinPub}/melt`, oldCoin.exchangeBaseUrl, ); logger.info("requesting melt done"); const meltHttpResp = await http.fetch(meltReqUrl.href, { method: "POST", body: meltReqBody, }); const meltResponse = await readSuccessResponseJsonOrThrow( meltHttpResp, codecForExchangeMeltResponse(), ); const norevealIndex = meltResponse.noreveal_index; const revealRequest = await assembleRefreshRevealRequest({ cryptoApi, derived: session, newDenoms: req.newDenoms.map((x) => ({ count: 1, denomPubHash: x.denomPubHash, })), norevealIndex, oldCoinPriv: oldCoin.coinPriv, oldCoinPub: oldCoin.coinPub, }); logger.info("requesting reveal"); const reqUrl = new URL( `refreshes/${session.hash}/reveal`, oldCoin.exchangeBaseUrl, ); const revealResp = await http.fetch(reqUrl.href, { method: "POST", body: revealRequest, }); logger.info("requesting reveal done"); const reveal = await readSuccessResponseJsonOrThrow( revealResp, codecForExchangeRevealResponse(), ); // We could unblind here, but we only use this function to // benchmark the exchange. } /** * Create a reserve for testing withdrawals. * * The reserve is created using the test-only API "/admin/add-incoming". */ export async function createTestingReserve(args: { http: HttpRequestLibrary; corebankApiBaseUrl: string; amount: string; reservePub: string; exchangeInfo: ExchangeInfo; }): Promise { const { http, corebankApiBaseUrl, amount, reservePub } = args; const paytoUri = args.exchangeInfo.keys.accounts[0].payto_uri; const pt = parsePaytoUri(paytoUri); if (!pt) { throw Error("failed to parse payto URI"); } const components = pt.targetPath.split("/"); const creditorAcct = components[components.length - 1]; const fbReq = await http.fetch( new URL( `accounts/${creditorAcct}/taler-wire-gateway/admin/add-incoming`, corebankApiBaseUrl, ).href, { method: "POST", body: { amount, reserve_pub: reservePub, debit_account: "payto://x-taler-bank/localhost/testdebtor", }, }, ); await readSuccessResponseJsonOrThrow(fbReq, codecForAny()); } /** * Check the status of a reserve, use long-polling to wait * until the reserve actually has been created. */ export async function checkReserve( http: HttpRequestLibrary, exchangeBaseUrl: string, reservePub: string, longpollTimeoutMs: number = 500, ): Promise { const reqUrl = new URL(`reserves/${reservePub}`, exchangeBaseUrl); if (longpollTimeoutMs) { reqUrl.searchParams.set("timeout_ms", `${longpollTimeoutMs}`); } const resp = await http.fetch(reqUrl.href, { method: "GET", }); if (resp.status !== 200) { throw new Error("reserve not okay"); } }