From fdbd55d2bde0961a4c1ff26b04e442459ab782b0 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 3 Aug 2023 18:35:07 +0200 Subject: -towards tip->reward rename --- .../taler-wallet-core/src/operations/reward.ts | 630 +++++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 packages/taler-wallet-core/src/operations/reward.ts (limited to 'packages/taler-wallet-core/src/operations/reward.ts') diff --git a/packages/taler-wallet-core/src/operations/reward.ts b/packages/taler-wallet-core/src/operations/reward.ts new file mode 100644 index 000000000..58c745780 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/reward.ts @@ -0,0 +1,630 @@ +/* + 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, + codecForTipPickupGetResponse, + CoinStatus, + DenomKeyType, + encodeCrock, + getRandomBytes, + j2s, + Logger, + NotificationType, + parseTipUri, + PrepareTipResult, + TalerErrorCode, + TalerPreciseTimestamp, + TipPlanchetDetail, + TransactionAction, + TransactionMajorState, + TransactionMinorState, + TransactionState, + TransactionType, + URL, +} from "@gnu-taler/taler-util"; +import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js"; +import { + CoinRecord, + CoinSourceType, + DenominationRecord, + RewardRecord, + RewardRecordStatus, +} 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, +} from "./common.js"; +import { updateExchangeFromUrl } from "./exchanges.js"; +import { + getCandidateWithdrawalDenoms, + getExchangeWithdrawalInfo, + updateWithdrawalDenoms, +} from "./withdraw.js"; +import { selectWithdrawalDenominations } from "../util/coinSelection.js"; +import { + constructTransactionIdentifier, + notifyTransition, + stopLongpolling, +} from "./transactions.js"; +import { PendingTaskType } from "../pending-types.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; + +const logger = new Logger("operations/tip.ts"); + +/** + * 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.SuspendidPickup: + return { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.Pickup, + }; + default: + assertUnreachable(tipRecord.status); + } +} + +export function computeTipTransactionActions( + tipRecord: RewardRecord, +): TransactionAction[] { + switch (tipRecord.status) { + case RewardRecordStatus.Done: + return [TransactionAction.Delete]; + case RewardRecordStatus.Aborted: + return [TransactionAction.Delete]; + case RewardRecordStatus.PendingPickup: + return [TransactionAction.Suspend, TransactionAction.Fail]; + case RewardRecordStatus.SuspendidPickup: + return [TransactionAction.Resume, TransactionAction.Fail]; + case RewardRecordStatus.DialogAccept: + return [TransactionAction.Abort]; + default: + assertUnreachable(tipRecord.status); + } +} + +export async function prepareTip( + ws: InternalWalletState, + talerTipUri: string, +): Promise { + const res = parseTipUri(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.merchantTipId, + res.merchantBaseUrl, + ]); + }); + + if (!tipRecord) { + const tipStatusUrl = new URL( + `tips/${res.merchantTipId}`, + res.merchantBaseUrl, + ); + logger.trace("checking tip status from", tipStatusUrl.href); + const merchantResp = await ws.http.get(tipStatusUrl.href); + const tipPickupStatus = await readSuccessResponseJsonOrThrow( + merchantResp, + codecForTipPickupGetResponse(), + ); + logger.trace(`status ${j2s(tipPickupStatus)}`); + + const amount = Amounts.parseOrThrow(tipPickupStatus.tip_amount); + + logger.trace("new tip, creating tip record"); + await updateExchangeFromUrl(ws, tipPickupStatus.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, + tipPickupStatus.exchange_url, + amount, + undefined, + ); + + const walletTipId = encodeCrock(getRandomBytes(32)); + await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url); + const denoms = await getCandidateWithdrawalDenoms( + ws, + tipPickupStatus.exchange_url, + ); + const selectedDenoms = selectWithdrawalDenominations(amount, denoms); + + const secretSeed = encodeCrock(getRandomBytes(64)); + const denomSelUid = encodeCrock(getRandomBytes(32)); + + const newTipRecord: RewardRecord = { + walletRewardId: walletTipId, + acceptedTimestamp: undefined, + status: RewardRecordStatus.DialogAccept, + rewardAmountRaw: Amounts.stringify(amount), + rewardExpiration: tipPickupStatus.expiration, + exchangeBaseUrl: tipPickupStatus.exchange_url, + next_url: tipPickupStatus.next_url, + merchantBaseUrl: res.merchantBaseUrl, + createdTimestamp: TalerPreciseTimestamp.now(), + merchantRewardId: res.merchantTipId, + 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: 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.SuspendidPickup: + 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( + `tips/${tipRecord.merchantRewardId}/pickup`, + tipRecord.merchantBaseUrl, + ); + + const req = { planchets: planchetsDetail }; + logger.trace(`sending tip request: ${j2s(req)}`); + const merchantResp = await ws.http.postJson(tipStatusUrl.href, 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_TIPPING_COIN_SIGNATURE_INVALID, + {}, + "invalid signature from the exchange (via merchant tip) 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 = 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 }); + + return TaskRunResult.finished(); +} + +export async function acceptTip( + ws: InternalWalletState, + walletTipId: string, +): Promise { + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: walletTipId, + }); + const dbRes = await ws.db + .mktx((x) => [x.rewards]) + .runReadWrite(async (tx) => { + const tipRecord = await tx.rewards.get(walletTipId); + 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 = 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: constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: walletTipId, + }), + next_url: tipRecord.next_url, + }; +} + +export async function suspendRewardTransaction( + ws: InternalWalletState, + walletRewardId: string, +): Promise { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.RewardPickup, + walletRewardId: walletRewardId, + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: walletRewardId, + }); + stopLongpolling(ws, taskId); + 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.SuspendidPickup: + case RewardRecordStatus.Aborted: + case RewardRecordStatus.DialogAccept: + break; + case RewardRecordStatus.PendingPickup: + newStatus = RewardRecordStatus.SuspendidPickup; + 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; + }); + ws.workAvailable.trigger(); + notifyTransition(ws, transactionId, transitionInfo); +} + +export async function resumeTipTransaction( + ws: InternalWalletState, + walletRewardId: string, +): Promise { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.RewardPickup, + walletRewardId: walletRewardId, + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: walletRewardId, + }); + stopLongpolling(ws, taskId); + 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: + break; + case RewardRecordStatus.SuspendidPickup: + 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); +} + +export async function failTipTransaction( + ws: InternalWalletState, + walletTipId: string, +): Promise { + // We don't have an "aborting" state, so this should never happen! + throw Error("can't run cance-aborting on tip transaction"); +} + +export async function abortTipTransaction( + ws: InternalWalletState, + walletRewardId: string, +): Promise { + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.RewardPickup, + walletRewardId: walletRewardId, + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Reward, + walletRewardId: walletRewardId, + }); + stopLongpolling(ws, taskId); + 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: + break; + case RewardRecordStatus.SuspendidPickup: + 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); +} -- cgit v1.2.3