pending operations (pay/proposals)

This commit is contained in:
Florian Dold 2019-12-03 00:52:15 +01:00
parent a5137c3265
commit c33dd75711
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 512 additions and 363 deletions

View File

@ -200,9 +200,11 @@ export function installAndroidWalletListener() {
const wallet = await wp.promise; const wallet = await wp.promise;
wallet.stop(); wallet.stop();
wp = openPromise<Wallet>(); wp = openPromise<Wallet>();
if (walletArgs && walletArgs.persistentStoragePath) { const oldArgs = walletArgs;
walletArgs = { ...oldArgs };
if (oldArgs && oldArgs.persistentStoragePath) {
try { try {
fs.unlinkSync(walletArgs.persistentStoragePath); fs.unlinkSync(oldArgs.persistentStoragePath);
} catch (e) { } catch (e) {
console.error("Error while deleting the wallet db:", e); console.error("Error while deleting the wallet db:", e);
} }
@ -210,6 +212,12 @@ export function installAndroidWalletListener() {
walletArgs.persistentStoragePath = undefined; walletArgs.persistentStoragePath = undefined;
} }
maybeWallet = undefined; maybeWallet = undefined;
const w = await getDefaultNodeWallet(walletArgs);
maybeWallet = w;
w.runLoopScheduledRetries().catch((e) => {
console.error("Error during wallet retry loop", e);
});
wp.resolve(w);
break; break;
} }
default: default:

View File

@ -586,9 +586,47 @@ export interface CoinRecord {
} }
export enum ProposalStatus { export enum ProposalStatus {
/**
* Not downloaded yet.
*/
DOWNLOADING = "downloading",
/**
* Proposal downloaded, but the user needs to accept/reject it.
*/
PROPOSED = "proposed", PROPOSED = "proposed",
/**
* The user has accepted the proposal.
*/
ACCEPTED = "accepted", ACCEPTED = "accepted",
/**
* The user has rejected the proposal.
*/
REJECTED = "rejected", REJECTED = "rejected",
/**
* Downloaded proposal was detected as a re-purchase.
*/
REPURCHASE = "repurchase",
}
@Checkable.Class()
export class ProposalDownload {
/**
* The contract that was offered by the merchant.
*/
@Checkable.Value(() => ContractTerms)
contractTerms: ContractTerms;
/**
* Signature by the merchant over the contract details.
*/
@Checkable.String()
merchantSig: string;
/**
* Signature by the merchant over the contract details.
*/
@Checkable.String()
contractTermsHash: string;
} }
/** /**
@ -603,22 +641,9 @@ export class ProposalRecord {
url: string; url: string;
/** /**
* The contract that was offered by the merchant. * Downloaded data from the merchant.
*/ */
@Checkable.Value(() => ContractTerms) download: ProposalDownload | undefined;
contractTerms: ContractTerms;
/**
* Signature by the merchant over the contract details.
*/
@Checkable.String()
merchantSig: string;
/**
* Hash of the contract terms.
*/
@Checkable.String()
contractTermsHash: string;
/** /**
* Unique ID when the order is stored in the wallet DB. * Unique ID when the order is stored in the wallet DB.
@ -639,9 +664,18 @@ export class ProposalRecord {
@Checkable.String() @Checkable.String()
noncePriv: string; noncePriv: string;
/**
* Public key for the nonce.
*/
@Checkable.String()
noncePub: string;
@Checkable.String() @Checkable.String()
proposalStatus: ProposalStatus; proposalStatus: ProposalStatus;
@Checkable.String()
repurchaseProposalId: string | undefined;
/** /**
* Session ID we got when downloading the contract. * Session ID we got when downloading the contract.
*/ */
@ -911,6 +945,12 @@ export interface PurchaseRecord {
* The abort (with refund) was completed for this (incomplete!) purchase. * The abort (with refund) was completed for this (incomplete!) purchase.
*/ */
abortDone: boolean; abortDone: boolean;
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
*/
proposalId: string;
} }
/** /**
@ -1076,7 +1116,7 @@ export namespace Stores {
class PurchasesStore extends Store<PurchaseRecord> { class PurchasesStore extends Store<PurchaseRecord> {
constructor() { constructor() {
super("purchases", { keyPath: "contractTermsHash" }); super("purchases", { keyPath: "proposalId" });
} }
fulfillmentUrlIndex = new Index<string, PurchaseRecord>( fulfillmentUrlIndex = new Index<string, PurchaseRecord>(

View File

@ -25,7 +25,6 @@
*/ */
import { openPromise } from "./promiseUtils"; import { openPromise } from "./promiseUtils";
/** /**
* Result of an inner join. * Result of an inner join.
*/ */
@ -67,7 +66,7 @@ export interface IndexOptions {
} }
function requestToPromise(req: IDBRequest): Promise<any> { function requestToPromise(req: IDBRequest): Promise<any> {
const stack = Error("Failed request was started here.") const stack = Error("Failed request was started here.");
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
req.onsuccess = () => { req.onsuccess = () => {
resolve(req.result); resolve(req.result);
@ -103,7 +102,7 @@ export async function oneShotGet<T>(
): Promise<T | undefined> { ): Promise<T | undefined> {
const tx = db.transaction([store.name], "readonly"); const tx = db.transaction([store.name], "readonly");
const req = tx.objectStore(store.name).get(key); const req = tx.objectStore(store.name).get(key);
const v = await requestToPromise(req) const v = await requestToPromise(req);
await transactionToPromise(tx); await transactionToPromise(tx);
return v; return v;
} }
@ -335,6 +334,17 @@ class TransactionHandle {
return requestToPromise(req); return requestToPromise(req);
} }
getIndexed<S extends IDBValidKey, T>(
index: Index<S, T>,
key: any,
): Promise<T | undefined> {
const req = this.tx
.objectStore(index.storeName)
.index(index.indexName)
.get(key);
return requestToPromise(req);
}
iter<T>(store: Store<T>, key?: any): ResultStream<T> { iter<T>(store: Store<T>, key?: any): ResultStream<T> {
const req = this.tx.objectStore(store.name).openCursor(key); const req = this.tx.objectStore(store.name).openCursor(key);
return new ResultStream<T>(req); return new ResultStream<T>(req);
@ -407,18 +417,20 @@ function runWithTransaction<T>(
}; };
const th = new TransactionHandle(tx); const th = new TransactionHandle(tx);
const resP = f(th); const resP = f(th);
resP.then(result => { resP
gotFunResult = true; .then(result => {
funResult = result; gotFunResult = true;
}).catch((e) => { funResult = result;
if (e == TransactionAbort) { })
console.info("aborting transaction"); .catch(e => {
} else { if (e == TransactionAbort) {
tx.abort(); console.info("aborting transaction");
console.error("Transaction failed:", e); } else {
console.error(stack); tx.abort();
} console.error("Transaction failed:", e);
}); console.error(stack);
}
});
}); });
} }

