summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/deposits.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/deposits.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts420
1 files changed, 420 insertions, 0 deletions
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 <http://www.gnu.org/licenses/>
+ */
+
+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<DepositSuccess> =>
+ buildCodecForObject<DepositSuccess>()
+ .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<void> {
+ 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<void> {
+ 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<void> {
+ await ws.memoProcessDeposit.memo(depositGroupId, async () => {
+ const onOpErr = (e: TalerErrorDetails): Promise<void> =>
+ 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<void> {
+ 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<TrackDepositGroupResponse> {
+ 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<CreateDepositGroupResponse> {
+ 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 };
+}