From 5f3c02d31a223add55a32b20f4a289210cbb4f15 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 18 Jan 2021 23:35:41 +0100 Subject: implement deposits --- .../taler-wallet-core/src/operations/deposits.ts | 420 +++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/deposits.ts (limited to 'packages/taler-wallet-core/src/operations/deposits.ts') diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts new file mode 100644 index 000000000..50921a170 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -0,0 +1,420 @@ +/* + 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 { + Amounts, + CreateDepositGroupRequest, + guardOperationException, + Logger, + NotificationType, + TalerErrorDetails, +} from ".."; +import { kdf } from "../crypto/primitives/kdf"; +import { + encodeCrock, + getRandomBytes, + stringToBytes, +} from "../crypto/talerCrypto"; +import { DepositGroupRecord, Stores } from "../types/dbTypes"; +import { ContractTerms } from "../types/talerTypes"; +import { CreateDepositGroupResponse, TrackDepositGroupRequest, TrackDepositGroupResponse } from "../types/walletTypes"; +import { + buildCodecForObject, + Codec, + codecForString, + codecOptional, +} from "../util/codec"; +import { canonicalJson } from "../util/helpers"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { parsePaytoUri } from "../util/payto"; +import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries"; +import { + codecForTimestamp, + durationFromSpec, + getTimestampNow, + Timestamp, + timestampAddDuration, + timestampTruncateToSecond, +} from "../util/time"; +import { URL } from "../util/url"; +import { + applyCoinSpend, + extractContractData, + generateDepositPermissions, + getCoinsForPayment, + getEffectiveDepositAmount, + getTotalPaymentCost, +} from "./pay"; +import { InternalWalletState } from "./state"; + +/** + * 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.mutate(Stores.depositGroups, depositGroupId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function incrementDepositRetry( + ws: InternalWalletState, + depositGroupId: string, + err: TalerErrorDetails | undefined, +): Promise { + await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const r = await tx.get(Stores.depositGroups, depositGroupId); + if (!r) { + return; + } + if (!r.retryInfo) { + return; + } + r.retryInfo.retryCounter++; + updateRetryInfoTimeout(r.retryInfo); + r.lastError = err; + await tx.put(Stores.depositGroups, 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.get(Stores.depositGroups, 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.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const dg = await tx.get(Stores.depositGroups, depositGroupId); + if (!dg) { + return; + } + dg.depositedPerCoin[i] = true; + await tx.put(Stores.depositGroups, dg); + }); + } + + await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const dg = await tx.get(Stores.depositGroups, depositGroupId); + if (!dg) { + return; + } + let allDeposited = true; + for (const d of depositGroup.depositedPerCoin) { + if (!d) { + allDeposited = false; + } + } + if (allDeposited) { + dg.timestampFinished = getTimestampNow(); + await tx.put(Stores.depositGroups, dg); + } + }); +} + + +export async function trackDepositGroup( + ws: InternalWalletState, + req: TrackDepositGroupRequest, +): Promise { + const responses: { + status: number; + body: any; + }[] = []; + const depositGroup = await ws.db.get( + Stores.depositGroups, + 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 allExchanges = await ws.db.iter(Stores.exchanges).toArray(); + const exchangeInfos: { url: string; master_pub: string }[] = []; + for (const e of allExchanges) { + if (!e.details) { + continue; + } + if (e.details.currency != amount.currency) { + continue; + } + exchangeInfos.push({ + master_pub: e.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 payCoinSel = await getCoinsForPayment(ws, contractData); + + 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.runWithWriteTransaction( + [ + Stores.depositGroups, + Stores.coins, + Stores.refreshGroups, + Stores.denominations, + ], + async (tx) => { + await applyCoinSpend(ws, tx, payCoinSel); + await tx.put(Stores.depositGroups, depositGroup); + }, + ); + + await ws.db.put(Stores.depositGroups, depositGroup); + + return { depositGroupId }; +} -- cgit v1.2.3