diff options
Diffstat (limited to 'src/operations')
-rw-r--r-- | src/operations/balance.ts | 158 | ||||
-rw-r--r-- | src/operations/errors.ts | 84 | ||||
-rw-r--r-- | src/operations/exchanges.ts | 505 | ||||
-rw-r--r-- | src/operations/history.ts | 221 | ||||
-rw-r--r-- | src/operations/pay.ts | 1494 | ||||
-rw-r--r-- | src/operations/payback.ts | 93 | ||||
-rw-r--r-- | src/operations/pending.ts | 452 | ||||
-rw-r--r-- | src/operations/refresh.ts | 479 | ||||
-rw-r--r-- | src/operations/reserves.ts | 630 | ||||
-rw-r--r-- | src/operations/return.ts | 267 | ||||
-rw-r--r-- | src/operations/state.ts | 68 | ||||
-rw-r--r-- | src/operations/tip.ts | 305 | ||||
-rw-r--r-- | src/operations/withdraw.ts | 699 |
13 files changed, 5455 insertions, 0 deletions
diff --git a/src/operations/balance.ts b/src/operations/balance.ts new file mode 100644 index 000000000..8c8a2a9cf --- /dev/null +++ b/src/operations/balance.ts @@ -0,0 +1,158 @@ +/* + 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 "../types/walletTypes"; +import { runWithReadTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus } from "../types/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/operations/errors.ts b/src/operations/errors.ts new file mode 100644 index 000000000..7e97fdb3c --- /dev/null +++ b/src/operations/errors.ts @@ -0,0 +1,84 @@ +import { OperationError } from "../types/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/operations/exchanges.ts b/src/operations/exchanges.ts new file mode 100644 index 000000000..836bce6e4 --- /dev/null +++ b/src/operations/exchanges.ts @@ -0,0 +1,505 @@ +/* + 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 "../types/talerTypes"; +import { getTimestampNow, OperationError } from "../types/walletTypes"; +import { + ExchangeRecord, + ExchangeUpdateStatus, + Stores, + DenominationRecord, + DenominationStatus, + WireFee, +} from "../types/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/operations/history.ts b/src/operations/history.ts new file mode 100644 index 000000000..9c4bb6a90 --- /dev/null +++ b/src/operations/history.ts @@ -0,0 +1,221 @@ +/* + 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, runWithReadTransaction } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord } from "../types/dbTypes"; +import * as Amounts from "../util/amounts"; +import { AmountJson } from "../util/amounts"; +import { HistoryQuery, HistoryEvent } from "../types/history"; + +/** + * 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/operations/pay.ts b/src/operations/pay.ts new file mode 100644 index 000000000..08d227927 --- /dev/null +++ b/src/operations/pay.ts @@ -0,0 +1,1494 @@ +/* + 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 "../types/talerTypes"; +import { + Timestamp, + CoinSelectionResult, + CoinWithDenom, + PayCoinInfo, + getTimestampNow, + PreparePayResult, + ConfirmPayResult, + OperationError, +} from "../types/walletTypes"; +import { + oneShotIter, + oneShotIterIndex, + oneShotGet, + runWithWriteTransaction, + oneShotPut, + oneShotGetIndexed, + oneShotMutate, +} from "../util/query"; +import { + Stores, + CoinStatus, + DenominationRecord, + ProposalRecord, + PurchaseRecord, + CoinRecord, + ProposalStatus, + initRetryInfo, + updateRetryInfoTimeout, +} from "../types/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"; +import { NotificationType } from "../types/notifications"; + +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/operations/payback.ts b/src/operations/payback.ts new file mode 100644 index 000000000..2d8a72839 --- /dev/null +++ b/src/operations/payback.ts @@ -0,0 +1,93 @@ +/* + 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 "../types/dbTypes"; + +import { Logger } from "../util/logging"; +import { PaybackConfirmation } from "../types/talerTypes"; +import { updateExchangeFromUrl } from "./exchanges"; +import { NotificationType } from "../types/notifications"; + +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/operations/pending.ts b/src/operations/pending.ts new file mode 100644 index 000000000..b9fc1d203 --- /dev/null +++ b/src/operations/pending.ts @@ -0,0 +1,452 @@ +/* + 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 { + getTimestampNow, + Timestamp, + Duration, +} from "../types/walletTypes"; +import { runWithReadTransaction, TransactionHandle } from "../util/query"; +import { InternalWalletState } from "./state"; +import { + Stores, + ExchangeUpdateStatus, + ReserveRecordStatus, + CoinStatus, + ProposalStatus, +} from "../types/dbTypes"; +import { PendingOperationsResponse } from "../types/pending"; + +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/operations/refresh.ts b/src/operations/refresh.ts new file mode 100644 index 000000000..4e4449d96 --- /dev/null +++ b/src/operations/refresh.ts @@ -0,0 +1,479 @@ +/* + 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 "../types/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, +} from "../types/walletTypes"; +import { guardOperationException } from "./errors"; +import { NotificationType } from "../types/notifications"; + +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/operations/reserves.ts b/src/operations/reserves.ts new file mode 100644 index 000000000..5ad13a67a --- /dev/null +++ b/src/operations/reserves.ts @@ -0,0 +1,630 @@ +/* + 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, +} from "../types/walletTypes"; +import { canonicalizeBaseUrl } from "../util/helpers"; +import { InternalWalletState } from "./state"; +import { + ReserveRecordStatus, + ReserveRecord, + CurrencyRecord, + Stores, + WithdrawalSessionRecord, + initRetryInfo, + updateRetryInfoTimeout, +} from "../types/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 "../types/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"; +import { NotificationType } from "../types/notifications"; + +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/operations/return.ts b/src/operations/return.ts new file mode 100644 index 000000000..74885a735 --- /dev/null +++ b/src/operations/return.ts @@ -0,0 +1,267 @@ +/* + 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 { + ReturnCoinsRequest, + CoinWithDenom, +} from "../types/walletTypes"; +import { runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query"; +import { InternalWalletState } from "./state"; +import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../types/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 "../types/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/operations/state.ts b/src/operations/state.ts new file mode 100644 index 000000000..47bf40de3 --- /dev/null +++ b/src/operations/state.ts @@ -0,0 +1,68 @@ +/* + 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, +} from "../types/walletTypes"; +import { SpeculativePayData } from "./pay"; +import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi"; +import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo"; +import { Logger } from "../util/logging"; +import { PendingOperationsResponse } from "../types/pending"; +import { WalletNotification } from "../types/notifications"; + +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/operations/tip.ts b/src/operations/tip.ts new file mode 100644 index 000000000..0a710f67e --- /dev/null +++ b/src/operations/tip.ts @@ -0,0 +1,305 @@ +/* + 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 } from "../types/walletTypes"; +import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../types/talerTypes"; +import * as Amounts from "../util/amounts"; +import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../types/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"; +import { NotificationType } from "../types/notifications"; + + +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/operations/withdraw.ts b/src/operations/withdraw.ts new file mode 100644 index 000000000..4ecc321f8 --- /dev/null +++ b/src/operations/withdraw.ts @@ -0,0 +1,699 @@ +/* + 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 "../types/dbTypes"; +import * as Amounts from "../util/amounts"; +import { + getTimestampNow, + AcceptWithdrawalResponse, + BankWithdrawDetails, + ExchangeWithdrawDetails, + WithdrawDetails, + OperationError, +} from "../types/walletTypes"; +import { WithdrawOperationStatusResponse } from "../types/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"; +import { NotificationType } from "../types/notifications"; + +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, + }; +} |