/* 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 */ /** * Imports. */ import { AcceptTipResponse, AgeRestriction, Amounts, BlindedDenominationSignature, codecForMerchantTipResponseV2, codecForRewardPickupGetResponse, CoinStatus, DenomKeyType, encodeCrock, getRandomBytes, j2s, Logger, NotificationType, parseRewardUri, PrepareTipResult, TalerErrorCode, TalerPreciseTimestamp, TipPlanchetDetail, TransactionAction, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionState, TransactionType, URL, } from "@gnu-taler/taler-util"; import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js"; import { CoinRecord, CoinSourceType, DenominationRecord, RewardRecord, RewardRecordStatus, timestampPreciseFromDb, timestampPreciseToDb, timestampProtocolFromDb, timestampProtocolToDb, } from "../db.js"; import { makeErrorDetail } from "@gnu-taler/taler-util"; import { InternalWalletState } from "../internal-wallet-state.js"; import { getHttpResponseErrorDetails, readSuccessResponseJsonOrThrow, } from "@gnu-taler/taler-util/http"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; import { constructTaskIdentifier, makeCoinAvailable, makeCoinsVisible, TaskRunResult, TaskRunResultType, TombstoneTag, TransactionContext, } from "./common.js"; import { fetchFreshExchange } from "./exchanges.js"; import { getCandidateWithdrawalDenoms, getExchangeWithdrawalInfo, updateWithdrawalDenoms, } from "./withdraw.js"; import { selectWithdrawalDenominations } from "../util/coinSelection.js"; import { constructTransactionIdentifier, notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; import { PendingTaskType } from "../pending-types.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; const logger = new Logger("operations/tip.ts"); export class RewardTransactionContext implements TransactionContext { public transactionId: string; public retryTag: string; constructor( public ws: InternalWalletState, public walletRewardId: string, ) { this.transactionId = constructTransactionIdentifier({ tag: TransactionType.Reward, walletRewardId, }); this.retryTag = constructTaskIdentifier({ tag: PendingTaskType.RewardPickup, walletRewardId, }); } async deleteTransaction(): Promise { const { ws, walletRewardId } = this; await ws.db .mktx((x) => [x.rewards, x.tombstones]) .runReadWrite(async (tx) => { const tipRecord = await tx.rewards.get(walletRewardId); if (tipRecord) { await tx.rewards.delete(walletRewardId); await tx.tombstones.put({ id: TombstoneTag.DeleteReward + ":" + walletRewardId, }); } }); } async suspendTransaction(): Promise { const { ws, walletRewardId, transactionId, retryTag } = this; const transitionInfo = await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { const tipRec = await tx.rewards.get(walletRewardId); if (!tipRec) { logger.warn(`transaction tip ${walletRewardId} not found`); return; } let newStatus: RewardRecordStatus | undefined = undefined; switch (tipRec.status) { case RewardRecordStatus.Done: case RewardRecordStatus.SuspendedPickup: case RewardRecordStatus.Aborted: case RewardRecordStatus.DialogAccept: case RewardRecordStatus.Failed: break; case RewardRecordStatus.PendingPickup: newStatus = RewardRecordStatus.SuspendedPickup; break; default: assertUnreachable(tipRec.status); } if (newStatus != null) { const oldTxState = computeRewardTransactionStatus(tipRec); tipRec.status = newStatus; const newTxState = computeRewardTransactionStatus(tipRec); await tx.rewards.put(tipRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } async abortTransaction(): Promise { const { ws, walletRewardId, transactionId, retryTag } = this; const transitionInfo = await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { const tipRec = await tx.rewards.get(walletRewardId); if (!tipRec) { logger.warn(`transaction tip ${walletRewardId} not found`); return; } let newStatus: RewardRecordStatus | undefined = undefined; switch (tipRec.status) { case RewardRecordStatus.Done: case RewardRecordStatus.Aborted: case RewardRecordStatus.PendingPickup: case RewardRecordStatus.DialogAccept: case RewardRecordStatus.Failed: break; case RewardRecordStatus.SuspendedPickup: newStatus = RewardRecordStatus.Aborted; break; default: assertUnreachable(tipRec.status); } if (newStatus != null) { const oldTxState = computeRewardTransactionStatus(tipRec); tipRec.status = newStatus; const newTxState = computeRewardTransactionStatus(tipRec); await tx.rewards.put(tipRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } async resumeTransaction(): Promise { const { ws, walletRewardId, transactionId, retryTag } = this; const transitionInfo = await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { const rewardRec = await tx.rewards.get(walletRewardId); if (!rewardRec) { logger.warn(`transaction reward ${walletRewardId} not found`); return; } let newStatus: RewardRecordStatus | undefined = undefined; switch (rewardRec.status) { case RewardRecordStatus.Done: case RewardRecordStatus.PendingPickup: case RewardRecordStatus.Aborted: case RewardRecordStatus.DialogAccept: case RewardRecordStatus.Failed: break; case RewardRecordStatus.SuspendedPickup: newStatus = RewardRecordStatus.PendingPickup; break; default: assertUnreachable(rewardRec.status); } if (newStatus != null) { const oldTxState = computeRewardTransactionStatus(rewardRec); rewardRec.status = newStatus; const newTxState = computeRewardTransactionStatus(rewardRec); await tx.rewards.put(rewardRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } async failTransaction(): Promise { const { ws, walletRewardId, transactionId, retryTag } = this; const transitionInfo = await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { const tipRec = await tx.rewards.get(walletRewardId); if (!tipRec) { logger.warn(`transaction tip ${walletRewardId} not found`); return; } let newStatus: RewardRecordStatus | undefined = undefined; switch (tipRec.status) { case RewardRecordStatus.Done: case RewardRecordStatus.Aborted: case RewardRecordStatus.Failed: break; case RewardRecordStatus.PendingPickup: case RewardRecordStatus.DialogAccept: case RewardRecordStatus.SuspendedPickup: newStatus = RewardRecordStatus.Failed; break; default: assertUnreachable(tipRec.status); } if (newStatus != null) { const oldTxState = computeRewardTransactionStatus(tipRec); tipRec.status = newStatus; const newTxState = computeRewardTransactionStatus(tipRec); await tx.rewards.put(tipRec); return { oldTxState, newTxState, }; } return undefined; }); notifyTransition(ws, transactionId, transitionInfo); } } /** * Get the (DD37-style) transaction status based on the * database record of a reward. */ export function computeRewardTransactionStatus( tipRecord: RewardRecord, ): TransactionState { switch (tipRecord.status) { case RewardRecordStatus.Done: return { major: TransactionMajorState.Done, }; case RewardRecordStatus.Aborted: return { major: TransactionMajorState.Aborted, }; case RewardRecordStatus.PendingPickup: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Pickup, }; case RewardRecordStatus.DialogAccept: return { major: TransactionMajorState.Dialog, minor: TransactionMinorState.Proposed, }; case RewardRecordStatus.SuspendedPickup: return { major: TransactionMajorState.Pending, minor: TransactionMinorState.Pickup, }; case RewardRecordStatus.Failed: return { major: TransactionMajorState.Failed, }; default: assertUnreachable(tipRecord.status); } } export function computeTipTransactionActions( tipRecord: RewardRecord, ): TransactionAction[] { switch (tipRecord.status) { case RewardRecordStatus.Done: return [TransactionAction.Delete]; case RewardRecordStatus.Failed: return [TransactionAction.Delete]; case RewardRecordStatus.Aborted: return [TransactionAction.Delete]; case RewardRecordStatus.PendingPickup: return [TransactionAction.Suspend, TransactionAction.Fail]; case RewardRecordStatus.SuspendedPickup: return [TransactionAction.Resume, TransactionAction.Fail]; case RewardRecordStatus.DialogAccept: return [TransactionAction.Abort]; default: assertUnreachable(tipRecord.status); } } export async function prepareReward( ws: InternalWalletState, talerTipUri: string, ): Promise { const res = parseRewardUri(talerTipUri); if (!res) { throw Error("invalid taler://tip URI"); } let tipRecord = await ws.db .mktx((x) => [x.rewards]) .runReadOnly(async (tx) => { return tx.rewards.indexes.byMerchantTipIdAndBaseUrl.get([ res.merchantRewardId, res.merchantBaseUrl, ]); }); if (!tipRecord) { const tipStatusUrl = new URL( `rewards/${res.merchantRewardId}`, res.merchantBaseUrl, ); logger.trace("checking tip status from", tipStatusUrl.href); const merchantResp = await ws.http.fetch(tipStatusUrl.href); const rewardPickupStatus = await readSuccessResponseJsonOrThrow( merchantResp, codecForRewardPickupGetResponse(), ); logger.trace(`status ${j2s(rewardPickupStatus)}`); const amount = Amounts.parseOrThrow(rewardPickupStatus.reward_amount); const currency = amount.currency; logger.trace("new tip, creating tip record"); await fetchFreshExchange(ws, rewardPickupStatus.exchange_url); //FIXME: is this needed? withdrawDetails is not used // * if the intention is to update the exchange information in the database // maybe we can use another name. `get` seems like a pure-function const withdrawDetails = await getExchangeWithdrawalInfo( ws, rewardPickupStatus.exchange_url, amount, undefined, ); const walletRewardId = encodeCrock(getRandomBytes(32)); await updateWithdrawalDenoms(ws, rewardPickupStatus.exchange_url); const denoms = await getCandidateWithdrawalDenoms( ws, rewardPickupStatus.exchange_url, currency, ); const selectedDenoms = selectWithdrawalDenominations(amount, denoms); const secretSeed = encodeCrock(getRandomBytes(64)); const denomSelUid = encodeCrock(getRandomBytes(32)); const newTipRecord: RewardRecord = { walletRewardId: walletRewardId, acceptedTimestamp: undefined, status: RewardRecordStatus.DialogAccept, rewardAmountRaw: Amounts.stringify(amount), rewardExpiration: timestampProtocolToDb(rewardPickupStatus.expiration), exchangeBaseUrl: rewardPickupStatus.exchange_url, next_url: rewardPickupStatus.next_url, merchantBaseUrl: res.merchantBaseUrl, createdTimestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()), merchantRewardId: res.merchantRewardId, rewardAmountEffective: Amounts.stringify(selectedDenoms.totalCoinValue), denomsSel: selectedDenoms, pickedUpTimestamp: undefined, secretSeed, denomSelUid, }; await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { await tx.rewards.put(newTipRecord); }); tipRecord = newTipRecord; } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Reward, walletRewardId: tipRecord.walletRewardId, }); const tipStatus: PrepareTipResult = { accepted: !!tipRecord && !!tipRecord.acceptedTimestamp, rewardAmountRaw: Amounts.stringify(tipRecord.rewardAmountRaw), exchangeBaseUrl: tipRecord.exchangeBaseUrl, merchantBaseUrl: tipRecord.merchantBaseUrl, expirationTimestamp: timestampProtocolFromDb(tipRecord.rewardExpiration), rewardAmountEffective: Amounts.stringify(tipRecord.rewardAmountEffective), walletRewardId: tipRecord.walletRewardId, transactionId, }; return tipStatus; } export async function processTip( ws: InternalWalletState, walletTipId: string, ): Promise { const tipRecord = await ws.db .mktx((x) => [x.rewards]) .runReadOnly(async (tx) => { return tx.rewards.get(walletTipId); }); if (!tipRecord) { return TaskRunResult.finished(); } switch (tipRecord.status) { case RewardRecordStatus.Aborted: case RewardRecordStatus.DialogAccept: case RewardRecordStatus.Done: case RewardRecordStatus.SuspendedPickup: return TaskRunResult.finished(); } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Reward, walletRewardId: walletTipId, }); const denomsForWithdraw = tipRecord.denomsSel; const planchets: DerivedTipPlanchet[] = []; // Planchets in the form that the merchant expects const planchetsDetail: TipPlanchetDetail[] = []; const denomForPlanchet: { [index: number]: DenominationRecord } = []; for (const dh of denomsForWithdraw.selectedDenoms) { const denom = await ws.db .mktx((x) => [x.denominations]) .runReadOnly(async (tx) => { return tx.denominations.get([ tipRecord.exchangeBaseUrl, dh.denomPubHash, ]); }); checkDbInvariant(!!denom, "denomination should be in database"); for (let i = 0; i < dh.count; i++) { const deriveReq = { denomPub: denom.denomPub, planchetIndex: planchets.length, secretSeed: tipRecord.secretSeed, }; logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`); const p = await ws.cryptoApi.createTipPlanchet(deriveReq); logger.trace(`derive result: ${j2s(p)}`); denomForPlanchet[planchets.length] = denom; planchets.push(p); planchetsDetail.push({ coin_ev: p.coinEv, denom_pub_hash: denom.denomPubHash, }); } } const tipStatusUrl = new URL( `rewards/${tipRecord.merchantRewardId}/pickup`, tipRecord.merchantBaseUrl, ); const req = { planchets: planchetsDetail }; logger.trace(`sending tip request: ${j2s(req)}`); const merchantResp = await ws.http.fetch(tipStatusUrl.href, { method: "POST", body: req, }); logger.trace(`got tip response, status ${merchantResp.status}`); // FIXME: Why do we do this? if ( (merchantResp.status >= 500 && merchantResp.status <= 599) || merchantResp.status === 424 ) { logger.trace(`got transient tip error`); // FIXME: wrap in another error code that indicates a transient error return { type: TaskRunResultType.Error, errorDetail: makeErrorDetail( TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR, getHttpResponseErrorDetails(merchantResp), "tip pickup failed (transient)", ), }; } let blindedSigs: BlindedDenominationSignature[] = []; const response = await readSuccessResponseJsonOrThrow( merchantResp, codecForMerchantTipResponseV2(), ); blindedSigs = response.blind_sigs.map((x) => x.blind_sig); if (blindedSigs.length !== planchets.length) { throw Error("number of tip responses does not match requested planchets"); } const newCoinRecords: CoinRecord[] = []; for (let i = 0; i < blindedSigs.length; i++) { const blindedSig = blindedSigs[i]; const denom = denomForPlanchet[i]; checkLogicInvariant(!!denom); const planchet = planchets[i]; checkLogicInvariant(!!planchet); if (denom.denomPub.cipher !== DenomKeyType.Rsa) { throw Error("unsupported cipher"); } if (blindedSig.cipher !== DenomKeyType.Rsa) { throw Error("unsupported cipher"); } const denomSigRsa = await ws.cryptoApi.rsaUnblind({ bk: planchet.blindingKey, blindedSig: blindedSig.blinded_rsa_signature, pk: denom.denomPub.rsa_public_key, }); const isValid = await ws.cryptoApi.rsaVerify({ hm: planchet.coinPub, pk: denom.denomPub.rsa_public_key, sig: denomSigRsa.sig, }); if (!isValid) { return { type: TaskRunResultType.Error, errorDetail: makeErrorDetail( TalerErrorCode.WALLET_REWARD_COIN_SIGNATURE_INVALID, {}, "invalid signature from the exchange (via merchant reward) after unblinding", ), }; } newCoinRecords.push({ blindingKey: planchet.blindingKey, coinPriv: planchet.coinPriv, coinPub: planchet.coinPub, coinSource: { type: CoinSourceType.Reward, coinIndex: i, walletRewardId: walletTipId, }, sourceTransactionId: transactionId, denomPubHash: denom.denomPubHash, denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa.sig }, exchangeBaseUrl: tipRecord.exchangeBaseUrl, status: CoinStatus.Fresh, coinEvHash: planchet.coinEvHash, maxAge: AgeRestriction.AGE_UNRESTRICTED, ageCommitmentProof: planchet.ageCommitmentProof, spendAllocation: undefined, }); } const transitionInfo = await ws.db .mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.rewards]) .runReadWrite(async (tx) => { const tr = await tx.rewards.get(walletTipId); if (!tr) { return; } if (tr.status !== RewardRecordStatus.PendingPickup) { return; } const oldTxState = computeRewardTransactionStatus(tr); tr.pickedUpTimestamp = timestampPreciseToDb(TalerPreciseTimestamp.now()); tr.status = RewardRecordStatus.Done; await tx.rewards.put(tr); const newTxState = computeRewardTransactionStatus(tr); for (const cr of newCoinRecords) { await makeCoinAvailable(ws, tx, cr); } await makeCoinsVisible(ws, tx, transactionId); return { oldTxState, newTxState }; }); notifyTransition(ws, transactionId, transitionInfo); ws.notify({ type: NotificationType.BalanceChange, hintTransactionId: transactionId, }); return TaskRunResult.finished(); } export async function acceptTipBackwardCompat( ws: InternalWalletState, walletTipId: string | undefined, transactionIdParam: TransactionIdStr | undefined, ): Promise { if (transactionIdParam) { return acceptTip(ws, transactionIdParam); } if (walletTipId) { /** * old clients use still use tipId */ const transactionId = constructTransactionIdentifier({ tag: TransactionType.Reward, walletRewardId: walletTipId, }); return acceptTip(ws, transactionId); } throw Error( "Unable to accept tip: neither tipId (deprecated) nor transactionId was specified", ); } export async function acceptTip( ws: InternalWalletState, transactionId: TransactionIdStr, ): Promise { const pTxId = parseTransactionIdentifier(transactionId); if (!pTxId) throw Error(`Unable to accept tip: invalid tx tag "${transactionId}"`); const rewardId = pTxId.tag === TransactionType.Reward ? pTxId.walletRewardId : undefined; if (!rewardId) throw Error( `Unable to accept tip: txId is not a reward tag "${pTxId.tag}"`, ); const dbRes = await ws.db .mktx((x) => [x.rewards]) .runReadWrite(async (tx) => { const tipRecord = await tx.rewards.get(rewardId); if (!tipRecord) { logger.error("tip not found"); return; } if (tipRecord.status != RewardRecordStatus.DialogAccept) { logger.warn("Unable to accept tip in the current state"); return { tipRecord }; } const oldTxState = computeRewardTransactionStatus(tipRecord); tipRecord.acceptedTimestamp = timestampPreciseToDb( TalerPreciseTimestamp.now(), ); tipRecord.status = RewardRecordStatus.PendingPickup; await tx.rewards.put(tipRecord); const newTxState = computeRewardTransactionStatus(tipRecord); return { tipRecord, transitionInfo: { oldTxState, newTxState } }; }); if (!dbRes) { throw Error("tip not found"); } notifyTransition(ws, transactionId, dbRes.transitionInfo); const tipRecord = dbRes.tipRecord; return { transactionId, next_url: tipRecord.next_url, }; }