fix tipping and adjust DB

This commit is contained in:
Florian Dold 2020-09-08 19:27:08 +05:30
parent b063382d25
commit b9e43e652e
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
13 changed files with 166 additions and 173 deletions

View File

@ -94,13 +94,19 @@ runTest(async (t: GlobalTestState) => {
console.log(ptr);
t.assertAmountEquals(ptr.tipAmountRaw, "TESTKUDOS:5");
t.assertAmountEquals(ptr.tipAmountEffective, "TESTKUDOS:4.85");
await wallet.acceptTip({
walletTipId: ptr.walletTipId,
});
await wallet.runUntilDone();
const bal = await wallet.getBalances();
console.log(bal);
t.assertAmountEquals(bal.balances[0].available, "TESTKUDOS:4.85");
});

View File

@ -22,6 +22,8 @@
*/
export enum TalerErrorCode {
/**
* Special code to indicate no error (or no "code" present).
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
@ -3270,10 +3272,18 @@ export enum TalerErrorCode {
*/
WALLET_WITHDRAWAL_GROUP_INCOMPLETE = 7015,
/**
* The signature on a coin by the exchange's denomination key (obtained through the merchant via tipping) is invalid after unblinding it.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
WALLET_TIPPING_COIN_SIGNATURE_INVALID = 7016,
/**
* End of error code range.
* Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0).
* (A value of 0 indicates that the error is generated client-side).
*/
END = 9999,
}

View File

