diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-12-03 00:52:15 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-12-03 00:52:15 +0100 |
commit | c33dd75711a39403bd4dd9940caab6d5e6ad2d77 (patch) | |
tree | 7d7d9c64b5074a8f533302add3b1674c5d424c8d | |
parent | a5137c32650b0b9aa2abbe55e4f4f3f60ed78e07 (diff) |
pending operations (pay/proposals)
-rw-r--r-- | src/android/index.ts | 12 | ||||
-rw-r--r-- | src/dbTypes.ts | 64 | ||||
-rw-r--r-- | src/util/query.ts | 42 | ||||
-rw-r--r-- | src/wallet-impl/history.ts | 2 | ||||
-rw-r--r-- | src/wallet-impl/pay.ts | 309 | ||||
-rw-r--r-- | src/wallet-impl/pending.ts | 357 | ||||
-rw-r--r-- | src/wallet-impl/refund.ts | 9 | ||||
-rw-r--r-- | src/wallet-impl/reserves.ts | 10 | ||||
-rw-r--r-- | src/wallet.ts | 8 | ||||
-rw-r--r-- | src/walletTypes.ts | 26 |
10 files changed, 494 insertions, 345 deletions
diff --git a/src/android/index.ts b/src/android/index.ts index 711441769..fb62a5b5a 100644 --- a/src/android/index.ts +++ b/src/android/index.ts @@ -200,9 +200,11 @@ export function installAndroidWalletListener() { const wallet = await wp.promise; wallet.stop(); wp = openPromise<Wallet>(); - if (walletArgs && walletArgs.persistentStoragePath) { + const oldArgs = walletArgs; + walletArgs = { ...oldArgs }; + if (oldArgs && oldArgs.persistentStoragePath) { try { - fs.unlinkSync(walletArgs.persistentStoragePath); + fs.unlinkSync(oldArgs.persistentStoragePath); } catch (e) { console.error("Error while deleting the wallet db:", e); } @@ -210,6 +212,12 @@ export function installAndroidWalletListener() { walletArgs.persistentStoragePath = 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; } default: diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 731f0358b..4f374c260 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -586,22 +586,30 @@ export interface CoinRecord { } export enum ProposalStatus { + /** + * Not downloaded yet. + */ + DOWNLOADING = "downloading", + /** + * Proposal downloaded, but the user needs to accept/reject it. + */ PROPOSED = "proposed", + /** + * The user has accepted the proposal. + */ ACCEPTED = "accepted", + /** + * The user has rejected the proposal. + */ REJECTED = "rejected", -} - -/** - * Record for a downloaded order, stored in the wallet's database. - */ -@Checkable.Class() -export class ProposalRecord { /** - * URL where the proposal was downloaded. + * Downloaded proposal was detected as a re-purchase. */ - @Checkable.String() - url: string; + REPURCHASE = "repurchase", +} +@Checkable.Class() +export class ProposalDownload { /** * The contract that was offered by the merchant. */ @@ -615,10 +623,27 @@ export class ProposalRecord { merchantSig: string; /** - * Hash of the contract terms. + * Signature by the merchant over the contract details. */ @Checkable.String() contractTermsHash: string; +} + +/** + * Record for a downloaded order, stored in the wallet's database. + */ +@Checkable.Class() +export class ProposalRecord { + /** + * URL where the proposal was downloaded. + */ + @Checkable.String() + url: string; + + /** + * Downloaded data from the merchant. + */ + download: ProposalDownload | undefined; /** * Unique ID when the order is stored in the wallet DB. @@ -639,9 +664,18 @@ export class ProposalRecord { @Checkable.String() noncePriv: string; + /** + * Public key for the nonce. + */ + @Checkable.String() + noncePub: string; + @Checkable.String() proposalStatus: ProposalStatus; + @Checkable.String() + repurchaseProposalId: string | undefined; + /** * 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. */ 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> { constructor() { - super("purchases", { keyPath: "contractTermsHash" }); + super("purchases", { keyPath: "proposalId" }); } fulfillmentUrlIndex = new Index<string, PurchaseRecord>( diff --git a/src/util/query.ts b/src/util/query.ts index 6942d471e..b1b19665b 100644 --- a/src/util/query.ts +++ b/src/util/query.ts @@ -25,7 +25,6 @@ */ import { openPromise } from "./promiseUtils"; - /** * Result of an inner join. */ @@ -67,7 +66,7 @@ export interface IndexOptions { } 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) => { req.onsuccess = () => { resolve(req.result); @@ -103,7 +102,7 @@ export async function oneShotGet<T>( ): Promise<T | undefined> { const tx = db.transaction([store.name], "readonly"); const req = tx.objectStore(store.name).get(key); - const v = await requestToPromise(req) + const v = await requestToPromise(req); await transactionToPromise(tx); return v; } @@ -335,6 +334,17 @@ class TransactionHandle { 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> { const req = this.tx.objectStore(store.name).openCursor(key); return new ResultStream<T>(req); @@ -407,18 +417,20 @@ function runWithTransaction<T>( }; const th = new TransactionHandle(tx); const resP = f(th); - resP.then(result => { - gotFunResult = true; - funResult = result; - }).catch((e) => { - if (e == TransactionAbort) { - console.info("aborting transaction"); - } else { - tx.abort(); - console.error("Transaction failed:", e); - console.error(stack); - } - }); + resP + .then(result => { + gotFunResult = true; + funResult = result; + }) + .catch(e => { + if (e == TransactionAbort) { + console.info("aborting transaction"); + } else { + tx.abort(); + console.error("Transaction failed:", e); + console.error(stack); + } + }); }); } diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts index 976dab885..f5a4e9d3e 100644 --- a/src/wallet-impl/history.ts +++ b/src/wallet-impl/history.ts @@ -39,6 +39,7 @@ export async function getHistory( // This works as timestamps are guaranteed to be monotonically // increasing even + /* const proposals = await oneShotIter(ws.db, Stores.proposals).toArray(); for (const p of proposals) { history.push({ @@ -51,6 +52,7 @@ export async function getHistory( explicit: false, }); } + */ const withdrawals = await oneShotIter( ws.db, diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts index 31a1500ec..69144d2d6 100644 --- a/src/wallet-impl/pay.ts +++ b/src/wallet-impl/pay.ts @@ -55,6 +55,7 @@ import { strcmp, extractTalerStamp, canonicalJson, + extractTalerStampOrThrow, } from "../util/helpers"; import { Logger } from "../util/logging"; import { InternalWalletState } from "./state"; @@ -320,31 +321,41 @@ async function recordConfirmPay( payCoinInfo: PayCoinInfo, chosenExchange: string, ): Promise<PurchaseRecord> { + const d = proposal.download; + if (!d) { + throw Error("proposal is in invalid state"); + } const payReq: PayReq = { coins: payCoinInfo.sigs, - merchant_pub: proposal.contractTerms.merchant_pub, + merchant_pub: d.contractTerms.merchant_pub, mode: "pay", - order_id: proposal.contractTerms.order_id, + order_id: d.contractTerms.order_id, }; const t: PurchaseRecord = { abortDone: false, abortRequested: false, - contractTerms: proposal.contractTerms, - contractTermsHash: proposal.contractTermsHash, + contractTerms: d.contractTerms, + contractTermsHash: d.contractTermsHash, finished: false, lastSessionId: undefined, - merchantSig: proposal.merchantSig, + merchantSig: d.merchantSig, payReq, refundsDone: {}, refundsPending: {}, timestamp: getTimestampNow(), timestamp_refund: undefined, + proposalId: proposal.proposalId, }; await runWithWriteTransaction( ws.db, - [Stores.coins, Stores.purchases], + [Stores.coins, Stores.purchases, Stores.proposals], 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); for (let c of payCoinInfo.updatedCoins) { await tx.put(Stores.coins, c); @@ -360,7 +371,7 @@ async function recordConfirmPay( function getNextUrl(contractTerms: ContractTerms): string { const f = contractTerms.fulfillment_url; 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); return fu.href; } else { @@ -370,9 +381,9 @@ function getNextUrl(contractTerms: ContractTerms): string { export async function abortFailedPayment( ws: InternalWalletState, - contractTermsHash: string, + proposalId: string, ): Promise<void> { - const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); if (!purchase) { throw Error("Purchase not found, unable to abort with refund"); } @@ -409,7 +420,7 @@ export async function abortFailedPayment( await acceptRefundResponse(ws, refundResponse); 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) { return; } @@ -418,6 +429,76 @@ export async function abortFailedPayment( }); } +export async function processDownloadProposal( + ws: InternalWalletState, + proposalId: string, +): Promise<void> { + const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + if (!proposal) { + return; + } + if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) { + return; + } + const parsed_url = new URL(proposal.url); + parsed_url.searchParams.set("nonce", proposal.noncePub); + const urlWithNonce = parsed_url.href; + console.log("downloading contract from '" + urlWithNonce + "'"); + let resp; + try { + resp = await ws.http.get(urlWithNonce); + } catch (e) { + console.log("contract download failed", e); + throw e; + } + + const proposalResp = Proposal.checked(resp.responseJson); + + const contractTermsHash = await ws.cryptoApi.hashString( + 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. @@ -425,7 +506,7 @@ export async function abortFailedPayment( * @param sessionId Current session ID, if the proposal is being * downloaded in the context of a session ID. */ -async function downloadProposal( +async function startDownloadProposal( ws: InternalWalletState, url: string, sessionId?: string, @@ -436,55 +517,38 @@ async function downloadProposal( url, ); if (oldProposal) { + await processDownloadProposal(ws, oldProposal.proposalId); return oldProposal.proposalId; } const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); - const parsed_url = new URL(url); - parsed_url.searchParams.set("nonce", pub); - const urlWithNonce = parsed_url.href; - console.log("downloading contract from '" + urlWithNonce + "'"); - let resp; - try { - resp = await ws.http.get(urlWithNonce); - } catch (e) { - console.log("contract download failed", e); - throw e; - } - - const proposal = Proposal.checked(resp.responseJson); - - const contractTermsHash = await ws.cryptoApi.hashString( - canonicalJson(proposal.contract_terms), - ); - const proposalId = encodeCrock(getRandomBytes(32)); const proposalRecord: ProposalRecord = { - contractTerms: proposal.contract_terms, - contractTermsHash, - merchantSig: proposal.sig, + download: undefined, noncePriv: priv, + noncePub: pub, timestamp: getTimestampNow(), url, downloadSessionId: sessionId, 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; } -async function submitPay( +export async function submitPay( ws: InternalWalletState, - contractTermsHash: string, + proposalId: string, sessionId: string | undefined, ): Promise<ConfirmPayResult> { - const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); if (!purchase) { - throw Error("Purchase not found: " + contractTermsHash); + throw Error("Purchase not found: " + proposalId); } if (purchase.abortRequested) { throw Error("not submitting payment for aborted purchase"); @@ -507,7 +571,7 @@ async function submitPay( const merchantPub = purchase.contractTerms.merchant_pub; const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( merchantResp.sig, - contractTermsHash, + purchase.contractTermsHash, merchantPub, ); if (!valid) { @@ -532,14 +596,16 @@ async function submitPay( [Stores.coins, Stores.purchases], async tx => { 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) { - 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); @@ -570,100 +636,67 @@ export async function preparePay( }; } - let proposalId: string; - try { - proposalId = await downloadProposal( - ws, - uriResult.downloadUrl, - uriResult.sessionId, - ); - } catch (e) { - return { - status: "error", - error: e.toString(), - }; - } - const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); - if (!proposal) { - throw Error(`could not get proposal ${proposalId}`); - } - - console.log("proposal", proposal); - - const differentPurchase = await oneShotGetIndexed( - ws.db, - Stores.purchases.fulfillmentUrlIndex, - proposal.contractTerms.fulfillment_url, + const proposalId = await startDownloadProposal( + ws, + uriResult.downloadUrl, + uriResult.sessionId, ); - let fulfillmentUrl = proposal.contractTerms.fulfillment_url; - let doublePurchaseDetection = false; - if (fulfillmentUrl.startsWith("http")) { - doublePurchaseDetection = true; + let proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); + if (!proposal) { + throw Error(`could not get proposal ${proposalId}`); } - - 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), - }; + 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); + // First check if we already payed for it. - const purchase = await oneShotGet( - ws.db, - Stores.purchases, - proposal.contractTermsHash, - ); + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); if (!purchase) { - const paymentAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + const paymentAmount = Amounts.parseOrThrow(contractTerms.amount); let wireFeeLimit; - if (proposal.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + if (contractTerms.max_wire_fee) { + wireFeeLimit = Amounts.parseOrThrow(contractTerms.max_wire_fee); } else { wireFeeLimit = Amounts.getZero(paymentAmount.currency); } // If not already payed, check if we could pay for it. const res = await getCoinsForPayment(ws, { - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), + allowedAuditors: contractTerms.auditors, + allowedExchanges: contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee), paymentAmount, - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + wireFeeAmortization: contractTerms.wire_fee_amortization || 1, wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, + wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp), + wireMethod: contractTerms.wire_method, }); if (!res) { console.log("not confirming payment, insufficient coins"); return { status: "insufficient-balance", - contractTerms: proposal.contractTerms, + contractTerms: contractTerms, proposalId: proposal.proposalId, }; } @@ -676,7 +709,7 @@ export async function preparePay( ) { const { exchangeUrl, cds, totalAmount } = res; const payCoinInfo = await ws.cryptoApi.signDeposit( - proposal.contractTerms, + contractTerms, cds, totalAmount, ); @@ -691,19 +724,19 @@ export async function preparePay( return { status: "payment-possible", - contractTerms: proposal.contractTerms, + contractTerms: contractTerms, proposalId: proposal.proposalId, totalFees: res.totalFees, }; } if (uriResult.sessionId) { - await submitPay(ws, purchase.contractTermsHash, uriResult.sessionId); + await submitPay(ws, proposalId, uriResult.sessionId); } return { status: "paid", - contractTerms: proposal.contractTerms, + contractTerms: purchase.contractTerms, nextUrl: getNextUrl(purchase.contractTerms), }; } @@ -762,39 +795,37 @@ export async function confirmPay( throw Error(`proposal with id ${proposalId} not found`); } + const d = proposal.download; + if (!d) { + throw Error("proposal is in invalid state"); + } + const sessionId = sessionIdOverride || proposal.downloadSessionId; - let purchase = await oneShotGet( - ws.db, - Stores.purchases, - proposal.contractTermsHash, - ); + let purchase = await oneShotGet(ws.db, Stores.purchases, d.contractTermsHash); if (purchase) { - return submitPay(ws, purchase.contractTermsHash, sessionId); + return submitPay(ws, proposalId, sessionId); } - const contractAmount = Amounts.parseOrThrow(proposal.contractTerms.amount); + const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount); let wireFeeLimit; - if (!proposal.contractTerms.max_wire_fee) { + if (!d.contractTerms.max_wire_fee) { wireFeeLimit = Amounts.getZero(contractAmount.currency); } else { - wireFeeLimit = Amounts.parseOrThrow(proposal.contractTerms.max_wire_fee); + wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee); } const res = await getCoinsForPayment(ws, { - allowedAuditors: proposal.contractTerms.auditors, - allowedExchanges: proposal.contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(proposal.contractTerms.max_fee), - paymentAmount: Amounts.parseOrThrow(proposal.contractTerms.amount), - wireFeeAmortization: proposal.contractTerms.wire_fee_amortization || 1, + allowedAuditors: d.contractTerms.auditors, + allowedExchanges: d.contractTerms.exchanges, + depositFeeLimit: Amounts.parseOrThrow(d.contractTerms.max_fee), + paymentAmount: Amounts.parseOrThrow(d.contractTerms.amount), + wireFeeAmortization: d.contractTerms.wire_fee_amortization || 1, wireFeeLimit, - // FIXME: parse this properly - wireFeeTime: extractTalerStamp(proposal.contractTerms.timestamp) || { - t_ms: 0, - }, - wireMethod: proposal.contractTerms.wire_method, + wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp), + wireMethod: d.contractTerms.wire_method, }); logger.trace("coin selection result", res); @@ -809,7 +840,7 @@ export async function confirmPay( if (!sd) { const { exchangeUrl, cds, totalAmount } = res; const payCoinInfo = await ws.cryptoApi.signDeposit( - proposal.contractTerms, + d.contractTerms, cds, totalAmount, ); @@ -823,5 +854,5 @@ export async function confirmPay( ); } - return submitPay(ws, purchase.contractTermsHash, sessionId); + return submitPay(ws, proposalId, sessionId); } diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts index dbc6672c7..72102e3a1 100644 --- a/src/wallet-impl/pending.ts +++ b/src/wallet-impl/pending.ts @@ -22,7 +22,7 @@ import { PendingOperationsResponse, getTimestampNow, } from "../walletTypes"; -import { oneShotIter } from "../util/query"; +import { runWithReadTransaction } from "../util/query"; import { InternalWalletState } from "./state"; import { Stores, @@ -37,187 +37,212 @@ export async function getPendingOperations( ): Promise<PendingOperationsResponse> { const pendingOperations: PendingOperationInfo[] = []; let minRetryDurationMs = 5000; - const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); - for (let e of exchanges) { - 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: { + await runWithReadTransaction( + ws.db, + [ + Stores.exchanges, + Stores.reserves, + Stores.refresh, + Stores.coins, + Stores.withdrawalSession, + Stores.proposals, + 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, - }, - }); - } - if (!e.details) { - pendingOperations.push({ - type: "bug", - message: - "Exchange record does not have details, but no update in progress.", - details: { + 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; } - if (!e.wireInfo) { - pendingOperations.push({ - type: "bug", - message: - "Exchange record does not have wire info, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); + }); + 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; } - break; - case ExchangeUpdateStatus.FETCH_KEYS: - pendingOperations.push({ - type: "exchange-update", - stage: "fetch-keys", - 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 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; + }); + + await tx.iter(Stores.refresh).forEach(r => { + if (r.finished) { + return; } - 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; + let refreshStatus: string; + if (r.norevealIndex === undefined) { + refreshStatus = "melt"; + } else { + refreshStatus = "reveal"; } - break; - default: + pendingOperations.push({ - type: "bug", - message: "Unknown reserve record status", - details: { - reservePub: reserve.reservePub, - reserveStatus: reserve.reserveStatus, - }, + type: "refresh", + oldCoinPub: r.meltCoinPub, + refreshStatus, + refreshOutputSize: r.newDenoms.length, + refreshSessionId: r.refreshSessionId, }); - 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 tx.iter(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 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({ + 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 tx.iter(Stores.proposals).forEach((proposal) => { + if (proposal.proposalStatus == ProposalStatus.PROPOSED) { + pendingOperations.push({ + type: "proposal-choice", + merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url, + proposalId: proposal.proposalId, + proposalTimestamp: proposal.timestamp, + }); + } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) { + pendingOperations.push({ + type: "proposal-download", + merchantBaseUrl: proposal.download!!.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, + await tx.iter(Stores.tips).forEach((tip) => { + if (tip.accepted && !tip.pickedUp) { + pendingOperations.push({ + type: "tip", + merchantBaseUrl: tip.merchantBaseUrl, + tipId: tip.tipId, + merchantTipId: tip.merchantTipId, + }); + } }); - } - }); + }, + ); return { pendingOperations, diff --git a/src/wallet-impl/refund.ts b/src/wallet-impl/refund.ts index 2a9dea149..4cd507e40 100644 --- a/src/wallet-impl/refund.ts +++ b/src/wallet-impl/refund.ts @@ -91,13 +91,12 @@ export async function getFullRefundFees( async function submitRefunds( ws: InternalWalletState, - contractTermsHash: string, + proposalId: string, ): Promise<void> { - const purchase = await oneShotGet(ws.db, Stores.purchases, contractTermsHash); + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); if (!purchase) { console.error( - "not submitting refunds, contract terms not found:", - contractTermsHash, + "not submitting refunds, payment not found:", ); return; } @@ -160,7 +159,7 @@ async function submitRefunds( ws.db, [Stores.purchases, Stores.coins], 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); }, ); diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts index 5d624fe27..c9cd10ca2 100644 --- a/src/wallet-impl/reserves.ts +++ b/src/wallet-impl/reserves.ts @@ -344,10 +344,16 @@ async function updateReserve( resp = await ws.http.get(reqUrl.href); } catch (e) { 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 { const m = e.message; - setReserveError(ws, reservePub, { + await setReserveError(ws, reservePub, { type: "network", details: {}, message: m, diff --git a/src/wallet.ts b/src/wallet.ts index a6eecb8a9..432a3e989 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -46,6 +46,7 @@ import { abortFailedPayment, preparePay, confirmPay, + processDownloadProposal, } from "./wallet-impl/pay"; import { @@ -227,12 +228,17 @@ export class Wallet { case "withdraw": await processWithdrawSession(this.ws, pending.withdrawSessionId); break; - case "proposal": + case "proposal-choice": // Nothing to do, user needs to accept/reject break; + case "proposal-download": + await processDownloadProposal(this.ws, pending.proposalId); + break; case "tip": await processTip(this.ws, pending.tipId); break; + case "pay": + break; default: assertUnreachable(pending); } diff --git a/src/walletTypes.ts b/src/walletTypes.ts index b12b29c56..be88fc5b0 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -578,13 +578,25 @@ export interface PendingRefreshOperation { refreshOutputSize: number; } + export interface PendingDirtyCoinOperation { type: "dirty-coin"; coinPub: string; } -export interface PendingProposalOperation { - type: "proposal"; +export interface PendingProposalDownloadOperation { + 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; proposalTimestamp: Timestamp; proposalId: string; @@ -597,6 +609,12 @@ export interface PendingTipOperation { merchantTipId: string; } +export interface PendingPayOperation { + type: "pay"; + proposalId: string; + isReplay: boolean; +} + export type PendingOperationInfo = | PendingWithdrawOperation | PendingReserveOperation @@ -605,7 +623,9 @@ export type PendingOperationInfo = | PendingExchangeUpdateOperation | PendingRefreshOperation | PendingTipOperation - | PendingProposalOperation; + | PendingProposalDownloadOperation + | PendingProposalChoiceOperation + | PendingPayOperation; export interface PendingOperationsResponse { pendingOperations: PendingOperationInfo[]; |