wallet-core/packages/taler-wallet-core/src/operations/tip.ts

465 lines
13 KiB
TypeScript
Raw Normal View History

/*
This file is part of GNU Taler
2019-12-16 16:59:09 +01:00
(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/>
*/
2021-03-17 17:56:37 +01:00
/**
* Imports.
*/
2019-12-16 16:59:09 +01:00
import {
2021-03-17 17:56:37 +01:00
PrepareTipResult,
parseTipUri,
codecForTipPickupGetResponse,
2021-03-17 17:56:37 +01:00
Amounts,
getTimestampNow,
TalerErrorDetails,
NotificationType,
TipPlanchetDetail,
TalerErrorCode,
codecForMerchantTipResponseV1,
2021-06-08 20:58:13 +02:00
Logger,
URL,
2021-11-17 10:23:22 +01:00
DenomKeyType,
BlindedDenominationSignature,
codecForMerchantTipResponseV2,
MerchantProtocolVersion,
2021-03-17 17:56:37 +01:00
} from "@gnu-taler/taler-util";
import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js";
2019-12-16 16:59:09 +01:00
import {
2021-03-17 17:56:37 +01:00
DenominationRecord,
2020-09-08 15:57:08 +02:00
CoinRecord,
CoinSourceType,
CoinStatus,
2021-03-17 17:56:37 +01:00
} from "../db.js";
import { j2s } from "@gnu-taler/taler-util";
2021-03-17 17:56:37 +01:00
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js";
import { guardOperationException, makeErrorDetails } from "../errors.js";
2021-03-17 17:56:37 +01:00
import { updateExchangeFromUrl } from "./exchanges.js";
import { InternalWalletState } from "../common.js";
2019-12-16 16:59:09 +01:00
import {
getExchangeWithdrawalInfo,
updateWithdrawalDenoms,
2021-01-14 18:00:00 +01:00
getCandidateWithdrawalDenoms,
selectWithdrawalDenominations,
2021-03-17 17:56:37 +01:00
denomSelectionInfoToState,
} from "./withdraw.js";
2021-06-14 16:08:58 +02:00
import {
getHttpResponseErrorDetails,
readSuccessResponseJsonOrThrow,
} from "../util/http.js";
import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
const logger = new Logger("operations/tip.ts");
2020-09-08 14:10:47 +02:00
export async function prepareTip(
ws: InternalWalletState,
2019-12-16 16:59:09 +01:00
talerTipUri: string,
2020-09-08 14:10:47 +02:00
): Promise<PrepareTipResult> {
const res = parseTipUri(talerTipUri);
if (!res) {
throw Error("invalid taler://tip URI");
}
2021-06-09 15:14:17 +02:00
let tipRecord = await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadOnly(async (tx) => {
return tx.tips.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);
2020-11-16 14:12:37 +01:00
logger.trace("new tip, creating tip record");
await updateExchangeFromUrl(ws, tipPickupStatus.exchange_url);
2019-12-09 19:59:08 +01:00
const withdrawDetails = await getExchangeWithdrawalInfo(
ws,
tipPickupStatus.exchange_url,
amount,
);
2020-09-08 14:10:47 +02:00
const walletTipId = encodeCrock(getRandomBytes(32));
await updateWithdrawalDenoms(ws, tipPickupStatus.exchange_url);
2021-01-14 18:00:00 +01:00
const denoms = await getCandidateWithdrawalDenoms(
2021-01-06 17:06:19 +01:00
ws,
tipPickupStatus.exchange_url,
);
2021-01-06 17:06:19 +01:00
const selectedDenoms = selectWithdrawalDenominations(amount, denoms);
const secretSeed = encodeCrock(getRandomBytes(64));
const denomSelUid = encodeCrock(getRandomBytes(32));
2021-06-09 15:14:17 +02:00
const newTipRecord = {
2020-09-08 14:10:47 +02:00
walletTipId: walletTipId,
2019-12-16 12:53:22 +01:00
acceptedTimestamp: undefined,
2020-09-08 15:57:08 +02:00
tipAmountRaw: amount,
tipExpiration: tipPickupStatus.expiration,
exchangeBaseUrl: tipPickupStatus.exchange_url,
merchantBaseUrl: res.merchantBaseUrl,
2019-12-05 19:38:19 +01:00
createdTimestamp: getTimestampNow(),
merchantTipId: res.merchantTipId,
2020-09-08 17:33:10 +02:00
tipAmountEffective: Amounts.sub(
amount,
Amounts.add(withdrawDetails.overhead, withdrawDetails.withdrawFee)
.amount,
).amount,
2019-12-05 19:38:19 +01:00
retryInfo: initRetryInfo(),
lastError: undefined,
denomsSel: denomSelectionInfoToState(selectedDenoms),
pickedUpTimestamp: undefined,
secretSeed,
denomSelUid,
};
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadWrite(async (tx) => {
await tx.tips.put(newTipRecord);
});
tipRecord = newTipRecord;
}
2020-09-08 14:10:47 +02:00
const tipStatus: PrepareTipResult = {
2019-12-16 12:53:22 +01:00
accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
tipAmountRaw: Amounts.stringify(tipRecord.tipAmountRaw),
exchangeBaseUrl: tipRecord.exchangeBaseUrl,
merchantBaseUrl: tipRecord.merchantBaseUrl,
expirationTimestamp: tipRecord.tipExpiration,
2020-09-08 15:57:08 +02:00
tipAmountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
2020-09-08 14:10:47 +02:00
walletTipId: tipRecord.walletTipId,
};
return tipStatus;
}
2019-12-05 19:38:19 +01:00
async function incrementTipRetry(
ws: InternalWalletState,
walletTipId: string,
2020-09-01 14:57:22 +02:00
err: TalerErrorDetails | undefined,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadWrite(async (tx) => {
const t = await tx.tips.get(walletTipId);
if (!t) {
return;
}
if (!t.retryInfo) {
return;
}
t.retryInfo.retryCounter++;
updateRetryInfoTimeout(t.retryInfo);
t.lastError = err;
await tx.tips.put(t);
});
2020-09-08 14:10:47 +02:00
if (err) {
ws.notify({ type: NotificationType.TipOperationError, error: err });
}
2019-12-05 19:38:19 +01:00
}
export async function processTip(
ws: InternalWalletState,
tipId: string,
2020-04-06 17:45:41 +02:00
forceNow = false,
2019-12-05 19:38:19 +01:00
): Promise<void> {
2020-09-01 14:57:22 +02:00
const onOpErr = (e: TalerErrorDetails): Promise<void> =>
2020-04-07 10:07:32 +02:00
incrementTipRetry(ws, tipId, e);
2019-12-16 16:59:09 +01:00
await guardOperationException(
() => processTipImpl(ws, tipId, forceNow),
onOpErr,
);
}
async function resetTipRetry(
ws: InternalWalletState,
tipId: string,
): Promise<void> {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadWrite(async (tx) => {
const x = await tx.tips.get(tipId);
2021-06-11 11:15:08 +02:00
if (x) {
2021-06-09 15:14:17 +02:00
x.retryInfo = initRetryInfo();
await tx.tips.put(x);
}
});
2019-12-05 19:38:19 +01:00
}
async function processTipImpl(
ws: InternalWalletState,
2020-09-08 15:57:08 +02:00
walletTipId: string,
forceNow: boolean,
2020-04-07 10:07:32 +02:00
): Promise<void> {
if (forceNow) {
2020-09-08 15:57:08 +02:00
await resetTipRetry(ws, walletTipId);
}
2021-06-09 15:14:17 +02:00
const tipRecord = await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadOnly(async (tx) => {
return tx.tips.get(walletTipId);
});
if (!tipRecord) {
return;
}
if (tipRecord.pickedUpTimestamp) {
logger.warn("tip already picked up");
return;
}
const denomsForWithdraw = tipRecord.denomsSel;
const planchets: DerivedTipPlanchet[] = [];
// Planchets in the form that the merchant expects
const planchetsDetail: TipPlanchetDetail[] = [];
2021-01-06 17:06:19 +01:00
const denomForPlanchet: { [index: number]: DenominationRecord } = [];
for (const dh of denomsForWithdraw.selectedDenoms) {
2021-06-09 15:14:17 +02:00
const denom = await ws.db
.mktx((x) => ({
denominations: 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++) {
2021-01-06 17:06:19 +01:00
const deriveReq = {
denomPub: denom.denomPub,
planchetIndex: planchets.length,
secretSeed: tipRecord.secretSeed,
2021-01-06 17:06:19 +01:00
};
2021-01-13 00:51:30 +01:00
logger.trace(`deriving tip planchet: ${j2s(deriveReq)}`);
2021-01-06 17:06:19 +01:00
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,
});
}
}
2020-09-08 14:10:47 +02:00
const tipStatusUrl = new URL(
2020-11-16 14:12:37 +01:00
`tips/${tipRecord.merchantTipId}/pickup`,
2020-09-08 14:10:47 +02:00
tipRecord.merchantBaseUrl,
);
2020-09-08 14:10:47 +02:00
const req = { planchets: planchetsDetail };
2021-01-06 17:06:19 +01:00
logger.trace(`sending tip request: ${j2s(req)}`);
2020-09-08 14:10:47 +02:00
const merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
2021-01-06 17:06:19 +01:00
logger.trace(`got tip response, status ${merchantResp.status}`);
// Hide transient errors.
if (
tipRecord.retryInfo.retryCounter < 5 &&
2021-01-06 17:06:19 +01:00
((merchantResp.status >= 500 && merchantResp.status <= 599) ||
merchantResp.status === 424)
) {
2021-01-06 17:06:19 +01:00
logger.trace(`got transient tip error`);
const err = makeErrorDetails(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
"tip pickup failed (transient)",
getHttpResponseErrorDetails(merchantResp),
);
await incrementTipRetry(ws, tipRecord.walletTipId, err);
// FIXME: Maybe we want to signal to the caller that the transient error happened?
return;
}
// FIXME: Do this earlier?
const merchantInfo = await ws.merchantOps.getMerchantInfo(
ws,
tipRecord.merchantBaseUrl,
2020-09-08 14:10:47 +02:00
);
let blindedSigs: BlindedDenominationSignature[] = [];
if (merchantInfo.protocolVersionCurrent === MerchantProtocolVersion.V3) {
const response = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForMerchantTipResponseV2(),
);
blindedSigs = response.blind_sigs.map((x) => x.blind_sig);
} else if (
merchantInfo.protocolVersionCurrent === MerchantProtocolVersion.V1
) {
const response = await readSuccessResponseJsonOrThrow(
merchantResp,
codecForMerchantTipResponseV1(),
);
blindedSigs = response.blind_sigs.map((x) => ({
cipher: DenomKeyType.Rsa,
blinded_rsa_signature: x.blind_sig,
}));
} else {
throw Error(
`unsupported merchant protocol version (${merchantInfo.protocolVersionCurrent})`,
);
}
if (blindedSigs.length !== planchets.length) {
throw Error("number of tip responses does not match requested planchets");
}
2020-09-08 15:57:08 +02:00
const newCoinRecords: CoinRecord[] = [];
for (let i = 0; i < blindedSigs.length; i++) {
const blindedSig = blindedSigs[i];
2020-09-08 15:57:08 +02:00
const denom = denomForPlanchet[i];
checkLogicInvariant(!!denom);
const planchet = planchets[i];
checkLogicInvariant(!!planchet);
2020-09-08 15:57:08 +02:00
2021-11-27 20:56:58 +01:00
if (
denom.denomPub.cipher !== DenomKeyType.Rsa &&
denom.denomPub.cipher !== DenomKeyType.LegacyRsa
) {
throw Error("unsupported cipher");
}
2021-11-27 20:56:58 +01:00
if (
blindedSig.cipher !== DenomKeyType.Rsa &&
blindedSig.cipher !== DenomKeyType.LegacyRsa
) {
2021-11-17 10:23:22 +01:00
throw Error("unsupported cipher");
}
const denomSigRsa = await ws.cryptoApi.rsaUnblind(
blindedSig.blinded_rsa_signature,
2020-09-08 15:57:08 +02:00
planchet.blindingKey,
2021-11-17 10:23:22 +01:00
denom.denomPub.rsa_public_key,
2020-09-08 15:57:08 +02:00
);
const isValid = await ws.cryptoApi.rsaVerify(
planchet.coinPub,
2021-11-17 10:23:22 +01:00
denomSigRsa,
denom.denomPub.rsa_public_key,
2020-09-08 15:57:08 +02:00
);
if (!isValid) {
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({ tips: x.tips }))
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(walletTipId);
if (!tipRecord) {
return;
}
tipRecord.lastError = makeErrorDetails(
TalerErrorCode.WALLET_TIPPING_COIN_SIGNATURE_INVALID,
"invalid signature from the exchange (via merchant tip) after unblinding",
{},
);
await tx.tips.put(tipRecord);
});
2020-09-08 15:57:08 +02:00
return;
}
newCoinRecords.push({
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
coinSource: {
type: CoinSourceType.Tip,
coinIndex: i,
walletTipId: walletTipId,
},
currentAmount: denom.value,
denomPub: denom.denomPub,
denomPubHash: denom.denomPubHash,
2021-11-17 10:23:22 +01:00
denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa },
exchangeBaseUrl: tipRecord.exchangeBaseUrl,
2020-09-08 15:57:08 +02:00
status: CoinStatus.Fresh,
suspended: false,
coinEvHash: planchet.coinEvHash,
2020-09-08 15:57:08 +02:00
});
}
2021-06-09 15:14:17 +02:00
await ws.db
.mktx((x) => ({
coins: x.coins,
tips: x.tips,
withdrawalGroups: x.withdrawalGroups,
}))
.runReadWrite(async (tx) => {
const tr = await tx.tips.get(walletTipId);
2019-12-16 16:59:09 +01:00
if (!tr) {
return;
}
if (tr.pickedUpTimestamp) {
2019-12-16 16:59:09 +01:00
return;
}
tr.pickedUpTimestamp = getTimestampNow();
tr.lastError = undefined;
tr.retryInfo = initRetryInfo();
2021-06-09 15:14:17 +02:00
await tx.tips.put(tr);
2020-09-08 15:57:08 +02:00
for (const cr of newCoinRecords) {
2021-06-09 15:14:17 +02:00
await tx.coins.put(cr);
}
2021-06-09 15:14:17 +02:00
});
}
export async function acceptTip(
ws: InternalWalletState,
tipId: string,
): Promise<void> {
2021-06-09 15:14:17 +02:00
const found = await ws.db
.mktx((x) => ({
tips: x.tips,
}))
.runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(tipId);
if (!tipRecord) {
logger.error("tip not found");
return false;
}
tipRecord.acceptedTimestamp = getTimestampNow();
await tx.tips.put(tipRecord);
return true;
});
if (found) {
await processTip(ws, tipId);
}
}