/*
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,
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,
} 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.SuspendedPickup:
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.SuspendedPickup:
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 = 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 tipPickupStatus = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForRewardPickupGetResponse(),
);
logger.trace(`status ${j2s(tipPickupStatus)}`);
const amount = Amounts.parseOrThrow(tipPickupStatus.reward_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: timestampProtocolToDb(tipPickupStatus.expiration),
exchangeBaseUrl: tipPickupStatus.exchange_url,
next_url: tipPickupStatus.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_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 = 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 });
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 = 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: 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.SuspendedPickup:
case RewardRecordStatus.Aborted:
case RewardRecordStatus.DialogAccept:
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;
});
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.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);
}
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.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);
}