From ffd2a62c3f7df94365980302fef3bc3376b48182 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 3 Aug 2020 13:00:48 +0530 Subject: modularize repo, use pnpm, improve typechecking --- packages/taler-wallet-core/src/operations/tip.ts | 343 +++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/tip.ts (limited to 'packages/taler-wallet-core/src/operations/tip.ts') diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts new file mode 100644 index 000000000..d6768bdb6 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -0,0 +1,343 @@ +/* + This file is part of GNU Taler + (C) 2019 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 { InternalWalletState } from "./state"; +import { parseTipUri } from "../util/taleruri"; +import { TipStatus, OperationErrorDetails } from "../types/walletTypes"; +import { + TipPlanchetDetail, + codecForTipPickupGetResponse, + codecForTipResponse, +} from "../types/talerTypes"; +import * as Amounts from "../util/amounts"; +import { + Stores, + PlanchetRecord, + WithdrawalGroupRecord, + initRetryInfo, + updateRetryInfoTimeout, + WithdrawalSourceType, + TipPlanchet, +} from "../types/dbTypes"; +import { + getExchangeWithdrawalInfo, + selectWithdrawalDenoms, + processWithdrawGroup, + denomSelectionInfoToState, +} from "./withdraw"; +import { updateExchangeFromUrl } from "./exchanges"; +import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; +import { guardOperationException } from "./errors"; +import { NotificationType } from "../types/notifications"; +import { getTimestampNow } from "../util/time"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { URL } from "../util/url"; + +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); + const tipPickupStatus = await readSuccessResponseJsonOrThrow( + merchantResp, + codecForTipPickupGetResponse(), + ); + console.log("status", tipPickupStatus); + + const amount = Amounts.parseOrThrow(tipPickupStatus.amount); + + const merchantOrigin = new URL(res.merchantBaseUrl).origin; + + let tipRecord = await ws.db.get(Stores.tips, [ + res.merchantTipId, + merchantOrigin, + ]); + + if (!tipRecord) { + await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url); + const withdrawDetails = await getExchangeWithdrawalInfo( + ws, + tipPickupStatus.exchange_url, + amount, + ); + + const tipId = encodeCrock(getRandomBytes(32)); + const selectedDenoms = await selectWithdrawalDenoms( + ws, + tipPickupStatus.exchange_url, + amount, + ); + + tipRecord = { + tipId, + acceptedTimestamp: undefined, + rejectedTimestamp: undefined, + amount, + deadline: 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, + denomsSel: denomSelectionInfoToState(selectedDenoms), + }; + await ws.db.put(Stores.tips, tipRecord); + } + + const tipStatus: TipStatus = { + accepted: !!tipRecord && !!tipRecord.acceptedTimestamp, + amount: Amounts.parseOrThrow(tipPickupStatus.amount), + amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), + exchangeUrl: tipPickupStatus.exchange_url, + nextUrl: tipPickupStatus.extra.next_url, + merchantOrigin: merchantOrigin, + merchantTipId: res.merchantTipId, + expirationTimestamp: tipPickupStatus.stamp_expire, + timestamp: tipPickupStatus.stamp_created, + totalFees: tipRecord.totalFees, + tipId: tipRecord.tipId, + }; + + return tipStatus; +} + +async function incrementTipRetry( + ws: InternalWalletState, + refreshSessionId: string, + err: OperationErrorDetails | undefined, +): Promise { + await ws.db.runWithWriteTransaction([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 = false, +): Promise { + const onOpErr = (e: OperationErrorDetails): Promise => + incrementTipRetry(ws, tipId, e); + await guardOperationException( + () => processTipImpl(ws, tipId, forceNow), + onOpErr, + ); +} + +async function resetTipRetry( + ws: InternalWalletState, + tipId: string, +): Promise { + await ws.db.mutate(Stores.tips, tipId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function processTipImpl( + ws: InternalWalletState, + tipId: string, + forceNow: boolean, +): Promise { + if (forceNow) { + await resetTipRetry(ws, tipId); + } + let tipRecord = await ws.db.get(Stores.tips, tipId); + if (!tipRecord) { + return; + } + + if (tipRecord.pickedUp) { + console.log("tip already picked up"); + return; + } + + const denomsForWithdraw = tipRecord.denomsSel; + + if (!tipRecord.planchets) { + const planchets: TipPlanchet[] = []; + + for (const sd of denomsForWithdraw.selectedDenoms) { + const denom = await ws.db.getIndexed( + Stores.denominations.denomPubHashIndex, + sd.denomPubHash, + ); + if (!denom) { + throw Error("denom does not exist anymore"); + } + for (let i = 0; i < sd.count; i++) { + const r = await ws.cryptoApi.createTipPlanchet(denom); + planchets.push(r); + } + } + await ws.db.mutate(Stores.tips, tipId, (r) => { + if (!r.planchets) { + r.planchets = planchets; + } + return r; + }); + } + + tipRecord = await ws.db.get(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 = codecForTipResponse().decode(await merchantResp.json()); + + if (response.reserve_sigs.length !== tipRecord.planchets.length) { + throw Error("number of tip responses does not match requested planchets"); + } + + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); + const planchets: PlanchetRecord[] = []; + + for (let i = 0; i < tipRecord.planchets.length; i++) { + const tipPlanchet = tipRecord.planchets[i]; + const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv); + 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, + coinEvHash, + coinIdx: i, + withdrawalDone: false, + withdrawalGroupId: withdrawalGroupId, + }; + planchets.push(planchet); + } + + const withdrawalGroup: WithdrawalGroupRecord = { + exchangeBaseUrl: tipRecord.exchangeUrl, + source: { + type: WithdrawalSourceType.Tip, + tipId: tipRecord.tipId, + }, + timestampStart: getTimestampNow(), + withdrawalGroupId: withdrawalGroupId, + rawWithdrawalAmount: tipRecord.amount, + lastErrorPerCoin: {}, + retryInfo: initRetryInfo(), + timestampFinish: undefined, + lastError: undefined, + denomsSel: tipRecord.denomsSel, + }; + + await ws.db.runWithWriteTransaction( + [Stores.tips, Stores.withdrawalGroups], + 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.withdrawalGroups, withdrawalGroup); + for (const p of planchets) { + await tx.put(Stores.planchets, p); + } + }, + ); + + await processWithdrawGroup(ws, withdrawalGroupId); +} + +export async function acceptTip( + ws: InternalWalletState, + tipId: string, +): Promise { + const tipRecord = await ws.db.get(Stores.tips, tipId); + if (!tipRecord) { + console.log("tip not found"); + return; + } + + tipRecord.acceptedTimestamp = getTimestampNow(); + await ws.db.put(Stores.tips, tipRecord); + + await processTip(ws, tipId); + return; +} -- cgit v1.2.3