aboutsummaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/reward.ts
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-08-03 18:35:07 +0200
committerFlorian Dold <florian@dold.me>2023-08-03 18:35:07 +0200
commitfdbd55d2bde0961a4c1ff26b04e442459ab782b0 (patch)
treed0d04f42a5477f6d7d39a8940d59ff1548166711 /packages/taler-wallet-core/src/operations/reward.ts
parent0fe4840ca2612dda06417cdebe5229eea98180be (diff)
downloadwallet-core-fdbd55d2bde0961a4c1ff26b04e442459ab782b0.tar.xz
-towards tip->reward rename
Diffstat (limited to 'packages/taler-wallet-core/src/operations/reward.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/reward.ts630
1 files changed, 630 insertions, 0 deletions
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 <http://www.gnu.org/licenses/>
+ */
+
+/**
+ * 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<PrepareTipResult> {
+ 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<TaskRunResult> {
+ 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<AcceptTipResponse> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ // 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<void> {
+ 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);
+}