View File

@ -39,6 +39,7 @@ export async function getHistory(
// This works as timestamps are guaranteed to be monotonically // This works as timestamps are guaranteed to be monotonically
// increasing even // increasing even
/*
const proposals = await oneShotIter(ws.db, Stores.proposals).toArray(); const proposals = await oneShotIter(ws.db, Stores.proposals).toArray();
for (const p of proposals) { for (const p of proposals) {
history.push({ history.push({
@ -51,6 +52,7 @@ export async function getHistory(
explicit: false, explicit: false,
}); });
} }
*/
const withdrawals = await oneShotIter( const withdrawals = await oneShotIter(
ws.db, ws.db,

View File

@ -55,6 +55,7 @@ import {
strcmp, strcmp,
extractTalerStamp, extractTalerStamp,
canonicalJson, canonicalJson,
extractTalerStampOrThrow,
} from "../util/helpers"; } from "../util/helpers";
import { Logger } from "../util/logging"; import { Logger } from "../util/logging";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
@ -320,31 +321,41 @@ async function recordConfirmPay(
payCoinInfo: PayCoinInfo, payCoinInfo: PayCoinInfo,
chosenExchange: string, chosenExchange: string,
): Promise<PurchaseRecord> { ): Promise<PurchaseRecord> {
const d = proposal.download;
if (!d) {
throw Error("proposal is in invalid state");
}
const payReq: PayReq = { const payReq: PayReq = {
coins: payCoinInfo.sigs, coins: payCoinInfo.sigs,
merchant_pub: proposal.contractTerms.merchant_pub, merchant_pub: d.contractTerms.merchant_pub,
mode: "pay", mode: "pay",
order_id: proposal.contractTerms.order_id, order_id: d.contractTerms.order_id,
}; };
const t: PurchaseRecord = { const t: PurchaseRecord = {
abortDone: false, abortDone: false,
abortRequested: false, abortRequested: false,
contractTerms: proposal.contractTerms, contractTerms: d.contractTerms,
contractTermsHash: proposal.contractTermsHash, contractTermsHash: d.contractTermsHash,
finished: false, finished: false,
lastSessionId: undefined, lastSessionId: undefined,
merchantSig: proposal.merchantSig, merchantSig: d.merchantSig,
payReq, payReq,
refundsDone: {}, refundsDone: {},
refundsPending: {}, refundsPending: {},
timestamp: getTimestampNow(), timestamp: getTimestampNow(),
timestamp_refund: undefined, timestamp_refund: undefined,
proposalId: proposal.proposalId,
}; };
await runWithWriteTransaction( await runWithWriteTransaction(
ws.db, ws.db,
[Stores.coins, Stores.purchases], [Stores.coins, Stores.purchases, Stores.proposals],
async tx => { async tx => {
const p = await tx.get(Stores.proposals, proposal.proposalId);
if (p) {
p.proposalStatus = ProposalStatus.ACCEPTED;
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 c of payCoinInfo.updatedCoins) {
await tx.put(Stores.coins, c); await tx.put(Stores.coins, c);
@ -360,7 +371,7 @@ async function recordConfirmPay(
function getNextUrl(contractTerms: ContractTerms): string { function getNextUrl(contractTerms: ContractTerms): string {
const f = contractTerms.fulfillment_url; const f = contractTerms.fulfillment_url;
if (f.startsWith("http://") || f.startsWith("https://")) { if (f.startsWith("http://") || f.startsWith("https://")) {
const fu = new URL(contractTerms.fulfillment_url) const fu = new URL(contractTerms.fulfillment_url);
fu.searchParams.set("order_id", contractTerms.order_id); fu.searchParams.set("order_id", contractTerms.order_id);
return fu.href; return fu.href;
} else { } else {
@ -370,9 +381,9 @@ function getNextUrl(contractTerms: ContractTerms): string {
export async function abortFailedPayment( export async function abortFailedPayment(
ws: InternalWalletState, ws: InternalWalletState,
contractTermsHash: string, proposalId: string,
): Promise<void> { ): Promise<void> {
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
if (!purchase) { if (!purchase) {
throw Error("Purchase not found, unable to abort with refund"); throw Error("Purchase not found, unable to abort with refund");
} }
@ -409,7 +420,7 @@ export async function abortFailedPayment(
await acceptRefundResponse(ws, refundResponse); await acceptRefundResponse(ws, refundResponse);
await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => {
const p = await tx.get(Stores.purchases, purchase.contractTermsHash); const p = await tx.get(Stores.purchases, proposalId);
if (!p) { if (!p) {
return; return;
} }
@ -418,30 +429,19 @@ export async function abortFailedPayment(
}); });
} }
/** export async function processDownloadProposal(
* Download a proposal and store it in the database.
* Returns an id for it to retrieve it later.
*
* @param sessionId Current session ID, if the proposal is being
* downloaded in the context of a session ID.
*/
async function downloadProposal(
ws: InternalWalletState, ws: InternalWalletState,
url: string, proposalId: string,
sessionId?: string, ): Promise<void> {
): Promise<string> { const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
const oldProposal = await oneShotGetIndexed( if (!proposal) {
ws.db, return;
Stores.proposals.urlIndex,
url,
);
if (oldProposal) {
return oldProposal.proposalId;
} }
if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) {
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); return;
const parsed_url = new URL(url); }
parsed_url.searchParams.set("nonce", pub); const parsed_url = new URL(proposal.url);
parsed_url.searchParams.set("nonce", proposal.noncePub);
const urlWithNonce = parsed_url.href; const urlWithNonce = parsed_url.href;
console.log("downloading contract from '" + urlWithNonce + "'"); console.log("downloading contract from '" + urlWithNonce + "'");
let resp; let resp;
@ -452,39 +452,103 @@ async function downloadProposal(
throw e; throw e;
} }
const proposal = Proposal.checked(resp.responseJson); const proposalResp = Proposal.checked(resp.responseJson);
const contractTermsHash = await ws.cryptoApi.hashString( const contractTermsHash = await ws.cryptoApi.hashString(
canonicalJson(proposal.contract_terms), canonicalJson(proposalResp.contract_terms),
); );
const fulfillmentUrl = proposalResp.contract_terms.fulfillment_url;
await runWithWriteTransaction(
ws.db,
[Stores.proposals, Stores.purchases],
async tx => {
const p = await tx.get(Stores.proposals, proposalId);
if (!p) {
return;
}
if (p.proposalStatus !== ProposalStatus.DOWNLOADING) {
return;
}
if (
fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://")
) {
const differentPurchase = await tx.getIndexed(
Stores.purchases.fulfillmentUrlIndex,
fulfillmentUrl,
);
if (differentPurchase) {
p.proposalStatus = ProposalStatus.REPURCHASE;
p.repurchaseProposalId = differentPurchase.proposalId;
await tx.put(Stores.proposals, p);
return;
}
}
p.download = {
contractTerms: proposalResp.contract_terms,
merchantSig: proposalResp.sig,
contractTermsHash,
};
p.proposalStatus = ProposalStatus.PROPOSED;
await tx.put(Stores.proposals, p);
},
);
ws.notifier.notify();
}
/**
* Download a proposal and store it in the database.
* Returns an id for it to retrieve it later.
*
* @param sessionId Current session ID, if the proposal is being
* downloaded in the context of a session ID.
*/
async function startDownloadProposal(
ws: InternalWalletState,
url: string,
sessionId?: string,
): Promise<string> {
const oldProposal = await oneShotGetIndexed(
ws.db,
Stores.proposals.urlIndex,
url,
);
if (oldProposal) {
await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId;
}
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
const proposalId = encodeCrock(getRandomBytes(32)); const proposalId = encodeCrock(getRandomBytes(32));
const proposalRecord: ProposalRecord = { const proposalRecord: ProposalRecord = {
contractTerms: proposal.contract_terms, download: undefined,
contractTermsHash,
merchantSig: proposal.sig,
noncePriv: priv, noncePriv: priv,
noncePub: pub,
timestamp: getTimestampNow(), timestamp: getTimestampNow(),
url, url,
downloadSessionId: sessionId, downloadSessionId: sessionId,
proposalId: proposalId, proposalId: proposalId,
proposalStatus: ProposalStatus.PROPOSED, proposalStatus: ProposalStatus.DOWNLOADING,
repurchaseProposalId: undefined,
}; };
await oneShotPut(ws.db, Stores.proposals, proposalRecord);
ws.notifier.notify();
await oneShotPut(ws.db, Stores.proposals, proposalRecord);
await processDownloadProposal(ws, proposalId);
return proposalId; return proposalId;
} }
async function submitPay( export async function submitPay(
ws: InternalWalletState, ws: InternalWalletState,
contractTermsHash: string, proposalId: string,
sessionId: string | undefined, sessionId: string | undefined,
): Promise<ConfirmPayResult> { ): Promise<ConfirmPayResult> {
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
if (!purchase) { if (!purchase) {
throw Error("Purchase not found: " + contractTermsHash); throw Error("Purchase not found: " + proposalId);
} }
if (purchase.abortRequested) { if (purchase.abortRequested) {
throw Error("not submitting payment for aborted purchase"); throw Error("not submitting payment for aborted purchase");
@ -507,7 +571,7 @@ async function submitPay(
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,
contractTermsHash, purchase.contractTermsHash,
merchantPub, merchantPub,
); );
if (!valid) { if (!valid) {
@ -532,14 +596,16 @@ async function submitPay(
[Stores.coins, Stores.purchases], [Stores.coins, Stores.purchases],
async tx => { async tx => {
for (let c of modifiedCoins) { for (let c of modifiedCoins) {
tx.put(Stores.coins, c); await tx.put(Stores.coins, c);
} }
tx.put(Stores.purchases, purchase); await tx.put(Stores.purchases, purchase);
}, },
); );
for (const c of purchase.payReq.coins) { for (const c of purchase.payReq.coins) {
refresh(ws, c.coin_pub); refresh(ws, c.coin_pub).catch(e => {
console.log("error in refreshing after payment:", e);
});
} }
const nextUrl = getNextUrl(purchase.contractTerms); const nextUrl = getNextUrl(purchase.contractTerms);
@ -570,100 +636,67 @@ export async function preparePay(
}; };
} }
let proposalId: string; const proposalId = await startDownloadProposal(
try { ws,
proposalId = await downloadProposal( uriResult.downloadUrl,
ws, uriResult.sessionId,
uriResult.downloadUrl, );
uriResult.sessionId,
); let proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
} catch (e) {
return {
status: "error",
error: e.toString(),
};
}
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
if (!proposal) { if (!proposal) {
throw Error(`could not get proposal ${proposalId}`); throw Error(`could not get proposal ${proposalId}`);
} }
if (proposal.proposalStatus === ProposalStatus.REPURCHASE) {
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
proposal = await oneShotGet(ws.db, Stores.proposals, existingProposalId);
if (!proposal) {
throw Error("existing proposal is in wrong state");
}
}
const d = proposal.download;
if (!d) {
console.error("bad proposal", proposal);
throw Error("proposal is in invalid state");
}
const contractTerms = d.contractTerms;
const merchantSig = d.merchantSig;
if (!contractTerms || !merchantSig) {
throw Error("BUG: proposal is in invalid state");
}
console.log("proposal", proposal); console.log("proposal", proposal);
const differentPurchase = await oneShotGetIndexed(
ws.db,
Stores.purchases.fulfillmentUrlIndex,
proposal.contractTerms.fulfillment_url,
);
let fulfillmentUrl = proposal.contractTerms.fulfillment_url;
let doublePurchaseDetection = false;
if (fulfillmentUrl.startsWith("http")) {
doublePurchaseDetection = true;
}
if (differentPurchase && doublePurchaseDetection) {
// We do this check to prevent merchant B to find out if we bought a
// digital product with merchant A by abusing the existing payment
// redirect feature.
if (
differentPurchase.contractTerms.merchant_pub !=
proposal.contractTerms.merchant_pub
) {
console.warn(
"merchant with different public key offered contract with same fulfillment URL as an existing purchase",
);
} else {
if (uriResult.sessionId) {
await submitPay(
ws,
differentPurchase.contractTermsHash,
uriResult.sessionId,
);
}
return {
status: "paid",
contractTerms: differentPurchase.contractTerms,
nextUrl: getNextUrl(differentPurchase.contractTerms),
};
}
}
// First check if we already payed for it. // First check if we already payed for it.
const purchase = await oneShotGet( const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
ws.db,
Stores.purchases,
proposal.contractTermsHash,
);
if (!purchase) { if (!purchase) {
const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); const paymentAmount = Amounts.parseOrThrow(contractTerms.amount);
let wireFeeLimit; let wireFeeLimit;
if (proposal.contractTerms.max_wire_fee) { if (contractTerms.max_wire_fee) {
wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee);
} else { } else {
wireFeeLimit = Amounts.getZero(paymentAmount.currency); wireFeeLimit = Amounts.getZero(paymentAmount.currency);
} }
// If not already payed, check if we could pay for it. // If not already payed, check if we could pay for it.
const res = await getCoinsForPayment(ws, { const res = await getCoinsForPayment(ws, {
allowedAuditors: proposal.contractTerms.auditors, allowedAuditors: contractTerms.auditors,
allowedExchanges: proposal.contractTerms.exchanges, allowedExchanges: contractTerms.exchanges,
depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee),
paymentAmount, paymentAmount,
wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, wireFeeAmortization: contractTerms.wire_fee_amortization || 1,
wireFeeLimit, wireFeeLimit,
// FIXME: parse this properly wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp),
wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { wireMethod: contractTerms.wire_method,
t_ms: 0,
},
wireMethod: proposal.contractTerms.wire_method,
}); });
if (!res) { if (!res) {
console.log("not confirming payment, insufficient coins"); console.log("not confirming payment, insufficient coins");
return { return {
status: "insufficient-balance", status: "insufficient-balance",
contractTerms: proposal.contractTerms, contractTerms: contractTerms,
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
}; };
} }
@ -676,7 +709,7 @@ export async function preparePay(
) { ) {
const { exchangeUrl, cds, totalAmount } = res; const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit( const payCoinInfo = await ws.cryptoApi.signDeposit(
proposal.contractTerms, contractTerms,
cds, cds,
totalAmount, totalAmount,
); );
@ -691,19 +724,19 @@ export async function preparePay(
return { return {
status: "payment-possible", status: "payment-possible",
contractTerms: proposal.contractTerms, contractTerms: contractTerms,
proposalId: proposal.proposalId, proposalId: proposal.proposalId,
totalFees: res.totalFees, totalFees: res.totalFees,
}; };
} }
if (uriResult.sessionId) { if (uriResult.sessionId) {
await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId); await submitPay(ws, proposalId, uriResult.sessionId);
} }
return { return {
status: "paid", status: "paid",
contractTerms: proposal.contractTerms, contractTerms: purchase.contractTerms,
nextUrl: getNextUrl(purchase.contractTerms), nextUrl: getNextUrl(purchase.contractTerms),
}; };
} }
@ -762,39 +795,37 @@ export async function confirmPay(
throw Error(`proposal with id ${proposalId} not found`); throw Error(`proposal with id ${proposalId} not found`);
} }
const sessionId = sessionIdOverride || proposal.downloadSessionId; const d = proposal.download;
if (!d) {
let purchase = await oneShotGet( throw Error("proposal is in invalid state");
ws.db,
Stores.purchases,
proposal.contractTermsHash,
);
if (purchase) {
return submitPay(ws, purchase.contractTermsHash, sessionId);
} }
const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); const sessionId = sessionIdOverride || proposal.downloadSessionId;
let purchase = await oneShotGet(ws.db, Stores.purchases, d.contractTermsHash);
if (purchase) {
return submitPay(ws, proposalId, sessionId);
}
const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount);
let wireFeeLimit; let wireFeeLimit;
if (!proposal.contractTerms.max_wire_fee) { if (!d.contractTerms.max_wire_fee) {
wireFeeLimit = Amounts.getZero(contractAmount.currency); wireFeeLimit = Amounts.getZero(contractAmount.currency);
} else { } else {
wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee);
} }
const res = await getCoinsForPayment(ws, { const res = await getCoinsForPayment(ws, {
allowedAuditors: proposal.contractTerms.auditors, allowedAuditors: d.contractTerms.auditors,
allowedExchanges: proposal.contractTerms.exchanges, allowedExchanges: d.contractTerms.exchanges,
depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee),
paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount),
wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1,
wireFeeLimit, wireFeeLimit,
// FIXME: parse this properly wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp),
wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { wireMethod: d.contractTerms.wire_method,
t_ms: 0,
},
wireMethod: proposal.contractTerms.wire_method,
}); });
logger.trace("coin selection result", res); logger.trace("coin selection result", res);
@ -809,7 +840,7 @@ export async function confirmPay(
if (!sd) { if (!sd) {
const { exchangeUrl, cds, totalAmount } = res; const { exchangeUrl, cds, totalAmount } = res;
const payCoinInfo = await ws.cryptoApi.signDeposit( const payCoinInfo = await ws.cryptoApi.signDeposit(
proposal.contractTerms, d.contractTerms,
cds, cds,
totalAmount, totalAmount,
); );
@ -823,5 +854,5 @@ export async function confirmPay(
); );
} }
return submitPay(ws, purchase.contractTermsHash, sessionId); return submitPay(ws, proposalId, sessionId);
} }

