aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian.dold@gmail.com>2019-12-03 00:52:15 +0100
committerFlorian Dold <florian.dold@gmail.com>2019-12-03 00:52:15 +0100
commitc33dd75711a39403bd4dd9940caab6d5e6ad2d77 (patch)
tree7d7d9c64b5074a8f533302add3b1674c5d424c8d
parenta5137c32650b0b9aa2abbe55e4f4f3f60ed78e07 (diff)
pending operations (pay/proposals)
-rw-r--r--src/android/index.ts12
-rw-r--r--src/dbTypes.ts64
-rw-r--r--src/util/query.ts42
-rw-r--r--src/wallet-impl/history.ts2
-rw-r--r--src/wallet-impl/pay.ts309
-rw-r--r--src/wallet-impl/pending.ts357
-rw-r--r--src/wallet-impl/refund.ts9
-rw-r--r--src/wallet-impl/reserves.ts10
-rw-r--r--src/wallet.ts8
-rw-r--r--src/walletTypes.ts26
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[];