diff options
Diffstat (limited to 'src/wallet-impl')
-rw-r--r-- | src/wallet-impl/balance.ts | 158 | ||||
-rw-r--r-- | src/wallet-impl/errors.ts | 84 | ||||
-rw-r--r-- | src/wallet-impl/exchanges.ts | 505 | ||||
-rw-r--r-- | src/wallet-impl/history.ts | 221 | ||||
-rw-r--r-- | src/wallet-impl/pay.ts | 1494 | ||||
-rw-r--r-- | src/wallet-impl/payback.ts | 93 | ||||
-rw-r--r-- | src/wallet-impl/pending.ts | 452 | ||||
-rw-r--r-- | src/wallet-impl/refresh.ts | 479 | ||||
-rw-r--r-- | src/wallet-impl/reserves.ts | 630 | ||||
-rw-r--r-- | src/wallet-impl/return.ts | 271 | ||||
-rw-r--r-- | src/wallet-impl/state.ts | 68 | ||||
-rw-r--r-- | src/wallet-impl/tip.ts | 304 | ||||
-rw-r--r-- | src/wallet-impl/withdraw.ts | 699 |
13 files changed, 0 insertions, 5458 deletions
diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts deleted file mode 100644 index 8ce91a173..000000000 --- a/src/wallet-impl/balance.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { WalletBalance, WalletBalanceEntry } from "../walletTypes"; -import { runWithReadTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; -import { Logger } from "../util/logging"; - -const logger = new Logger("withdraw.ts"); - -/** - * Get detailed balance information, sliced by exchange and by currency. - */ -export async function getBalances( - ws: InternalWalletState, -): Promise<WalletBalance> { - logger.trace("starting to compute balance"); - /** - * Add amount to a balance field, both for - * the slicing by exchange and currency. - */ - function addTo( - balance: WalletBalance, - field: keyof WalletBalanceEntry, - amount: AmountJson, - exchange: string, - ): void { - const z = Amounts.getZero(amount.currency); - const balanceIdentity = { - available: z, - paybackAmount: z, - pendingIncoming: z, - pendingPayment: z, - pendingIncomingDirty: z, - pendingIncomingRefresh: z, - pendingIncomingWithdraw: z, - }; - let entryCurr = balance.byCurrency[amount.currency]; - if (!entryCurr) { - balance.byCurrency[amount.currency] = entryCurr = { - ...balanceIdentity, - }; - } - let entryEx = balance.byExchange[exchange]; - if (!entryEx) { - balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; - } - entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; - entryEx[field] = Amounts.add(entryEx[field], amount).amount; - } - - const balanceStore = { - byCurrency: {}, - byExchange: {}, - }; - - await runWithReadTransaction( - ws.db, - [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases, Stores.withdrawalSession], - async tx => { - await tx.iter(Stores.coins).forEach(c => { - if (c.suspended) { - return; - } - if (c.status === CoinStatus.Fresh) { - addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl); - } - if (c.status === CoinStatus.Dirty) { - addTo( - balanceStore, - "pendingIncoming", - c.currentAmount, - c.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingDirty", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - }); - await tx.iter(Stores.refresh).forEach(r => { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.finishedTimestamp) { - return; - } - addTo( - balanceStore, - "pendingIncoming", - r.valueOutput, - r.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingRefresh", - r.valueOutput, - r.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.withdrawalSession).forEach(wds => { - let w = wds.totalCoinValue; - for (let i = 0; i < wds.planchets.length; i++) { - if (wds.withdrawn[i]) { - const p = wds.planchets[i]; - if (p) { - w = Amounts.sub(w, p.coinValue).amount; - } - } - } - addTo( - balanceStore, - "pendingIncoming", - w, - wds.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.purchases).forEach(t => { - if (t.firstSuccessfulPayTimestamp) { - return; - } - for (const c of t.payReq.coins) { - addTo( - balanceStore, - "pendingPayment", - Amounts.parseOrThrow(c.contribution), - c.exchange_url, - ); - } - }); - }, - ); - - logger.trace("computed balances:", balanceStore); - return balanceStore; -} diff --git a/src/wallet-impl/errors.ts b/src/wallet-impl/errors.ts deleted file mode 100644 index 803497e66..000000000 --- a/src/wallet-impl/errors.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { OperationError } from "../walletTypes"; - -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * This exception is there to let the caller know that an error happened, - * but the error has already been reported by writing it to the database. - */ -export class OperationFailedAndReportedError extends Error { - constructor(message: string) { - super(message); - - // Set the prototype explicitly. - Object.setPrototypeOf(this, OperationFailedAndReportedError.prototype); - } -} - -/** - * This exception is thrown when an error occured and the caller is - * responsible for recording the failure in the database. - */ -export class OperationFailedError extends Error { - constructor(message: string, public err: OperationError) { - super(message); - - // Set the prototype explicitly. - Object.setPrototypeOf(this, OperationFailedError.prototype); - } -} - -/** - * Run an operation and call the onOpError callback - * when there was an exception or operation error that must be reported. - * The cause will be re-thrown to the caller. - */ -export async function guardOperationException<T>( - op: () => Promise<T>, - onOpError: (e: OperationError) => Promise<void>, -): Promise<T> { - try { - return await op(); - } catch (e) { - console.log("guard: caught exception"); - if (e instanceof OperationFailedAndReportedError) { - throw e; - } - if (e instanceof OperationFailedError) { - await onOpError(e.err); - throw new OperationFailedAndReportedError(e.message); - } - if (e instanceof Error) { - console.log("guard: caught Error"); - await onOpError({ - type: "exception", - message: e.message, - details: {}, - }); - throw new OperationFailedAndReportedError(e.message); - } - console.log("guard: caught something else"); - await onOpError({ - type: "exception", - message: "non-error exception thrown", - details: { - value: e.toString(), - }, - }); - throw new OperationFailedAndReportedError(e.message); - } -}
\ No newline at end of file diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts deleted file mode 100644 index 1e5f86b4f..000000000 --- a/src/wallet-impl/exchanges.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { InternalWalletState } from "./state"; -import { WALLET_CACHE_BREAKER_CLIENT_VERSION } from "../wallet"; -import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes"; -import { getTimestampNow, OperationError } from "../walletTypes"; -import { - ExchangeRecord, - ExchangeUpdateStatus, - Stores, - DenominationRecord, - DenominationStatus, - WireFee, -} from "../dbTypes"; -import { - canonicalizeBaseUrl, - extractTalerStamp, - extractTalerStampOrThrow, -} from "../util/helpers"; -import { - oneShotGet, - oneShotPut, - runWithWriteTransaction, - oneShotMutate, -} from "../util/query"; -import * as Amounts from "../util/amounts"; -import { parsePaytoUri } from "../util/payto"; -import { - OperationFailedAndReportedError, - guardOperationException, -} from "./errors"; - -async function denominationRecordFromKeys( - ws: InternalWalletState, - exchangeBaseUrl: string, - denomIn: Denomination, -): Promise<DenominationRecord> { - const denomPubHash = await ws.cryptoApi.hashDenomPub(denomIn.denom_pub); - const d: DenominationRecord = { - denomPub: denomIn.denom_pub, - denomPubHash, - exchangeBaseUrl, - feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), - feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), - feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), - feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), - isOffered: true, - masterSig: denomIn.master_sig, - stampExpireDeposit: extractTalerStampOrThrow(denomIn.stamp_expire_deposit), - stampExpireLegal: extractTalerStampOrThrow(denomIn.stamp_expire_legal), - stampExpireWithdraw: extractTalerStampOrThrow( - denomIn.stamp_expire_withdraw, - ), - stampStart: extractTalerStampOrThrow(denomIn.stamp_start), - status: DenominationStatus.Unverified, - value: Amounts.parseOrThrow(denomIn.value), - }; - return d; -} - -async function setExchangeError( - ws: InternalWalletState, - baseUrl: string, - err: OperationError, -): Promise<void> { - const mut = (exchange: ExchangeRecord) => { - exchange.lastError = err; - return exchange; - }; - await oneShotMutate(ws.db, Stores.exchanges, baseUrl, mut); -} - -/** - * Fetch the exchange's /keys and update our database accordingly. - * - * Exceptions thrown in this method must be caught and reported - * in the pending operations. - */ -async function updateExchangeWithKeys( - ws: InternalWalletState, - baseUrl: string, -): Promise<void> { - const existingExchangeRecord = await oneShotGet( - ws.db, - Stores.exchanges, - baseUrl, - ); - - if (existingExchangeRecord?.updateStatus != ExchangeUpdateStatus.FETCH_KEYS) { - return; - } - const keysUrl = new URL("keys", baseUrl); - keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - - let keysResp; - try { - const r = await ws.http.get(keysUrl.href); - if (r.status !== 200) { - throw Error(`unexpected status for keys: ${r.status}`); - } - keysResp = await r.json(); - } catch (e) { - const m = `Fetching keys failed: ${e.message}`; - await setExchangeError(ws, baseUrl, { - type: "network", - details: { - requestUrl: e.config?.url, - }, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - let exchangeKeysJson: KeysJson; - try { - exchangeKeysJson = KeysJson.checked(keysResp); - } catch (e) { - const m = `Parsing /keys response failed: ${e.message}`; - await setExchangeError(ws, baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const lastUpdateTimestamp = extractTalerStamp( - exchangeKeysJson.list_issue_date, - ); - if (!lastUpdateTimestamp) { - const m = `Parsing /keys response failed: invalid list_issue_date.`; - await setExchangeError(ws, baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - if (exchangeKeysJson.denoms.length === 0) { - const m = "exchange doesn't offer any denominations"; - await setExchangeError(ws, baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const protocolVersion = exchangeKeysJson.version; - if (!protocolVersion) { - const m = "outdate exchange, no version in /keys response"; - await setExchangeError(ws, baseUrl, { - type: "protocol-violation", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - - const currency = Amounts.parseOrThrow(exchangeKeysJson.denoms[0].value) - .currency; - - const newDenominations = await Promise.all( - exchangeKeysJson.denoms.map(d => - denominationRecordFromKeys(ws, baseUrl, d), - ), - ); - - await runWithWriteTransaction( - ws.db, - [Stores.exchanges, Stores.denominations], - async tx => { - const r = await tx.get(Stores.exchanges, baseUrl); - if (!r) { - console.warn(`exchange ${baseUrl} no longer present`); - return; - } - if (r.details) { - // FIXME: We need to do some consistency checks! - } - r.details = { - auditors: exchangeKeysJson.auditors, - currency: currency, - lastUpdateTime: lastUpdateTimestamp, - masterPublicKey: exchangeKeysJson.master_public_key, - protocolVersion: protocolVersion, - }; - r.updateStatus = ExchangeUpdateStatus.FETCH_WIRE; - r.lastError = undefined; - await tx.put(Stores.exchanges, r); - - for (const newDenom of newDenominations) { - const oldDenom = await tx.get(Stores.denominations, [ - baseUrl, - newDenom.denomPub, - ]); - if (oldDenom) { - // FIXME: Do consistency check - } else { - await tx.put(Stores.denominations, newDenom); - } - } - }, - ); -} - -async function updateExchangeWithTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, -) { - const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) { - return; - } - const reqUrl = new URL("terms", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const headers = { - Accept: "text/plain", - }; - - const resp = await ws.http.get(reqUrl.href, { headers }); - if (resp.status !== 200) { - throw Error(`/terms response has unexpected status code (${resp.status})`); - } - - const tosText = await resp.text(); - const tosEtag = resp.headers.get("etag") || undefined; - - await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FETCH_TERMS) { - return; - } - r.termsOfServiceText = tosText; - r.termsOfServiceLastEtag = tosEtag; - r.updateStatus = ExchangeUpdateStatus.FINISHED; - await tx.put(Stores.exchanges, r); - }); -} - -export async function acceptExchangeTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, - etag: string | undefined, -) { - await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - r.termsOfServiceAcceptedEtag = etag; - r.termsOfServiceAcceptedTimestamp = getTimestampNow(); - await tx.put(Stores.exchanges, r); - }); -} - -/** - * Fetch wire information for an exchange and store it in the database. - * - * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. - */ -async function updateExchangeWithWireInfo( - ws: InternalWalletState, - exchangeBaseUrl: string, -) { - const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - return; - } - if (exchange.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { - return; - } - const details = exchange.details; - if (!details) { - throw Error("invalid exchange state"); - } - const reqUrl = new URL("wire", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - - const resp = await ws.http.get(reqUrl.href); - if (resp.status !== 200) { - throw Error(`/wire response has unexpected status code (${resp.status})`); - } - const wiJson = await resp.json(); - if (!wiJson) { - throw Error("/wire response malformed"); - } - const wireInfo = ExchangeWireJson.checked(wiJson); - for (const a of wireInfo.accounts) { - console.log("validating exchange acct"); - const isValid = await ws.cryptoApi.isValidWireAccount( - a.url, - a.master_sig, - details.masterPublicKey, - ); - if (!isValid) { - throw Error("exchange acct signature invalid"); - } - } - const feesForType: { [wireMethod: string]: WireFee[] } = {}; - for (const wireMethod of Object.keys(wireInfo.fees)) { - const feeList: WireFee[] = []; - for (const x of wireInfo.fees[wireMethod]) { - const startStamp = extractTalerStamp(x.start_date); - if (!startStamp) { - throw Error("wrong date format"); - } - const endStamp = extractTalerStamp(x.end_date); - if (!endStamp) { - throw Error("wrong date format"); - } - const fee: WireFee = { - closingFee: Amounts.parseOrThrow(x.closing_fee), - endStamp, - sig: x.sig, - startStamp, - wireFee: Amounts.parseOrThrow(x.wire_fee), - }; - const isValid = await ws.cryptoApi.isValidWireFee( - wireMethod, - fee, - details.masterPublicKey, - ); - if (!isValid) { - throw Error("exchange wire fee signature invalid"); - } - feeList.push(fee); - } - feesForType[wireMethod] = feeList; - } - - await runWithWriteTransaction(ws.db, [Stores.exchanges], async tx => { - const r = await tx.get(Stores.exchanges, exchangeBaseUrl); - if (!r) { - return; - } - if (r.updateStatus != ExchangeUpdateStatus.FETCH_WIRE) { - return; - } - r.wireInfo = { - accounts: wireInfo.accounts, - feesForType: feesForType, - }; - r.updateStatus = ExchangeUpdateStatus.FETCH_TERMS; - r.lastError = undefined; - await tx.put(Stores.exchanges, r); - }); -} - -export async function updateExchangeFromUrl( - ws: InternalWalletState, - baseUrl: string, - forceNow: boolean = false, -): Promise<ExchangeRecord> { - const onOpErr = (e: OperationError) => setExchangeError(ws, baseUrl, e); - return await guardOperationException( - () => updateExchangeFromUrlImpl(ws, baseUrl, forceNow), - onOpErr, - ); -} - -/** - * Update or add exchange DB entry by fetching the /keys and /wire information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. - */ -async function updateExchangeFromUrlImpl( - ws: InternalWalletState, - baseUrl: string, - forceNow: boolean = false, -): Promise<ExchangeRecord> { - const now = getTimestampNow(); - baseUrl = canonicalizeBaseUrl(baseUrl); - - const r = await oneShotGet(ws.db, Stores.exchanges, baseUrl); - if (!r) { - const newExchangeRecord: ExchangeRecord = { - baseUrl: baseUrl, - details: undefined, - wireInfo: undefined, - updateStatus: ExchangeUpdateStatus.FETCH_KEYS, - updateStarted: now, - updateReason: "initial", - timestampAdded: getTimestampNow(), - termsOfServiceAcceptedEtag: undefined, - termsOfServiceAcceptedTimestamp: undefined, - termsOfServiceLastEtag: undefined, - termsOfServiceText: undefined, - }; - await oneShotPut(ws.db, Stores.exchanges, newExchangeRecord); - } else { - await runWithWriteTransaction(ws.db, [Stores.exchanges], async t => { - const rec = await t.get(Stores.exchanges, baseUrl); - if (!rec) { - return; - } - if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && !forceNow) { - return; - } - if (rec.updateStatus != ExchangeUpdateStatus.FETCH_KEYS && forceNow) { - rec.updateReason = "forced"; - } - rec.updateStarted = now; - rec.updateStatus = ExchangeUpdateStatus.FETCH_KEYS; - rec.lastError = undefined; - t.put(Stores.exchanges, rec); - }); - } - - await updateExchangeWithKeys(ws, baseUrl); - await updateExchangeWithWireInfo(ws, baseUrl); - await updateExchangeWithTermsOfService(ws, baseUrl); - - const updatedExchange = await oneShotGet(ws.db, Stores.exchanges, baseUrl); - - if (!updatedExchange) { - // This should practically never happen - throw Error("exchange not found"); - } - return updatedExchange; -} - -/** - * Check if and how an exchange is trusted and/or audited. - */ -export async function getExchangeTrust( - ws: InternalWalletState, - exchangeInfo: ExchangeRecord, -): Promise<{ isTrusted: boolean; isAudited: boolean }> { - let isTrusted = false; - let isAudited = false; - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const currencyRecord = await oneShotGet( - ws.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (currencyRecord) { - for (const trustedExchange of currencyRecord.exchanges) { - if (trustedExchange.exchangePub === exchangeDetails.masterPublicKey) { - isTrusted = true; - break; - } - } - for (const trustedAuditor of currencyRecord.auditors) { - for (const exchangeAuditor of exchangeDetails.auditors) { - if (trustedAuditor.auditorPub === exchangeAuditor.auditor_pub) { - isAudited = true; - break; - } - } - } - } - return { isTrusted, isAudited }; -} - -export async function getExchangePaytoUri( - ws: InternalWalletState, - exchangeBaseUrl: string, - supportedTargetTypes: string[], -): Promise<string> { - // We do the update here, since the exchange might not even exist - // yet in our database. - const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); - if (!exchangeRecord) { - throw Error(`Exchange '${exchangeBaseUrl}' not found.`); - } - const exchangeWireInfo = exchangeRecord.wireInfo; - if (!exchangeWireInfo) { - throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); - } - for (let account of exchangeWireInfo.accounts) { - const res = parsePaytoUri(account.url); - if (!res) { - continue; - } - if (supportedTargetTypes.includes(res.targetType)) { - return account.url; - } - } - throw Error("no matching exchange account found"); -} diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts deleted file mode 100644 index 99e51c8de..000000000 --- a/src/wallet-impl/history.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { HistoryQuery, HistoryEvent } from "../walletTypes"; -import { oneShotIter, runWithReadTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; - -/** - * Retrive the full event history for this wallet. - */ -export async function getHistory( - ws: InternalWalletState, - historyQuery?: HistoryQuery, -): Promise<{ history: HistoryEvent[] }> { - const history: HistoryEvent[] = []; - - // FIXME: do pagination instead of generating the full history - // We uniquely identify history rows via their timestamp. - // This works as timestamps are guaranteed to be monotonically - // increasing even - - await runWithReadTransaction( - ws.db, - [ - Stores.currencies, - Stores.coins, - Stores.denominations, - Stores.exchanges, - Stores.proposals, - Stores.purchases, - Stores.refresh, - Stores.reserves, - Stores.tips, - Stores.withdrawalSession, - ], - async tx => { - await tx.iter(Stores.proposals).forEach(p => { - history.push({ - detail: {}, - timestamp: p.timestamp, - type: "claim-order", - explicit: false, - }); - }); - - await tx.iter(Stores.withdrawalSession).forEach(w => { - history.push({ - detail: { - withdrawalAmount: w.rawWithdrawalAmount, - }, - timestamp: w.startTimestamp, - type: "withdraw-started", - explicit: false, - }); - if (w.finishTimestamp) { - history.push({ - detail: { - withdrawalAmount: w.rawWithdrawalAmount, - }, - timestamp: w.finishTimestamp, - type: "withdraw-finished", - explicit: false, - }); - } - }); - - await tx.iter(Stores.purchases).forEach(p => { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.acceptTimestamp, - type: "pay-started", - explicit: false, - }); - if (p.firstSuccessfulPayTimestamp) { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.firstSuccessfulPayTimestamp, - type: "pay-finished", - explicit: false, - }); - } - if (p.lastRefundStatusTimestamp) { - const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); - const amountsPending = Object.keys(p.refundsPending).map(x => - Amounts.parseOrThrow(p.refundsPending[x].refund_amount), - ); - const amountsDone = Object.keys(p.refundsDone).map(x => - Amounts.parseOrThrow(p.refundsDone[x].refund_amount), - ); - const amounts: AmountJson[] = amountsPending.concat(amountsDone); - const amount = Amounts.add( - Amounts.getZero(contractAmount.currency), - ...amounts, - ).amount; - - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - refundAmount: amount, - }, - timestamp: p.lastRefundStatusTimestamp, - type: "refund", - explicit: false, - }); - } - }); - - await tx.iter(Stores.reserves).forEach(r => { - const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-created", - explicit: false, - }); - if (r.timestampConfirmed) { - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-confirmed", - explicit: false, - }); - } - }); - - await tx.iter(Stores.tips).forEach(tip => { - history.push({ - detail: { - accepted: tip.accepted, - amount: tip.amount, - merchantBaseUrl: tip.merchantBaseUrl, - tipId: tip.merchantTipId, - }, - timestamp: tip.createdTimestamp, - explicit: false, - type: "tip", - }); - }); - - await tx.iter(Stores.exchanges).forEach(exchange => { - history.push({ - type: "exchange-added", - explicit: false, - timestamp: exchange.timestampAdded, - detail: { - exchangeBaseUrl: exchange.baseUrl, - }, - }); - }); - - await tx.iter(Stores.refresh).forEach((r) => { - history.push({ - type: "refresh-started", - explicit: false, - timestamp: r.created, - detail: { - refreshSessionId: r.refreshSessionId, - }, - }); - if (r.finishedTimestamp) { - history.push({ - type: "refresh-finished", - explicit: false, - timestamp: r.finishedTimestamp, - detail: { - refreshSessionId: r.refreshSessionId, - }, - }); - } - - }); - }, - ); - - history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); - - return { history }; -} diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts deleted file mode 100644 index af9d44066..000000000 --- a/src/wallet-impl/pay.ts +++ /dev/null @@ -1,1494 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { AmountJson } from "../util/amounts"; -import { - Auditor, - ExchangeHandle, - MerchantRefundResponse, - PayReq, - Proposal, - ContractTerms, - MerchantRefundPermission, - RefundRequest, -} from "../talerTypes"; -import { - Timestamp, - CoinSelectionResult, - CoinWithDenom, - PayCoinInfo, - getTimestampNow, - PreparePayResult, - ConfirmPayResult, - OperationError, - NotificationType, -} from "../walletTypes"; -import { - oneShotIter, - oneShotIterIndex, - oneShotGet, - runWithWriteTransaction, - oneShotPut, - oneShotGetIndexed, - oneShotMutate, -} from "../util/query"; -import { - Stores, - CoinStatus, - DenominationRecord, - ProposalRecord, - PurchaseRecord, - CoinRecord, - ProposalStatus, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { - amountToPretty, - strcmp, - canonicalJson, - extractTalerStampOrThrow, - extractTalerDurationOrThrow, - extractTalerDuration, -} from "../util/helpers"; -import { Logger } from "../util/logging"; -import { InternalWalletState } from "./state"; -import { - parsePayUri, - parseRefundUri, - getOrderDownloadUrl, -} from "../util/taleruri"; -import { getTotalRefreshCost, refresh } from "./refresh"; -import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; -import { guardOperationException } from "./errors"; -import { assertUnreachable } from "../util/assertUnreachable"; - -export interface SpeculativePayData { - payCoinInfo: PayCoinInfo; - exchangeUrl: string; - orderDownloadId: string; - proposal: ProposalRecord; -} - -interface CoinsForPaymentArgs { - allowedAuditors: Auditor[]; - allowedExchanges: ExchangeHandle[]; - depositFeeLimit: AmountJson; - paymentAmount: AmountJson; - wireFeeAmortization: number; - wireFeeLimit: AmountJson; - wireFeeTime: Timestamp; - wireMethod: string; -} - -interface SelectPayCoinsResult { - cds: CoinWithDenom[]; - totalFees: AmountJson; -} - -const logger = new Logger("pay.ts"); - -/** - * Select coins for a payment under the merchant's constraints. - * - * @param denoms all available denoms, used to compute refresh fees - */ -export function selectPayCoins( - denoms: DenominationRecord[], - cds: CoinWithDenom[], - paymentAmount: AmountJson, - depositFeeLimit: AmountJson, -): SelectPayCoinsResult | undefined { - if (cds.length === 0) { - return undefined; - } - // Sort by ascending deposit fee and denomPub if deposit fee is the same - // (to guarantee deterministic results) - cds.sort( - (o1, o2) => - Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || - strcmp(o1.denom.denomPub, o2.denom.denomPub), - ); - const currency = cds[0].denom.value.currency; - const cdsResult: CoinWithDenom[] = []; - let accDepositFee: AmountJson = Amounts.getZero(currency); - let accAmount: AmountJson = Amounts.getZero(currency); - for (const { coin, denom } of cds) { - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { - continue; - } - cdsResult.push({ coin, denom }); - accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; - let leftAmount = Amounts.sub( - coin.currentAmount, - Amounts.sub(paymentAmount, accAmount).amount, - ).amount; - accAmount = Amounts.add(coin.currentAmount, accAmount).amount; - const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; - const coversAmountWithFee = - Amounts.cmp( - accAmount, - Amounts.add(paymentAmount, denom.feeDeposit).amount, - ) >= 0; - const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; - - logger.trace("candidate coin selection", { - coversAmount, - isBelowFee, - accDepositFee, - accAmount, - paymentAmount, - }); - - if ((coversAmount && isBelowFee) || coversAmountWithFee) { - const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) - .amount; - leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; - logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover)); - let totalFees: AmountJson = Amounts.getZero(currency); - if (coversAmountWithFee && !isBelowFee) { - // these are the fees the customer has to pay - // because the merchant doesn't cover them - totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; - } - totalFees = Amounts.add( - totalFees, - getTotalRefreshCost(denoms, denom, leftAmount), - ).amount; - return { cds: cdsResult, totalFees }; - } - } - return undefined; -} - -/** - * Get exchanges and associated coins that are still spendable, but only - * if the sum the coins' remaining value covers the payment amount and fees. - */ -async function getCoinsForPayment( - ws: InternalWalletState, - args: CoinsForPaymentArgs, -): Promise<CoinSelectionResult | undefined> { - const { - allowedAuditors, - allowedExchanges, - depositFeeLimit, - paymentAmount, - wireFeeAmortization, - wireFeeLimit, - wireFeeTime, - wireMethod, - } = args; - - let remainingAmount = paymentAmount; - - const exchanges = await oneShotIter(ws.db, Stores.exchanges).toArray(); - - for (const exchange of exchanges) { - let isOkay: boolean = false; - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - continue; - } - const exchangeFees = exchange.wireInfo; - if (!exchangeFees) { - continue; - } - - // is the exchange explicitly allowed? - for (const allowedExchange of allowedExchanges) { - if (allowedExchange.master_pub === exchangeDetails.masterPublicKey) { - isOkay = true; - break; - } - } - - // is the exchange allowed because of one of its auditors? - if (!isOkay) { - for (const allowedAuditor of allowedAuditors) { - for (const auditor of exchangeDetails.auditors) { - if (auditor.auditor_pub === allowedAuditor.auditor_pub) { - isOkay = true; - break; - } - } - if (isOkay) { - break; - } - } - } - - if (!isOkay) { - continue; - } - - const coins = await oneShotIterIndex( - ws.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const denoms = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || coins.length === 0) { - continue; - } - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - let totalFees = Amounts.getZero(currency); - let wireFee: AmountJson | undefined; - for (const fee of exchangeFees.feesForType[wireMethod] || []) { - if (fee.startStamp <= wireFeeTime && fee.endStamp >= wireFeeTime) { - wireFee = fee.wireFee; - break; - } - } - - if (wireFee) { - const amortizedWireFee = Amounts.divide(wireFee, wireFeeAmortization); - if (Amounts.cmp(wireFeeLimit, amortizedWireFee) < 0) { - totalFees = Amounts.add(amortizedWireFee, totalFees).amount; - remainingAmount = Amounts.add(amortizedWireFee, remainingAmount).amount; - } - } - - const res = selectPayCoins(denoms, cds, remainingAmount, depositFeeLimit); - - if (res) { - totalFees = Amounts.add(totalFees, res.totalFees).amount; - return { - cds: res.cds, - exchangeUrl: exchange.baseUrl, - totalAmount: remainingAmount, - totalFees, - }; - } - } - return undefined; -} - -/** - * Record all information that is necessary to - * pay for a proposal in the wallet's database. - */ -async function recordConfirmPay( - ws: InternalWalletState, - proposal: ProposalRecord, - payCoinInfo: PayCoinInfo, - chosenExchange: string, - sessionIdOverride: string | undefined, -): Promise<PurchaseRecord> { - const d = proposal.download; - if (!d) { - throw Error("proposal is in invalid state"); - } - let sessionId; - if (sessionIdOverride) { - sessionId = sessionIdOverride; - } else { - sessionId = proposal.downloadSessionId; - } - logger.trace(`recording payment with session ID ${sessionId}`); - const payReq: PayReq = { - coins: payCoinInfo.sigs, - merchant_pub: d.contractTerms.merchant_pub, - mode: "pay", - order_id: d.contractTerms.order_id, - }; - const t: PurchaseRecord = { - abortDone: false, - abortRequested: false, - contractTerms: d.contractTerms, - contractTermsHash: d.contractTermsHash, - lastSessionId: sessionId, - merchantSig: d.merchantSig, - payReq, - refundsDone: {}, - refundsPending: {}, - acceptTimestamp: getTimestampNow(), - lastRefundStatusTimestamp: undefined, - proposalId: proposal.proposalId, - lastPayError: undefined, - lastRefundStatusError: undefined, - payRetryInfo: initRetryInfo(), - refundStatusRetryInfo: initRetryInfo(), - refundStatusRequested: false, - lastRefundApplyError: undefined, - refundApplyRetryInfo: initRetryInfo(), - firstSuccessfulPayTimestamp: undefined, - autoRefundDeadline: undefined, - paymentSubmitPending: true, - }; - - await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.purchases, Stores.proposals], - async tx => { - const p = await tx.get(Stores.proposals, proposal.proposalId); - if (p) { - p.proposalStatus = ProposalStatus.ACCEPTED; - p.lastError = undefined; - p.retryInfo = initRetryInfo(false); - await tx.put(Stores.proposals, p); - } - await tx.put(Stores.purchases, t); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - - ws.notify({ - type: NotificationType.ProposalAccepted, - proposalId: proposal.proposalId, - }); - return t; -} - -function getNextUrl(contractTerms: ContractTerms): string { - const f = contractTerms.fulfillment_url; - if (f.startsWith("http://") || f.startsWith("https://")) { - const fu = new URL(contractTerms.fulfillment_url); - fu.searchParams.set("order_id", contractTerms.order_id); - return fu.href; - } else { - return f; - } -} - -export async function abortFailedPayment( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - throw Error("Purchase not found, unable to abort with refund"); - } - if (purchase.firstSuccessfulPayTimestamp) { - throw Error("Purchase already finished, not aborting"); - } - if (purchase.abortDone) { - console.warn("abort requested on already aborted purchase"); - return; - } - - purchase.abortRequested = true; - - // From now on, we can't retry payment anymore, - // so mark this in the DB in case the /pay abort - // does not complete on the first try. - await oneShotPut(ws.db, Stores.purchases, purchase); - - let resp; - - const abortReq = { ...purchase.payReq, mode: "abort-refund" }; - - const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; - - try { - resp = await ws.http.postJson(payUrl, abortReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("aborting payment failed", e); - throw e; - } - - if (resp.status !== 200) { - throw Error(`unexpected status for /pay (${resp.status})`); - } - - const refundResponse = MerchantRefundResponse.checked(await resp.json()); - await acceptRefundResponse(ws, purchase.proposalId, refundResponse); - - await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - p.abortDone = true; - await tx.put(Stores.purchases, p); - }); -} - -async function incrementProposalRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise<void> { - await runWithWriteTransaction(ws.db, [Stores.proposals], async tx => { - const pr = await tx.get(Stores.proposals, proposalId); - if (!pr) { - return; - } - if (!pr.retryInfo) { - return; - } - pr.retryInfo.retryCounter++; - updateRetryInfoTimeout(pr.retryInfo); - pr.lastError = err; - await tx.put(Stores.proposals, pr); - }); - ws.notify({ type: NotificationType.ProposalOperationError }); -} - -async function incrementPurchasePayRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise<void> { - console.log("incrementing purchase pay retry with error", err); - await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { - const pr = await tx.get(Stores.purchases, proposalId); - if (!pr) { - return; - } - if (!pr.payRetryInfo) { - return; - } - pr.payRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.payRetryInfo); - pr.lastPayError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.PayOperationError }); -} - -async function incrementPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise<void> { - console.log("incrementing purchase refund query retry with error", err); - await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { - const pr = await tx.get(Stores.purchases, proposalId); - if (!pr) { - return; - } - if (!pr.refundStatusRetryInfo) { - return; - } - pr.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.refundStatusRetryInfo); - pr.lastRefundStatusError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.RefundStatusOperationError }); -} - -async function incrementPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, - err: OperationError | undefined, -): Promise<void> { - console.log("incrementing purchase refund apply retry with error", err); - await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { - const pr = await tx.get(Stores.purchases, proposalId); - if (!pr) { - return; - } - if (!pr.refundApplyRetryInfo) { - return; - } - pr.refundApplyRetryInfo.retryCounter++; - updateRetryInfoTimeout(pr.refundStatusRetryInfo); - pr.lastRefundApplyError = err; - await tx.put(Stores.purchases, pr); - }); - ws.notify({ type: NotificationType.RefundApplyOperationError }); -} - -export async function processDownloadProposal( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (err: OperationError) => - incrementProposalRetry(ws, proposalId, err); - await guardOperationException( - () => processDownloadProposalImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetDownloadProposalRetry( - ws: InternalWalletState, - proposalId: string, -) { - await oneShotMutate(ws.db, Stores.proposals, proposalId, x => { - if (x.retryInfo.active) { - x.retryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processDownloadProposalImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise<void> { - if (forceNow) { - await resetDownloadProposalRetry(ws, proposalId); - } - const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); - if (!proposal) { - return; - } - if (proposal.proposalStatus != ProposalStatus.DOWNLOADING) { - return; - } - - const parsedUrl = new URL( - getOrderDownloadUrl(proposal.merchantBaseUrl, proposal.orderId), - ); - parsedUrl.searchParams.set("nonce", proposal.noncePub); - const urlWithNonce = parsedUrl.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; - } - - if (resp.status !== 200) { - throw Error(`contract download failed with status ${resp.status}`); - } - - const proposalResp = Proposal.checked(await resp.json()); - - 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) { - console.log("repurchase detected"); - 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.notify({ - type: NotificationType.ProposalDownloaded, - proposalId: proposal.proposalId, - }); -} - -/** - * 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, - merchantBaseUrl: string, - orderId: string, - sessionId: string | undefined, -): Promise<string> { - const oldProposal = await oneShotGetIndexed( - ws.db, - Stores.proposals.urlAndOrderIdIndex, - [merchantBaseUrl, orderId], - ); - 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 = { - download: undefined, - noncePriv: priv, - noncePub: pub, - timestamp: getTimestampNow(), - merchantBaseUrl, - orderId, - proposalId: proposalId, - proposalStatus: ProposalStatus.DOWNLOADING, - repurchaseProposalId: undefined, - retryInfo: initRetryInfo(), - lastError: undefined, - downloadSessionId: sessionId, - }; - - await runWithWriteTransaction(ws.db, [Stores.proposals], async (tx) => { - const existingRecord = await tx.getIndexed(Stores.proposals.urlAndOrderIdIndex, [ - merchantBaseUrl, - orderId, - ]); - if (existingRecord) { - // Created concurrently - return; - } - await tx.put(Stores.proposals, proposalRecord); - }); - - await processDownloadProposal(ws, proposalId); - return proposalId; -} - -export async function submitPay( - ws: InternalWalletState, - proposalId: string, -): Promise<ConfirmPayResult> { - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - throw Error("Purchase not found: " + proposalId); - } - if (purchase.abortRequested) { - throw Error("not submitting payment for aborted purchase"); - } - const sessionId = purchase.lastSessionId; - let resp; - const payReq = { ...purchase.payReq, session_id: sessionId }; - - console.log("paying with session ID", sessionId); - - const payUrl = new URL("pay", purchase.contractTerms.merchant_base_url).href; - - try { - resp = await ws.http.postJson(payUrl, payReq); - } catch (e) { - // Gives the user the option to retry / abort and refresh - console.log("payment failed", e); - throw e; - } - if (resp.status !== 200) { - throw Error(`unexpected status (${resp.status}) for /pay`); - } - const merchantResp = await resp.json(); - console.log("got success from pay URL", merchantResp); - - const merchantPub = purchase.contractTerms.merchant_pub; - const valid: boolean = await ws.cryptoApi.isValidPaymentSignature( - merchantResp.sig, - purchase.contractTermsHash, - merchantPub, - ); - if (!valid) { - console.error("merchant payment signature invalid"); - // FIXME: properly display error - throw Error("merchant payment signature invalid"); - } - const isFirst = purchase.firstSuccessfulPayTimestamp === undefined; - purchase.firstSuccessfulPayTimestamp = getTimestampNow(); - purchase.paymentSubmitPending = false; - purchase.lastPayError = undefined; - purchase.payRetryInfo = initRetryInfo(false); - if (isFirst) { - const ar = purchase.contractTerms.auto_refund; - if (ar) { - console.log("auto_refund present"); - const autoRefundDelay = extractTalerDuration(ar); - console.log("auto_refund valid", autoRefundDelay); - if (autoRefundDelay) { - purchase.refundStatusRequested = true; - purchase.refundStatusRetryInfo = initRetryInfo(); - purchase.lastRefundStatusError = undefined; - purchase.autoRefundDeadline = { - t_ms: getTimestampNow().t_ms + autoRefundDelay.d_ms, - }; - } - } - } - - const modifiedCoins: CoinRecord[] = []; - for (const pc of purchase.payReq.coins) { - const c = await oneShotGet(ws.db, Stores.coins, pc.coin_pub); - if (!c) { - console.error("coin not found"); - throw Error("coin used in payment not found"); - } - c.status = CoinStatus.Dirty; - modifiedCoins.push(c); - } - - await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.purchases], - async tx => { - for (let c of modifiedCoins) { - await tx.put(Stores.coins, c); - } - await tx.put(Stores.purchases, purchase); - }, - ); - - for (const c of purchase.payReq.coins) { - refresh(ws, c.coin_pub).catch(e => { - console.log("error in refreshing after payment:", e); - }); - } - - const nextUrl = getNextUrl(purchase.contractTerms); - ws.cachedNextUrl[purchase.contractTerms.fulfillment_url] = { - nextUrl, - lastSessionId: sessionId, - }; - - return { nextUrl }; -} - -/** - * Check if a payment for the given taler://pay/ URI is possible. - * - * If the payment is possible, the signature are already generated but not - * yet send to the merchant. - */ -export async function preparePay( - ws: InternalWalletState, - talerPayUri: string, -): Promise<PreparePayResult> { - const uriResult = parsePayUri(talerPayUri); - - if (!uriResult) { - return { - status: "error", - error: "URI not supported", - }; - } - - let proposalId = await startDownloadProposal( - ws, - uriResult.merchantBaseUrl, - uriResult.orderId, - 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"); - } - console.log("using existing purchase for same product"); - 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"); - } - - proposalId = proposal.proposalId; - - // First check if we already payed for it. - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - - if (!purchase) { - const paymentAmount = Amounts.parseOrThrow(contractTerms.amount); - let wireFeeLimit; - 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: contractTerms.auditors, - allowedExchanges: contractTerms.exchanges, - depositFeeLimit: Amounts.parseOrThrow(contractTerms.max_fee), - paymentAmount, - wireFeeAmortization: contractTerms.wire_fee_amortization || 1, - wireFeeLimit, - wireFeeTime: extractTalerStampOrThrow(contractTerms.timestamp), - wireMethod: contractTerms.wire_method, - }); - - if (!res) { - console.log("not confirming payment, insufficient coins"); - return { - status: "insufficient-balance", - contractTerms: contractTerms, - proposalId: proposal.proposalId, - }; - } - - // Only create speculative signature if we don't already have one for this proposal - if ( - !ws.speculativePayData || - (ws.speculativePayData && - ws.speculativePayData.orderDownloadId !== proposalId) - ) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await ws.cryptoApi.signDeposit( - contractTerms, - cds, - totalAmount, - ); - ws.speculativePayData = { - exchangeUrl, - payCoinInfo, - proposal, - orderDownloadId: proposalId, - }; - logger.trace("created speculative pay data for payment"); - } - - return { - status: "payment-possible", - contractTerms: contractTerms, - proposalId: proposal.proposalId, - totalFees: res.totalFees, - }; - } - - if (uriResult.sessionId) { - await submitPay(ws, proposalId); - } - - return { - status: "paid", - contractTerms: purchase.contractTerms, - nextUrl: getNextUrl(purchase.contractTerms), - }; -} - -/** - * Get the speculative pay data, but only if coins have not changed in between. - */ -async function getSpeculativePayData( - ws: InternalWalletState, - proposalId: string, -): Promise<SpeculativePayData | undefined> { - const sp = ws.speculativePayData; - if (!sp) { - return; - } - if (sp.orderDownloadId !== proposalId) { - return; - } - const coinKeys = sp.payCoinInfo.updatedCoins.map(x => x.coinPub); - const coins: CoinRecord[] = []; - for (let coinKey of coinKeys) { - const cc = await oneShotGet(ws.db, Stores.coins, coinKey); - if (cc) { - coins.push(cc); - } - } - for (let i = 0; i < coins.length; i++) { - const specCoin = sp.payCoinInfo.originalCoins[i]; - const currentCoin = coins[i]; - - // Coin does not exist anymore! - if (!currentCoin) { - return; - } - if (Amounts.cmp(specCoin.currentAmount, currentCoin.currentAmount) !== 0) { - return; - } - } - return sp; -} - -/** - * Add a contract to the wallet and sign coins, and send them. - */ -export async function confirmPay( - ws: InternalWalletState, - proposalId: string, - sessionIdOverride: string | undefined, -): Promise<ConfirmPayResult> { - logger.trace( - `executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`, - ); - const proposal = await oneShotGet(ws.db, Stores.proposals, proposalId); - - if (!proposal) { - throw Error(`proposal with id ${proposalId} not found`); - } - - const d = proposal.download; - if (!d) { - throw Error("proposal is in invalid state"); - } - - let purchase = await oneShotGet(ws.db, Stores.purchases, d.contractTermsHash); - - if (purchase) { - if ( - sessionIdOverride !== undefined && - sessionIdOverride != purchase.lastSessionId - ) { - logger.trace(`changing session ID to ${sessionIdOverride}`); - await oneShotMutate(ws.db, Stores.purchases, purchase.proposalId, x => { - x.lastSessionId = sessionIdOverride; - x.paymentSubmitPending = true; - return x; - }); - } - logger.trace("confirmPay: submitting payment for existing purchase"); - return submitPay(ws, proposalId); - } - - logger.trace("confirmPay: purchase record does not exist yet"); - - const contractAmount = Amounts.parseOrThrow(d.contractTerms.amount); - - let wireFeeLimit; - if (!d.contractTerms.max_wire_fee) { - wireFeeLimit = Amounts.getZero(contractAmount.currency); - } else { - wireFeeLimit = Amounts.parseOrThrow(d.contractTerms.max_wire_fee); - } - - const res = await getCoinsForPayment(ws, { - 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, - wireFeeTime: extractTalerStampOrThrow(d.contractTerms.timestamp), - wireMethod: d.contractTerms.wire_method, - }); - - logger.trace("coin selection result", res); - - if (!res) { - // Should not happen, since checkPay should be called first - console.log("not confirming payment, insufficient coins"); - throw Error("insufficient balance"); - } - - const sd = await getSpeculativePayData(ws, proposalId); - if (!sd) { - const { exchangeUrl, cds, totalAmount } = res; - const payCoinInfo = await ws.cryptoApi.signDeposit( - d.contractTerms, - cds, - totalAmount, - ); - purchase = await recordConfirmPay( - ws, - proposal, - payCoinInfo, - exchangeUrl, - sessionIdOverride, - ); - } else { - purchase = await recordConfirmPay( - ws, - sd.proposal, - sd.payCoinInfo, - sd.exchangeUrl, - sessionIdOverride, - ); - } - - logger.trace("confirmPay: submitting payment after creating purchase record"); - return submitPay(ws, proposalId); -} - -export async function getFullRefundFees( - ws: InternalWalletState, - refundPermissions: MerchantRefundPermission[], -): Promise<AmountJson> { - if (refundPermissions.length === 0) { - throw Error("no refunds given"); - } - const coin0 = await oneShotGet( - ws.db, - Stores.coins, - refundPermissions[0].coin_pub, - ); - if (!coin0) { - throw Error("coin not found"); - } - let feeAcc = Amounts.getZero( - Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, - ); - - const denoms = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ).toArray(); - - for (const rp of refundPermissions) { - const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); - if (!coin) { - throw Error("coin not found"); - } - const denom = await oneShotGet(ws.db, Stores.denominations, [ - coin0.exchangeBaseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error(`denom not found (${coin.denomPub})`); - } - // FIXME: this assumes that the refund already happened. - // When it hasn't, the refresh cost is inaccurate. To fix this, - // we need introduce a flag to tell if a coin was refunded or - // refreshed normally (and what about incremental refunds?) - const refundAmount = Amounts.parseOrThrow(rp.refund_amount); - const refundFee = Amounts.parseOrThrow(rp.refund_fee); - const refreshCost = getTotalRefreshCost( - denoms, - denom, - Amounts.sub(refundAmount, refundFee).amount, - ); - feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; - } - return feeAcc; -} - -async function acceptRefundResponse( - ws: InternalWalletState, - proposalId: string, - refundResponse: MerchantRefundResponse, -): Promise<void> { - const refundPermissions = refundResponse.refund_permissions; - - let numNewRefunds = 0; - - await runWithWriteTransaction(ws.db, [Stores.purchases], async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - console.error("purchase not found, not adding refunds"); - return; - } - - if (!p.refundStatusRequested) { - return; - } - - for (const perm of refundPermissions) { - if ( - !p.refundsPending[perm.merchant_sig] && - !p.refundsDone[perm.merchant_sig] - ) { - p.refundsPending[perm.merchant_sig] = perm; - numNewRefunds++; - } - } - - // Are we done with querying yet, or do we need to do another round - // after a retry delay? - let queryDone = true; - - if (numNewRefunds === 0) { - if ( - p.autoRefundDeadline && - p.autoRefundDeadline.t_ms > getTimestampNow().t_ms - ) { - queryDone = false; - } - } - - if (queryDone) { - p.lastRefundStatusTimestamp = getTimestampNow(); - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - p.refundStatusRequested = false; - console.log("refund query done"); - } else { - // No error, but we need to try again! - p.lastRefundStatusTimestamp = getTimestampNow(); - p.refundStatusRetryInfo.retryCounter++; - updateRetryInfoTimeout(p.refundStatusRetryInfo); - p.lastRefundStatusError = undefined; - console.log("refund query not done"); - } - - if (numNewRefunds) { - p.lastRefundApplyError = undefined; - p.refundApplyRetryInfo = initRetryInfo(); - } - - await tx.put(Stores.purchases, p); - }); - ws.notify({ - type: NotificationType.RefundQueried, - }); - if (numNewRefunds > 0) { - await processPurchaseApplyRefund(ws, proposalId); - } -} - -async function startRefundQuery( - ws: InternalWalletState, - proposalId: string, -): Promise<void> { - const success = await runWithWriteTransaction( - ws.db, - [Stores.purchases], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - console.log("no purchase found for refund URL"); - return false; - } - p.refundStatusRequested = true; - p.lastRefundStatusError = undefined; - p.refundStatusRetryInfo = initRetryInfo(); - await tx.put(Stores.purchases, p); - return true; - }, - ); - - if (!success) { - return; - } - - ws.notify({ - type: NotificationType.RefundStarted, - }); - - await processPurchaseQueryRefund(ws, proposalId); -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise<string> { - const parseResult = parseRefundUri(talerRefundUri); - - console.log("applying refund"); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const purchase = await oneShotGetIndexed( - ws.db, - Stores.purchases.orderIdIndex, - [parseResult.merchantBaseUrl, parseResult.orderId], - ); - - if (!purchase) { - throw Error("no purchase for the taler://refund/ URI was found"); - } - - console.log("processing purchase for refund"); - await startRefundQuery(ws, purchase.proposalId); - - return purchase.contractTermsHash; -} - -export async function processPurchasePay( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (e: OperationError) => - incrementPurchasePayRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchasePayImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchasePayRetry( - ws: InternalWalletState, - proposalId: string, -) { - await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { - if (x.payRetryInfo.active) { - x.payRetryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processPurchasePayImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise<void> { - if (forceNow) { - await resetPurchasePayRetry(ws, proposalId); - } - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - return; - } - if (!purchase.paymentSubmitPending) { - return; - } - logger.trace(`processing purchase pay ${proposalId}`); - await submitPay(ws, proposalId); -} - -export async function processPurchaseQueryRefund( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (e: OperationError) => - incrementPurchaseQueryRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseQueryRefundImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchaseQueryRefundRetry( - ws: InternalWalletState, - proposalId: string, -) { - await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { - if (x.refundStatusRetryInfo.active) { - x.refundStatusRetryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processPurchaseQueryRefundImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise<void> { - if (forceNow) { - await resetPurchaseQueryRefundRetry(ws, proposalId); - } - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - return; - } - if (!purchase.refundStatusRequested) { - return; - } - - const refundUrlObj = new URL( - "refund", - purchase.contractTerms.merchant_base_url, - ); - refundUrlObj.searchParams.set("order_id", purchase.contractTerms.order_id); - const refundUrl = refundUrlObj.href; - let resp; - try { - resp = await ws.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - if (resp.status !== 200) { - throw Error(`unexpected status code (${resp.status}) for /refund`); - } - - const refundResponse = MerchantRefundResponse.checked(await resp.json()); - await acceptRefundResponse(ws, proposalId, refundResponse); -} - -export async function processPurchaseApplyRefund( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (e: OperationError) => - incrementPurchaseApplyRefundRetry(ws, proposalId, e); - await guardOperationException( - () => processPurchaseApplyRefundImpl(ws, proposalId, forceNow), - onOpErr, - ); -} - -async function resetPurchaseApplyRefundRetry( - ws: InternalWalletState, - proposalId: string, -) { - await oneShotMutate(ws.db, Stores.purchases, proposalId, x => { - if (x.refundApplyRetryInfo.active) { - x.refundApplyRetryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processPurchaseApplyRefundImpl( - ws: InternalWalletState, - proposalId: string, - forceNow: boolean, -): Promise<void> { - if (forceNow) { - await resetPurchaseApplyRefundRetry(ws, proposalId); - } - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - console.error("not submitting refunds, payment not found:"); - return; - } - const pendingKeys = Object.keys(purchase.refundsPending); - if (pendingKeys.length === 0) { - console.log("no pending refunds"); - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URL("refund", exchangeUrl); - const resp = await ws.http.postJson(reqUrl.href, req); - console.log("sent refund permission"); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - let allRefundsProcessed = false; - - await runWithWriteTransaction( - ws.db, - [Stores.purchases, Stores.coins], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - if (p.refundsPending[pk]) { - p.refundsDone[pk] = p.refundsPending[pk]; - delete p.refundsPending[pk]; - } - if (Object.keys(p.refundsPending).length === 0) { - p.refundStatusRetryInfo = initRetryInfo(); - p.lastRefundStatusError = undefined; - allRefundsProcessed = true; - } - await tx.put(Stores.purchases, p); - const c = await tx.get(Stores.coins, perm.coin_pub); - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - await tx.put(Stores.coins, c); - }, - ); - if (allRefundsProcessed) { - ws.notify({ - type: NotificationType.RefundFinished, - }); - } - await refresh(ws, perm.coin_pub); - } - - ws.notify({ - type: NotificationType.RefundsSubmitted, - proposalId, - }); -} diff --git a/src/wallet-impl/payback.ts b/src/wallet-impl/payback.ts deleted file mode 100644 index 8cdfbf7ed..000000000 --- a/src/wallet-impl/payback.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - oneShotIter, - runWithWriteTransaction, - oneShotGet, - oneShotPut, -} from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus } from "../dbTypes"; - -import { Logger } from "../util/logging"; -import { PaybackConfirmation } from "../talerTypes"; -import { updateExchangeFromUrl } from "./exchanges"; -import { NotificationType } from "../walletTypes"; - -const logger = new Logger("payback.ts"); - -export async function payback( - ws: InternalWalletState, - coinPub: string, -): Promise<void> { - let coin = await oneShotGet(ws.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't request payback`); - } - const reservePub = coin.reservePub; - if (!reservePub) { - throw Error(`Can't request payback for a refreshed coin`); - } - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve of coin ${coinPub} not found`); - } - switch (coin.status) { - case CoinStatus.Dormant: - throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); - } - coin.status = CoinStatus.Dormant; - // Even if we didn't get the payback yet, we suspend withdrawal, since - // technically we might update reserve status before we get the response - // from the reserve for the payback request. - reserve.hasPayback = true; - await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.reserves], - async tx => { - await tx.put(Stores.coins, coin!!); - await tx.put(Stores.reserves, reserve); - }, - ); - ws.notify({ - type: NotificationType.PaybackStarted, - }); - - const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); - const reqUrl = new URL("payback", coin.exchangeBaseUrl); - const resp = await ws.http.postJson(reqUrl.href, paybackRequest); - if (resp.status !== 200) { - throw Error(); - } - const paybackConfirmation = PaybackConfirmation.checked(await resp.json()); - if (paybackConfirmation.reserve_pub !== coin.reservePub) { - throw Error(`Coin's reserve doesn't match reserve on payback`); - } - coin = await oneShotGet(ws.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't confirm payback`); - } - coin.status = CoinStatus.Dormant; - await oneShotPut(ws.db, Stores.coins, coin); - ws.notify({ - type: NotificationType.PaybackFinished, - }); - await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true); -} diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts deleted file mode 100644 index 7079fa5ff..000000000 --- a/src/wallet-impl/pending.ts +++ /dev/null @@ -1,452 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - PendingOperationsResponse, - getTimestampNow, - Timestamp, - Duration, -} from "../walletTypes"; -import { runWithReadTransaction, TransactionHandle } from "../util/query"; -import { InternalWalletState } from "./state"; -import { - Stores, - ExchangeUpdateStatus, - ReserveRecordStatus, - CoinStatus, - ProposalStatus, -} from "../dbTypes"; - -function updateRetryDelay( - oldDelay: Duration, - now: Timestamp, - retryTimestamp: Timestamp, -): Duration { - if (retryTimestamp.t_ms <= now.t_ms) { - return { d_ms: 0 }; - } - return { d_ms: Math.min(oldDelay.d_ms, retryTimestamp.t_ms - now.t_ms) }; -} - -async function gatherExchangePending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - if (onlyDue) { - // FIXME: exchanges should also be updated regularly - return; - } - await tx.iter(Stores.exchanges).forEach(e => { - switch (e.updateStatus) { - case ExchangeUpdateStatus.FINISHED: - if (e.lastError) { - resp.pendingOperations.push({ - type: "bug", - givesLifeness: false, - message: - "Exchange record is in FINISHED state but has lastError set", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - if (!e.details) { - resp.pendingOperations.push({ - type: "bug", - givesLifeness: false, - message: - "Exchange record does not have details, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - if (!e.wireInfo) { - resp.pendingOperations.push({ - type: "bug", - givesLifeness: false, - message: - "Exchange record does not have wire info, but no update in progress.", - details: { - exchangeBaseUrl: e.baseUrl, - }, - }); - } - break; - case ExchangeUpdateStatus.FETCH_KEYS: - resp.pendingOperations.push({ - type: "exchange-update", - givesLifeness: false, - stage: "fetch-keys", - exchangeBaseUrl: e.baseUrl, - lastError: e.lastError, - reason: e.updateReason || "unknown", - }); - break; - case ExchangeUpdateStatus.FETCH_WIRE: - resp.pendingOperations.push({ - type: "exchange-update", - givesLifeness: false, - stage: "fetch-wire", - exchangeBaseUrl: e.baseUrl, - lastError: e.lastError, - reason: e.updateReason || "unknown", - }); - break; - default: - resp.pendingOperations.push({ - type: "bug", - givesLifeness: false, - message: "Unknown exchangeUpdateStatus", - details: { - exchangeBaseUrl: e.baseUrl, - exchangeUpdateStatus: e.updateStatus, - }, - }); - break; - } - }); -} - -async function gatherReservePending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - // FIXME: this should be optimized by using an index for "onlyDue==true". - await tx.iter(Stores.reserves).forEach(reserve => { - const reserveType = reserve.bankWithdrawStatusUrl ? "taler-bank" : "manual"; - if (!reserve.retryInfo.active) { - return; - } - switch (reserve.reserveStatus) { - case ReserveRecordStatus.DORMANT: - // nothing to report as pending - break; - case ReserveRecordStatus.UNCONFIRMED: - if (onlyDue) { - break; - } - resp.pendingOperations.push({ - type: "reserve", - givesLifeness: false, - stage: reserve.reserveStatus, - timestampCreated: reserve.created, - reserveType, - reservePub: reserve.reservePub, - retryInfo: reserve.retryInfo, - }); - break; - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - case ReserveRecordStatus.WITHDRAWING: - case ReserveRecordStatus.QUERYING_STATUS: - case ReserveRecordStatus.REGISTERING_BANK: - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - reserve.retryInfo.nextRetry, - ); - if (onlyDue && reserve.retryInfo.nextRetry.t_ms > now.t_ms) { - return; - } - resp.pendingOperations.push({ - type: "reserve", - givesLifeness: true, - stage: reserve.reserveStatus, - timestampCreated: reserve.created, - reserveType, - reservePub: reserve.reservePub, - retryInfo: reserve.retryInfo, - }); - break; - default: - resp.pendingOperations.push({ - type: "bug", - givesLifeness: false, - message: "Unknown reserve record status", - details: { - reservePub: reserve.reservePub, - reserveStatus: reserve.reserveStatus, - }, - }); - break; - } - }); -} - -async function gatherRefreshPending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - await tx.iter(Stores.refresh).forEach(r => { - if (r.finishedTimestamp) { - return; - } - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - r.retryInfo.nextRetry, - ); - if (onlyDue && r.retryInfo.nextRetry.t_ms > now.t_ms) { - return; - } - let refreshStatus: string; - if (r.norevealIndex === undefined) { - refreshStatus = "melt"; - } else { - refreshStatus = "reveal"; - } - - resp.pendingOperations.push({ - type: "refresh", - givesLifeness: true, - oldCoinPub: r.meltCoinPub, - refreshStatus, - refreshOutputSize: r.newDenoms.length, - refreshSessionId: r.refreshSessionId, - }); - }); -} - -async function gatherCoinsPending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - // Refreshing dirty coins is always due. - await tx.iter(Stores.coins).forEach(coin => { - if (coin.status == CoinStatus.Dirty) { - resp.nextRetryDelay = { d_ms: 0 }; - resp.pendingOperations.push({ - givesLifeness: true, - type: "dirty-coin", - coinPub: coin.coinPub, - }); - } - }); -} - -async function gatherWithdrawalPending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - await tx.iter(Stores.withdrawalSession).forEach(wsr => { - if (wsr.finishTimestamp) { - return; - } - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - wsr.retryInfo.nextRetry, - ); - if (onlyDue && wsr.retryInfo.nextRetry.t_ms > now.t_ms) { - return; - } - const numCoinsWithdrawn = wsr.withdrawn.reduce( - (a, x) => a + (x ? 1 : 0), - 0, - ); - const numCoinsTotal = wsr.withdrawn.length; - resp.pendingOperations.push({ - type: "withdraw", - givesLifeness: true, - numCoinsTotal, - numCoinsWithdrawn, - source: wsr.source, - withdrawSessionId: wsr.withdrawSessionId, - }); - }); -} - -async function gatherProposalPending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - await tx.iter(Stores.proposals).forEach(proposal => { - if (proposal.proposalStatus == ProposalStatus.PROPOSED) { - if (onlyDue) { - return; - } - resp.pendingOperations.push({ - type: "proposal-choice", - givesLifeness: false, - merchantBaseUrl: proposal.download!!.contractTerms.merchant_base_url, - proposalId: proposal.proposalId, - proposalTimestamp: proposal.timestamp, - }); - } else if (proposal.proposalStatus == ProposalStatus.DOWNLOADING) { - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - proposal.retryInfo.nextRetry, - ); - if (onlyDue && proposal.retryInfo.nextRetry.t_ms > now.t_ms) { - return; - } - resp.pendingOperations.push({ - type: "proposal-download", - givesLifeness: true, - merchantBaseUrl: proposal.merchantBaseUrl, - orderId: proposal.orderId, - proposalId: proposal.proposalId, - proposalTimestamp: proposal.timestamp, - lastError: proposal.lastError, - retryInfo: proposal.retryInfo, - }); - } - }); -} - -async function gatherTipPending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - await tx.iter(Stores.tips).forEach(tip => { - if (tip.pickedUp) { - return; - } - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - tip.retryInfo.nextRetry, - ); - if (onlyDue && tip.retryInfo.nextRetry.t_ms > now.t_ms) { - return; - } - if (tip.accepted) { - resp.pendingOperations.push({ - type: "tip", - givesLifeness: true, - merchantBaseUrl: tip.merchantBaseUrl, - tipId: tip.tipId, - merchantTipId: tip.merchantTipId, - }); - } - }); -} - -async function gatherPurchasePending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise<void> { - await tx.iter(Stores.purchases).forEach(pr => { - if (pr.paymentSubmitPending) { - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - pr.payRetryInfo.nextRetry, - ); - if (!onlyDue || pr.payRetryInfo.nextRetry.t_ms <= now.t_ms) { - resp.pendingOperations.push({ - type: "pay", - givesLifeness: true, - isReplay: false, - proposalId: pr.proposalId, - retryInfo: pr.payRetryInfo, - lastError: pr.lastPayError, - }); - } - } - if (pr.refundStatusRequested) { - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - pr.refundStatusRetryInfo.nextRetry, - ); - if (!onlyDue || pr.refundStatusRetryInfo.nextRetry.t_ms <= now.t_ms) { - resp.pendingOperations.push({ - type: "refund-query", - givesLifeness: true, - proposalId: pr.proposalId, - retryInfo: pr.refundStatusRetryInfo, - lastError: pr.lastRefundStatusError, - }); - } - } - const numRefundsPending = Object.keys(pr.refundsPending).length; - if (numRefundsPending > 0) { - const numRefundsDone = Object.keys(pr.refundsDone).length; - resp.nextRetryDelay = updateRetryDelay( - resp.nextRetryDelay, - now, - pr.refundApplyRetryInfo.nextRetry, - ); - if (!onlyDue || pr.refundApplyRetryInfo.nextRetry.t_ms <= now.t_ms) { - resp.pendingOperations.push({ - type: "refund-apply", - numRefundsDone, - numRefundsPending, - givesLifeness: true, - proposalId: pr.proposalId, - retryInfo: pr.refundApplyRetryInfo, - lastError: pr.lastRefundApplyError, - }); - } - } - }); -} - -export async function getPendingOperations( - ws: InternalWalletState, - onlyDue: boolean = false, -): Promise<PendingOperationsResponse> { - const resp: PendingOperationsResponse = { - nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER }, - pendingOperations: [], - }; - const now = getTimestampNow(); - await runWithReadTransaction( - ws.db, - [ - Stores.exchanges, - Stores.reserves, - Stores.refresh, - Stores.coins, - Stores.withdrawalSession, - Stores.proposals, - Stores.tips, - Stores.purchases, - ], - async tx => { - await gatherExchangePending(tx, now, resp, onlyDue); - await gatherReservePending(tx, now, resp, onlyDue); - await gatherRefreshPending(tx, now, resp, onlyDue); - await gatherCoinsPending(tx, now, resp, onlyDue); - await gatherWithdrawalPending(tx, now, resp, onlyDue); - await gatherProposalPending(tx, now, resp, onlyDue); - await gatherTipPending(tx, now, resp, onlyDue); - await gatherPurchasePending(tx, now, resp, onlyDue); - }, - ); - return resp; -} diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts deleted file mode 100644 index a33511c34..000000000 --- a/src/wallet-impl/refresh.ts +++ /dev/null @@ -1,479 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { AmountJson } from "../util/amounts"; -import * as Amounts from "../util/amounts"; -import { - DenominationRecord, - Stores, - CoinStatus, - RefreshPlanchetRecord, - CoinRecord, - RefreshSessionRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import { amountToPretty } from "../util/helpers"; -import { - oneShotGet, - oneShotMutate, - runWithWriteTransaction, - TransactionAbort, - oneShotIterIndex, -} from "../util/query"; -import { InternalWalletState } from "./state"; -import { Logger } from "../util/logging"; -import { getWithdrawDenomList } from "./withdraw"; -import { updateExchangeFromUrl } from "./exchanges"; -import { - getTimestampNow, - OperationError, - NotificationType, -} from "../walletTypes"; -import { guardOperationException } from "./errors"; - -const logger = new Logger("refresh.ts"); - -/** - * Get the amount that we lose when refreshing a coin of the given denomination - * with a certain amount left. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: DenominationRecord, - amountLeft: AmountJson, -): AmountJson { - const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) - .amount; - const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); - const resultingAmount = Amounts.add( - Amounts.getZero(withdrawAmount.currency), - ...withdrawDenoms.map(d => d.value), - ).amount; - const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - logger.trace( - "total refresh cost for", - amountToPretty(amountLeft), - "is", - amountToPretty(totalCost), - ); - return totalCost; -} - -async function refreshMelt( - ws: InternalWalletState, - refreshSessionId: string, -): Promise<void> { - const refreshSession = await oneShotGet( - ws.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.norevealIndex !== undefined) { - return; - } - - const coin = await oneShotGet( - ws.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - - if (!coin) { - console.error("can't melt coin, it does not exist"); - return; - } - - const reqUrl = new URL("refresh/melt", refreshSession.exchangeBaseUrl); - const meltReq = { - coin_pub: coin.coinPub, - confirm_sig: refreshSession.confirmSig, - denom_pub_hash: coin.denomPubHash, - denom_sig: coin.denomSig, - rc: refreshSession.hash, - value_with_fee: refreshSession.valueWithFee, - }; - logger.trace("melt request:", meltReq); - const resp = await ws.http.postJson(reqUrl.href, meltReq); - if (resp.status !== 200) { - throw Error(`unexpected status code ${resp.status} for refresh/melt`); - } - - const respJson = await resp.json(); - - logger.trace("melt response:", respJson); - - if (resp.status !== 200) { - console.error(respJson); - throw Error("refresh failed"); - } - - const norevealIndex = respJson.noreveal_index; - - if (typeof norevealIndex !== "number") { - throw Error("invalid response"); - } - - refreshSession.norevealIndex = norevealIndex; - - await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, rs => { - if (rs.norevealIndex !== undefined) { - return; - } - if (rs.finishedTimestamp) { - return; - } - rs.norevealIndex = norevealIndex; - return rs; - }); - - ws.notify({ - type: NotificationType.RefreshMelted, - }); -} - -async function refreshReveal( - ws: InternalWalletState, - refreshSessionId: string, -): Promise<void> { - const refreshSession = await oneShotGet( - ws.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - const norevealIndex = refreshSession.norevealIndex; - if (norevealIndex === undefined) { - throw Error("can't reveal without melting first"); - } - const privs = Array.from(refreshSession.transferPrivs); - privs.splice(norevealIndex, 1); - - const planchets = refreshSession.planchetsForGammas[norevealIndex]; - if (!planchets) { - throw Error("refresh index error"); - } - - const meltCoinRecord = await oneShotGet( - ws.db, - Stores.coins, - refreshSession.meltCoinPub, - ); - if (!meltCoinRecord) { - throw Error("inconsistent database"); - } - - const evs = planchets.map((x: RefreshPlanchetRecord) => x.coinEv); - - const linkSigs: string[] = []; - for (let i = 0; i < refreshSession.newDenoms.length; i++) { - const linkSig = await ws.cryptoApi.signCoinLink( - meltCoinRecord.coinPriv, - refreshSession.newDenomHashes[i], - refreshSession.meltCoinPub, - refreshSession.transferPubs[norevealIndex], - planchets[i].coinEv, - ); - linkSigs.push(linkSig); - } - - const req = { - coin_evs: evs, - new_denoms_h: refreshSession.newDenomHashes, - rc: refreshSession.hash, - transfer_privs: privs, - transfer_pub: refreshSession.transferPubs[norevealIndex], - link_sigs: linkSigs, - }; - - const reqUrl = new URL("refresh/reveal", refreshSession.exchangeBaseUrl); - logger.trace("reveal request:", req); - - let resp; - try { - resp = await ws.http.postJson(reqUrl.href, req); - } catch (e) { - console.error("got error during /refresh/reveal request"); - console.error(e); - return; - } - - logger.trace("session:", refreshSession); - logger.trace("reveal response:", resp); - - if (resp.status !== 200) { - console.error("error: /refresh/reveal returned status " + resp.status); - return; - } - - const respJson = await resp.json(); - - if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { - console.error("/refresh/reveal did not contain ev_sigs"); - return; - } - - const coins: CoinRecord[] = []; - - for (let i = 0; i < respJson.ev_sigs.length; i++) { - const denom = await oneShotGet(ws.db, Stores.denominations, [ - refreshSession.exchangeBaseUrl, - refreshSession.newDenoms[i], - ]); - if (!denom) { - console.error("denom not found"); - continue; - } - const pc = - refreshSession.planchetsForGammas[refreshSession.norevealIndex!][i]; - const denomSig = await ws.cryptoApi.rsaUnblind( - respJson.ev_sigs[i].ev_sig, - pc.blindingKey, - denom.denomPub, - ); - const coin: CoinRecord = { - blindingKey: pc.blindingKey, - coinPriv: pc.privateKey, - coinPub: pc.publicKey, - currentAmount: denom.value, - denomPub: denom.denomPub, - denomPubHash: denom.denomPubHash, - denomSig, - exchangeBaseUrl: refreshSession.exchangeBaseUrl, - reservePub: undefined, - status: CoinStatus.Fresh, - coinIndex: -1, - withdrawSessionId: "", - }; - - coins.push(coin); - } - - await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.refresh], - async tx => { - const rs = await tx.get(Stores.refresh, refreshSessionId); - if (!rs) { - console.log("no refresh session found"); - return; - } - if (rs.finishedTimestamp) { - console.log("refresh session already finished"); - return; - } - rs.finishedTimestamp = getTimestampNow(); - rs.retryInfo = initRetryInfo(false); - for (let coin of coins) { - await tx.put(Stores.coins, coin); - } - await tx.put(Stores.refresh, rs); - }, - ); - console.log("refresh finished (end of reveal)"); - ws.notify({ - type: NotificationType.RefreshRevealed, - }); -} - -async function incrementRefreshRetry( - ws: InternalWalletState, - refreshSessionId: string, - err: OperationError | undefined, -): Promise<void> { - await runWithWriteTransaction(ws.db, [Stores.refresh], async tx => { - const r = await tx.get(Stores.refresh, refreshSessionId); - if (!r) { - return; - } - if (!r.retryInfo) { - return; - } - r.retryInfo.retryCounter++; - updateRetryInfoTimeout(r.retryInfo); - r.lastError = err; - await tx.put(Stores.refresh, r); - }); - ws.notify({ type: NotificationType.RefreshOperationError }); -} - -export async function processRefreshSession( - ws: InternalWalletState, - refreshSessionId: string, - forceNow: boolean = false, -) { - return ws.memoProcessRefresh.memo(refreshSessionId, async () => { - const onOpErr = (e: OperationError) => - incrementRefreshRetry(ws, refreshSessionId, e); - return guardOperationException( - () => processRefreshSessionImpl(ws, refreshSessionId, forceNow), - onOpErr, - ); - }); -} - -async function resetRefreshSessionRetry( - ws: InternalWalletState, - refreshSessionId: string, -) { - await oneShotMutate(ws.db, Stores.refresh, refreshSessionId, (x) => { - if (x.retryInfo.active) { - x.retryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processRefreshSessionImpl( - ws: InternalWalletState, - refreshSessionId: string, - forceNow: boolean, -) { - if (forceNow) { - await resetRefreshSessionRetry(ws, refreshSessionId); - } - const refreshSession = await oneShotGet( - ws.db, - Stores.refresh, - refreshSessionId, - ); - if (!refreshSession) { - return; - } - if (refreshSession.finishedTimestamp) { - return; - } - if (typeof refreshSession.norevealIndex !== "number") { - await refreshMelt(ws, refreshSession.refreshSessionId); - } - await refreshReveal(ws, refreshSession.refreshSessionId); - logger.trace("refresh finished"); -} - -export async function refresh( - ws: InternalWalletState, - oldCoinPub: string, - force: boolean = false, -): Promise<void> { - const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub); - if (!coin) { - console.warn("can't refresh, coin not in database"); - return; - } - switch (coin.status) { - case CoinStatus.Dirty: - break; - case CoinStatus.Dormant: - return; - case CoinStatus.Fresh: - if (!force) { - return; - } - break; - } - - const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); - if (!exchange) { - throw Error("db inconsistent: exchange of coin not found"); - } - - const oldDenom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - const availableDenoms: DenominationRecord[] = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) - .amount; - - const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); - - if (newCoinDenoms.length === 0) { - logger.trace( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => { - if (x.status != coin.status) { - // Concurrent modification? - return; - } - x.status = CoinStatus.Dormant; - return x; - }); - ws.notify({ type: NotificationType.RefreshRefused }); - return; - } - - const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( - exchange.baseUrl, - 3, - coin, - newCoinDenoms, - oldDenom.feeRefresh, - ); - - // Store refresh session and subtract refreshed amount from - // coin in the same transaction. - await runWithWriteTransaction( - ws.db, - [Stores.refresh, Stores.coins], - async tx => { - const c = await tx.get(Stores.coins, coin.coinPub); - if (!c) { - return; - } - if (c.status !== CoinStatus.Dirty) { - return; - } - const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); - if (r.saturated) { - console.log("can't refresh coin, no amount left"); - return; - } - c.currentAmount = r.amount; - c.status = CoinStatus.Dormant; - await tx.put(Stores.refresh, refreshSession); - await tx.put(Stores.coins, c); - }, - ); - logger.info(`created refresh session ${refreshSession.refreshSessionId}`); - ws.notify({ type: NotificationType.RefreshStarted }); - - await processRefreshSession(ws, refreshSession.refreshSessionId); -} diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts deleted file mode 100644 index 504cf10f0..000000000 --- a/src/wallet-impl/reserves.ts +++ /dev/null @@ -1,630 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - CreateReserveRequest, - CreateReserveResponse, - getTimestampNow, - ConfirmReserveRequest, - OperationError, - NotificationType, -} from "../walletTypes"; -import { canonicalizeBaseUrl } from "../util/helpers"; -import { InternalWalletState } from "./state"; -import { - ReserveRecordStatus, - ReserveRecord, - CurrencyRecord, - Stores, - WithdrawalSessionRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import { - oneShotMutate, - oneShotPut, - oneShotGet, - runWithWriteTransaction, - TransactionAbort, -} from "../util/query"; -import { Logger } from "../util/logging"; -import * as Amounts from "../util/amounts"; -import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges"; -import { WithdrawOperationStatusResponse, ReserveStatus } from "../talerTypes"; -import { assertUnreachable } from "../util/assertUnreachable"; -import { encodeCrock } from "../crypto/talerCrypto"; -import { randomBytes } from "../crypto/primitives/nacl-fast"; -import { - getVerifiedWithdrawDenomList, - processWithdrawSession, -} from "./withdraw"; -import { guardOperationException, OperationFailedAndReportedError } from "./errors"; - -const logger = new Logger("reserves.ts"); - -/** - * Create a reserve, but do not flag it as confirmed yet. - * - * Adds the corresponding exchange as a trusted exchange if it is neither - * audited nor trusted already. - */ -export async function createReserve( - ws: InternalWalletState, - req: CreateReserveRequest, -): Promise<CreateReserveResponse> { - const keypair = await ws.cryptoApi.createEddsaKeypair(); - const now = getTimestampNow(); - const canonExchange = canonicalizeBaseUrl(req.exchange); - - let reserveStatus; - if (req.bankWithdrawStatusUrl) { - reserveStatus = ReserveRecordStatus.REGISTERING_BANK; - } else { - reserveStatus = ReserveRecordStatus.UNCONFIRMED; - } - - const currency = req.amount.currency; - - const reserveRecord: ReserveRecord = { - created: now, - withdrawAllocatedAmount: Amounts.getZero(currency), - withdrawCompletedAmount: Amounts.getZero(currency), - withdrawRemainingAmount: Amounts.getZero(currency), - exchangeBaseUrl: canonExchange, - hasPayback: false, - initiallyRequestedAmount: req.amount, - reservePriv: keypair.priv, - reservePub: keypair.pub, - senderWire: req.senderWire, - timestampConfirmed: undefined, - timestampReserveInfoPosted: undefined, - bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, - exchangeWire: req.exchangeWire, - reserveStatus, - lastSuccessfulStatusQuery: undefined, - retryInfo: initRetryInfo(), - lastError: undefined, - }; - - const senderWire = req.senderWire; - if (senderWire) { - const rec = { - paytoUri: senderWire, - }; - await oneShotPut(ws.db, Stores.senderWires, rec); - } - - const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - console.log(exchangeDetails); - throw Error("exchange not updated"); - } - const { isAudited, isTrusted } = await getExchangeTrust(ws, exchangeInfo); - let currencyRecord = await oneShotGet( - ws.db, - Stores.currencies, - exchangeDetails.currency, - ); - if (!currencyRecord) { - currencyRecord = { - auditors: [], - exchanges: [], - fractionalDigits: 2, - name: exchangeDetails.currency, - }; - } - - if (!isAudited && !isTrusted) { - currencyRecord.exchanges.push({ - baseUrl: req.exchange, - exchangePub: exchangeDetails.masterPublicKey, - }); - } - - const cr: CurrencyRecord = currencyRecord; - - const resp = await runWithWriteTransaction( - ws.db, - [Stores.currencies, Stores.reserves, Stores.bankWithdrawUris], - async tx => { - // Check if we have already created a reserve for that bankWithdrawStatusUrl - if (reserveRecord.bankWithdrawStatusUrl) { - const bwi = await tx.get( - Stores.bankWithdrawUris, - reserveRecord.bankWithdrawStatusUrl, - ); - if (bwi) { - const otherReserve = await tx.get(Stores.reserves, bwi.reservePub); - if (otherReserve) { - logger.trace( - "returning existing reserve for bankWithdrawStatusUri", - ); - return { - exchange: otherReserve.exchangeBaseUrl, - reservePub: otherReserve.reservePub, - }; - } - } - await tx.put(Stores.bankWithdrawUris, { - reservePub: reserveRecord.reservePub, - talerWithdrawUri: reserveRecord.bankWithdrawStatusUrl, - }); - } - await tx.put(Stores.currencies, cr); - await tx.put(Stores.reserves, reserveRecord); - const r: CreateReserveResponse = { - exchange: canonExchange, - reservePub: keypair.pub, - }; - return r; - }, - ); - - ws.notify({ type: NotificationType.ReserveCreated }); - - // Asynchronously process the reserve, but return - // to the caller already. - processReserve(ws, resp.reservePub, true).catch(e => { - console.error("Processing reserve failed:", e); - }); - - return resp; -} - -/** - * First fetch information requred to withdraw from the reserve, - * then deplete the reserve, withdrawing coins until it is empty. - * - * The returned promise resolves once the reserve is set to the - * state DORMANT. - */ -export async function processReserve( - ws: InternalWalletState, - reservePub: string, - forceNow: boolean = false, -): Promise<void> { - return ws.memoProcessReserve.memo(reservePub, async () => { - const onOpError = (err: OperationError) => - incrementReserveRetry(ws, reservePub, err); - await guardOperationException( - () => processReserveImpl(ws, reservePub, forceNow), - onOpError, - ); - }); -} - - -async function registerReserveWithBank( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - case ReserveRecordStatus.REGISTERING_BANK: - break; - default: - return; - } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - return; - } - console.log("making selection"); - if (reserve.timestampReserveInfoPosted) { - throw Error("bank claims that reserve info selection is not done"); - } - const bankResp = await ws.http.postJson(bankStatusUrl, { - reserve_pub: reservePub, - selected_exchange: reserve.exchangeWire, - }); - console.log("got response", bankResp); - await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.REGISTERING_BANK: - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - r.timestampReserveInfoPosted = getTimestampNow(); - r.reserveStatus = ReserveRecordStatus.WAIT_CONFIRM_BANK; - r.retryInfo = initRetryInfo(); - return r; - }); - ws.notify( { type: NotificationType.Wildcard }); - return processReserveBankStatus(ws, reservePub); -} - -export async function processReserveBankStatus( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - const onOpError = (err: OperationError) => - incrementReserveRetry(ws, reservePub, err); - await guardOperationException( - () => processReserveBankStatusImpl(ws, reservePub), - onOpError, - ); -} - -async function processReserveBankStatusImpl( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - let reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - case ReserveRecordStatus.REGISTERING_BANK: - break; - default: - return; - } - const bankStatusUrl = reserve.bankWithdrawStatusUrl; - if (!bankStatusUrl) { - return; - } - - let status: WithdrawOperationStatusResponse; - try { - const statusResp = await ws.http.get(bankStatusUrl); - if (statusResp.status !== 200) { - throw Error(`unexpected status ${statusResp.status} for bank status query`); - } - status = WithdrawOperationStatusResponse.checked(await statusResp.json()); - } catch (e) { - throw e; - } - - ws.notify( { type: NotificationType.Wildcard }); - - if (status.selection_done) { - if (reserve.reserveStatus === ReserveRecordStatus.REGISTERING_BANK) { - await registerReserveWithBank(ws, reservePub); - return await processReserveBankStatus(ws, reservePub); - } - } else { - await registerReserveWithBank(ws, reservePub); - return await processReserveBankStatus(ws, reservePub); - } - - if (status.transfer_done) { - await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.REGISTERING_BANK: - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - const now = getTimestampNow(); - r.timestampConfirmed = now; - r.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; - r.retryInfo = initRetryInfo(); - return r; - }); - await processReserveImpl(ws, reservePub, true); - } else { - await oneShotMutate(ws.db, Stores.reserves, reservePub, r => { - switch (r.reserveStatus) { - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - break; - default: - return; - } - r.bankWithdrawConfirmUrl = status.confirm_transfer_url; - return r; - }); - await incrementReserveRetry(ws, reservePub, undefined); - } - ws.notify( { type: NotificationType.Wildcard }); -} - -async function incrementReserveRetry( - ws: InternalWalletState, - reservePub: string, - err: OperationError | undefined, -): Promise<void> { - await runWithWriteTransaction(ws.db, [Stores.reserves], async tx => { - const r = await tx.get(Stores.reserves, reservePub); - if (!r) { - return; - } - if (!r.retryInfo) { - return; - } - r.retryInfo.retryCounter++; - updateRetryInfoTimeout(r.retryInfo); - r.lastError = err; - await tx.put(Stores.reserves, r); - }); - ws.notify({ type: NotificationType.ReserveOperationError }); -} - -/** - * Update the information about a reserve that is stored in the wallet - * by quering the reserve's exchange. - */ -async function updateReserve( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error("reserve not in db"); - } - - if (reserve.timestampConfirmed === undefined) { - throw Error("reserve not confirmed yet"); - } - - if (reserve.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { - return; - } - - const reqUrl = new URL("reserve/status", reserve.exchangeBaseUrl); - reqUrl.searchParams.set("reserve_pub", reservePub); - let resp; - try { - resp = await ws.http.get(reqUrl.href); - if (resp.status === 404) { - const m = "The exchange does not know about this reserve (yet)."; - await incrementReserveRetry(ws, reservePub, undefined); - return; - } - if (resp.status !== 200) { - throw Error(`unexpected status code ${resp.status} for reserve/status`) - } - } catch (e) { - const m = e.message; - await incrementReserveRetry(ws, reservePub, { - type: "network", - details: {}, - message: m, - }); - throw new OperationFailedAndReportedError(m); - } - const reserveInfo = ReserveStatus.checked(await resp.json()); - const balance = Amounts.parseOrThrow(reserveInfo.balance); - await oneShotMutate(ws.db, Stores.reserves, reserve.reservePub, r => { - if (r.reserveStatus !== ReserveRecordStatus.QUERYING_STATUS) { - return; - } - - // FIXME: check / compare history! - if (!r.lastSuccessfulStatusQuery) { - // FIXME: check if this matches initial expectations - r.withdrawRemainingAmount = balance; - } else { - const expectedBalance = Amounts.sub( - r.withdrawAllocatedAmount, - r.withdrawCompletedAmount, - ); - const cmp = Amounts.cmp(balance, expectedBalance.amount); - if (cmp == 0) { - // Nothing changed. - return; - } - if (cmp > 0) { - const extra = Amounts.sub(balance, expectedBalance.amount).amount; - r.withdrawRemainingAmount = Amounts.add( - r.withdrawRemainingAmount, - extra, - ).amount; - } else { - // We're missing some money. - } - } - r.lastSuccessfulStatusQuery = getTimestampNow(); - r.reserveStatus = ReserveRecordStatus.WITHDRAWING; - r.retryInfo = initRetryInfo(); - return r; - }); - ws.notify( { type: NotificationType.ReserveUpdated }); -} - -async function processReserveImpl( - ws: InternalWalletState, - reservePub: string, - forceNow: boolean = false, -): Promise<void> { - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - console.log("not processing reserve: reserve does not exist"); - return; - } - if (!forceNow) { - const now = getTimestampNow(); - if (reserve.retryInfo.nextRetry.t_ms > now.t_ms) { - logger.trace("processReserve retry not due yet"); - return; - } - } - logger.trace( - `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, - ); - switch (reserve.reserveStatus) { - case ReserveRecordStatus.UNCONFIRMED: - // nothing to do - break; - case ReserveRecordStatus.REGISTERING_BANK: - await processReserveBankStatus(ws, reservePub); - return processReserveImpl(ws, reservePub, true); - case ReserveRecordStatus.QUERYING_STATUS: - await updateReserve(ws, reservePub); - return processReserveImpl(ws, reservePub, true); - case ReserveRecordStatus.WITHDRAWING: - await depleteReserve(ws, reservePub); - break; - case ReserveRecordStatus.DORMANT: - // nothing to do - break; - case ReserveRecordStatus.WAIT_CONFIRM_BANK: - await processReserveBankStatus(ws, reservePub); - break; - default: - console.warn("unknown reserve record status:", reserve.reserveStatus); - assertUnreachable(reserve.reserveStatus); - break; - } -} - -export async function confirmReserve( - ws: InternalWalletState, - req: ConfirmReserveRequest, -): Promise<void> { - const now = getTimestampNow(); - await oneShotMutate(ws.db, Stores.reserves, req.reservePub, reserve => { - if (reserve.reserveStatus !== ReserveRecordStatus.UNCONFIRMED) { - return; - } - reserve.timestampConfirmed = now; - reserve.reserveStatus = ReserveRecordStatus.QUERYING_STATUS; - reserve.retryInfo = initRetryInfo(); - return reserve; - }); - - ws.notify({ type: NotificationType.ReserveUpdated }); - - processReserve(ws, req.reservePub, true).catch(e => { - console.log("processing reserve failed:", e); - }); -} - -/** - * Withdraw coins from a reserve until it is empty. - * - * When finished, marks the reserve as depleted by setting - * the depleted timestamp. - */ -async function depleteReserve( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - return; - } - if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return; - } - logger.trace(`depleting reserve ${reservePub}`); - - const withdrawAmount = reserve.withdrawRemainingAmount; - - logger.trace(`getting denom list`); - - const denomsForWithdraw = await getVerifiedWithdrawDenomList( - ws, - reserve.exchangeBaseUrl, - withdrawAmount, - ); - logger.trace(`got denom list`); - if (denomsForWithdraw.length === 0) { - const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; - await incrementReserveRetry(ws, reserve.reservePub, { - type: "internal", - message: m, - details: {}, - }); - console.log(m); - throw new OperationFailedAndReportedError(m); - } - - logger.trace("selected denominations"); - - const withdrawalSessionId = encodeCrock(randomBytes(32)); - - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) - .amount; - - const withdrawalRecord: WithdrawalSessionRecord = { - withdrawSessionId: withdrawalSessionId, - exchangeBaseUrl: reserve.exchangeBaseUrl, - source: { - type: "reserve", - reservePub: reserve.reservePub, - }, - rawWithdrawalAmount: withdrawAmount, - startTimestamp: getTimestampNow(), - denoms: denomsForWithdraw.map(x => x.denomPub), - withdrawn: denomsForWithdraw.map(x => false), - planchets: denomsForWithdraw.map(x => undefined), - totalCoinValue, - retryInfo: initRetryInfo(), - lastCoinErrors: denomsForWithdraw.map(x => undefined), - lastError: undefined, - }; - - const totalCoinWithdrawFee = Amounts.sum( - denomsForWithdraw.map(x => x.feeWithdraw), - ).amount; - const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee) - .amount; - - function mutateReserve(r: ReserveRecord): ReserveRecord { - const remaining = Amounts.sub( - r.withdrawRemainingAmount, - totalWithdrawAmount, - ); - if (remaining.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - const allocated = Amounts.add( - r.withdrawAllocatedAmount, - totalWithdrawAmount, - ); - if (allocated.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - r.withdrawRemainingAmount = remaining.amount; - r.withdrawAllocatedAmount = allocated.amount; - r.reserveStatus = ReserveRecordStatus.DORMANT; - r.retryInfo = initRetryInfo(false); - return r; - } - - const success = await runWithWriteTransaction( - ws.db, - [Stores.withdrawalSession, Stores.reserves], - async tx => { - const myReserve = await tx.get(Stores.reserves, reservePub); - if (!myReserve) { - return false; - } - if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return false; - } - await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); - await tx.put(Stores.withdrawalSession, withdrawalRecord); - return true; - }, - ); - - if (success) { - console.log("processing new withdraw session"); - ws.notify({ - type: NotificationType.WithdrawSessionCreated, - withdrawSessionId: withdrawalSessionId, - }); - await processWithdrawSession(ws, withdrawalSessionId); - } else { - console.trace("withdraw session already existed"); - } -} diff --git a/src/wallet-impl/return.ts b/src/wallet-impl/return.ts deleted file mode 100644 index 0c142f9a6..000000000 --- a/src/wallet-impl/return.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - HistoryQuery, - HistoryEvent, - WalletBalance, - WalletBalanceEntry, - ReturnCoinsRequest, - CoinWithDenom, -} from "../walletTypes"; -import { oneShotIter, runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; -import { Logger } from "../util/logging"; -import { canonicalJson } from "../util/helpers"; -import { ContractTerms } from "../talerTypes"; -import { selectPayCoins } from "./pay"; - -const logger = new Logger("return.ts"); - -async function getCoinsForReturn( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise<CoinWithDenom[] | undefined> { - const exchange = await oneShotGet( - ws.db, - Stores.exchanges, - exchangeBaseUrl, - ); - if (!exchange) { - throw Error(`Exchange ${exchangeBaseUrl} not known to the wallet`); - } - - const coins: CoinRecord[] = await oneShotIterIndex( - ws.db, - Stores.coins.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - if (!coins || !coins.length) { - return []; - } - - const denoms = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - // Denomination of the first coin, we assume that all other - // coins have the same currency - const firstDenom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coins[0].denomPub, - ]); - if (!firstDenom) { - throw Error("db inconsistent"); - } - const currency = firstDenom.value.currency; - - const cds: CoinWithDenom[] = []; - for (const coin of coins) { - const denom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error("db inconsistent"); - } - if (denom.value.currency !== currency) { - console.warn( - `same pubkey for different currencies at exchange ${exchange.baseUrl}`, - ); - continue; - } - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - cds.push({ coin, denom }); - } - - const res = selectPayCoins(denoms, cds, amount, amount); - if (res) { - return res.cds; - } - return undefined; -} - - -/** - * Trigger paying coins back into the user's account. - */ -export async function returnCoins( - ws: InternalWalletState, - req: ReturnCoinsRequest, -): Promise<void> { - logger.trace("got returnCoins request", req); - const wireType = (req.senderWire as any).type; - logger.trace("wireType", wireType); - if (!wireType || typeof wireType !== "string") { - console.error(`wire type must be a non-empty string, not ${wireType}`); - return; - } - const stampSecNow = Math.floor(new Date().getTime() / 1000); - const exchange = await oneShotGet(ws.db, Stores.exchanges, req.exchange); - if (!exchange) { - console.error(`Exchange ${req.exchange} not known to the wallet`); - return; - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - throw Error("exchange information needs to be updated first."); - } - logger.trace("selecting coins for return:", req); - const cds = await getCoinsForReturn(ws, req.exchange, req.amount); - logger.trace(cds); - - if (!cds) { - throw Error("coin return impossible, can't select coins"); - } - - const { priv, pub } = await ws.cryptoApi.createEddsaKeypair(); - - const wireHash = await ws.cryptoApi.hashString( - canonicalJson(req.senderWire), - ); - - const contractTerms: ContractTerms = { - H_wire: wireHash, - amount: Amounts.toString(req.amount), - auditors: [], - exchanges: [ - { master_pub: exchangeDetails.masterPublicKey, url: exchange.baseUrl }, - ], - extra: {}, - fulfillment_url: "", - locations: [], - max_fee: Amounts.toString(req.amount), - merchant: {}, - merchant_pub: pub, - order_id: "none", - pay_deadline: `/Date(${stampSecNow + 30 * 5})/`, - wire_transfer_deadline: `/Date(${stampSecNow + 60 * 5})/`, - merchant_base_url: "taler://return-to-account", - products: [], - refund_deadline: `/Date(${stampSecNow + 60 * 5})/`, - timestamp: `/Date(${stampSecNow})/`, - wire_method: wireType, - }; - - const contractTermsHash = await ws.cryptoApi.hashString( - canonicalJson(contractTerms), - ); - - const payCoinInfo = await ws.cryptoApi.signDeposit( - contractTerms, - cds, - Amounts.parseOrThrow(contractTerms.amount), - ); - - logger.trace("pci", payCoinInfo); - - const coins = payCoinInfo.sigs.map(s => ({ coinPaySig: s })); - - const coinsReturnRecord: CoinsReturnRecord = { - coins, - contractTerms, - contractTermsHash, - exchange: exchange.baseUrl, - merchantPriv: priv, - wire: req.senderWire, - }; - - await runWithWriteTransaction( - ws.db, - [Stores.coinsReturns, Stores.coins], - async tx => { - await tx.put(Stores.coinsReturns, coinsReturnRecord); - for (let c of payCoinInfo.updatedCoins) { - await tx.put(Stores.coins, c); - } - }, - ); - - depositReturnedCoins(ws, coinsReturnRecord); -} - -async function depositReturnedCoins( - ws: InternalWalletState, - coinsReturnRecord: CoinsReturnRecord, -): Promise<void> { - for (const c of coinsReturnRecord.coins) { - if (c.depositedSig) { - continue; - } - const req = { - H_wire: coinsReturnRecord.contractTerms.H_wire, - coin_pub: c.coinPaySig.coin_pub, - coin_sig: c.coinPaySig.coin_sig, - contribution: c.coinPaySig.contribution, - denom_pub: c.coinPaySig.denom_pub, - h_contract_terms: coinsReturnRecord.contractTermsHash, - merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, - pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, - refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, - timestamp: coinsReturnRecord.contractTerms.timestamp, - ub_sig: c.coinPaySig.ub_sig, - wire: coinsReturnRecord.wire, - wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, - }; - logger.trace("req", req); - const reqUrl = new URL("deposit", coinsReturnRecord.exchange); - const resp = await ws.http.postJson(reqUrl.href, req); - if (resp.status !== 200) { - console.error("deposit failed due to status code", resp); - continue; - } - const respJson = await resp.json(); - if (respJson.status !== "DEPOSIT_OK") { - console.error("deposit failed", resp); - continue; - } - - if (!respJson.sig) { - console.error("invalid 'sig' field", resp); - continue; - } - - // FIXME: verify signature - - // For every successful deposit, we replace the old record with an updated one - const currentCrr = await oneShotGet( - ws.db, - Stores.coinsReturns, - coinsReturnRecord.contractTermsHash, - ); - if (!currentCrr) { - console.error("database inconsistent"); - continue; - } - for (const nc of currentCrr.coins) { - if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { - nc.depositedSig = respJson.sig; - } - } - await oneShotPut(ws.db, Stores.coinsReturns, currentCrr); - } -} diff --git a/src/wallet-impl/state.ts b/src/wallet-impl/state.ts deleted file mode 100644 index 18df861f1..000000000 --- a/src/wallet-impl/state.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { HttpRequestLibrary } from "../util/http"; -import { - NextUrlResult, - WalletBalance, - PendingOperationsResponse, - WalletNotification, -} from "../walletTypes"; -import { SpeculativePayData } from "./pay"; -import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi"; -import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo"; -import { Logger } from "../util/logging"; - -type NotificationListener = (n: WalletNotification) => void; - -const logger = new Logger("state.ts"); - -export class InternalWalletState { - speculativePayData: SpeculativePayData | undefined = undefined; - cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; - memoProcessReserve: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoMakePlanchet: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - memoGetPending: AsyncOpMemoSingle< - PendingOperationsResponse - > = new AsyncOpMemoSingle(); - memoGetBalance: AsyncOpMemoSingle<WalletBalance> = new AsyncOpMemoSingle(); - memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); - cryptoApi: CryptoApi; - - listeners: NotificationListener[] = []; - - constructor( - public db: IDBDatabase, - public http: HttpRequestLibrary, - cryptoWorkerFactory: CryptoWorkerFactory, - ) { - this.cryptoApi = new CryptoApi(cryptoWorkerFactory); - } - - public notify(n: WalletNotification) { - logger.trace("Notification", n); - for (const l of this.listeners) { - const nc = JSON.parse(JSON.stringify(n)); - setImmediate(() => { - l(nc); - }); - } - } - - addNotificationListener(f: (n: WalletNotification) => void): void { - this.listeners.push(f); - } -} diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts deleted file mode 100644 index 22ec37793..000000000 --- a/src/wallet-impl/tip.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - - -import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { parseTipUri } from "../util/taleruri"; -import { TipStatus, getTimestampNow, OperationError, NotificationType } from "../walletTypes"; -import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes"; -import * as Amounts from "../util/amounts"; -import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../dbTypes"; -import { getExchangeWithdrawalInfo, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw"; -import { getTalerStampSec, extractTalerStampOrThrow } from "../util/helpers"; -import { updateExchangeFromUrl } from "./exchanges"; -import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; -import { guardOperationException } from "./errors"; - - -export async function getTipStatus( - ws: InternalWalletState, - talerTipUri: string): Promise<TipStatus> { - const res = parseTipUri(talerTipUri); - if (!res) { - throw Error("invalid taler://tip URI"); - } - - const tipStatusUrl = new URL("tip-pickup", res.merchantBaseUrl); - tipStatusUrl.searchParams.set("tip_id", res.merchantTipId); - console.log("checking tip status from", tipStatusUrl.href); - const merchantResp = await ws.http.get(tipStatusUrl.href); - if (merchantResp.status !== 200) { - throw Error(`unexpected status ${merchantResp.status} for tip-pickup`); - } - const respJson = await merchantResp.json(); - console.log("resp:", respJson); - const tipPickupStatus = TipPickupGetResponse.checked(respJson); - - console.log("status", tipPickupStatus); - - let amount = Amounts.parseOrThrow(tipPickupStatus.amount); - - let tipRecord = await oneShotGet(ws.db, Stores.tips, [ - res.merchantTipId, - res.merchantOrigin, - ]); - - if (!tipRecord) { - const withdrawDetails = await getExchangeWithdrawalInfo( - ws, - tipPickupStatus.exchange_url, - amount, - ); - - const tipId = encodeCrock(getRandomBytes(32)); - - tipRecord = { - tipId, - accepted: false, - amount, - deadline: extractTalerStampOrThrow(tipPickupStatus.stamp_expire), - exchangeUrl: tipPickupStatus.exchange_url, - merchantBaseUrl: res.merchantBaseUrl, - nextUrl: undefined, - pickedUp: false, - planchets: undefined, - response: undefined, - createdTimestamp: getTimestampNow(), - merchantTipId: res.merchantTipId, - totalFees: Amounts.add( - withdrawDetails.overhead, - withdrawDetails.withdrawFee, - ).amount, - retryInfo: initRetryInfo(), - lastError: undefined, - }; - await oneShotPut(ws.db, Stores.tips, tipRecord); - } - - const tipStatus: TipStatus = { - accepted: !!tipRecord && tipRecord.accepted, - amount: Amounts.parseOrThrow(tipPickupStatus.amount), - amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left), - exchangeUrl: tipPickupStatus.exchange_url, - nextUrl: tipPickupStatus.extra.next_url, - merchantOrigin: res.merchantOrigin, - merchantTipId: res.merchantTipId, - expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!, - timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!, - totalFees: tipRecord.totalFees, - tipId: tipRecord.tipId, - }; - - return tipStatus; -} - -async function incrementTipRetry( - ws: InternalWalletState, - refreshSessionId: string, - err: OperationError | undefined, -): Promise<void> { - await runWithWriteTransaction(ws.db, [Stores.tips], async tx => { - const t = await tx.get(Stores.tips, refreshSessionId); - if (!t) { - return; - } - if (!t.retryInfo) { - return; - } - t.retryInfo.retryCounter++; - updateRetryInfoTimeout(t.retryInfo); - t.lastError = err; - await tx.put(Stores.tips, t); - }); - ws.notify({ type: NotificationType.TipOperationError }); -} - -export async function processTip( - ws: InternalWalletState, - tipId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (e: OperationError) => incrementTipRetry(ws, tipId, e); - await guardOperationException(() => processTipImpl(ws, tipId, forceNow), onOpErr); -} - -async function resetTipRetry( - ws: InternalWalletState, - tipId: string, -): Promise<void> { - await oneShotMutate(ws.db, Stores.tips, tipId, (x) => { - if (x.retryInfo.active) { - x.retryInfo = initRetryInfo(); - } - return x; - }) -} - -async function processTipImpl( - ws: InternalWalletState, - tipId: string, - forceNow: boolean, -) { - if (forceNow) { - await resetTipRetry(ws, tipId); - } - let tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); - if (!tipRecord) { - return; - } - - if (tipRecord.pickedUp) { - console.log("tip already picked up"); - return; - } - - if (!tipRecord.planchets) { - await updateExchangeFromUrl(ws, tipRecord.exchangeUrl); - const denomsForWithdraw = await getVerifiedWithdrawDenomList( - ws, - tipRecord.exchangeUrl, - tipRecord.amount, - ); - - const planchets = await Promise.all( - denomsForWithdraw.map(d => ws.cryptoApi.createTipPlanchet(d)), - ); - - await oneShotMutate(ws.db, Stores.tips, tipId, r => { - if (!r.planchets) { - r.planchets = planchets; - } - return r; - }); - } - - tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); - if (!tipRecord) { - throw Error("tip not in database"); - } - - if (!tipRecord.planchets) { - throw Error("invariant violated"); - } - - console.log("got planchets for tip!"); - - // Planchets in the form that the merchant expects - const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({ - coin_ev: p.coinEv, - denom_pub_hash: p.denomPubHash, - })); - - let merchantResp; - - const tipStatusUrl = new URL("tip-pickup", tipRecord.merchantBaseUrl); - - try { - const req = { planchets: planchetsDetail, tip_id: tipRecord.merchantTipId }; - merchantResp = await ws.http.postJson(tipStatusUrl.href, req); - if (merchantResp.status !== 200) { - throw Error(`unexpected status ${merchantResp.status} for tip-pickup`); - } - console.log("got merchant resp:", merchantResp); - } catch (e) { - console.log("tipping failed", e); - throw e; - } - - const response = TipResponse.checked(await merchantResp.json()); - - if (response.reserve_sigs.length !== tipRecord.planchets.length) { - throw Error("number of tip responses does not match requested planchets"); - } - - const planchets: PlanchetRecord[] = []; - - for (let i = 0; i < tipRecord.planchets.length; i++) { - const tipPlanchet = tipRecord.planchets[i]; - const planchet: PlanchetRecord = { - blindingKey: tipPlanchet.blindingKey, - coinEv: tipPlanchet.coinEv, - coinPriv: tipPlanchet.coinPriv, - coinPub: tipPlanchet.coinPub, - coinValue: tipPlanchet.coinValue, - denomPub: tipPlanchet.denomPub, - denomPubHash: tipPlanchet.denomPubHash, - reservePub: response.reserve_pub, - withdrawSig: response.reserve_sigs[i].reserve_sig, - isFromTip: true, - }; - planchets.push(planchet); - } - - const withdrawalSessionId = encodeCrock(getRandomBytes(32)); - - const withdrawalSession: WithdrawalSessionRecord = { - denoms: planchets.map((x) => x.denomPub), - exchangeBaseUrl: tipRecord.exchangeUrl, - planchets: planchets, - source: { - type: "tip", - tipId: tipRecord.tipId, - }, - startTimestamp: getTimestampNow(), - withdrawSessionId: withdrawalSessionId, - rawWithdrawalAmount: tipRecord.amount, - withdrawn: planchets.map((x) => false), - totalCoinValue: Amounts.sum(planchets.map((p) => p.coinValue)).amount, - lastCoinErrors: planchets.map((x) => undefined), - retryInfo: initRetryInfo(), - finishTimestamp: undefined, - lastError: undefined, - }; - - - await runWithWriteTransaction(ws.db, [Stores.tips, Stores.withdrawalSession], async (tx) => { - const tr = await tx.get(Stores.tips, tipId); - if (!tr) { - return; - } - if (tr.pickedUp) { - return; - } - tr.pickedUp = true; - tr.retryInfo = initRetryInfo(false); - - await tx.put(Stores.tips, tr); - await tx.put(Stores.withdrawalSession, withdrawalSession); - }); - - await processWithdrawSession(ws, withdrawalSessionId); - - return; -} - -export async function acceptTip( - ws: InternalWalletState, - tipId: string, -): Promise<void> { - const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); - if (!tipRecord) { - console.log("tip not found"); - return; - } - - tipRecord.accepted = true; - await oneShotPut(ws.db, Stores.tips, tipRecord); - - await processTip(ws, tipId); - return; -} diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts deleted file mode 100644 index d8b2b599c..000000000 --- a/src/wallet-impl/withdraw.ts +++ /dev/null @@ -1,699 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { AmountJson } from "../util/amounts"; -import { - DenominationRecord, - Stores, - DenominationStatus, - CoinStatus, - CoinRecord, - PlanchetRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { - getTimestampNow, - AcceptWithdrawalResponse, - BankWithdrawDetails, - ExchangeWithdrawDetails, - WithdrawDetails, - OperationError, - NotificationType, -} from "../walletTypes"; -import { WithdrawOperationStatusResponse } from "../talerTypes"; -import { InternalWalletState } from "./state"; -import { parseWithdrawUri } from "../util/taleruri"; -import { Logger } from "../util/logging"; -import { - oneShotGet, - oneShotPut, - oneShotIterIndex, - oneShotGetIndexed, - runWithWriteTransaction, - oneShotMutate, -} from "../util/query"; -import { - updateExchangeFromUrl, - getExchangePaytoUri, - getExchangeTrust, -} from "./exchanges"; -import { createReserve, processReserveBankStatus } from "./reserves"; -import { WALLET_PROTOCOL_VERSION } from "../wallet"; - -import * as LibtoolVersion from "../util/libtoolVersion"; -import { guardOperationException } from "./errors"; - -const logger = new Logger("withdraw.ts"); - -function isWithdrawableDenom(d: DenominationRecord) { - const now = getTimestampNow(); - const started = now.t_ms >= d.stampStart.t_ms; - const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms; - return started && stillOkay; -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -export function getWithdrawDenomList( - amountAvailable: AmountJson, - denoms: DenominationRecord[], -): DenominationRecord[] { - let remaining = Amounts.copy(amountAvailable); - const ds: DenominationRecord[] = []; - - denoms = denoms.filter(isWithdrawableDenom); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - // This is an arbitrary number of coins - // we can withdraw in one go. It's not clear if this limit - // is useful ... - for (let i = 0; i < 1000; i++) { - let found = false; - for (const d of denoms) { - const cost = Amounts.add(d.value, d.feeWithdraw).amount; - if (Amounts.cmp(remaining, cost) < 0) { - continue; - } - found = true; - remaining = Amounts.sub(remaining, cost).amount; - ds.push(d); - break; - } - if (!found) { - break; - } - } - return ds; -} - -/** - * Get information about a withdrawal from - * a taler://withdraw URI by asking the bank. - */ -async function getBankWithdrawalInfo( - ws: InternalWalletState, - talerWithdrawUri: string, -): Promise<BankWithdrawDetails> { - const uriResult = parseWithdrawUri(talerWithdrawUri); - if (!uriResult) { - throw Error("can't parse URL"); - } - const resp = await ws.http.get(uriResult.statusUrl); - if (resp.status !== 200) { - throw Error(`unexpected status (${resp.status}) from bank for ${uriResult.statusUrl}`); - } - const respJson = await resp.json(); - console.log("resp:", respJson); - const status = WithdrawOperationStatusResponse.checked(respJson); - return { - amount: Amounts.parseOrThrow(status.amount), - confirmTransferUrl: status.confirm_transfer_url, - extractedStatusUrl: uriResult.statusUrl, - selectionDone: status.selection_done, - senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, - transferDone: status.transfer_done, - wireTypes: status.wire_types, - }; -} - -export async function acceptWithdrawal( - ws: InternalWalletState, - talerWithdrawUri: string, - selectedExchange: string, -): Promise<AcceptWithdrawalResponse> { - const withdrawInfo = await getBankWithdrawalInfo(ws, talerWithdrawUri); - const exchangeWire = await getExchangePaytoUri( - ws, - selectedExchange, - withdrawInfo.wireTypes, - ); - const reserve = await createReserve(ws, { - amount: withdrawInfo.amount, - bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, - exchange: selectedExchange, - senderWire: withdrawInfo.senderWire, - exchangeWire: exchangeWire, - }); - // We do this here, as the reserve should be registered before we return, - // so that we can redirect the user to the bank's status page. - await processReserveBankStatus(ws, reserve.reservePub); - console.log("acceptWithdrawal: returning"); - return { - reservePub: reserve.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - }; -} - -async function getPossibleDenoms( - ws: InternalWalletState, - exchangeBaseUrl: string, -): Promise<DenominationRecord[]> { - return await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - exchangeBaseUrl, - ).filter(d => { - return ( - d.status === DenominationStatus.Unverified || - d.status === DenominationStatus.VerifiedGood - ); - }); -} - -/** - * Given a planchet, withdraw a coin from the exchange. - */ -async function processPlanchet( - ws: InternalWalletState, - withdrawalSessionId: string, - coinIdx: number, -): Promise<void> { - const withdrawalSession = await oneShotGet( - ws.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - return; - } - if (withdrawalSession.withdrawn[coinIdx]) { - return; - } - if (withdrawalSession.source.type === "reserve") { - } - const planchet = withdrawalSession.planchets[coinIdx]; - if (!planchet) { - console.log("processPlanchet: planchet not found"); - return; - } - const exchange = await oneShotGet( - ws.db, - Stores.exchanges, - withdrawalSession.exchangeBaseUrl, - ); - if (!exchange) { - console.error("db inconsistent: exchange for planchet not found"); - return; - } - - const denom = await oneShotGet(ws.db, Stores.denominations, [ - withdrawalSession.exchangeBaseUrl, - planchet.denomPub, - ]); - - if (!denom) { - console.error("db inconsistent: denom for planchet not found"); - return; - } - - const wd: any = {}; - wd.denom_pub_hash = planchet.denomPubHash; - wd.reserve_pub = planchet.reservePub; - wd.reserve_sig = planchet.withdrawSig; - wd.coin_ev = planchet.coinEv; - const reqUrl = new URL("reserve/withdraw", exchange.baseUrl).href; - const resp = await ws.http.postJson(reqUrl, wd); - if (resp.status !== 200) { - throw Error(`unexpected status ${resp.status} for withdraw`); - } - - const r = await resp.json(); - - const denomSig = await ws.cryptoApi.rsaUnblind( - r.ev_sig, - planchet.blindingKey, - planchet.denomPub, - ); - - - const isValid = await ws.cryptoApi.rsaVerify(planchet.coinPub, denomSig, planchet.denomPub); - if (!isValid) { - throw Error("invalid RSA signature by the exchange"); - } - - const coin: CoinRecord = { - blindingKey: planchet.blindingKey, - coinPriv: planchet.coinPriv, - coinPub: planchet.coinPub, - currentAmount: planchet.coinValue, - denomPub: planchet.denomPub, - denomPubHash: planchet.denomPubHash, - denomSig, - exchangeBaseUrl: withdrawalSession.exchangeBaseUrl, - reservePub: planchet.reservePub, - status: CoinStatus.Fresh, - coinIndex: coinIdx, - withdrawSessionId: withdrawalSessionId, - }; - - let withdrawSessionFinished = false; - let reserveDepleted = false; - - const success = await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.withdrawalSession, Stores.reserves], - async tx => { - const ws = await tx.get(Stores.withdrawalSession, withdrawalSessionId); - if (!ws) { - return false; - } - if (ws.withdrawn[coinIdx]) { - // Already withdrawn - return false; - } - ws.withdrawn[coinIdx] = true; - ws.lastCoinErrors[coinIdx] = undefined; - let numDone = 0; - for (let i = 0; i < ws.withdrawn.length; i++) { - if (ws.withdrawn[i]) { - numDone++; - } - } - if (numDone === ws.denoms.length) { - ws.finishTimestamp = getTimestampNow(); - ws.lastError = undefined; - ws.retryInfo = initRetryInfo(false); - withdrawSessionFinished = true; - } - await tx.put(Stores.withdrawalSession, ws); - if (!planchet.isFromTip) { - const r = await tx.get(Stores.reserves, planchet.reservePub); - if (r) { - r.withdrawCompletedAmount = Amounts.add( - r.withdrawCompletedAmount, - Amounts.add(denom.value, denom.feeWithdraw).amount, - ).amount; - if (Amounts.cmp(r.withdrawCompletedAmount, r.withdrawAllocatedAmount) == 0) { - reserveDepleted = true; - } - await tx.put(Stores.reserves, r); - } - } - await tx.add(Stores.coins, coin); - return true; - }, - ); - - if (success) { - ws.notify( { - type: NotificationType.CoinWithdrawn, - } ); - } - - if (withdrawSessionFinished) { - ws.notify({ - type: NotificationType.WithdrawSessionFinished, - withdrawSessionId: withdrawalSessionId, - }); - } - - if (reserveDepleted && withdrawalSession.source.type === "reserve") { - ws.notify({ - type: NotificationType.ReserveDepleted, - reservePub: withdrawalSession.source.reservePub, - }); - } -} - -/** - * Get a list of denominations to withdraw from the given exchange for the - * given amount, making sure that all denominations' signatures are verified. - * - * Writes to the DB in order to record the result from verifying - * denominations. - */ -export async function getVerifiedWithdrawDenomList( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise<DenominationRecord[]> { - const exchange = await oneShotGet(ws.db, Stores.exchanges, exchangeBaseUrl); - if (!exchange) { - console.log("exchange not found"); - throw Error(`exchange ${exchangeBaseUrl} not found`); - } - const exchangeDetails = exchange.details; - if (!exchangeDetails) { - console.log("exchange details not available"); - throw Error(`exchange ${exchangeBaseUrl} details not available`); - } - - console.log("getting possible denoms"); - - const possibleDenoms = await getPossibleDenoms(ws, exchange.baseUrl); - - console.log("got possible denoms"); - - let allValid = false; - - let selectedDenoms: DenominationRecord[]; - - do { - allValid = true; - const nextPossibleDenoms = []; - selectedDenoms = getWithdrawDenomList(amount, possibleDenoms); - console.log("got withdraw denom list"); - for (const denom of selectedDenoms || []) { - if (denom.status === DenominationStatus.Unverified) { - console.log( - "checking validity", - denom, - exchangeDetails.masterPublicKey, - ); - const valid = await ws.cryptoApi.isValidDenom( - denom, - exchangeDetails.masterPublicKey, - ); - console.log("done checking validity"); - if (!valid) { - denom.status = DenominationStatus.VerifiedBad; - allValid = false; - } else { - denom.status = DenominationStatus.VerifiedGood; - nextPossibleDenoms.push(denom); - } - await oneShotPut(ws.db, Stores.denominations, denom); - } else { - nextPossibleDenoms.push(denom); - } - } - } while (selectedDenoms.length > 0 && !allValid); - - console.log("returning denoms"); - - return selectedDenoms; -} - -async function makePlanchet( - ws: InternalWalletState, - withdrawalSessionId: string, - coinIndex: number, -): Promise<void> { - const withdrawalSession = await oneShotGet( - ws.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - return; - } - const src = withdrawalSession.source; - if (src.type !== "reserve") { - throw Error("invalid state"); - } - const reserve = await oneShotGet(ws.db, Stores.reserves, src.reservePub); - if (!reserve) { - return; - } - const denom = await oneShotGet(ws.db, Stores.denominations, [ - withdrawalSession.exchangeBaseUrl, - withdrawalSession.denoms[coinIndex], - ]); - if (!denom) { - return; - } - const r = await ws.cryptoApi.createPlanchet({ - denomPub: denom.denomPub, - feeWithdraw: denom.feeWithdraw, - reservePriv: reserve.reservePriv, - reservePub: reserve.reservePub, - value: denom.value, - }); - const newPlanchet: PlanchetRecord = { - blindingKey: r.blindingKey, - coinEv: r.coinEv, - coinPriv: r.coinPriv, - coinPub: r.coinPub, - coinValue: r.coinValue, - denomPub: r.denomPub, - denomPubHash: r.denomPubHash, - isFromTip: false, - reservePub: r.reservePub, - withdrawSig: r.withdrawSig, - }; - await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => { - const myWs = await tx.get(Stores.withdrawalSession, withdrawalSessionId); - if (!myWs) { - return; - } - if (myWs.planchets[coinIndex]) { - return; - } - myWs.planchets[coinIndex] = newPlanchet; - await tx.put(Stores.withdrawalSession, myWs); - }); -} - -async function processWithdrawCoin( - ws: InternalWalletState, - withdrawalSessionId: string, - coinIndex: number, -) { - logger.trace("starting withdraw for coin", coinIndex); - const withdrawalSession = await oneShotGet( - ws.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - console.log("ws doesn't exist"); - return; - } - - const coin = await oneShotGetIndexed( - ws.db, - Stores.coins.byWithdrawalWithIdx, - [withdrawalSessionId, coinIndex], - ); - - if (coin) { - console.log("coin already exists"); - return; - } - - if (!withdrawalSession.planchets[coinIndex]) { - const key = `${withdrawalSessionId}-${coinIndex}`; - await ws.memoMakePlanchet.memo(key, async () => { - logger.trace("creating planchet for coin", coinIndex); - return makePlanchet(ws, withdrawalSessionId, coinIndex); - }); - } - await processPlanchet(ws, withdrawalSessionId, coinIndex); -} - -async function incrementWithdrawalRetry( - ws: InternalWalletState, - withdrawalSessionId: string, - err: OperationError | undefined, -): Promise<void> { - await runWithWriteTransaction(ws.db, [Stores.withdrawalSession], async tx => { - const wsr = await tx.get(Stores.withdrawalSession, withdrawalSessionId); - if (!wsr) { - return; - } - if (!wsr.retryInfo) { - return; - } - wsr.retryInfo.retryCounter++; - updateRetryInfoTimeout(wsr.retryInfo); - wsr.lastError = err; - await tx.put(Stores.withdrawalSession, wsr); - }); - ws.notify({ type: NotificationType.WithdrawOperationError }); -} - -export async function processWithdrawSession( - ws: InternalWalletState, - withdrawalSessionId: string, - forceNow: boolean = false, -): Promise<void> { - const onOpErr = (e: OperationError) => - incrementWithdrawalRetry(ws, withdrawalSessionId, e); - await guardOperationException( - () => processWithdrawSessionImpl(ws, withdrawalSessionId, forceNow), - onOpErr, - ); -} - -async function resetWithdrawSessionRetry( - ws: InternalWalletState, - withdrawalSessionId: string, -) { - await oneShotMutate(ws.db, Stores.withdrawalSession, withdrawalSessionId, (x) => { - if (x.retryInfo.active) { - x.retryInfo = initRetryInfo(); - } - return x; - }); -} - -async function processWithdrawSessionImpl( - ws: InternalWalletState, - withdrawalSessionId: string, - forceNow: boolean, -): Promise<void> { - logger.trace("processing withdraw session", withdrawalSessionId); - if (forceNow) { - await resetWithdrawSessionRetry(ws, withdrawalSessionId); - } - const withdrawalSession = await oneShotGet( - ws.db, - Stores.withdrawalSession, - withdrawalSessionId, - ); - if (!withdrawalSession) { - logger.trace("withdraw session doesn't exist"); - return; - } - - const ps = withdrawalSession.denoms.map((d, i) => - processWithdrawCoin(ws, withdrawalSessionId, i), - ); - await Promise.all(ps); - return; -} - -export async function getExchangeWithdrawalInfo( - ws: InternalWalletState, - baseUrl: string, - amount: AmountJson, -): Promise<ExchangeWithdrawDetails> { - const exchangeInfo = await updateExchangeFromUrl(ws, baseUrl); - const exchangeDetails = exchangeInfo.details; - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeWireInfo = exchangeInfo.wireInfo; - if (!exchangeWireInfo) { - throw Error(`exchange ${exchangeInfo.baseUrl} wire details not available`); - } - - const selectedDenoms = await getVerifiedWithdrawDenomList( - ws, - baseUrl, - amount, - ); - let acc = Amounts.getZero(amount.currency); - for (const d of selectedDenoms) { - acc = Amounts.add(acc, d.feeWithdraw).amount; - } - const actualCoinCost = selectedDenoms - .map((d: DenominationRecord) => Amounts.add(d.value, d.feeWithdraw).amount) - .reduce((a, b) => Amounts.add(a, b).amount); - - const exchangeWireAccounts: string[] = []; - for (let account of exchangeWireInfo.accounts) { - exchangeWireAccounts.push(account.url); - } - - const { isTrusted, isAudited } = await getExchangeTrust(ws, exchangeInfo); - - let earliestDepositExpiration = selectedDenoms[0].stampExpireDeposit; - for (let i = 1; i < selectedDenoms.length; i++) { - const expireDeposit = selectedDenoms[i].stampExpireDeposit; - if (expireDeposit.t_ms < earliestDepositExpiration.t_ms) { - earliestDepositExpiration = expireDeposit; - } - } - - const possibleDenoms = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - baseUrl, - ).filter(d => d.isOffered); - - const trustedAuditorPubs = []; - const currencyRecord = await oneShotGet( - ws.db, - Stores.currencies, - amount.currency, - ); - if (currencyRecord) { - trustedAuditorPubs.push(...currencyRecord.auditors.map(a => a.auditorPub)); - } - - let versionMatch; - if (exchangeDetails.protocolVersion) { - versionMatch = LibtoolVersion.compare( - WALLET_PROTOCOL_VERSION, - exchangeDetails.protocolVersion, - ); - - if ( - versionMatch && - !versionMatch.compatible && - versionMatch.currentCmp === -1 - ) { - console.warn( - `wallet version ${WALLET_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, - ); - } - } - - let tosAccepted = false; - - if (exchangeInfo.termsOfServiceAcceptedTimestamp) { - if (exchangeInfo.termsOfServiceAcceptedEtag == exchangeInfo.termsOfServiceLastEtag) { - tosAccepted = true; - } - } - - const ret: ExchangeWithdrawDetails = { - earliestDepositExpiration, - exchangeInfo, - exchangeWireAccounts, - exchangeVersion: exchangeDetails.protocolVersion || "unknown", - isAudited, - isTrusted, - numOfferedDenoms: possibleDenoms.length, - overhead: Amounts.sub(amount, actualCoinCost).amount, - selectedDenoms, - trustedAuditorPubs, - versionMatch, - walletVersion: WALLET_PROTOCOL_VERSION, - wireFees: exchangeWireInfo, - withdrawFee: acc, - termsOfServiceAccepted: tosAccepted, - }; - return ret; -} - -export async function getWithdrawDetailsForUri( - ws: InternalWalletState, - talerWithdrawUri: string, - maybeSelectedExchange?: string, -): Promise<WithdrawDetails> { - const info = await getBankWithdrawalInfo(ws, talerWithdrawUri); - let rci: ExchangeWithdrawDetails | undefined = undefined; - if (maybeSelectedExchange) { - rci = await getExchangeWithdrawalInfo( - ws, - maybeSelectedExchange, - info.amount, - ); - } - return { - bankWithdrawDetails: info, - exchangeWithdrawDetails: rci, - }; -} |