View File

@ -22,7 +22,7 @@ import {
PendingOperationsResponse, PendingOperationsResponse,
getTimestampNow, getTimestampNow,
} from "../walletTypes"; } from "../walletTypes";
import { oneShotIter } from "../util/query"; import { runWithReadTransaction } from "../util/query";
import { InternalWalletState } from "./state"; import { InternalWalletState } from "./state";
import { import {
Stores, Stores,
@ -37,187 +37,212 @@ export async function getPendingOperations(
): Promise<PendingOperationsResponse> { ): Promise<PendingOperationsResponse> {
const pendingOperations: PendingOperationInfo[] = []; const pendingOperations: PendingOperationInfo[] = [];
let minRetryDurationMs = 5000; let minRetryDurationMs = 5000;
const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); await runWithReadTransaction(
for (let e of exchanges) { ws.db,
switch (e.updateStatus) { [
case ExchangeUpdateStatus.FINISHED: Stores.exchanges,
if (e.lastError) { Stores.reserves,
pendingOperations.push({ Stores.refresh,
type: "bug", Stores.coins,
message: Stores.withdrawalSession,
"Exchange record is in FINISHED state but has lastError set", Stores.proposals,
details: { Stores.tips,
],
async tx => {
await tx.iter(Stores.exchanges).forEach(e => {
switch (e.updateStatus) {
case ExchangeUpdateStatus.FINISHED:
if (e.lastError) {
pendingOperations.push({
type: "bug",
message:
"Exchange record is in FINISHED state but has lastError set",
details: {
exchangeBaseUrl: e.baseUrl,
},
});
}
if (!e.details) {
pendingOperations.push({
type: "bug",
message:
"Exchange record does not have details, but no update in progress.",
details: {
exchangeBaseUrl: e.baseUrl,
},
});
}
if (!e.wireInfo) {
pendingOperations.push({
type: "bug",
message:
"Exchange record does not have wire info, but no update in progress.",
details: {
exchangeBaseUrl: e.baseUrl,
},
});
}
break;
case ExchangeUpdateStatus.FETCH_KEYS:
pendingOperations.push({
type: "exchange-update",
stage: "fetch-keys",
exchangeBaseUrl: e.baseUrl, exchangeBaseUrl: e.baseUrl,
}, lastError: e.lastError,
reason: e.updateReason || "unknown",
});
break;
case ExchangeUpdateStatus.FETCH_WIRE:
pendingOperations.push({
type: "exchange-update",
stage: "fetch-wire",
exchangeBaseUrl: e.baseUrl,
lastError: e.lastError,
reason: e.updateReason || "unknown",
});
break;
default:
pendingOperations.push({
type: "bug",
message: "Unknown exchangeUpdateStatus",
details: {
exchangeBaseUrl: e.baseUrl,
exchangeUpdateStatus: e.updateStatus,
},
});
break;
}
});
await tx.iter(Stores.reserves).forEach(reserve => {
const reserveType = reserve.bankWithdrawStatusUrl
? "taler-bank"
: "manual";
const now = getTimestampNow();
switch (reserve.reserveStatus) {
case ReserveRecordStatus.DORMANT:
// nothing to report as pending
break;
case ReserveRecordStatus.WITHDRAWING:
case ReserveRecordStatus.UNCONFIRMED:
case ReserveRecordStatus.QUERYING_STATUS:
case ReserveRecordStatus.REGISTERING_BANK:
pendingOperations.push({
type: "reserve",
stage: reserve.reserveStatus,
timestampCreated: reserve.created,
reserveType,
reservePub: reserve.reservePub,
});
if (reserve.created.t_ms < now.t_ms - 5000) {
minRetryDurationMs = 500;
} else if (reserve.created.t_ms < now.t_ms - 30000) {
minRetryDurationMs = 2000;
}
break;
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
pendingOperations.push({
type: "reserve",
stage: reserve.reserveStatus,
timestampCreated: reserve.created,
reserveType,
reservePub: reserve.reservePub,
bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl,
});
if (reserve.created.t_ms < now.t_ms - 5000) {
minRetryDurationMs = 500;
} else if (reserve.created.t_ms < now.t_ms - 30000) {
minRetryDurationMs = 2000;
}
break;
default:
pendingOperations.push({
type: "bug",
message: "Unknown reserve record status",
details: {
reservePub: reserve.reservePub,
reserveStatus: reserve.reserveStatus,
},
});
break;
}
});
await tx.iter(Stores.refresh).forEach(r => {
if (r.finished) {
return;
}
let refreshStatus: string;
if (r.norevealIndex === undefined) {
refreshStatus = "melt";
} else {
refreshStatus = "reveal";
}
pendingOperations.push({
type: "refresh",
oldCoinPub: r.meltCoinPub,
refreshStatus,
refreshOutputSize: r.newDenoms.length,
refreshSessionId: r.refreshSessionId,
});
});
await tx.iter(Stores.coins).forEach(coin => {
if (coin.status == CoinStatus.Dirty) {
pendingOperations.push({
type: "dirty-coin",
coinPub: coin.coinPub,
}); });
} }
if (!e.details) { });
await tx.iter(Stores.withdrawalSession).forEach(ws => {
const numCoinsWithdrawn = ws.withdrawn.reduce(
(a, x) => a + (x ? 1 : 0),
0,
);
const numCoinsTotal = ws.withdrawn.length;
if (numCoinsWithdrawn < numCoinsTotal) {
pendingOperations.push({ pendingOperations.push({
type: "bug", type: "withdraw",
message: numCoinsTotal,
"Exchange record does not have details, but no update in progress.", numCoinsWithdrawn,
details: { source: ws.source,
exchangeBaseUrl: e.baseUrl, withdrawSessionId: ws.withdrawSessionId,
},
}); });
} }
if (!e.wireInfo) { });
await tx.iter(Stores.proposals).forEach((proposal) => {
if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
pendingOperations.push({ pendingOperations.push({
type: "bug", type: "proposal-choice",
message: merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url,
"Exchange record does not have wire info, but no update in progress.", proposalId: proposal.proposalId,
details: { proposalTimestamp: proposal.timestamp,
exchangeBaseUrl: e.baseUrl, });
}, } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) {
pendingOperations.push({
type: "proposal-download",
merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url,
proposalId: proposal.proposalId,
proposalTimestamp: proposal.timestamp,
}); });
} }
break; });
case ExchangeUpdateStatus.FETCH_KEYS:
pendingOperations.push({ await tx.iter(Stores.tips).forEach((tip) => {
type: "exchange-update", if (tip.accepted && !tip.pickedUp) {
stage: "fetch-keys", pendingOperations.push({
exchangeBaseUrl: e.baseUrl, type: "tip",
lastError: e.lastError, merchantBaseUrl: tip.merchantBaseUrl,
reason: e.updateReason || "unknown", tipId: tip.tipId,
}); merchantTipId: tip.merchantTipId,
break; });
case ExchangeUpdateStatus.FETCH_WIRE:
pendingOperations.push({
type: "exchange-update",
stage: "fetch-wire",
exchangeBaseUrl: e.baseUrl,
lastError: e.lastError,
reason: e.updateReason || "unknown",
});
break;
default:
pendingOperations.push({
type: "bug",
message: "Unknown exchangeUpdateStatus",
details: {
exchangeBaseUrl: e.baseUrl,
exchangeUpdateStatus: e.updateStatus,
},
});
break;
}
}
await oneShotIter(ws.db, Stores.reserves).forEach(reserve => {
const reserveType = reserve.bankWithdrawStatusUrl ? "taler-bank" : "manual";
const now = getTimestampNow();
switch (reserve.reserveStatus) {
case ReserveRecordStatus.DORMANT:
// nothing to report as pending
break;
case ReserveRecordStatus.WITHDRAWING:
case ReserveRecordStatus.UNCONFIRMED:
case ReserveRecordStatus.QUERYING_STATUS:
case ReserveRecordStatus.REGISTERING_BANK:
pendingOperations.push({
type: "reserve",
stage: reserve.reserveStatus,
timestampCreated: reserve.created,
reserveType,
reservePub: reserve.reservePub,
});
if (reserve.created.t_ms < now.t_ms - 5000) {
minRetryDurationMs = 500;
} else if (reserve.created.t_ms < now.t_ms - 30000) {
minRetryDurationMs = 2000;
} }
break;
case ReserveRecordStatus.WAIT_CONFIRM_BANK:
pendingOperations.push({
type: "reserve",
stage: reserve.reserveStatus,
timestampCreated: reserve.created,
reserveType,
reservePub: reserve.reservePub,
bankWithdrawConfirmUrl: reserve.bankWithdrawConfirmUrl,
});
if (reserve.created.t_ms < now.t_ms - 5000) {
minRetryDurationMs = 500;
} else if (reserve.created.t_ms < now.t_ms - 30000) {
minRetryDurationMs = 2000;
}
break;
default:
pendingOperations.push({
type: "bug",
message: "Unknown reserve record status",
details: {
reservePub: reserve.reservePub,
reserveStatus: reserve.reserveStatus,
},
});
break;
}
});
await oneShotIter(ws.db, Stores.refresh).forEach(r => {
if (r.finished) {
return;
}
let refreshStatus: string;
if (r.norevealIndex === undefined) {
refreshStatus = "melt";
} else {
refreshStatus = "reveal";
}
pendingOperations.push({
type: "refresh",
oldCoinPub: r.meltCoinPub,
refreshStatus,
refreshOutputSize: r.newDenoms.length,
refreshSessionId: r.refreshSessionId,
});
});
await oneShotIter(ws.db, Stores.coins).forEach(coin => {
if (coin.status == CoinStatus.Dirty) {
pendingOperations.push({
type: "dirty-coin",
coinPub: coin.coinPub,
}); });
} },
}); );
await oneShotIter(ws.db, Stores.withdrawalSession).forEach(ws => {
const numCoinsWithdrawn = ws.withdrawn.reduce((a, x) => a + (x ? 1 : 0), 0);
const numCoinsTotal = ws.withdrawn.length;
if (numCoinsWithdrawn < numCoinsTotal) {
pendingOperations.push({
type: "withdraw",
numCoinsTotal,
numCoinsWithdrawn,
source: ws.source,
withdrawSessionId: ws.withdrawSessionId,
});
}
});
await oneShotIter(ws.db, Stores.proposals).forEach(proposal => {
if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
pendingOperations.push({
type: "proposal",
merchantBaseUrl: proposal.contractTerms.merchant_base_url,
proposalId: proposal.proposalId,
proposalTimestamp: proposal.timestamp,
});
}
});
await oneShotIter(ws.db, Stores.tips).forEach(tip => {
if (tip.accepted && !tip.pickedUp) {
pendingOperations.push({
type: "tip",
merchantBaseUrl: tip.merchantBaseUrl,
tipId: tip.tipId,
merchantTipId: tip.merchantTipId,
});
}
});
return { return {
pendingOperations, pendingOperations,

View File

@ -91,13 +91,12 @@ export async function getFullRefundFees(
async function submitRefunds( async function submitRefunds(
ws: InternalWalletState, ws: InternalWalletState,
contractTermsHash: string, proposalId: string,
): Promise<void> { ): Promise<void> {
const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId);
if (!purchase) { if (!purchase) {
console.error( console.error(
"not submitting refunds, contract terms not found:", "not submitting refunds, payment not found:",
contractTermsHash,
); );
return; return;
} }
@ -160,7 +159,7 @@ async function submitRefunds(
ws.db, ws.db,
[Stores.purchases, Stores.coins], [Stores.purchases, Stores.coins],
async tx => { async tx => {
await tx.mutate(Stores.purchases, contractTermsHash, transformPurchase); await tx.mutate(Stores.purchases, proposalId, transformPurchase);
await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); await tx.mutate(Stores.coins, perm.coin_pub, transformCoin);
}, },
); );

View File

@ -344,10 +344,16 @@ async function updateReserve(
resp = await ws.http.get(reqUrl.href); resp = await ws.http.get(reqUrl.href);
} catch (e) { } catch (e) {
if (e.response?.status === 404) { if (e.response?.status === 404) {
return; const m = "The exchange does not know about this reserve (yet).";
await setReserveError(ws, reservePub, {
type: "waiting",
details: {},
message: "The exchange does not know about this reserve (yet).",
});
throw new OperationFailedAndReportedError(m);
} else { } else {
const m = e.message; const m = e.message;
setReserveError(ws, reservePub, { await setReserveError(ws, reservePub, {
type: "network", type: "network",
details: {}, details: {},
message: m, message: m,

View File

@ -46,6 +46,7 @@ import {
abortFailedPayment, abortFailedPayment,
preparePay, preparePay,
confirmPay, confirmPay,
processDownloadProposal,
} from "./wallet-impl/pay"; } from "./wallet-impl/pay";
import { import {
@ -227,12 +228,17 @@ export class Wallet {
case "withdraw": case "withdraw":
await processWithdrawSession(this.ws, pending.withdrawSessionId); await processWithdrawSession(this.ws, pending.withdrawSessionId);
break; break;
case "proposal": case "proposal-choice":
// Nothing to do, user needs to accept/reject // Nothing to do, user needs to accept/reject
break; break;
case "proposal-download":
await processDownloadProposal(this.ws, pending.proposalId);
break;
case "tip": case "tip":
await processTip(this.ws, pending.tipId); await processTip(this.ws, pending.tipId);
break; break;
case "pay":
break;
default: default:
assertUnreachable(pending); assertUnreachable(pending);
} }

View File

@ -578,13 +578,25 @@ export interface PendingRefreshOperation {
refreshOutputSize: number; refreshOutputSize: number;
} }
export interface PendingDirtyCoinOperation { export interface PendingDirtyCoinOperation {
type: "dirty-coin"; type: "dirty-coin";
coinPub: string; coinPub: string;
} }
export interface PendingProposalOperation { export interface PendingProposalDownloadOperation {
type: "proposal"; type: "proposal-download";
merchantBaseUrl: string;
proposalTimestamp: Timestamp;
proposalId: string;
}
/**
* User must choose whether to accept or reject the merchant's
* proposed contract terms.
*/
export interface PendingProposalChoiceOperation {
type: "proposal-choice";
merchantBaseUrl: string; merchantBaseUrl: string;
proposalTimestamp: Timestamp; proposalTimestamp: Timestamp;
proposalId: string; proposalId: string;
@ -597,6 +609,12 @@ export interface PendingTipOperation {
merchantTipId: string; merchantTipId: string;
} }
export interface PendingPayOperation {
type: "pay";
proposalId: string;
isReplay: boolean;
}
export type PendingOperationInfo = export type PendingOperationInfo =
| PendingWithdrawOperation | PendingWithdrawOperation
| PendingReserveOperation | PendingReserveOperation
@ -605,7 +623,9 @@ export type PendingOperationInfo =
| PendingExchangeUpdateOperation | PendingExchangeUpdateOperation
| PendingRefreshOperation | PendingRefreshOperation
| PendingTipOperation | PendingTipOperation
| PendingProposalOperation; | PendingProposalDownloadOperation
| PendingProposalChoiceOperation
| PendingPayOperation;
export interface PendingOperationsResponse { export interface PendingOperationsResponse {
pendingOperations: PendingOperationInfo[]; pendingOperations: PendingOperationInfo[];