/*
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");
}
}