2019-12-02 00:42:40 +01:00
|
|
|
/*
|
|
|
|
This file is part of GNU Taler
|
2019-12-16 16:59:09 +01:00
|
|
|
(C) 2019 Taler Systems S.A.
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
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/>
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { InternalWalletState } from "./state";
|
|
|
|
import { parseTipUri } from "../util/taleruri";
|
2020-03-30 12:39:32 +02:00
|
|
|
import { TipStatus, OperationError } from "../types/walletTypes";
|
2019-12-16 16:59:09 +01:00
|
|
|
import {
|
|
|
|
TipPlanchetDetail,
|
2019-12-19 20:42:49 +01:00
|
|
|
codecForTipPickupGetResponse,
|
|
|
|
codecForTipResponse,
|
2019-12-16 16:59:09 +01:00
|
|
|
} from "../types/talerTypes";
|
2019-12-02 00:42:40 +01:00
|
|
|
import * as Amounts from "../util/amounts";
|
2019-12-16 16:59:09 +01:00
|
|
|
import {
|
|
|
|
Stores,
|
|
|
|
PlanchetRecord,
|
2020-04-02 17:03:01 +02:00
|
|
|
WithdrawalGroupRecord,
|
2019-12-16 16:59:09 +01:00
|
|
|
initRetryInfo,
|
|
|
|
updateRetryInfoTimeout,
|
2020-04-02 17:03:01 +02:00
|
|
|
WithdrawalSourceType,
|
2019-12-16 16:59:09 +01:00
|
|
|
} from "../types/dbTypes";
|
|
|
|
import {
|
|
|
|
getExchangeWithdrawalInfo,
|
|
|
|
getVerifiedWithdrawDenomList,
|
2020-04-02 17:03:01 +02:00
|
|
|
processWithdrawGroup,
|
2019-12-16 16:59:09 +01:00
|
|
|
} from "./withdraw";
|
2019-12-02 00:42:40 +01:00
|
|
|
import { updateExchangeFromUrl } from "./exchanges";
|
|
|
|
import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
|
2019-12-05 19:38:19 +01:00
|
|
|
import { guardOperationException } from "./errors";
|
2019-12-12 20:53:15 +01:00
|
|
|
import { NotificationType } from "../types/notifications";
|
2019-12-19 20:42:49 +01:00
|
|
|
import { getTimestampNow } from "../util/time";
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
export async function getTipStatus(
|
|
|
|
ws: InternalWalletState,
|
2019-12-16 16:59:09 +01:00
|
|
|
talerTipUri: string,
|
|
|
|
): Promise<TipStatus> {
|
2019-12-02 00:42:40 +01:00
|
|
|
const res = parseTipUri(talerTipUri);
|
|
|
|
if (!res) {
|
|
|
|
throw Error("invalid taler://tip URI");
|
|
|
|
}
|
|
|
|
|
|
|
|
const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl);
|
|
|
|
tipStatusUrl.searchParams.set("tip_id", res.merchantTipId);
|
|
|
|
console.log("checking tip status from", tipStatusUrl.href);
|
|
|
|
const merchantResp = await ws.http.get(tipStatusUrl.href);
|
2019-12-09 13:29:11 +01:00
|
|
|
if (merchantResp.status !== 200) {
|
|
|
|
throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
|
|
|
|
}
|
|
|
|
const respJson = await merchantResp.json();
|
|
|
|
console.log("resp:", respJson);
|
2019-12-19 20:42:49 +01:00
|
|
|
const tipPickupStatus = codecForTipPickupGetResponse().decode(respJson);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
console.log("status", tipPickupStatus);
|
|
|
|
|
2020-04-06 17:45:41 +02:00
|
|
|
const amount = Amounts.parseOrThrow(tipPickupStatus.amount);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
let tipRecord = await ws.db.get(Stores.tips, [
|
2019-12-02 00:42:40 +01:00
|
|
|
res.merchantTipId,
|
|
|
|
res.merchantOrigin,
|
|
|
|
]);
|
|
|
|
|
|
|
|
if (!tipRecord) {
|
2019-12-09 19:59:08 +01:00
|
|
|
const withdrawDetails = await getExchangeWithdrawalInfo(
|
2019-12-02 00:42:40 +01:00
|
|
|
ws,
|
|
|
|
tipPickupStatus.exchange_url,
|
|
|
|
amount,
|
|
|
|
);
|
|
|
|
|
|
|
|
const tipId = encodeCrock(getRandomBytes(32));
|
|
|
|
|
|
|
|
tipRecord = {
|
|
|
|
tipId,
|
2019-12-16 12:53:22 +01:00
|
|
|
acceptedTimestamp: undefined,
|
|
|
|
rejectedTimestamp: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
amount,
|
2019-12-19 20:42:49 +01:00
|
|
|
deadline: tipPickupStatus.stamp_expire,
|
2019-12-02 00:42:40 +01:00
|
|
|
exchangeUrl: tipPickupStatus.exchange_url,
|
|
|
|
merchantBaseUrl: res.merchantBaseUrl,
|
|
|
|
nextUrl: undefined,
|
|
|
|
pickedUp: false,
|
|
|
|
planchets: undefined,
|
|
|
|
response: undefined,
|
2019-12-05 19:38:19 +01:00
|
|
|
createdTimestamp: getTimestampNow(),
|
2019-12-02 00:42:40 +01:00
|
|
|
merchantTipId: res.merchantTipId,
|
|
|
|
totalFees: Amounts.add(
|
|
|
|
withdrawDetails.overhead,
|
|
|
|
withdrawDetails.withdrawFee,
|
|
|
|
).amount,
|
2019-12-05 19:38:19 +01:00
|
|
|
retryInfo: initRetryInfo(),
|
|
|
|
lastError: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.put(Stores.tips, tipRecord);
|
2019-12-02 00:42:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const tipStatus: TipStatus = {
|
2019-12-16 12:53:22 +01:00
|
|
|
accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
|
2019-12-02 00:42:40 +01:00
|
|
|
amount: Amounts.parseOrThrow(tipPickupStatus.amount),
|
|
|
|
amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
|
|
|
|
exchangeUrl: tipPickupStatus.exchange_url,
|
|
|
|
nextUrl: tipPickupStatus.extra.next_url,
|
|
|
|
merchantOrigin: res.merchantOrigin,
|
|
|
|
merchantTipId: res.merchantTipId,
|
2019-12-19 20:42:49 +01:00
|
|
|
expirationTimestamp: tipPickupStatus.stamp_expire,
|
|
|
|
timestamp: tipPickupStatus.stamp_created,
|
2019-12-02 00:42:40 +01:00
|
|
|
totalFees: tipRecord.totalFees,
|
|
|
|
tipId: tipRecord.tipId,
|
|
|
|
};
|
|
|
|
|
|
|
|
return tipStatus;
|
|
|
|
}
|
|
|
|
|
2019-12-05 19:38:19 +01:00
|
|
|
async function incrementTipRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
refreshSessionId: string,
|
|
|
|
err: OperationError | undefined,
|
|
|
|
): Promise<void> {
|
2020-03-30 12:39:32 +02:00
|
|
|
await ws.db.runWithWriteTransaction([Stores.tips], async (tx) => {
|
2019-12-05 19:38:19 +01:00
|
|
|
const t = await tx.get(Stores.tips, refreshSessionId);
|
|
|
|
if (!t) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (!t.retryInfo) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
t.retryInfo.retryCounter++;
|
|
|
|
updateRetryInfoTimeout(t.retryInfo);
|
|
|
|
t.lastError = err;
|
|
|
|
await tx.put(Stores.tips, t);
|
|
|
|
});
|
2019-12-06 02:52:16 +01:00
|
|
|
ws.notify({ type: NotificationType.TipOperationError });
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
|
|
|
|
2019-12-02 00:42:40 +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-04-07 10:07:32 +02:00
|
|
|
const onOpErr = (e: OperationError): Promise<void> =>
|
|
|
|
incrementTipRetry(ws, tipId, e);
|
2019-12-16 16:59:09 +01:00
|
|
|
await guardOperationException(
|
|
|
|
() => processTipImpl(ws, tipId, forceNow),
|
|
|
|
onOpErr,
|
|
|
|
);
|
2019-12-07 22:02:11 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async function resetTipRetry(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
tipId: string,
|
|
|
|
): Promise<void> {
|
2020-03-30 12:39:32 +02:00
|
|
|
await ws.db.mutate(Stores.tips, tipId, (x) => {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (x.retryInfo.active) {
|
|
|
|
x.retryInfo = initRetryInfo();
|
|
|
|
}
|
|
|
|
return x;
|
2019-12-16 16:59:09 +01:00
|
|
|
});
|
2019-12-05 19:38:19 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async function processTipImpl(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
tipId: string,
|
2019-12-07 22:02:11 +01:00
|
|
|
forceNow: boolean,
|
2020-04-07 10:07:32 +02:00
|
|
|
): Promise<void> {
|
2019-12-07 22:02:11 +01:00
|
|
|
if (forceNow) {
|
|
|
|
await resetTipRetry(ws, tipId);
|
|
|
|
}
|
2019-12-12 22:39:45 +01:00
|
|
|
let tipRecord = await ws.db.get(Stores.tips, tipId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!tipRecord) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (tipRecord.pickedUp) {
|
|
|
|
console.log("tip already picked up");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!tipRecord.planchets) {
|
|
|
|
await updateExchangeFromUrl(ws, tipRecord.exchangeUrl);
|
|
|
|
const denomsForWithdraw = await getVerifiedWithdrawDenomList(
|
|
|
|
ws,
|
|
|
|
tipRecord.exchangeUrl,
|
|
|
|
tipRecord.amount,
|
|
|
|
);
|
|
|
|
|
|
|
|
const planchets = await Promise.all(
|
2020-03-30 12:39:32 +02:00
|
|
|
denomsForWithdraw.map((d) => ws.cryptoApi.createTipPlanchet(d)),
|
2019-12-02 00:42:40 +01:00
|
|
|
);
|
|
|
|
|
2020-03-30 12:39:32 +02:00
|
|
|
await ws.db.mutate(Stores.tips, tipId, (r) => {
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!r.planchets) {
|
|
|
|
r.planchets = planchets;
|
|
|
|
}
|
|
|
|
return r;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-12-12 22:39:45 +01:00
|
|
|
tipRecord = await ws.db.get(Stores.tips, tipId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!tipRecord) {
|
|
|
|
throw Error("tip not in database");
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!tipRecord.planchets) {
|
|
|
|
throw Error("invariant violated");
|
|
|
|
}
|
|
|
|
|
|
|
|
console.log("got planchets for tip!");
|
|
|
|
|
|
|
|
// Planchets in the form that the merchant expects
|
2020-03-30 12:39:32 +02:00
|
|
|
const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map((p) => ({
|
2019-12-02 00:42:40 +01:00
|
|
|
coin_ev: p.coinEv,
|
|
|
|
denom_pub_hash: p.denomPubHash,
|
|
|
|
}));
|
|
|
|
|
|
|
|
let merchantResp;
|
|
|
|
|
|
|
|
const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl);
|
|
|
|
|
|
|
|
try {
|
|
|
|
const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId };
|
|
|
|
merchantResp = await ws.http.postJson(tipStatusUrl.href, req);
|
2019-12-09 13:29:11 +01:00
|
|
|
if (merchantResp.status !== 200) {
|
|
|
|
throw Error(`unexpected status ${merchantResp.status} for tip-pickup`);
|
|
|
|
}
|
2019-12-02 00:42:40 +01:00
|
|
|
console.log("got merchant resp:", merchantResp);
|
|
|
|
} catch (e) {
|
|
|
|
console.log("tipping failed", e);
|
|
|
|
throw e;
|
|
|
|
}
|
|
|
|
|
2019-12-19 20:42:49 +01:00
|
|
|
const response = codecForTipResponse().decode(await merchantResp.json());
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
if (response.reserve_sigs.length !== tipRecord.planchets.length) {
|
|
|
|
throw Error("number of tip responses does not match requested planchets");
|
|
|
|
}
|
|
|
|
|
|
|
|
const planchets: PlanchetRecord[] = [];
|
|
|
|
|
|
|
|
for (let i = 0; i < tipRecord.planchets.length; i++) {
|
|
|
|
const tipPlanchet = tipRecord.planchets[i];
|
2020-04-02 17:03:01 +02:00
|
|
|
const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv);
|
2019-12-02 00:42:40 +01:00
|
|
|
const planchet: PlanchetRecord = {
|
|
|
|
blindingKey: tipPlanchet.blindingKey,
|
|
|
|
coinEv: tipPlanchet.coinEv,
|
|
|
|
coinPriv: tipPlanchet.coinPriv,
|
|
|
|
coinPub: tipPlanchet.coinPub,
|
|
|
|
coinValue: tipPlanchet.coinValue,
|
|
|
|
denomPub: tipPlanchet.denomPub,
|
|
|
|
denomPubHash: tipPlanchet.denomPubHash,
|
|
|
|
reservePub: response.reserve_pub,
|
|
|
|
withdrawSig: response.reserve_sigs[i].reserve_sig,
|
|
|
|
isFromTip: true,
|
2020-04-02 17:03:01 +02:00
|
|
|
coinEvHash,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
planchets.push(planchet);
|
|
|
|
}
|
|
|
|
|
2020-04-02 17:03:01 +02:00
|
|
|
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2020-04-02 17:03:01 +02:00
|
|
|
const withdrawalGroup: WithdrawalGroupRecord = {
|
2020-03-30 12:39:32 +02:00
|
|
|
denoms: planchets.map((x) => x.denomPub),
|
2019-12-02 00:42:40 +01:00
|
|
|
exchangeBaseUrl: tipRecord.exchangeUrl,
|
|
|
|
planchets: planchets,
|
|
|
|
source: {
|
2020-04-02 17:03:01 +02:00
|
|
|
type: WithdrawalSourceType.Tip,
|
2019-12-02 00:42:40 +01:00
|
|
|
tipId: tipRecord.tipId,
|
|
|
|
},
|
2019-12-16 16:20:45 +01:00
|
|
|
timestampStart: getTimestampNow(),
|
2020-04-02 17:03:01 +02:00
|
|
|
withdrawalGroupId: withdrawalGroupId,
|
2019-12-03 01:33:25 +01:00
|
|
|
rawWithdrawalAmount: tipRecord.amount,
|
2020-03-30 12:39:32 +02:00
|
|
|
withdrawn: planchets.map((x) => false),
|
|
|
|
totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount,
|
2019-12-16 12:53:22 +01:00
|
|
|
lastErrorPerCoin: {},
|
2019-12-05 19:38:19 +01:00
|
|
|
retryInfo: initRetryInfo(),
|
2019-12-16 16:20:45 +01:00
|
|
|
timestampFinish: undefined,
|
2019-12-05 19:38:19 +01:00
|
|
|
lastError: undefined,
|
2019-12-02 00:42:40 +01:00
|
|
|
};
|
|
|
|
|
2019-12-16 16:59:09 +01:00
|
|
|
await ws.db.runWithWriteTransaction(
|
2020-04-02 17:03:01 +02:00
|
|
|
[Stores.tips, Stores.withdrawalGroups],
|
2020-03-30 12:39:32 +02:00
|
|
|
async (tx) => {
|
2019-12-16 16:59:09 +01:00
|
|
|
const tr = await tx.get(Stores.tips, tipId);
|
|
|
|
if (!tr) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (tr.pickedUp) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
tr.pickedUp = true;
|
|
|
|
tr.retryInfo = initRetryInfo(false);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2019-12-16 16:59:09 +01:00
|
|
|
await tx.put(Stores.tips, tr);
|
2020-04-02 17:03:01 +02:00
|
|
|
await tx.put(Stores.withdrawalGroups, withdrawalGroup);
|
2019-12-16 16:59:09 +01:00
|
|
|
},
|
|
|
|
);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
2020-04-02 17:03:01 +02:00
|
|
|
await processWithdrawGroup(ws, withdrawalGroupId);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function acceptTip(
|
|
|
|
ws: InternalWalletState,
|
|
|
|
tipId: string,
|
|
|
|
): Promise<void> {
|
2019-12-12 22:39:45 +01:00
|
|
|
const tipRecord = await ws.db.get(Stores.tips, tipId);
|
2019-12-02 00:42:40 +01:00
|
|
|
if (!tipRecord) {
|
|
|
|
console.log("tip not found");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-12-16 12:53:22 +01:00
|
|
|
tipRecord.acceptedTimestamp = getTimestampNow();
|
2019-12-12 22:39:45 +01:00
|
|
|
await ws.db.put(Stores.tips, tipRecord);
|
2019-12-02 00:42:40 +01:00
|
|
|
|
|
|
|
await processTip(ws, tipId);
|
|
|
|
return;
|
|
|
|
}
|