From 74433c3e05734aa1194049fcbcaa92c70ce61c74 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 12 Dec 2019 20:53:15 +0100 Subject: refactor: re-structure type definitions --- src/operations/tip.ts | 305 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/operations/tip.ts (limited to 'src/operations/tip.ts') diff --git a/src/operations/tip.ts b/src/operations/tip.ts new file mode 100644 index 000000000..0a710f67e --- /dev/null +++ b/src/operations/tip.ts @@ -0,0 +1,305 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + 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 { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { parseTipUri } from "../util/taleruri"; +import { TipStatus, getTimestampNow, OperationError } from "../types/walletTypes"; +import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../types/talerTypes"; +import * as Amounts from "../util/amounts"; +import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../types/dbTypes"; +import { getExchangeWithdrawalInfo, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw"; +import { getTalerStampSec, extractTalerStampOrThrow } from "../util/helpers"; +import { updateExchangeFromUrl } from "./exchanges"; +import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; +import { guardOperationException } from "./errors"; +import { NotificationType } from "../types/notifications"; + + +export async function getTipStatus( + ws: InternalWalletState, + talerTipUri: string): Promise { + const res = parseTipUri(talerTipUri); + if (!res) { + throw Error("invalid taler://tip URI"); + } + + const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl); + tipStatusUrl.searchParams.set("tip_id", res.merchantTipId); + console.log("checking tip status from", tipStatusUrl.href); + const merchantResp = await ws.http.get(tipStatusUrl.href); + if (merchantResp.status !== 200) { + throw Error(`unexpected status ${merchantResp.status} for tip-pickup`); + } + const respJson = await merchantResp.json(); + console.log("resp:", respJson); + const tipPickupStatus = TipPickupGetResponse.checked(respJson); + + console.log("status", tipPickupStatus); + + let amount = Amounts.parseOrThrow(tipPickupStatus.amount); + + let tipRecord = await oneShotGet(ws.db, Stores.tips, [ + res.merchantTipId, + res.merchantOrigin, + ]); + + if (!tipRecord) { + const withdrawDetails = await getExchangeWithdrawalInfo( + ws, + tipPickupStatus.exchange_url, + amount, + ); + + const tipId = encodeCrock(getRandomBytes(32)); + + tipRecord = { + tipId, + accepted: false, + amount, + deadline: extractTalerStampOrThrow(tipPickupStatus.stamp_expire), + exchangeUrl: tipPickupStatus.exchange_url, + merchantBaseUrl: res.merchantBaseUrl, + nextUrl: undefined, + pickedUp: false, + planchets: undefined, + response: undefined, + createdTimestamp: getTimestampNow(), + merchantTipId: res.merchantTipId, + totalFees: Amounts.add( + withdrawDetails.overhead, + withdrawDetails.withdrawFee, + ).amount, + retryInfo: initRetryInfo(), + lastError: undefined, + }; + await oneShotPut(ws.db, Stores.tips, tipRecord); + } + + const tipStatus: TipStatus = { + accepted: !!tipRecord && tipRecord.accepted, + amount: Amounts.parseOrThrow(tipPickupStatus.amount), + amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), + exchangeUrl: tipPickupStatus.exchange_url, + nextUrl: tipPickupStatus.extra.next_url, + merchantOrigin: res.merchantOrigin, + merchantTipId: res.merchantTipId, + expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, + timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, + totalFees: tipRecord.totalFees, + tipId: tipRecord.tipId, + }; + + return tipStatus; +} + +async function incrementTipRetry( + ws: InternalWalletState, + refreshSessionId: string, + err: OperationError | undefined, +): Promise { + await runWithWriteTransaction(ws.db, [Stores.tips], async tx => { + const t = await tx.get(Stores.tips, refreshSessionId); + if (!t) { + return; + } + if (!t.retryInfo) { + return; + } + t.retryInfo.retryCounter++; + updateRetryInfoTimeout(t.retryInfo); + t.lastError = err; + await tx.put(Stores.tips, t); + }); + ws.notify({ type: NotificationType.TipOperationError }); +} + +export async function processTip( + ws: InternalWalletState, + tipId: string, + forceNow: boolean = false, +): Promise { + const onOpErr = (e: OperationError) => incrementTipRetry(ws, tipId, e); + await guardOperationException(() => processTipImpl(ws, tipId, forceNow), onOpErr); +} + +async function resetTipRetry( + ws: InternalWalletState, + tipId: string, +): Promise { + await oneShotMutate(ws.db, Stores.tips, tipId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }) +} + +async function processTipImpl( + ws: InternalWalletState, + tipId: string, + forceNow: boolean, +) { + if (forceNow) { + await resetTipRetry(ws, tipId); + } + let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + return; + } + + if (tipRecord.pickedUp) { + console.log("tip already picked up"); + return; + } + + if (!tipRecord.planchets) { + await updateExchangeFromUrl(ws, tipRecord.exchangeUrl); + const denomsForWithdraw = await getVerifiedWithdrawDenomList( + ws, + tipRecord.exchangeUrl, + tipRecord.amount, + ); + + const planchets = await Promise.all( + denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)), + ); + + await oneShotMutate(ws.db, Stores.tips, tipId, r => { + if (!r.planchets) { + r.planchets = planchets; + } + return r; + }); + } + + tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + throw Error("tip not in database"); + } + + if (!tipRecord.planchets) { + throw Error("invariant violated"); + } + + console.log("got planchets for tip!"); + + // Planchets in the form that the merchant expects + const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ + coin_ev: p.coinEv, + denom_pub_hash: p.denomPubHash, + })); + + let merchantResp; + + const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl); + + try { + const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId }; + merchantResp = await ws.http.postJson(tipStatusUrl.href, req); + if (merchantResp.status !== 200) { + throw Error(`unexpected status ${merchantResp.status} for tip-pickup`); + } + console.log("got merchant resp:", merchantResp); + } catch (e) { + console.log("tipping failed", e); + throw e; + } + + const response = TipResponse.checked(await merchantResp.json()); + + if (response.reserve_sigs.length !== tipRecord.planchets.length) { + throw Error("number of tip responses does not match requested planchets"); + } + + const planchets: PlanchetRecord[] = []; + + for (let i = 0; i < tipRecord.planchets.length; i++) { + const tipPlanchet = tipRecord.planchets[i]; + const planchet: PlanchetRecord = { + blindingKey: tipPlanchet.blindingKey, + coinEv: tipPlanchet.coinEv, + coinPriv: tipPlanchet.coinPriv, + coinPub: tipPlanchet.coinPub, + coinValue: tipPlanchet.coinValue, + denomPub: tipPlanchet.denomPub, + denomPubHash: tipPlanchet.denomPubHash, + reservePub: response.reserve_pub, + withdrawSig: response.reserve_sigs[i].reserve_sig, + isFromTip: true, + }; + planchets.push(planchet); + } + + const withdrawalSessionId = encodeCrock(getRandomBytes(32)); + + const withdrawalSession: WithdrawalSessionRecord = { + denoms: planchets.map((x) => x.denomPub), + exchangeBaseUrl: tipRecord.exchangeUrl, + planchets: planchets, + source: { + type: "tip", + tipId: tipRecord.tipId, + }, + startTimestamp: getTimestampNow(), + withdrawSessionId: withdrawalSessionId, + rawWithdrawalAmount: tipRecord.amount, + withdrawn: planchets.map((x) => false), + totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount, + lastCoinErrors: planchets.map((x) => undefined), + retryInfo: initRetryInfo(), + finishTimestamp: undefined, + lastError: undefined, + }; + + + await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], async (tx) => { + const tr = await tx.get(Stores.tips, tipId); + if (!tr) { + return; + } + if (tr.pickedUp) { + return; + } + tr.pickedUp = true; + tr.retryInfo = initRetryInfo(false); + + await tx.put(Stores.tips, tr); + await tx.put(Stores.withdrawalSession, withdrawalSession); + }); + + await processWithdrawSession(ws, withdrawalSessionId); + + return; +} + +export async function acceptTip( + ws: InternalWalletState, + tipId: string, +): Promise { + const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); + if (!tipRecord) { + console.log("tip not found"); + return; + } + + tipRecord.accepted = true; + await oneShotPut(ws.db, Stores.tips, tipRecord); + + await processTip(ws, tipId); + return; +} -- cgit v1.2.3