simplify /pay, add pay event

This commit is contained in:
Florian Dold 2019-12-15 21:40:06 +01:00
parent 59bd755f7d
commit 1b9c5855a8
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
7 changed files with 105 additions and 141 deletions

View File

@ -39,7 +39,7 @@ import { ContractTerms, PaybackRequest } from "../../types/talerTypes";
import { import {
BenchmarkResult, BenchmarkResult,
CoinWithDenom, CoinWithDenom,
PayCoinInfo, PaySigInfo,
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
@ -387,8 +387,8 @@ export class CryptoApi {
contractTerms: ContractTerms, contractTerms: ContractTerms,
cds: CoinWithDenom[], cds: CoinWithDenom[],
totalAmount: AmountJson, totalAmount: AmountJson,
): Promise<PayCoinInfo> { ): Promise<PaySigInfo> {
return this.doRpc<PayCoinInfo>( return this.doRpc<PaySigInfo>(
"signDeposit", "signDeposit",
3, 3,
contractTerms, contractTerms,

View File

@ -39,11 +39,12 @@ import { CoinPaySig, ContractTerms, PaybackRequest } from "../../types/talerType
import { import {
BenchmarkResult, BenchmarkResult,
CoinWithDenom, CoinWithDenom,
PayCoinInfo, PaySigInfo,
Timestamp, Timestamp,
PlanchetCreationResult, PlanchetCreationResult,
PlanchetCreationRequest, PlanchetCreationRequest,
getTimestampNow, getTimestampNow,
CoinPayInfo,
} from "../../types/walletTypes"; } from "../../types/walletTypes";
import { canonicalJson, getTalerStampSec } from "../../util/helpers"; import { canonicalJson, getTalerStampSec } from "../../util/helpers";
import { AmountJson } from "../../util/amounts"; import { AmountJson } from "../../util/amounts";
@ -348,11 +349,9 @@ export class CryptoImplementation {
contractTerms: ContractTerms, contractTerms: ContractTerms,
cds: CoinWithDenom[], cds: CoinWithDenom[],
totalAmount: AmountJson, totalAmount: AmountJson,
): PayCoinInfo { ): PaySigInfo {
const ret: PayCoinInfo = { const ret: PaySigInfo = {
originalCoins: [], coinInfo: [],
sigs: [],
updatedCoins: [],
}; };
const contractTermsHash = this.hashString(canonicalJson(contractTerms)); const contractTermsHash = this.hashString(canonicalJson(contractTerms));
@ -369,8 +368,6 @@ export class CryptoImplementation {
let amountRemaining = total; let amountRemaining = total;
for (const cd of cds) { for (const cd of cds) {
const originalCoin = { ...cd.coin };
if (amountRemaining.value === 0 && amountRemaining.fraction === 0) { if (amountRemaining.value === 0 && amountRemaining.fraction === 0) {
break; break;
} }
@ -416,9 +413,12 @@ export class CryptoImplementation {
exchange_url: cd.denom.exchangeBaseUrl, exchange_url: cd.denom.exchangeBaseUrl,
ub_sig: cd.coin.denomSig, ub_sig: cd.coin.denomSig,
}; };
ret.sigs.push(s); const coinInfo: CoinPayInfo = {
ret.updatedCoins.push(cd.coin); sig: s,
ret.originalCoins.push(originalCoin); coinPub: cd.coin.coinPub,
subtractedAmount: coinSpend,
};
ret.coinInfo.push(coinInfo);
} }
return ret; return ret;
} }

View File

@ -36,6 +36,7 @@ import {
RefundReason, RefundReason,
Stores, Stores,
updateRetryInfoTimeout, updateRetryInfoTimeout,
PayEventRecord,
} from "../types/dbTypes"; } from "../types/dbTypes";
import { NotificationType } from "../types/notifications"; import { NotificationType } from "../types/notifications";
import { import {
@ -52,7 +53,7 @@ import {
ConfirmPayResult, ConfirmPayResult,
getTimestampNow, getTimestampNow,
OperationError, OperationError,
PayCoinInfo, PaySigInfo,
PreparePayResult, PreparePayResult,
RefreshReason, RefreshReason,
Timestamp, Timestamp,
@ -73,13 +74,6 @@ import { createRefreshGroup, getTotalRefreshCost } from "./refresh";
import { acceptRefundResponse } from "./refund"; import { acceptRefundResponse } from "./refund";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
export interface SpeculativePayData {
payCoinInfo: PayCoinInfo;
exchangeUrl: string;
orderDownloadId: string;
proposal: ProposalRecord;
}
interface CoinsForPaymentArgs { interface CoinsForPaymentArgs {
allowedAuditors: Auditor[]; allowedAuditors: Auditor[];
allowedExchanges: ExchangeHandle[]; allowedExchanges: ExchangeHandle[];
@ -323,8 +317,7 @@ async function getCoinsForPayment(
async function recordConfirmPay( async function recordConfirmPay(
ws: InternalWalletState, ws: InternalWalletState,
proposal: ProposalRecord, proposal: ProposalRecord,
payCoinInfo: PayCoinInfo, payCoinInfo: PaySigInfo,
chosenExchange: string,
sessionIdOverride: string | undefined, sessionIdOverride: string | undefined,
): Promise<PurchaseRecord> { ): Promise<PurchaseRecord> {
const d = proposal.download; const d = proposal.download;
@ -339,7 +332,7 @@ async function recordConfirmPay(
} }
logger.trace(`recording payment with session ID ${sessionId}`); logger.trace(`recording payment with session ID ${sessionId}`);
const payReq: PayReq = { const payReq: PayReq = {
coins: payCoinInfo.sigs, coins: payCoinInfo.coinInfo.map((x) => x.sig),
merchant_pub: d.contractTerms.merchant_pub, merchant_pub: d.contractTerms.merchant_pub,
mode: "pay", mode: "pay",
order_id: d.contractTerms.order_id, order_id: d.contractTerms.order_id,
@ -374,7 +367,7 @@ async function recordConfirmPay(
}; };
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.purchases, Stores.proposals], [Stores.coins, Stores.purchases, Stores.proposals, Stores.refreshGroups],
async tx => { async tx => {
const p = await tx.get(Stores.proposals, proposal.proposalId); const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) { if (p) {
@ -384,9 +377,21 @@ async function recordConfirmPay(
await tx.put(Stores.proposals, p); await tx.put(Stores.proposals, p);
} }
await tx.put(Stores.purchases, t); await tx.put(Stores.purchases, t);
for (let c of payCoinInfo.updatedCoins) { for (let coinInfo of payCoinInfo.coinInfo) {
await tx.put(Stores.coins, c); const coin = await tx.get(Stores.coins, coinInfo.coinPub);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
} }
coin.status = CoinStatus.Dormant;
const remaining = Amounts.sub(coin.currentAmount, coinInfo.subtractedAmount);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
await tx.put(Stores.coins, coin);
}
const refreshCoinPubs = payCoinInfo.coinInfo.map((x) => ({coinPub: x.coinPub}));
await createRefreshGroup(tx, refreshCoinPubs, RefreshReason.Pay);
}, },
); );
@ -707,6 +712,8 @@ export async function submitPay(
const merchantResp = await resp.json(); const merchantResp = await resp.json();
console.log("got success from pay URL", merchantResp); console.log("got success from pay URL", merchantResp);
const now = getTimestampNow();
const merchantPub = purchase.contractTerms.merchant_pub; const merchantPub = purchase.contractTerms.merchant_pub;
const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( const valid: boolean = await ws.cryptoApi.isValidPaymentSignature(
merchantResp.sig, merchantResp.sig,
@ -719,7 +726,7 @@ export async function submitPay(
throw Error("merchant payment signature invalid"); throw Error("merchant payment signature invalid");
} }
const isFirst = purchase.firstSuccessfulPayTimestamp === undefined; const isFirst = purchase.firstSuccessfulPayTimestamp === undefined;
purchase.firstSuccessfulPayTimestamp = getTimestampNow(); purchase.firstSuccessfulPayTimestamp = now;
purchase.paymentSubmitPending = false; purchase.paymentSubmitPending = false;
purchase.lastPayError = undefined; purchase.lastPayError = undefined;
purchase.payRetryInfo = initRetryInfo(false); purchase.payRetryInfo = initRetryInfo(false);
@ -734,35 +741,22 @@ export async function submitPay(
purchase.refundStatusRetryInfo = initRetryInfo(); purchase.refundStatusRetryInfo = initRetryInfo();
purchase.lastRefundStatusError = undefined; purchase.lastRefundStatusError = undefined;
purchase.autoRefundDeadline = { purchase.autoRefundDeadline = {
t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms, t_ms: now.t_ms + autoRefundDelay.d_ms,
}; };
} }
} }
} }
const modifiedCoins: CoinRecord[] = [];
for (const pc of purchase.payReq.coins) {
const c = await ws.db.get(Stores.coins, pc.coin_pub);
if (!c) {
console.error("coin not found");
throw Error("coin used in payment not found");
}
c.status = CoinStatus.Dormant;
modifiedCoins.push(c);
}
await ws.db.runWithWriteTransaction( await ws.db.runWithWriteTransaction(
[Stores.coins, Stores.purchases, Stores.refreshGroups], [Stores.purchases, Stores.payEvents],
async tx => { async tx => {
for (let c of modifiedCoins) {
await tx.put(Stores.coins, c);
}
await createRefreshGroup(
tx,
modifiedCoins.map(x => ({ coinPub: x.coinPub })),
RefreshReason.Pay,
);
await tx.put(Stores.purchases, purchase); await tx.put(Stores.purchases, purchase);
const payEvent: PayEventRecord = {
proposalId,
sessionId,
timestamp: now,
};
await tx.put(Stores.payEvents, payEvent);
}, },
); );
@ -861,27 +855,6 @@ export async function preparePay(
}; };
} }
// Only create speculative signature if we don't already have one for this proposal
if (
!ws.speculativePayData ||
(ws.speculativePayData &&
ws.speculativePayData.orderDownloadId !== proposalId)
) {
const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit(
contractTerms,
cds,
totalAmount,
);
ws.speculativePayData = {
exchangeUrl,
payCoinInfo,
proposal,
orderDownloadId: proposalId,
};
logger.trace("created speculative pay data for payment");
}
return { return {
status: "payment-possible", status: "payment-possible",
contractTerms: contractTerms, contractTerms: contractTerms,
@ -901,43 +874,6 @@ export async function preparePay(
}; };
} }
/**
* Get the speculative pay data, but only if coins have not changed in between.
*/
async function getSpeculativePayData(
ws: InternalWalletState,
proposalId: string,
): Promise<SpeculativePayData | undefined> {
const sp = ws.speculativePayData;
if (!sp) {
return;
}
if (sp.orderDownloadId !== proposalId) {
return;
}
const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub);
const coins: CoinRecord[] = [];
for (let coinKey of coinKeys) {
const cc = await ws.db.get(Stores.coins, coinKey);
if (cc) {
coins.push(cc);
}
}
for (let i = 0; i < coins.length; i++) {
const specCoin = sp.payCoinInfo.originalCoins[i];
const currentCoin = coins[i];
// Coin does not exist anymore!
if (!currentCoin) {
return;
}
if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) {
return;
}
}
return sp;
}
/** /**
* Add a contract to the wallet and sign coins, and send them. * Add a contract to the wallet and sign coins, and send them.
*/ */
@ -1008,9 +944,7 @@ export async function confirmPay(
throw Error("insufficient balance"); throw Error("insufficient balance");
} }
const sd = await getSpeculativePayData(ws, proposalId); const { cds, totalAmount } = res;
if (!sd) {
const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit( const payCoinInfo = await ws.cryptoApi.signDeposit(
d.contractTerms, d.contractTerms,
cds, cds,
@ -1020,18 +954,8 @@ export async function confirmPay(
ws, ws,
proposal, proposal,
payCoinInfo, payCoinInfo,
exchangeUrl, sessionIdOverride
sessionIdOverride,
); );
} else {
purchase = await recordConfirmPay(
ws,
sd.proposal,
sd.payCoinInfo,
sd.exchangeUrl,
sessionIdOverride,
);
}
logger.trace("confirmPay: submitting payment after creating purchase record"); logger.trace("confirmPay: submitting payment after creating purchase record");
return submitPay(ws, proposalId); return submitPay(ws, proposalId);

View File

@ -176,7 +176,7 @@ export async function returnCoins(
logger.trace("pci", payCoinInfo); logger.trace("pci", payCoinInfo);
const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); const coins = payCoinInfo.coinInfo.map(s => ({ coinPaySig: s.sig }));
const coinsReturnRecord: CoinsReturnRecord = { const coinsReturnRecord: CoinsReturnRecord = {
coins, coins,
@ -191,8 +191,17 @@ export async function returnCoins(
[Stores.coinsReturns, Stores.coins], [Stores.coinsReturns, Stores.coins],
async tx => { async tx => {
await tx.put(Stores.coinsReturns, coinsReturnRecord); await tx.put(Stores.coinsReturns, coinsReturnRecord);
for (let c of payCoinInfo.updatedCoins) { for (let coinInfo of payCoinInfo.coinInfo) {
await tx.put(Stores.coins, c); const coin = await tx.get(Stores.coins, coinInfo.coinPub);
if (!coin) {
throw Error("coin allocated for deposit not in database anymore");
}
const remaining = Amounts.sub(coin.currentAmount, coinInfo.subtractedAmount);
if (remaining.saturated) {
throw Error("coin allocated for deposit does not have enough balance");
}
coin.currentAmount = remaining.amount;
await tx.put(Stores.coins, coin);
} }
}, },
); );

View File

@ -19,7 +19,6 @@ import {
NextUrlResult, NextUrlResult,
WalletBalance, WalletBalance,
} from "../types/walletTypes"; } from "../types/walletTypes";
import { SpeculativePayData } from "./pay";
import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi"; import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo"; import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
@ -32,7 +31,6 @@ type NotificationListener = (n: WalletNotification) => void;
const logger = new Logger("state.ts"); const logger = new Logger("state.ts");
export class InternalWalletState { export class InternalWalletState {
speculativePayData: SpeculativePayData | undefined = undefined;
cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap();
memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap();

View File

@ -1059,6 +1059,16 @@ export interface PurchaseRefundState {
refundsFailed: { [refundSig: string]: RefundInfo }; refundsFailed: { [refundSig: string]: RefundInfo };
} }
/**
* Record stored for every time we successfully submitted
* a payment to the merchant (both first time and re-play).
*/
export interface PayEventRecord {
proposalId: string;
sessionId: string | undefined;
timestamp: Timestamp;
}
/** /**
* Record that stores status information about one purchase, starting from when * Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable. * the customer accepts a proposal. Includes refund status if applicable.
@ -1432,6 +1442,12 @@ export namespace Stores {
} }
} }
class PayEventsStore extends Store<PayEventRecord> {
constructor() {
super("payEvents", { keyPath: "proposalId" });
}
}
class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> { class BankWithdrawUrisStore extends Store<BankWithdrawUriRecord> {
constructor() { constructor() {
super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); super("bankWithdrawUris", { keyPath: "talerWithdrawUri" });
@ -1457,6 +1473,7 @@ export namespace Stores {
export const withdrawalSession = new WithdrawalSessionsStore(); export const withdrawalSession = new WithdrawalSessionsStore();
export const bankWithdrawUris = new BankWithdrawUrisStore(); export const bankWithdrawUris = new BankWithdrawUrisStore();
export const refundEvents = new RefundEventsStore(); export const refundEvents = new RefundEventsStore();
export const payEvents = new PayEventsStore();
} }
/* tslint:enable:completed-docs */ /* tslint:enable:completed-docs */

View File

@ -195,14 +195,30 @@ export interface WalletBalanceEntry {
pendingIncomingDirty: AmountJson; pendingIncomingDirty: AmountJson;
} }
export interface CoinPayInfo {
/**
* Amount that will be subtracted from the coin when the payment is finalized.
*/
subtractedAmount: AmountJson;
/**
* Public key of the coin that is being spent.
*/
coinPub: string;
/**
* Signature together with the other information needed by the merchant,
* directly in the format expected by the merchant.
*/
sig: CoinPaySig;
}
/** /**
* Coins used for a payment, with signatures authorizing the payment and the * Coins used for a payment, with signatures authorizing the payment and the
* coins with remaining value updated to accomodate for a payment. * coins with remaining value updated to accomodate for a payment.
*/ */
export interface PayCoinInfo { export interface PaySigInfo {
originalCoins: CoinRecord[]; coinInfo: CoinPayInfo[];
updatedCoins: CoinRecord[];
sigs: CoinPaySig[];
} }
/** /**