diff options
Diffstat (limited to 'packages/taler-wallet-core/src/dbless.ts')
-rw-r--r-- | packages/taler-wallet-core/src/dbless.ts | 419 |
1 files changed, 419 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts new file mode 100644 index 000000000..dfefe6ef5 --- /dev/null +++ b/packages/taler-wallet-core/src/dbless.ts @@ -0,0 +1,419 @@ +/* + 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/> + */ + +/** + * 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 { ExchangeInfo, downloadExchangeInfo } from "./exchanges.js"; +import { assembleRefreshRevealRequest } from "./refresh.js"; +import { isWithdrawableDenom } from "./denominations.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; +} + +/** + * 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<void> { + 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"); + } +} + +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.suggestedExchange) { + 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<CoinInfo> { + 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; + contractTermsHash?: string; + // 16 bytes, crockford encoded + wireSalt?: string; +}): Promise<void> { + 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 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_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<void> { + 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<void> { + 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()); +} |