/*
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
*/
import { kdf } from "../crypto/primitives/kdf";
import {
encodeCrock,
getRandomBytes,
stringToBytes,
} from "../crypto/talerCrypto";
import { selectPayCoins } from "../util/coinSelection";
import { canonicalJson } from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries";
import {
Amounts,
buildCodecForObject,
Codec,
codecForString,
codecForTimestamp,
codecOptional,
ContractTerms,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
durationFromSpec,
getTimestampNow,
NotificationType,
parsePaytoUri,
TalerErrorDetails,
Timestamp,
timestampAddDuration,
timestampTruncateToSecond,
TrackDepositGroupRequest,
TrackDepositGroupResponse,
} from "@gnu-taler/taler-util";
import { URL } from "../util/url";
import {
applyCoinSpend,
extractContractData,
generateDepositPermissions,
getCandidatePayCoins,
getEffectiveDepositAmount,
getTotalPaymentCost,
} from "./pay";
import { InternalWalletState } from "./state";
import { Logger } from "@gnu-taler/taler-util";
import { DepositGroupRecord } from "../db.js";
import { guardOperationException } from "./errors.js";
import { getExchangeDetails } from "./exchanges.js";
/**
* Logger.
*/
const logger = new Logger("deposits.ts");
interface DepositSuccess {
// Optional base URL of the exchange for looking up wire transfers
// associated with this transaction. If not given,
// the base URL is the same as the one used for this request.
// Can be used if the base URL for /transactions/ differs from that
// for /coins/, i.e. for load balancing. Clients SHOULD
// respect the transaction_base_url if provided. Any HTTP server
// belonging to an exchange MUST generate a 307 or 308 redirection
// to the correct base URL should a client uses the wrong base
// URL, or if the base URL has changed since the deposit.
transaction_base_url?: string;
// timestamp when the deposit was received by the exchange.
exchange_timestamp: Timestamp;
// the EdDSA signature of TALER_DepositConfirmationPS using a current
// signing key of the exchange affirming the successful
// deposit and that the exchange will transfer the funds after the refund
// deadline, or as soon as possible if the refund deadline is zero.
exchange_sig: string;
// public EdDSA key of the exchange that was used to
// generate the signature.
// Should match one of the exchange's signing keys from /keys. It is given
// explicitly as the client might otherwise be confused by clock skew as to
// which signing key was used.
exchange_pub: string;
}
const codecForDepositSuccess = (): Codec =>
buildCodecForObject()
.property("exchange_pub", codecForString())
.property("exchange_sig", codecForString())
.property("exchange_timestamp", codecForTimestamp)
.property("transaction_base_url", codecOptional(codecForString()))
.build("DepositSuccess");
function hashWire(paytoUri: string, salt: string): string {
const r = kdf(
64,
stringToBytes(paytoUri + "\0"),
stringToBytes(salt + "\0"),
stringToBytes("merchant-wire-signature"),
);
return encodeCrock(r);
}
async function resetDepositGroupRetry(
ws: InternalWalletState,
depositGroupId: string,
): Promise {
await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
}))
.runReadWrite(async (tx) => {
const x = await tx.depositGroups.get(depositGroupId);
if (x && x.retryInfo.active) {
x.retryInfo = initRetryInfo();
await tx.depositGroups.put(x);
}
});
}
async function incrementDepositRetry(
ws: InternalWalletState,
depositGroupId: string,
err: TalerErrorDetails | undefined,
): Promise {
await ws.db
.mktx((x) => ({ depositGroups: x.depositGroups }))
.runReadWrite(async (tx) => {
const r = await tx.depositGroups.get(depositGroupId);
if (!r) {
return;
}
if (!r.retryInfo) {
return;
}
r.retryInfo.retryCounter++;
updateRetryInfoTimeout(r.retryInfo);
r.lastError = err;
await tx.depositGroups.put(r);
});
if (err) {
ws.notify({ type: NotificationType.DepositOperationError, error: err });
}
}
export async function processDepositGroup(
ws: InternalWalletState,
depositGroupId: string,
forceNow = false,
): Promise {
await ws.memoProcessDeposit.memo(depositGroupId, async () => {
const onOpErr = (e: TalerErrorDetails): Promise =>
incrementDepositRetry(ws, depositGroupId, e);
return await guardOperationException(
async () => await processDepositGroupImpl(ws, depositGroupId, forceNow),
onOpErr,
);
});
}
async function processDepositGroupImpl(
ws: InternalWalletState,
depositGroupId: string,
forceNow: boolean = false,
): Promise {
if (forceNow) {
await resetDepositGroupRetry(ws, depositGroupId);
}
const depositGroup = await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
}))
.runReadOnly(async (tx) => {
return tx.depositGroups.get(depositGroupId);
});
if (!depositGroup) {
logger.warn(`deposit group ${depositGroupId} not found`);
return;
}
if (depositGroup.timestampFinished) {
logger.trace(`deposit group ${depositGroupId} already finished`);
return;
}
const contractData = extractContractData(
depositGroup.contractTermsRaw,
depositGroup.contractTermsHash,
"",
);
const depositPermissions = await generateDepositPermissions(
ws,
depositGroup.payCoinSelection,
contractData,
);
for (let i = 0; i < depositPermissions.length; i++) {
if (depositGroup.depositedPerCoin[i]) {
continue;
}
const perm = depositPermissions[i];
const url = new URL(`/coins/${perm.coin_pub}/deposit`, perm.exchange_url);
const httpResp = await ws.http.postJson(url.href, {
contribution: Amounts.stringify(perm.contribution),
wire: depositGroup.wire,
h_wire: depositGroup.contractTermsRaw.h_wire,
h_contract_terms: depositGroup.contractTermsHash,
ub_sig: perm.ub_sig,
timestamp: depositGroup.contractTermsRaw.timestamp,
wire_transfer_deadline:
depositGroup.contractTermsRaw.wire_transfer_deadline,
refund_deadline: depositGroup.contractTermsRaw.refund_deadline,
coin_sig: perm.coin_sig,
denom_pub_hash: perm.h_denom,
merchant_pub: depositGroup.merchantPub,
});
await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess());
await ws.db
.mktx((x) => ({ depositGroups: x.depositGroups }))
.runReadWrite(async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
dg.depositedPerCoin[i] = true;
await tx.depositGroups.put(dg);
});
}
await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
}))
.runReadWrite(async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
let allDeposited = true;
for (const d of depositGroup.depositedPerCoin) {
if (!d) {
allDeposited = false;
}
}
if (allDeposited) {
dg.timestampFinished = getTimestampNow();
await tx.depositGroups.put(dg);
}
});
}
export async function trackDepositGroup(
ws: InternalWalletState,
req: TrackDepositGroupRequest,
): Promise {
const responses: {
status: number;
body: any;
}[] = [];
const depositGroup = await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
}))
.runReadOnly(async (tx) => {
return tx.depositGroups.get(req.depositGroupId);
});
if (!depositGroup) {
throw Error("deposit group not found");
}
const contractData = extractContractData(
depositGroup.contractTermsRaw,
depositGroup.contractTermsHash,
"",
);
const depositPermissions = await generateDepositPermissions(
ws,
depositGroup.payCoinSelection,
contractData,
);
const wireHash = depositGroup.contractTermsRaw.h_wire;
for (const dp of depositPermissions) {
const url = new URL(
`/deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`,
dp.exchange_url,
);
const sig = await ws.cryptoApi.signTrackTransaction({
coinPub: dp.coin_pub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
merchantPub: depositGroup.merchantPub,
wireHash,
});
url.searchParams.set("merchant_sig", sig);
const httpResp = await ws.http.get(url.href);
const body = await httpResp.json();
responses.push({
body,
status: httpResp.status,
});
}
return {
responses,
};
}
export async function createDepositGroup(
ws: InternalWalletState,
req: CreateDepositGroupRequest,
): Promise {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
const exchangeInfos: { url: string; master_pub: string }[] = [];
await ws.db
.mktx((x) => ({
exchanges: x.exchanges,
exchangeDetails: x.exchangeDetails,
}))
.runReadOnly(async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
const details = await getExchangeDetails(tx, e.baseUrl);
if (!details) {
continue;
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
url: e.baseUrl,
});
}
});
const timestamp = getTimestampNow();
const timestampRound = timestampTruncateToSecond(timestamp);
const noncePair = await ws.cryptoApi.createEddsaKeypair();
const merchantPair = await ws.cryptoApi.createEddsaKeypair();
const wireSalt = encodeCrock(getRandomBytes(64));
const wireHash = hashWire(req.depositPaytoUri, wireSalt);
const contractTerms: ContractTerms = {
auditors: [],
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
max_wire_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: timestampRound,
merchant_base_url: "",
summary: "",
nonce: noncePair.pub,
wire_transfer_deadline: timestampRound,
order_id: "",
h_wire: wireHash,
pay_deadline: timestampAddDuration(
timestampRound,
durationFromSpec({ hours: 1 }),
),
merchant: {
name: "",
},
merchant_pub: merchantPair.pub,
refund_deadline: { t_ms: 0 },
};
const contractTermsHash = await ws.cryptoApi.hashString(
canonicalJson(contractTerms),
);
const contractData = extractContractData(
contractTerms,
contractTermsHash,
"",
);
const candidates = await getCandidatePayCoins(ws, {
allowedAuditors: contractData.allowedAuditors,
allowedExchanges: contractData.allowedExchanges,
amount: contractData.amount,
maxDepositFee: contractData.maxDepositFee,
maxWireFee: contractData.maxWireFee,
timestamp: contractData.timestamp,
wireFeeAmortization: contractData.wireFeeAmortization,
wireMethod: contractData.wireMethod,
});
const payCoinSel = selectPayCoins({
candidates,
contractTermsAmount: contractData.amount,
depositFeeLimit: contractData.maxDepositFee,
wireFeeAmortization: contractData.wireFeeAmortization ?? 1,
wireFeeLimit: contractData.maxWireFee,
prevPayCoins: [],
});
if (!payCoinSel) {
throw Error("insufficient funds");
}
const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel);
const depositGroupId = encodeCrock(getRandomBytes(32));
const effectiveDepositAmount = await getEffectiveDepositAmount(
ws,
p.targetType,
payCoinSel,
);
const depositGroup: DepositGroupRecord = {
contractTermsHash,
contractTermsRaw: contractTerms,
depositGroupId,
noncePriv: noncePair.priv,
noncePub: noncePair.pub,
timestampCreated: timestamp,
timestampFinished: undefined,
payCoinSelection: payCoinSel,
depositedPerCoin: payCoinSel.coinPubs.map((x) => false),
merchantPriv: merchantPair.priv,
merchantPub: merchantPair.pub,
totalPayCost: totalDepositCost,
effectiveDepositAmount,
wire: {
payto_uri: req.depositPaytoUri,
salt: wireSalt,
},
retryInfo: initRetryInfo(true),
lastError: undefined,
};
await ws.db
.mktx((x) => ({
depositGroups: x.depositGroups,
coins: x.coins,
refreshGroups: x.refreshGroups,
denominations: x.denominations,
}))
.runReadWrite(async (tx) => {
await applyCoinSpend(ws, tx, payCoinSel);
await tx.depositGroups.put(depositGroup);
});
return { depositGroupId };
}