@ -286,7 +286,6 @@ async function gatherWithdrawalPending(
givesLifeness: true,
numCoinsTotal,
numCoinsWithdrawn,
source: wsr.source,
withdrawalGroupId: wsr.withdrawalGroupId,
lastError: wsr.lastError,
retryInfo: wsr.retryInfo,

View File

@ -818,10 +818,7 @@ async function depleteReserve(
const withdrawalRecord: WithdrawalGroupRecord = {
withdrawalGroupId: withdrawalGroupId,
exchangeBaseUrl: newReserve.exchangeBaseUrl,
source: {
type: WithdrawalSourceType.Reserve,
reservePub: newReserve.reservePub,
},
reservePub: newReserve.reservePub,
rawWithdrawalAmount: withdrawAmount,
timestampStart: getTimestampNow(),
retryInfo: initRetryInfo(),

View File

@ -31,6 +31,9 @@ import {
updateRetryInfoTimeout,
WithdrawalSourceType,
TipPlanchet,
CoinRecord,
CoinSourceType,
CoinStatus,
} from "../types/dbTypes";
import {
getExchangeWithdrawalInfo,
@ -40,13 +43,14 @@ import {
} from "./withdraw";
import { updateExchangeFromUrl } from "./exchanges";
import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto";
import { guardOperationException } from "./errors";
import { guardOperationException, makeErrorDetails } from "./errors";
import { NotificationType } from "../types/notifications";
import { getTimestampNow } from "../util/time";
import { readSuccessResponseJsonOrThrow } from "../util/http";
import { URL } from "../util/url";
import { Logger } from "../util/logging";
import { checkDbInvariant } from "../util/invariants";
import { TalerErrorCode } from "../TalerErrorCode";
const logger = new Logger("operations/tip.ts");
@ -99,7 +103,7 @@ export async function prepareTip(
walletTipId: walletTipId,
acceptedTimestamp: undefined,
rejectedTimestamp: undefined,
amount,
tipAmountRaw: amount,
deadline: tipPickupStatus.expiration,
exchangeUrl: tipPickupStatus.exchange_url,
merchantBaseUrl: res.merchantBaseUrl,
@ -109,10 +113,10 @@ export async function prepareTip(
response: undefined,
createdTimestamp: getTimestampNow(),
merchantTipId: res.merchantTipId,
totalFees: Amounts.add(
tipAmountEffective: Amounts.sub(amount, Amounts.add(
withdrawDetails.overhead,
withdrawDetails.withdrawFee,
).amount,
).amount).amount,
retryInfo: initRetryInfo(),
lastError: undefined,
denomsSel: denomSelectionInfoToState(selectedDenoms),
@ -122,10 +126,10 @@ export async function prepareTip(
const tipStatus: PrepareTipResult = {
accepted: !!tipRecord && !!tipRecord.acceptedTimestamp,
amount: Amounts.stringify(tipPickupStatus.tip_amount),
tipAmountRaw: Amounts.stringify(tipPickupStatus.tip_amount),
exchangeBaseUrl: tipPickupStatus.exchange_url,
expirationTimestamp: tipPickupStatus.expiration,
totalFees: Amounts.stringify(tipRecord.totalFees),
tipAmountEffective: Amounts.stringify(tipRecord.tipAmountEffective),
walletTipId: tipRecord.walletTipId,
};
@ -182,13 +186,13 @@ async function resetTipRetry(
async function processTipImpl(
ws: InternalWalletState,
tipId: string,
walletTipId: string,
forceNow: boolean,
): Promise<void> {
if (forceNow) {
await resetTipRetry(ws, tipId);
await resetTipRetry(ws, walletTipId);
}
let tipRecord = await ws.db.get(Stores.tips, tipId);
let tipRecord = await ws.db.get(Stores.tips, walletTipId);
if (!tipRecord) {
return;
}
@ -216,7 +220,7 @@ async function processTipImpl(
planchets.push(r);
}
}
await ws.db.mutate(Stores.tips, tipId, (r) => {
await ws.db.mutate(Stores.tips, walletTipId, (r) => {
if (!r.planchets) {
r.planchets = planchets;
}
@ -224,7 +228,7 @@ async function processTipImpl(
});
}
tipRecord = await ws.db.get(Stores.tips, tipId);
tipRecord = await ws.db.get(Stores.tips, walletTipId);
checkDbInvariant(!!tipRecord, "tip record should be in database");
checkDbInvariant(!!tipRecord.planchets, "tip record should have planchets");
@ -246,55 +250,68 @@ async function processTipImpl(
codecForTipResponse(),
);
if (response.reserve_sigs.length !== tipRecord.planchets.length) {
if (response.blind_sigs.length !== tipRecord.planchets.length) {
throw Error("number of tip responses does not match requested planchets");
}
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const planchets: PlanchetRecord[] = [];
const newCoinRecords: CoinRecord[] = [];
for (let i = 0; i < tipRecord.planchets.length; i++) {
const tipPlanchet = tipRecord.planchets[i];
const coinEvHash = await ws.cryptoApi.hashEncoded(tipPlanchet.coinEv);
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,
coinEvHash,
coinIdx: i,
withdrawalDone: false,
withdrawalGroupId: withdrawalGroupId,
lastError: undefined,
};
planchets.push(planchet);
for (let i = 0; i < response.blind_sigs.length; i++) {
const blindedSig = response.blind_sigs[i].blind_sig;
const planchet = tipRecord.planchets[i];
const denomSig = await ws.cryptoApi.rsaUnblind(
blindedSig,
planchet.blindingKey,
planchet.denomPub,
);
const isValid = await ws.cryptoApi.rsaVerify(
planchet.coinPub,
denomSig,
planchet.denomPub,
);
if (!isValid) {
await ws.db.runWithWriteTransaction([Stores.planchets], async (tx) => {
const tipRecord = await tx.get(Stores.tips, 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.put(Stores.tips, tipRecord);
});
return;
}
newCoinRecords.push({
blindingKey: planchet.blindingKey,
coinPriv: planchet.coinPriv,
coinPub: planchet.coinPub,
coinSource: {
type: CoinSourceType.Tip,
coinIndex: i,
walletTipId: walletTipId,
},
currentAmount: planchet.coinValue,
denomPub: planchet.denomPub,
denomPubHash: planchet.denomPubHash,
denomSig: denomSig,
exchangeBaseUrl: tipRecord.exchangeUrl,
status: CoinStatus.Fresh,
suspended: false,
});
}
const withdrawalGroup: WithdrawalGroupRecord = {
exchangeBaseUrl: tipRecord.exchangeUrl,
source: {
type: WithdrawalSourceType.Tip,
tipId: tipRecord.walletTipId,
},
timestampStart: getTimestampNow(),
withdrawalGroupId: withdrawalGroupId,
rawWithdrawalAmount: tipRecord.amount,
retryInfo: initRetryInfo(),
timestampFinish: undefined,
lastError: undefined,
denomsSel: tipRecord.denomsSel,
};
await ws.db.runWithWriteTransaction(
[Stores.tips, Stores.withdrawalGroups],
[Stores.coins, Stores.tips, Stores.withdrawalGroups],
async (tx) => {
const tr = await tx.get(Stores.tips, tipId);
const tr = await tx.get(Stores.tips, walletTipId);
if (!tr) {
return;
}
@ -303,16 +320,12 @@ async function processTipImpl(
}
tr.pickedUp = true;
tr.retryInfo = initRetryInfo(false);
await tx.put(Stores.tips, tr);
await tx.put(Stores.withdrawalGroups, withdrawalGroup);
for (const p of planchets) {
await tx.put(Stores.planchets, p);
for (const cr of newCoinRecords) {
await tx.put(Stores.coins, cr);
}
},
);
await processWithdrawGroup(ws, withdrawalGroupId);
}
export async function acceptTip(

View File

@ -116,63 +116,49 @@ export async function getTransactions(
return;
}
switch (wsr.source.type) {
case WithdrawalSourceType.Reserve:
{
const r = await tx.get(Stores.reserves, wsr.source.reservePub);
if (!r) {
break;
}
let amountRaw: AmountJson | undefined = undefined;
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
amountRaw = r.instructedAmount;
} else {
amountRaw = wsr.denomsSel.totalWithdrawCost;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: true,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
const exchange = await tx.get(
Stores.exchanges,
r.exchangeBaseUrl,
);
if (!exchange) {
// FIXME: report somehow
break;
}
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
exchangePaytoUris:
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
};
}
transactions.push({
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(
wsr.denomsSel.totalCoinValue,
),
amountRaw: Amounts.stringify(amountRaw),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
...(wsr.lastError ? { error: wsr.lastError } : {}),
});
}
break;
default:
// Tips are reported via their own event
break;
const r = await tx.get(Stores.reserves, wsr.reservePub);
if (!r) {
return;
}
let amountRaw: AmountJson | undefined = undefined;
if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) {
amountRaw = r.instructedAmount;
} else {
amountRaw = wsr.denomsSel.totalWithdrawCost;
}
let withdrawalDetails: WithdrawalDetails;
if (r.bankInfo) {
withdrawalDetails = {
type: WithdrawalType.TalerBankIntegrationApi,
confirmed: true,
bankConfirmationUrl: r.bankInfo.confirmUrl,
};
} else {
const exchange = await tx.get(Stores.exchanges, r.exchangeBaseUrl);
if (!exchange) {
// FIXME: report somehow
return;
}
withdrawalDetails = {
type: WithdrawalType.ManualTransfer,
exchangePaytoUris:
exchange.wireInfo?.accounts.map((x) => x.payto_uri) ?? [],
};
}
transactions.push({
type: TransactionType.Withdrawal,
amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue),
amountRaw: Amounts.stringify(amountRaw),
withdrawalDetails,
exchangeBaseUrl: wsr.exchangeBaseUrl,
pending: !wsr.timestampFinish,
timestamp: wsr.timestampStart,
transactionId: makeEventId(
TransactionType.Withdrawal,
wsr.withdrawalGroupId,
),
...(wsr.lastError ? { error: wsr.lastError } : {}),
});
});
// Report pending withdrawals based on reserves that

View File

@ -242,12 +242,9 @@ async function processPlanchetGenerate(
if (!denom) {
throw Error("invariant violated");
}
if (withdrawalGroup.source.type != WithdrawalSourceType.Reserve) {
throw Error("invariant violated");
}
const reserve = await ws.db.get(
Stores.reserves,
withdrawalGroup.source.reservePub,
withdrawalGroup.reservePub,
);
if (!reserve) {
throw Error("invariant violated");
@ -420,7 +417,7 @@ async function processPlanchetVerifyAndStoreCoin(
if (!isValid) {
await ws.db.runWithWriteTransaction([Stores.planchets], async (tx) => {
let planchet = await ws.db.getIndexed(Stores.planchets.byGroupAndIndex, [
let planchet = await tx.getIndexed(Stores.planchets.byGroupAndIndex, [
withdrawalGroupId,
coinIdx,
]);
@ -700,7 +697,7 @@ async function processWithdrawGroupImpl(
if (finishedForFirstTime) {
ws.notify({
type: NotificationType.WithdrawGroupFinished,
withdrawalSource: withdrawalGroup.source,
reservePub: withdrawalGroup.reservePub,
});
}
}

View File

@ -694,17 +694,28 @@ export interface PlanchetRecord {
lastError: TalerErrorDetails | undefined;
/**
* Public key of the reserve, this might be a reserve not
* known to the wallet if the planchet is from a tip.
* Public key of the reserve that this planchet
* is being withdrawn from.
*
* Can be the empty string (non-null/undefined for DB indexing)
* if this is a tipping reserve.
*/
reservePub: string;
denomPubHash: string;
denomPub: string;
blindingKey: string;
withdrawSig: string;
coinEv: string;
coinEvHash: string;
coinValue: AmountJson;
isFromTip: boolean;
}
@ -772,6 +783,8 @@ export interface RefreshCoinSource {
export interface TipCoinSource {
type: CoinSourceType.Tip;
walletTipId: string;
coinIndex: number;
}
export type CoinSource = WithdrawCoinSource | RefreshCoinSource | TipCoinSource;
@ -950,9 +963,9 @@ export interface TipRecord {
/**
* The tipped amount.
*/
amount: AmountJson;
tipAmountRaw: AmountJson;
totalFees: AmountJson;
tipAmountEffective: AmountJson;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
@ -1481,18 +1494,6 @@ export enum WithdrawalSourceType {
Reserve = "reserve",
}
export interface WithdrawalSourceTip {
type: WithdrawalSourceType.Tip;
tipId: string;
}
export interface WithdrawalSourceReserve {
type: WithdrawalSourceType.Reserve;
reservePub: string;
}
export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve;
export interface DenominationSelectionInfo {
totalCoinValue: AmountJson;
totalWithdrawCost: AmountJson;
@ -1524,12 +1525,7 @@ export interface DenomSelectionState {
export interface WithdrawalGroupRecord {
withdrawalGroupId: string;
/**
* Withdrawal source. Fields that don't apply to the respective
* withdrawal source type must be null (i.e. can't be absent),
* otherwise the IndexedDB indexing won't like us.
*/
source: WithdrawalSource;
reservePub: string;
exchangeBaseUrl: string;

View File

@ -23,7 +23,6 @@
* Imports.
*/
import { TalerErrorDetails } from "./walletTypes";
import { WithdrawalSource } from "./dbTypes";
import { ReserveHistorySummary } from "../util/reserveHistoryUtil";
export enum NotificationType {
@ -141,7 +140,7 @@ export interface WithdrawalGroupCreatedNotification {
export interface WithdrawalGroupFinishedNotification {
type: NotificationType.WithdrawGroupFinished;
withdrawalSource: WithdrawalSource;
reservePub: string;
}
export interface WaitingForRetryNotification {

View File

@ -22,7 +22,7 @@
* Imports.
*/
import { TalerErrorDetails, BalancesResponse } from "./walletTypes";
import { WithdrawalSource, RetryInfo, ReserveRecordStatus } from "./dbTypes";
import { RetryInfo, ReserveRecordStatus } from "./dbTypes";
import { Timestamp, Duration } from "../util/time";
export enum PendingOperationType {
@ -219,7 +219,6 @@ export interface PendingRecoupOperation {
*/
export interface PendingWithdrawOperation {
type: PendingOperationType.Withdraw;
source: WithdrawalSource;
lastError: TalerErrorDetails | undefined;
retryInfo: RetryInfo;
withdrawalGroupId: string;

View File

@ -593,11 +593,11 @@ export interface TipPickupRequest {
* Reserve signature, defined as separate class to facilitate
* schema validation with "@Checkable".
*/
export class ReserveSigSingleton {
export class BlindSigWrapper {
/**
* Reserve signature.
*/
reserve_sig: string;
blind_sig: string;
}
/**
@ -605,15 +605,10 @@ export class ReserveSigSingleton {
* to the TipPickupRequest.
*/
export class TipResponse {
/**
* Public key of the reserve
*/
reserve_pub: string;
/**
* The order of the signatures matches the planchets list.
*/
reserve_sigs: ReserveSigSingleton[];
blind_sigs: BlindSigWrapper[];
}
/**
@ -1166,15 +1161,14 @@ export const codecForMerchantRefundResponse = (): Codec<
.property("refunds", codecForList(codecForMerchantRefundPermission()))
.build("MerchantRefundResponse");
export const codecForReserveSigSingleton = (): Codec<ReserveSigSingleton> =>
buildCodecForObject<ReserveSigSingleton>()
.property("reserve_sig", codecForString())
.build("ReserveSigSingleton");
export const codecForBlindSigWrapper = (): Codec<BlindSigWrapper> =>
buildCodecForObject<BlindSigWrapper>()
.property("blind_sig", codecForString())
.build("BlindSigWrapper");
export const codecForTipResponse = (): Codec<TipResponse> =>
buildCodecForObject<TipResponse>()
.property("reserve_pub", codecForString())
.property("reserve_sigs", codecForList(codecForReserveSigSingleton()))
.property("blind_sigs", codecForList(codecForBlindSigWrapper()))
.build("TipResponse");
export const codecForRecoup = (): Codec<Recoup> =>

View File

@ -359,8 +359,8 @@ export interface PrepareTipResult {
* Has the tip already been accepted?
*/
accepted: boolean;
amount: AmountString;
totalFees: AmountString;
tipAmountRaw: AmountString;
tipAmountEffective: AmountString;
exchangeBaseUrl: string;
expirationTimestamp: Timestamp;
}
@ -368,8 +368,8 @@ export interface PrepareTipResult {
export const codecForPrepareTipResult = (): Codec<PrepareTipResult> =>
buildCodecForObject<PrepareTipResult>()
.property("accepted", codecForBoolean())
.property("amount", codecForAmountString())
.property("totalFees", codecForAmountString())
.property("tipAmountRaw", codecForAmountString())
.property("tipAmountEffective", codecForAmountString())
.property("exchangeBaseUrl", codecForString())
.property("expirationTimestamp", codecForTimestamp)
.property("walletTipId", codecForString())

View File

@ -86,7 +86,6 @@ import {
codecForPreparePayRequest,
codecForIntegrationTestArgs,
WithdrawTestBalanceRequest,
withdrawTestBalanceDefaults,
codecForWithdrawTestBalance,
codecForTestPayArgs,
codecForSetCoinSuspendedRequest,
@ -916,9 +915,7 @@ export class Wallet {
console.error("no withdrawal session found for coin");
continue;
}
if (ws.source.type == "reserve") {
withdrawalReservePub = ws.source.reservePub;
}
withdrawalReservePub = ws.reservePub;
}
coinsJson.coins.push({
coin_pub: c.coinPub,