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;
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:

View File

@ -586,9 +586,47 @@ 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",
/**
* 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;
/**
* The contract that was offered by the merchant.
* Downloaded data from the merchant.
*/
@Checkable.Value(() => ContractTerms)
contractTerms: ContractTerms;
/**
* Signature by the merchant over the contract details.
*/
@Checkable.String()
merchantSig: string;
/**
* Hash of the contract terms.
*/
@Checkable.String()
contractTermsHash: string;
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>(

View File

@ -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);
}
});
});
}

View File

@ -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,

View File

@ -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,30 +429,19 @@ export async function abortFailedPayment(
});
}
/**
* 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(
export async function processDownloadProposal(
ws: InternalWalletState,
url: string,
sessionId?: string,
): Promise<string> {
const oldProposal = await oneShotGetIndexed(
ws.db,
Stores.proposals.urlIndex,
url,
);
if (oldProposal) {
return oldProposal.proposalId;
proposalId: string,
): Promise<void> {
const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
if (!proposal) {
return;
}
const { priv, pub } = await ws.cryptoApi.createEddsaKeypair();
const parsed_url = new URL(url);
parsed_url.searchParams.set("nonce", pub);
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;
@ -452,39 +452,103 @@ async function downloadProposal(
throw e;
}
const proposal = Proposal.checked(resp.responseJson);
const proposalResp = Proposal.checked(resp.responseJson);
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 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);
const proposalId = await startDownloadProposal(
ws,
uriResult.downloadUrl,
uriResult.sessionId,
);
let proposal = await oneShotGet(ws.db, Stores.proposals, proposalId);
if (!proposal) {
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);
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.
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 sessionId = sessionIdOverride || proposal.downloadSessionId;
let purchase = await oneShotGet(
ws.db,
Stores.purchases,
proposal.contractTermsHash,
);
if (purchase) {
return submitPay(ws, purchase.contractTermsHash, sessionId);
const d = proposal.download;
if (!d) {
throw Error("proposal is in invalid state");
}
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;
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);
}

View File

@ -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,
},
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({
type: "bug",
message:
"Exchange record does not have details, but no update in progress.",
details: {
exchangeBaseUrl: e.baseUrl,
},
type: "withdraw",
numCoinsTotal,
numCoinsWithdrawn,
source: ws.source,
withdrawSessionId: ws.withdrawSessionId,
});
}
if (!e.wireInfo) {
});
await tx.iter(Stores.proposals).forEach((proposal) => {
if (proposal.proposalStatus == ProposalStatus.PROPOSED) {
pendingOperations.push({
type: "bug",
message:
"Exchange record does not have wire info, but no update in progress.",
details: {
exchangeBaseUrl: e.baseUrl,
},
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,
});
}
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.tips).forEach((tip) => {
if (tip.accepted && !tip.pickedUp) {
pendingOperations.push({
type: "tip",
merchantBaseUrl: tip.merchantBaseUrl,
tipId: tip.tipId,
merchantTipId: tip.merchantTipId,
});
}
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 {
pendingOperations,

View File

@ -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);
},
);

View File

@ -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,

View File

@ -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);
}

View File

@ -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[];