From 74433c3e05734aa1194049fcbcaa92c70ce61c74 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 12 Dec 2019 20:53:15 +0100 Subject: refactor: re-structure type definitions --- src/crypto/workers/cryptoApi.ts | 6 +- src/crypto/workers/cryptoImplementation.ts | 6 +- src/db.ts | 10 +- src/dbTypes.ts | 1388 -------------------------- src/headless/helpers.ts | 6 +- src/headless/merchant.ts | 2 +- src/headless/taler-wallet-cli.ts | 2 +- src/i18n.tsx | 267 ----- src/operations/balance.ts | 158 +++ src/operations/errors.ts | 84 ++ src/operations/exchanges.ts | 505 ++++++++++ src/operations/history.ts | 221 ++++ src/operations/pay.ts | 1494 ++++++++++++++++++++++++++++ src/operations/payback.ts | 93 ++ src/operations/pending.ts | 452 +++++++++ src/operations/refresh.ts | 479 +++++++++ src/operations/reserves.ts | 630 ++++++++++++ src/operations/return.ts | 267 +++++ src/operations/state.ts | 68 ++ src/operations/tip.ts | 305 ++++++ src/operations/withdraw.ts | 699 +++++++++++++ src/talerTypes.ts | 944 ------------------ src/types-test.ts | 164 --- src/types/dbTypes.ts | 1388 ++++++++++++++++++++++++++ src/types/history.ts | 58 ++ src/types/notifications.ts | 213 ++++ src/types/pending.ts | 161 +++ src/types/talerTypes.ts | 944 ++++++++++++++++++ src/types/types-test.ts | 164 +++ src/types/walletTypes.ts | 512 ++++++++++ src/util/RequestThrottler.ts | 2 +- src/util/helpers.ts | 2 +- src/util/wire.ts | 2 +- src/wallet-impl/balance.ts | 158 --- src/wallet-impl/errors.ts | 84 -- src/wallet-impl/exchanges.ts | 505 ---------- src/wallet-impl/history.ts | 221 ---- src/wallet-impl/pay.ts | 1494 ---------------------------- src/wallet-impl/payback.ts | 93 -- src/wallet-impl/pending.ts | 452 --------- src/wallet-impl/refresh.ts | 479 --------- src/wallet-impl/reserves.ts | 630 ------------ src/wallet-impl/return.ts | 271 ----- src/wallet-impl/state.ts | 68 -- src/wallet-impl/tip.ts | 304 ------ src/wallet-impl/withdraw.ts | 699 ------------- src/wallet-test.ts | 6 +- src/wallet.ts | 46 +- src/walletTypes.ts | 873 ---------------- src/webex/i18n.tsx | 267 +++++ src/webex/messages.ts | 9 +- src/webex/pages/add-auditor.tsx | 2 +- src/webex/pages/auditors.tsx | 2 +- src/webex/pages/benchmark.tsx | 4 +- src/webex/pages/pay.tsx | 4 +- src/webex/pages/payback.tsx | 2 +- src/webex/pages/popup.tsx | 6 +- src/webex/pages/refund.tsx | 2 +- src/webex/pages/return-coins.tsx | 4 +- src/webex/pages/tip.tsx | 4 +- src/webex/pages/welcome.tsx | 2 +- src/webex/pages/withdraw.tsx | 4 +- src/webex/renderHtml.tsx | 6 +- src/webex/wxApi.ts | 4 +- src/webex/wxBackend.ts | 14 +- tsconfig.json | 39 +- 66 files changed, 9261 insertions(+), 9193 deletions(-) delete mode 100644 src/dbTypes.ts delete mode 100644 src/i18n.tsx create mode 100644 src/operations/balance.ts create mode 100644 src/operations/errors.ts create mode 100644 src/operations/exchanges.ts create mode 100644 src/operations/history.ts create mode 100644 src/operations/pay.ts create mode 100644 src/operations/payback.ts create mode 100644 src/operations/pending.ts create mode 100644 src/operations/refresh.ts create mode 100644 src/operations/reserves.ts create mode 100644 src/operations/return.ts create mode 100644 src/operations/state.ts create mode 100644 src/operations/tip.ts create mode 100644 src/operations/withdraw.ts delete mode 100644 src/talerTypes.ts delete mode 100644 src/types-test.ts create mode 100644 src/types/dbTypes.ts create mode 100644 src/types/history.ts create mode 100644 src/types/notifications.ts create mode 100644 src/types/pending.ts create mode 100644 src/types/talerTypes.ts create mode 100644 src/types/types-test.ts create mode 100644 src/types/walletTypes.ts delete mode 100644 src/wallet-impl/balance.ts delete mode 100644 src/wallet-impl/errors.ts delete mode 100644 src/wallet-impl/exchanges.ts delete mode 100644 src/wallet-impl/history.ts delete mode 100644 src/wallet-impl/pay.ts delete mode 100644 src/wallet-impl/payback.ts delete mode 100644 src/wallet-impl/pending.ts delete mode 100644 src/wallet-impl/refresh.ts delete mode 100644 src/wallet-impl/reserves.ts delete mode 100644 src/wallet-impl/return.ts delete mode 100644 src/wallet-impl/state.ts delete mode 100644 src/wallet-impl/tip.ts delete mode 100644 src/wallet-impl/withdraw.ts delete mode 100644 src/walletTypes.ts create mode 100644 src/webex/i18n.tsx diff --git a/src/crypto/workers/cryptoApi.ts b/src/crypto/workers/cryptoApi.ts index aa1ff2c42..3c6758670 100644 --- a/src/crypto/workers/cryptoApi.ts +++ b/src/crypto/workers/cryptoApi.ts @@ -30,11 +30,11 @@ import { RefreshSessionRecord, TipPlanchet, WireFee, -} from "../../dbTypes"; +} from "../../types/dbTypes"; import { CryptoWorker } from "./cryptoWorker"; -import { ContractTerms, PaybackRequest } from "../../talerTypes"; +import { ContractTerms, PaybackRequest } from "../../types/talerTypes"; import { BenchmarkResult, @@ -42,7 +42,7 @@ import { PayCoinInfo, PlanchetCreationResult, PlanchetCreationRequest, -} from "../../walletTypes"; +} from "../../types/walletTypes"; import * as timer from "../../util/timer"; diff --git a/src/crypto/workers/cryptoImplementation.ts b/src/crypto/workers/cryptoImplementation.ts index fa5a30d68..04d15fb4b 100644 --- a/src/crypto/workers/cryptoImplementation.ts +++ b/src/crypto/workers/cryptoImplementation.ts @@ -33,9 +33,9 @@ import { TipPlanchet, WireFee, initRetryInfo, -} from "../../dbTypes"; +} from "../../types/dbTypes"; -import { CoinPaySig, ContractTerms, PaybackRequest } from "../../talerTypes"; +import { CoinPaySig, ContractTerms, PaybackRequest } from "../../types/talerTypes"; import { BenchmarkResult, CoinWithDenom, @@ -44,7 +44,7 @@ import { PlanchetCreationResult, PlanchetCreationRequest, getTimestampNow, -} from "../../walletTypes"; +} from "../../types/walletTypes"; import { canonicalJson, getTalerStampSec } from "../../util/helpers"; import { AmountJson } from "../../util/amounts"; import * as Amounts from "../../util/amounts"; diff --git a/src/db.ts b/src/db.ts index ddf3771b7..70338122e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,4 @@ -import { Stores, WALLET_DB_VERSION } from "./dbTypes"; +import { Stores, WALLET_DB_VERSION } from "./types/dbTypes"; import { Store, Index } from "./util/query"; const DB_NAME = "taler"; @@ -7,7 +7,7 @@ const DB_NAME = "taler"; * Return a promise that resolves * to the taler wallet db. */ -export function openTalerDb( +export function openDatabase( idbFactory: IDBFactory, onVersionChange: () => void, onUpgradeUnsupported: (oldVersion: number, newVersion: number) => void, @@ -59,7 +59,7 @@ export function openTalerDb( }); } -export function exportDb(db: IDBDatabase): Promise { +export function exportDatabase(db: IDBDatabase): Promise { const dump = { name: db.name, stores: {} as { [s: string]: any }, @@ -89,7 +89,7 @@ export function exportDb(db: IDBDatabase): Promise { }); } -export function importDb(db: IDBDatabase, dump: any): Promise { +export function importDatabase(db: IDBDatabase, dump: any): Promise { console.log("importing db", dump); return new Promise((resolve, reject) => { const tx = db.transaction(Array.from(db.objectStoreNames), "readwrite"); @@ -113,6 +113,6 @@ export function importDb(db: IDBDatabase, dump: any): Promise { }); } -export function deleteDb(idbFactory: IDBFactory) { +export function deleteDatabase(idbFactory: IDBFactory) { idbFactory.deleteDatabase(DB_NAME); } diff --git a/src/dbTypes.ts b/src/dbTypes.ts deleted file mode 100644 index 28c1ee2e3..000000000 --- a/src/dbTypes.ts +++ /dev/null @@ -1,1388 +0,0 @@ -/* - This file is part of TALER - (C) 2018 GNUnet e.V. and INRIA - - 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. - - 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 - TALER; see the file COPYING. If not, see - */ - -/** - * Types for records stored in the wallet's database. - * - * Types for the objects in the database should end in "-Record". - */ - -/** - * Imports. - */ -import { AmountJson } from "./util/amounts"; -import { Checkable } from "./util/checkable"; -import { - Auditor, - CoinPaySig, - ContractTerms, - Denomination, - MerchantRefundPermission, - PayReq, - TipResponse, -} from "./talerTypes"; - -import { Index, Store } from "./util/query"; -import { - Timestamp, - OperationError, - Duration, - getTimestampNow, -} from "./walletTypes"; - -/** - * Current database version, should be incremented - * each time we do incompatible schema changes on the database. - * In the future we might consider adding migration functions for - * each version increment. - */ -export const WALLET_DB_VERSION = 28; - -export enum ReserveRecordStatus { - /** - * Waiting for manual confirmation. - */ - UNCONFIRMED = "unconfirmed", - - /** - * Reserve must be registered with the bank. - */ - REGISTERING_BANK = "registering-bank", - - /** - * We've registered reserve's information with the bank - * and are now waiting for the user to confirm the withdraw - * with the bank (typically 2nd factor auth). - */ - WAIT_CONFIRM_BANK = "wait-confirm-bank", - - /** - * Querying reserve status with the exchange. - */ - QUERYING_STATUS = "querying-status", - - /** - * Status is queried, the wallet must now select coins - * and start withdrawing. - */ - WITHDRAWING = "withdrawing", - - /** - * The corresponding withdraw record has been created. - * No further processing is done, unless explicitly requested - * by the user. - */ - DORMANT = "dormant", -} - -export interface RetryInfo { - firstTry: Timestamp; - nextRetry: Timestamp; - retryCounter: number; - active: boolean; -} - -export interface RetryPolicy { - readonly backoffDelta: Duration; - readonly backoffBase: number; -} - -const defaultRetryPolicy: RetryPolicy = { - backoffBase: 1.5, - backoffDelta: { d_ms: 200 }, -}; - -export function updateRetryInfoTimeout( - r: RetryInfo, - p: RetryPolicy = defaultRetryPolicy, -): void { - const now = getTimestampNow(); - const t = - now.t_ms + p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); - r.nextRetry = { t_ms: t }; -} - -export function initRetryInfo( - active: boolean = true, - p: RetryPolicy = defaultRetryPolicy, -): RetryInfo { - if (!active) { - return { - active: false, - firstTry: { t_ms: Number.MAX_SAFE_INTEGER }, - nextRetry: { t_ms: Number.MAX_SAFE_INTEGER }, - retryCounter: 0, - }; - } - const info = { - firstTry: getTimestampNow(), - active: true, - nextRetry: { t_ms: 0 }, - retryCounter: 0, - }; - updateRetryInfoTimeout(info, p); - return info; -} - -/** - * A reserve record as stored in the wallet's database. - */ -export interface ReserveRecord { - /** - * The reserve public key. - */ - reservePub: string; - - /** - * The reserve private key. - */ - reservePriv: string; - - /** - * The exchange base URL. - */ - exchangeBaseUrl: string; - - /** - * Time when the reserve was created. - */ - created: Timestamp; - - /** - * Time when the information about this reserve was posted to the bank. - * - * Only applies if bankWithdrawStatusUrl is defined. - * - * Set to 0 if that hasn't happened yet. - */ - timestampReserveInfoPosted: Timestamp | undefined; - - /** - * Time when the reserve was confirmed. - * - * Set to 0 if not confirmed yet. - */ - timestampConfirmed: Timestamp | undefined; - - /** - * Amount that's still available for withdrawing - * from this reserve. - */ - withdrawRemainingAmount: AmountJson; - - /** - * Amount allocated for withdrawing. - * The corresponding withdraw operation may or may not - * have been completed yet. - */ - withdrawAllocatedAmount: AmountJson; - - withdrawCompletedAmount: AmountJson; - - /** - * Amount requested when the reserve was created. - * When a reserve is re-used (rare!) the current_amount can - * be higher than the requested_amount - */ - initiallyRequestedAmount: AmountJson; - - /** - * We got some payback to this reserve. We'll cease to automatically - * withdraw money from it. - */ - hasPayback: boolean; - - /** - * Wire information (as payto URI) for the bank account that - * transfered funds for this reserve. - */ - senderWire?: string; - - /** - * Wire information (as payto URI) for the exchange, specifically - * the account that was transferred to when creating the reserve. - */ - exchangeWire: string; - - bankWithdrawStatusUrl?: string; - - /** - * URL that the bank gave us to redirect the customer - * to in order to confirm a withdrawal. - */ - bankWithdrawConfirmUrl?: string; - - reserveStatus: ReserveRecordStatus; - - /** - * Time of the last successful status query. - */ - lastSuccessfulStatusQuery: Timestamp | undefined; - - /** - * Retry info. This field is present even if no retry is scheduled, - * because we need it to be present for the index on the object store - * to work. - */ - retryInfo: RetryInfo; - - /** - * Last error that happened in a reserve operation - * (either talking to the bank or the exchange). - */ - lastError: OperationError | undefined; -} - -/** - * Auditor record as stored with currencies in the exchange database. - */ -export interface AuditorRecord { - /** - * Base url of the auditor. - */ - baseUrl: string; - /** - * Public signing key of the auditor. - */ - auditorPub: string; - /** - * Time when the auditing expires. - */ - expirationStamp: number; -} - -/** - * Exchange for currencies as stored in the wallet's currency - * information database. - */ -export interface ExchangeForCurrencyRecord { - /** - * FIXME: unused? - */ - exchangePub: string; - /** - * Base URL of the exchange. - */ - baseUrl: string; -} - -/** - * Information about a currency as displayed in the wallet's database. - */ -export interface CurrencyRecord { - /** - * Name of the currency. - */ - name: string; - /** - * Number of fractional digits to show when rendering the currency. - */ - fractionalDigits: number; - /** - * Auditors that the wallet trusts for this currency. - */ - auditors: AuditorRecord[]; - /** - * Exchanges that the wallet trusts for this currency. - */ - exchanges: ExchangeForCurrencyRecord[]; -} - -/** - * Status of a denomination. - */ -export enum DenominationStatus { - /** - * Verification was delayed. - */ - Unverified, - /** - * Verified as valid. - */ - VerifiedGood, - /** - * Verified as invalid. - */ - VerifiedBad, -} - -/** - * Denomination record as stored in the wallet's database. - */ -@Checkable.Class() -export class DenominationRecord { - /** - * Value of one coin of the denomination. - */ - @Checkable.Value(() => AmountJson) - value: AmountJson; - - /** - * The denomination public key. - */ - @Checkable.String() - denomPub: string; - - /** - * Hash of the denomination public key. - * Stored in the database for faster lookups. - */ - @Checkable.String() - denomPubHash: string; - - /** - * Fee for withdrawing. - */ - @Checkable.Value(() => AmountJson) - feeWithdraw: AmountJson; - - /** - * Fee for depositing. - */ - @Checkable.Value(() => AmountJson) - feeDeposit: AmountJson; - - /** - * Fee for refreshing. - */ - @Checkable.Value(() => AmountJson) - feeRefresh: AmountJson; - - /** - * Fee for refunding. - */ - @Checkable.Value(() => AmountJson) - feeRefund: AmountJson; - - /** - * Validity start date of the denomination. - */ - @Checkable.Value(() => Timestamp) - stampStart: Timestamp; - - /** - * Date after which the currency can't be withdrawn anymore. - */ - @Checkable.Value(() => Timestamp) - stampExpireWithdraw: Timestamp; - - /** - * Date after the denomination officially doesn't exist anymore. - */ - @Checkable.Value(() => Timestamp) - stampExpireLegal: Timestamp; - - /** - * Data after which coins of this denomination can't be deposited anymore. - */ - @Checkable.Value(() => Timestamp) - stampExpireDeposit: Timestamp; - - /** - * Signature by the exchange's master key over the denomination - * information. - */ - @Checkable.String() - masterSig: string; - - /** - * Did we verify the signature on the denomination? - */ - @Checkable.Number() - status: DenominationStatus; - - /** - * Was this denomination still offered by the exchange the last time - * we checked? - * Only false when the exchange redacts a previously published denomination. - */ - @Checkable.Boolean() - isOffered: boolean; - - /** - * Base URL of the exchange. - */ - @Checkable.String() - exchangeBaseUrl: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => Denomination; -} - -/** - * Details about the exchange that we only know after - * querying /keys and /wire. - */ -export interface ExchangeDetails { - /** - * Master public key of the exchange. - */ - masterPublicKey: string; - /** - * Auditors (partially) auditing the exchange. - */ - auditors: Auditor[]; - - /** - * Currency that the exchange offers. - */ - currency: string; - - /** - * Last observed protocol version. - */ - protocolVersion: string; - - /** - * Timestamp for last update. - */ - lastUpdateTime: Timestamp; -} - -export const enum ExchangeUpdateStatus { - FETCH_KEYS = "fetch_keys", - FETCH_WIRE = "fetch_wire", - FETCH_TERMS = "fetch_terms", - FINISHED = "finished", -} - -export interface ExchangeBankAccount { - url: string; -} - -export interface ExchangeWireInfo { - feesForType: { [wireMethod: string]: WireFee[] }; - accounts: ExchangeBankAccount[]; -} - -/** - * Exchange record as stored in the wallet's database. - */ -export interface ExchangeRecord { - /** - * Base url of the exchange. - */ - baseUrl: string; - - /** - * Details, once known. - */ - details: ExchangeDetails | undefined; - - /** - * Mapping from wire method type to the wire fee. - */ - wireInfo: ExchangeWireInfo | undefined; - - /** - * When was the exchange added to the wallet? - */ - timestampAdded: Timestamp; - - /** - * Terms of service text or undefined if not downloaded yet. - */ - termsOfServiceText: string | undefined; - - /** - * ETag for last terms of service download. - */ - termsOfServiceLastEtag: string | undefined; - - /** - * ETag for last terms of service download. - */ - termsOfServiceAcceptedEtag: string | undefined; - - /** - * ETag for last terms of service download. - */ - termsOfServiceAcceptedTimestamp: Timestamp | undefined; - - /** - * Time when the update to the exchange has been started or - * undefined if no update is in progress. - */ - updateStarted: Timestamp | undefined; - updateStatus: ExchangeUpdateStatus; - updateReason?: "initial" | "forced"; - - lastError?: OperationError; -} - -/** - * A coin that isn't yet signed by an exchange. - */ -export interface PlanchetRecord { - /** - * Public key of the coin. - */ - coinPub: string; - coinPriv: string; - /** - * Public key of the reserve, this might be a reserve not - * known to the wallet if the planchet is from a tip. - */ - reservePub: string; - denomPubHash: string; - denomPub: string; - blindingKey: string; - withdrawSig: string; - coinEv: string; - coinValue: AmountJson; - isFromTip: boolean; -} - -/** - * Planchet for a coin during refrehs. - */ -export interface RefreshPlanchetRecord { - /** - * Public key for the coin. - */ - publicKey: string; - /** - * Private key for the coin. - */ - privateKey: string; - /** - * Blinded public key. - */ - coinEv: string; - /** - * Blinding key used. - */ - blindingKey: string; -} - -/** - * Status of a coin. - */ -export enum CoinStatus { - /** - * Withdrawn and never shown to anybody. - */ - Fresh = "fresh", - /** - * Used for a completed transaction and now dirty. - */ - Dirty = "dirty", - /** - * A coin that has been spent and refreshed. - */ - Dormant = "dormant", -} - -export enum CoinSource { - Withdraw = "withdraw", - Refresh = "refresh", - Tip = "tip", -} - -/** - * CoinRecord as stored in the "coins" data store - * of the wallet database. - */ -export interface CoinRecord { - /** - * Withdraw session ID, or "" (empty string) if withdrawn via refresh. - */ - withdrawSessionId: string; - - /** - * Index of the coin in the withdrawal session. - */ - coinIndex: number; - - /** - * Public key of the coin. - */ - coinPub: string; - - /** - * Private key to authorize operations on the coin. - */ - coinPriv: string; - - /** - * Key used by the exchange used to sign the coin. - */ - denomPub: string; - - /** - * Hash of the public key that signs the coin. - */ - denomPubHash: string; - - /** - * Unblinded signature by the exchange. - */ - denomSig: string; - - /** - * Amount that's left on the coin. - */ - currentAmount: AmountJson; - - /** - * Base URL that identifies the exchange from which we got the - * coin. - */ - exchangeBaseUrl: string; - - /** - * We have withdrawn the coin, but it's not accepted by the exchange anymore. - * We have to tell an auditor and wait for compensation or for the exchange - * to fix it. - */ - suspended?: boolean; - - /** - * Blinding key used when withdrawing the coin. - * Potentionally sed again during payback. - */ - blindingKey: string; - - /** - * Reserve public key for the reserve we got this coin from, - * or zero when we got the coin from refresh. - */ - reservePub: string | undefined; - - /** - * Status of the coin. - */ - status: CoinStatus; -} - -export enum ProposalStatus { - /** - * Not downloaded yet. - */ - DOWNLOADING = "downloading", - /** - * Proposal downloaded, but the user needs to accept/reject it. - */ - PROPOSED = "proposed", - /** - * The user has accepted the proposal. - */ - ACCEPTED = "accepted", - /** - * The user has rejected the proposal. - */ - REJECTED = "rejected", - /** - * Downloaded proposal was detected as a re-purchase. - */ - REPURCHASE = "repurchase", -} - -@Checkable.Class() -export class ProposalDownload { - /** - * The contract that was offered by the merchant. - */ - @Checkable.Value(() => ContractTerms) - contractTerms: ContractTerms; - - /** - * Signature by the merchant over the contract details. - */ - @Checkable.String() - merchantSig: string; - - /** - * Signature by the merchant over the contract details. - */ - @Checkable.String() - contractTermsHash: string; -} - -/** - * Record for a downloaded order, stored in the wallet's database. - */ -@Checkable.Class() -export class ProposalRecord { - @Checkable.String() - orderId: string; - - @Checkable.String() - merchantBaseUrl: string; - - /** - * Downloaded data from the merchant. - */ - download: ProposalDownload | undefined; - - /** - * Unique ID when the order is stored in the wallet DB. - */ - @Checkable.String() - proposalId: string; - - /** - * Timestamp (in ms) of when the record - * was created. - */ - @Checkable.Number() - timestamp: Timestamp; - - /** - * Private key for the nonce. - */ - @Checkable.String() - noncePriv: string; - - /** - * Public key for the nonce. - */ - @Checkable.String() - noncePub: string; - - @Checkable.String() - proposalStatus: ProposalStatus; - - @Checkable.String() - repurchaseProposalId: string | undefined; - - /** - * Session ID we got when downloading the contract. - */ - @Checkable.Optional(Checkable.String()) - downloadSessionId?: string; - - /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. - */ - retryInfo: RetryInfo; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => ProposalRecord; - - lastError: OperationError | undefined; -} - -/** - * Status of a tip we got from a merchant. - */ -export interface TipRecord { - lastError: OperationError | undefined; - /** - * Has the user accepted the tip? Only after the tip has been accepted coins - * withdrawn from the tip may be used. - */ - accepted: boolean; - - /** - * Have we picked up the tip record from the merchant already? - */ - pickedUp: boolean; - - /** - * The tipped amount. - */ - amount: AmountJson; - - totalFees: AmountJson; - - /** - * Timestamp, the tip can't be picked up anymore after this deadline. - */ - deadline: Timestamp; - - /** - * The exchange that will sign our coins, chosen by the merchant. - */ - exchangeUrl: string; - - /** - * Base URL of the merchant that is giving us the tip. - */ - merchantBaseUrl: string; - - /** - * Planchets, the members included in TipPlanchetDetail will be sent to the - * merchant. - */ - planchets?: TipPlanchet[]; - - /** - * Response if the merchant responded, - * undefined otherwise. - */ - response?: TipResponse[]; - - /** - * Tip ID chosen by the wallet. - */ - tipId: string; - - /** - * The merchant's identifier for this tip. - */ - merchantTipId: string; - - /** - * URL to go to once the tip has been accepted. - */ - nextUrl?: string; - - createdTimestamp: Timestamp; - - /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. - */ - retryInfo: RetryInfo; -} - -/** - * Ongoing refresh - */ -export interface RefreshSessionRecord { - lastError: OperationError | undefined; - - /** - * Public key that's being melted in this session. - */ - meltCoinPub: string; - - /** - * How much of the coin's value is melted away - * with this refresh session? - */ - valueWithFee: AmountJson; - - /** - * Sum of the value of denominations we want - * to withdraw in this session, without fees. - */ - valueOutput: AmountJson; - - /** - * Signature to confirm the melting. - */ - confirmSig: string; - - /** - * Hased denominations of the newly requested coins. - */ - newDenomHashes: string[]; - - /** - * Denominations of the newly requested coins. - */ - newDenoms: string[]; - - /** - * Planchets for each cut-and-choose instance. - */ - planchetsForGammas: RefreshPlanchetRecord[][]; - - /** - * The transfer keys, kappa of them. - */ - transferPubs: string[]; - - /** - * Private keys for the transfer public keys. - */ - transferPrivs: string[]; - - /** - * The no-reveal-index after we've done the melting. - */ - norevealIndex?: number; - - /** - * Hash of the session. - */ - hash: string; - - /** - * Base URL for the exchange we're doing the refresh with. - */ - exchangeBaseUrl: string; - - /** - * Timestamp when the refresh session finished. - */ - finishedTimestamp: Timestamp | undefined; - - /** - * A 32-byte base32-crockford encoded random identifier. - */ - refreshSessionId: string; - - /** - * When has this refresh session been created? - */ - created: Timestamp; - - /** - * Retry info, even present when the operation isn't active to allow indexing - * on the next retry timestamp. - */ - retryInfo: RetryInfo; -} - -/** - * Tipping planchet stored in the database. - */ -export interface TipPlanchet { - blindingKey: string; - coinEv: string; - coinPriv: string; - coinPub: string; - coinValue: AmountJson; - denomPubHash: string; - denomPub: string; -} - -/** - * Wire fee for one wire method as stored in the - * wallet's database. - */ -export interface WireFee { - /** - * Fee for wire transfers. - */ - wireFee: AmountJson; - - /** - * Fees to close and refund a reserve. - */ - closingFee: AmountJson; - - /** - * Start date of the fee. - */ - startStamp: Timestamp; - - /** - * End date of the fee. - */ - endStamp: Timestamp; - - /** - * Signature made by the exchange master key. - */ - sig: string; -} - -/** - * Record that stores status information about one purchase, starting from when - * the customer accepts a proposal. Includes refund status if applicable. - */ -export interface PurchaseRecord { - /** - * Proposal ID for this purchase. Uniquely identifies the - * purchase and the proposal. - */ - proposalId: string; - - /** - * Hash of the contract terms. - */ - contractTermsHash: string; - - /** - * Contract terms we got from the merchant. - */ - contractTerms: ContractTerms; - - /** - * The payment request, ready to be send to the merchant's - * /pay URL. - */ - payReq: PayReq; - - /** - * Signature from the merchant over the contract terms. - */ - merchantSig: string; - - firstSuccessfulPayTimestamp: Timestamp | undefined; - - /** - * Pending refunds for the purchase. - */ - refundsPending: { [refundSig: string]: MerchantRefundPermission }; - - /** - * Submitted refunds for the purchase. - */ - refundsDone: { [refundSig: string]: MerchantRefundPermission }; - - /** - * When was the purchase made? - * Refers to the time that the user accepted. - */ - acceptTimestamp: Timestamp; - - /** - * When was the last refund made? - * Set to 0 if no refund was made on the purchase. - */ - lastRefundStatusTimestamp: Timestamp | undefined; - - /** - * Last session signature that we submitted to /pay (if any). - */ - lastSessionId: string | undefined; - - /** - * Set for the first payment, or on re-plays. - */ - paymentSubmitPending: boolean; - - /** - * Do we need to query the merchant for the refund status - * of the payment? - */ - refundStatusRequested: boolean; - - /** - * An abort (with refund) was requested for this (incomplete!) purchase. - */ - abortRequested: boolean; - - /** - * The abort (with refund) was completed for this (incomplete!) purchase. - */ - abortDone: boolean; - - payRetryInfo: RetryInfo; - - lastPayError: OperationError | undefined; - - /** - * Retry information for querying the refund status with the merchant. - */ - refundStatusRetryInfo: RetryInfo; - - /** - * Last error (or undefined) for querying the refund status with the merchant. - */ - lastRefundStatusError: OperationError | undefined; - - /** - * Retry information for querying the refund status with the merchant. - */ - refundApplyRetryInfo: RetryInfo; - - /** - * Last error (or undefined) for querying the refund status with the merchant. - */ - lastRefundApplyError: OperationError | undefined; - - /** - * Continue querying the refund status until this deadline has expired. - */ - autoRefundDeadline: Timestamp | undefined; -} - -/** - * Information about wire information for bank accounts we withdrew coins from. - */ -export interface SenderWireRecord { - paytoUri: string; -} - -/** - * Configuration key/value entries to configure - * the wallet. - */ -export interface ConfigRecord { - key: string; - value: any; -} - -/** - * Coin that we're depositing ourselves. - */ -export interface DepositCoin { - coinPaySig: CoinPaySig; - - /** - * Undefined if coin not deposited, otherwise signature - * from the exchange confirming the deposit. - */ - depositedSig?: string; -} - -/** - * Record stored in the wallet's database when the user sends coins back to - * their own bank account. Stores the status of coins that are deposited to - * the wallet itself, where the wallet acts as a "merchant" for the customer. - */ -export interface CoinsReturnRecord { - /** - * Hash of the contract for sending coins to our own bank account. - */ - contractTermsHash: string; - - contractTerms: ContractTerms; - - /** - * Private key where corresponding - * public key is used in the contract terms - * as merchant pub. - */ - merchantPriv: string; - - coins: DepositCoin[]; - - /** - * Exchange base URL to deposit coins at. - */ - exchange: string; - - /** - * Our own wire information for the deposit. - */ - wire: any; -} - -export interface WithdrawalSourceTip { - type: "tip"; - tipId: string; -} - -export interface WithdrawalSourceReserve { - type: "reserve"; - reservePub: string; -} - -export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve; - -export interface WithdrawalSessionRecord { - withdrawSessionId: string; - - source: WithdrawalSource; - - exchangeBaseUrl: string; - - /** - * When was the withdrawal operation started started? - * Timestamp in milliseconds. - */ - startTimestamp: Timestamp; - - /** - * When was the withdrawal operation completed? - */ - finishTimestamp?: Timestamp; - - totalCoinValue: AmountJson; - - /** - * Amount including fees (i.e. the amount subtracted from the - * reserve to withdraw all coins in this withdrawal session). - */ - rawWithdrawalAmount: AmountJson; - - denoms: string[]; - - planchets: (undefined | PlanchetRecord)[]; - - /** - * Coins in this session that are withdrawn are set to true. - */ - withdrawn: boolean[]; - - /** - * Retry info, always present even on completed operations so that indexing works. - */ - retryInfo: RetryInfo; - - /** - * Last error per coin/planchet, or undefined if no error occured for - * the coin/planchet. - */ - lastCoinErrors: (OperationError | undefined)[]; - - lastError: OperationError | undefined; -} - -export interface BankWithdrawUriRecord { - /** - * The withdraw URI we got from the bank. - */ - talerWithdrawUri: string; - - /** - * Reserve that was created for the withdraw URI. - */ - reservePub: string; -} - -/* tslint:disable:completed-docs */ - -/** - * The stores and indices for the wallet database. - */ -export namespace Stores { - class ExchangesStore extends Store { - constructor() { - super("exchanges", { keyPath: "baseUrl" }); - } - } - - class CoinsStore extends Store { - constructor() { - super("coins", { keyPath: "coinPub" }); - } - - exchangeBaseUrlIndex = new Index( - this, - "exchangeBaseUrl", - "exchangeBaseUrl", - ); - denomPubIndex = new Index( - this, - "denomPubIndex", - "denomPub", - ); - byWithdrawalWithIdx = new Index( - this, - "planchetsByWithdrawalWithIdxIndex", - ["withdrawSessionId", "coinIndex"], - ); - } - - class ProposalsStore extends Store { - constructor() { - super("proposals", { keyPath: "proposalId" }); - } - urlAndOrderIdIndex = new Index(this, "urlIndex", [ - "merchantBaseUrl", - "orderId", - ]); - } - - class PurchasesStore extends Store { - constructor() { - super("purchases", { keyPath: "proposalId" }); - } - - fulfillmentUrlIndex = new Index( - this, - "fulfillmentUrlIndex", - "contractTerms.fulfillment_url", - ); - orderIdIndex = new Index(this, "orderIdIndex", [ - "contractTerms.merchant_base_url", - "contractTerms.order_id", - ]); - } - - class DenominationsStore extends Store { - constructor() { - // cast needed because of bug in type annotations - super("denominations", { - keyPath: (["exchangeBaseUrl", "denomPub"] as any) as IDBKeyPath, - }); - } - - denomPubHashIndex = new Index( - this, - "denomPubHashIndex", - "denomPubHash", - ); - exchangeBaseUrlIndex = new Index( - this, - "exchangeBaseUrlIndex", - "exchangeBaseUrl", - ); - denomPubIndex = new Index( - this, - "denomPubIndex", - "denomPub", - ); - } - - class CurrenciesStore extends Store { - constructor() { - super("currencies", { keyPath: "name" }); - } - } - - class ConfigStore extends Store { - constructor() { - super("config", { keyPath: "key" }); - } - } - - class ReservesStore extends Store { - constructor() { - super("reserves", { keyPath: "reservePub" }); - } - } - - class TipsStore extends Store { - constructor() { - super("tips", { keyPath: "tipId" }); - } - } - - class SenderWiresStore extends Store { - constructor() { - super("senderWires", { keyPath: "paytoUri" }); - } - } - - class WithdrawalSessionsStore extends Store { - constructor() { - super("withdrawals", { keyPath: "withdrawSessionId" }); - } - } - - class BankWithdrawUrisStore extends Store { - constructor() { - super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); - } - } - - export const coins = new CoinsStore(); - export const coinsReturns = new Store("coinsReturns", { - keyPath: "contractTermsHash", - }); - export const config = new ConfigStore(); - export const currencies = new CurrenciesStore(); - export const denominations = new DenominationsStore(); - export const exchanges = new ExchangesStore(); - export const proposals = new ProposalsStore(); - export const refresh = new Store("refresh", { - keyPath: "refreshSessionId", - }); - export const reserves = new ReservesStore(); - export const purchases = new PurchasesStore(); - export const tips = new TipsStore(); - export const senderWires = new SenderWiresStore(); - export const withdrawalSession = new WithdrawalSessionsStore(); - export const bankWithdrawUris = new BankWithdrawUrisStore(); -} - -/* tslint:enable:completed-docs */ diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index 791bd6ab5..2c0824a7d 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -23,7 +23,7 @@ */ import { Wallet } from "../wallet"; import { MemoryBackend, BridgeIDBFactory, shimIndexedDB } from "idb-bridge"; -import { openTalerDb } from "../db"; +import { openDatabase } from "../db"; import Axios, { AxiosPromise, AxiosResponse } from "axios"; import { HttpRequestLibrary, @@ -36,9 +36,9 @@ import { Bank } from "./bank"; import fs = require("fs"); import { Logger } from "../util/logging"; import { NodeThreadCryptoWorkerFactory } from "../crypto/workers/nodeThreadWorker"; -import { NotificationType, WalletNotification } from "../walletTypes"; import { SynchronousCryptoWorkerFactory } from "../crypto/workers/synchronousWorker"; import { RequestThrottler } from "../util/RequestThrottler"; +import { WalletNotification, NotificationType } from "../types/notifications"; const logger = new Logger("helpers.ts"); @@ -191,7 +191,7 @@ export async function getDefaultNodeWallet( shimIndexedDB(myBridgeIdbFactory); - const myDb = await openTalerDb( + const myDb = await openDatabase( myIdbFactory, myVersionChange, myUnsupportedUpgrade, diff --git a/src/headless/merchant.ts b/src/headless/merchant.ts index 5ce50cb53..6a2d0ad2e 100644 --- a/src/headless/merchant.ts +++ b/src/headless/merchant.ts @@ -23,7 +23,7 @@ * Imports. */ import axios from "axios"; -import { CheckPaymentResponse } from "../talerTypes"; +import { CheckPaymentResponse } from "../types/talerTypes"; /** * Connection to the *internal* merchant backend. diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index d2ace124d..2e2ded52c 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -26,7 +26,7 @@ import { BridgeIDBFactory, MemoryBackend } from "idb-bridge"; import { Logger } from "../util/logging"; import * as Amounts from "../util/amounts"; import { decodeCrock } from "../crypto/talerCrypto"; -import { OperationFailedAndReportedError } from "../wallet-impl/errors"; +import { OperationFailedAndReportedError } from "../operations/errors"; import { Bank } from "./bank"; import { classifyTalerUri, TalerUriType } from "../util/taleruri"; diff --git a/src/i18n.tsx b/src/i18n.tsx deleted file mode 100644 index 67df6c516..000000000 --- a/src/i18n.tsx +++ /dev/null @@ -1,267 +0,0 @@ -/* - This file is part of TALER - (C) 2016 GNUnet e.V. - - 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. - - 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 - TALER; see the file COPYING. If not, see - */ - -/** - * Translation helpers for React components and template literals. - */ - -/** - * Imports. - */ -import {strings} from "./i18n/strings"; - -// @ts-ignore: no type decl for this library -import * as jedLib from "jed"; - -import * as React from "react"; - - -const jed = setupJed(); - -let enableTracing = false; - - -/** - * Set up jed library for internationalization, - * based on browser language settings. - */ -function setupJed(): any { - let lang: string; - try { - lang = chrome.i18n.getUILanguage(); - // Chrome gives e.g. "en-US", but Firefox gives us "en_US" - lang = lang.replace("_", "-"); - } catch (e) { - lang = "en"; - console.warn("i18n default language not available"); - } - - if (!strings[lang]) { - lang = "en-US"; - console.log(`language ${lang} not found, defaulting to english`); - } - return new jedLib.Jed(strings[lang]); -} - - -/** - * Convert template strings to a msgid - */ -function toI18nString(stringSeq: ReadonlyArray) { - let s = ""; - for (let i = 0; i < stringSeq.length; i++) { - s += stringSeq[i]; - if (i < stringSeq.length - 1) { - s += `%${i + 1}$s`; - } - } - return s; -} - - -/** - * Internationalize a string template with arbitrary serialized values. - */ -export function str(stringSeq: TemplateStringsArray, ...values: any[]) { - const s = toI18nString(stringSeq); - const tr = jed.translate(s).ifPlural(1, s).fetch(...values); - return tr; -} - - -interface TranslateSwitchProps { - target: number; -} - - -function stringifyChildren(children: any): string { - let n = 1; - const ss = React.Children.map(children, (c) => { - if (typeof c === "string") { - return c; - } - return `%${n++}$s`; - }); - const s = ss.join("").replace(/ +/g, " ").trim(); - enableTracing && console.log("translation lookup", JSON.stringify(s)); - return s; -} - - -interface TranslateProps { - /** - * Component that the translated element should be wrapped in. - * Defaults to "div". - */ - wrap?: any; - - /** - * Props to give to the wrapped component. - */ - wrapProps?: any; -} - - -/** - * Translate text node children of this component. - * If a child component might produce a text node, it must be wrapped - * in a another non-text element. - * - * Example: - * ``` - * - * Hello. Your score is - * - * ``` - */ -export class Translate extends React.Component { - render(): JSX.Element { - const s = stringifyChildren(this.props.children); - const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); - const childArray = React.Children.toArray(this.props.children!); - for (let i = 0; i < childArray.length - 1; ++i) { - if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { - childArray[i + 1] = (childArray[i] as string).concat(childArray[i + 1] as string); - childArray.splice(i, 1); - } - } - const result = []; - while (childArray.length > 0) { - const x = childArray.shift(); - if (x === undefined) { - continue; - } - if (typeof x === "string") { - const t = tr.shift(); - result.push(t); - } else { - result.push(x); - } - } - if (!this.props.wrap) { - return
{result}
; - } - return React.createElement(this.props.wrap, this.props.wrapProps, result); - } -} - - -/** - * Switch translation based on singular or plural based on the target prop. - * Should only contain TranslateSingular and TransplatePlural as children. - * - * Example: - * ``` - * - * I have {n} apple. - * I have {n} apples. - * - * ``` - */ -export class TranslateSwitch extends React.Component { - render(): JSX.Element { - let singular: React.ReactElement | undefined; - let plural: React.ReactElement | undefined; - const children = this.props.children; - if (children) { - React.Children.forEach(children, (child: any) => { - if (child.type === TranslatePlural) { - plural = child; - } - if (child.type === TranslateSingular) { - singular = child; - } - }); - } - if ((!singular) || (!plural)) { - console.error("translation not found"); - return React.createElement("span", {}, ["translation not found"]); - } - singular.props.target = this.props.target; - plural.props.target = this.props.target; - // We're looking up the translation based on the - // singular, even if we must use the plural form. - return singular; - } -} - - -interface TranslationPluralProps { - target: number; -} - -/** - * See [[TranslateSwitch]]. - */ -export class TranslatePlural extends React.Component { - render(): JSX.Element { - const s = stringifyChildren(this.props.children); - const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); - const childArray = React.Children.toArray(this.props.children!); - for (let i = 0; i < childArray.length - 1; ++i) { - if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { - childArray[i + i] = childArray[i] as string + childArray[i + 1] as string; - childArray.splice(i, 1); - } - } - const result = []; - while (childArray.length > 0) { - const x = childArray.shift(); - if (x === undefined) { - continue; - } - if (typeof x === "string") { - const t = tr.shift(); - result.push(t); - } else { - result.push(x); - } - } - return
{result}
; - } -} - - -/** - * See [[TranslateSwitch]]. - */ -export class TranslateSingular extends React.Component { - render(): JSX.Element { - const s = stringifyChildren(this.props.children); - const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); - const childArray = React.Children.toArray(this.props.children!); - for (let i = 0; i < childArray.length - 1; ++i) { - if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { - childArray[i + i] = childArray[i] as string + childArray[i + 1] as string; - childArray.splice(i, 1); - } - } - const result = []; - while (childArray.length > 0) { - const x = childArray.shift(); - if (x === undefined) { - continue; - } - if (typeof x === "string") { - const t = tr.shift(); - result.push(t); - } else { - result.push(x); - } - } - return
{result}
; - } -} 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 + */ + +/** + * 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 { + 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 + */ + +/** + * 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( + op: () => Promise, + onOpError: (e: OperationError) => Promise, +): Promise { + 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 + */ + +/** + * 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + */ + +/** + * 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 { + 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 + */ + +/** + * 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 { + 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 { + // 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 + */ + +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 { + 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 { + 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 { + 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 { + const onOpError = (err: OperationError) => + incrementReserveRetry(ws, reservePub, err); + await guardOperationException( + () => processReserveBankStatusImpl(ws, reservePub), + onOpError, + ); +} + +async function processReserveBankStatusImpl( + ws: InternalWalletState, + reservePub: string, +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + */ + +/** + * 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 { + 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 { + 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 { + 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 + */ + +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 = new AsyncOpMemoMap(); + memoMakePlanchet: AsyncOpMemoMap = new AsyncOpMemoMap(); + memoGetPending: AsyncOpMemoSingle< + PendingOperationsResponse + > = new AsyncOpMemoSingle(); + memoGetBalance: AsyncOpMemoSingle = new AsyncOpMemoSingle(); + memoProcessRefresh: AsyncOpMemoMap = 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 + */ + + +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 { + 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 { + 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 { + const onOpErr = (e: OperationError) => incrementTipRetry(ws, tipId, e); + await guardOperationException(() => processTipImpl(ws, tipId, forceNow), onOpErr); +} + +async function resetTipRetry( + ws: InternalWalletState, + tipId: string, +): Promise { + 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 { + 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 + */ + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/src/talerTypes.ts b/src/talerTypes.ts deleted file mode 100644 index 840321db7..000000000 --- a/src/talerTypes.ts +++ /dev/null @@ -1,944 +0,0 @@ -/* - This file is part of TALER - (C) 2018 GNUnet e.V. and INRIA - - 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. - - 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 - TALER; see the file COPYING. If not, see - */ - -/** - * Type and schema definitions for the base taler protocol. - * - * All types here should be "@Checkable". - * - * Even though the rest of the wallet uses camelCase for fields, use snake_case - * here, since that's the convention for the Taler JSON+HTTP API. - */ - -/** - * Imports. - */ -import { Checkable } from "./util/checkable"; - -import * as Amounts from "./util/amounts"; - -import { timestampCheck } from "./util/helpers"; - -/** - * Denomination as found in the /keys response from the exchange. - */ -@Checkable.Class() -export class Denomination { - /** - * Value of one coin of the denomination. - */ - @Checkable.String(Amounts.check) - value: string; - - /** - * Public signing key of the denomination. - */ - @Checkable.String() - denom_pub: string; - - /** - * Fee for withdrawing. - */ - @Checkable.String(Amounts.check) - fee_withdraw: string; - - /** - * Fee for depositing. - */ - @Checkable.String(Amounts.check) - fee_deposit: string; - - /** - * Fee for refreshing. - */ - @Checkable.String(Amounts.check) - fee_refresh: string; - - /** - * Fee for refunding. - */ - @Checkable.String(Amounts.check) - fee_refund: string; - - /** - * Start date from which withdraw is allowed. - */ - @Checkable.String(timestampCheck) - stamp_start: string; - - /** - * End date for withdrawing. - */ - @Checkable.String(timestampCheck) - stamp_expire_withdraw: string; - - /** - * Expiration date after which the exchange can forget about - * the currency. - */ - @Checkable.String(timestampCheck) - stamp_expire_legal: string; - - /** - * Date after which the coins of this denomination can't be - * deposited anymore. - */ - @Checkable.String(timestampCheck) - stamp_expire_deposit: string; - - /** - * Signature over the denomination information by the exchange's master - * signing key. - */ - @Checkable.String() - master_sig: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => Denomination; -} - -/** - * Signature by the auditor that a particular denomination key is audited. - */ -@Checkable.Class() -export class AuditorDenomSig { - /** - * Denomination public key's hash. - */ - @Checkable.String() - denom_pub_h: string; - - /** - * The signature. - */ - @Checkable.String() - auditor_sig: string; -} - -/** - * Auditor information as given by the exchange in /keys. - */ -@Checkable.Class() -export class Auditor { - /** - * Auditor's public key. - */ - @Checkable.String() - auditor_pub: string; - - /** - * Base URL of the auditor. - */ - @Checkable.String() - auditor_url: string; - - /** - * List of signatures for denominations by the auditor. - */ - @Checkable.List(Checkable.Value(() => AuditorDenomSig)) - denomination_keys: AuditorDenomSig[]; -} - -/** - * Request that we send to the exchange to get a payback. - */ -export interface PaybackRequest { - /** - * Denomination public key of the coin we want to get - * paid back. - */ - denom_pub: string; - - /** - * Signature over the coin public key by the denomination. - */ - denom_sig: string; - - /** - * Coin public key of the coin we want to refund. - */ - coin_pub: string; - - /** - * Blinding key that was used during withdraw, - * used to prove that we were actually withdrawing the coin. - */ - coin_blind_key_secret: string; - - /** - * Signature made by the coin, authorizing the payback. - */ - coin_sig: string; -} - -/** - * Response that we get from the exchange for a payback request. - */ -@Checkable.Class() -export class PaybackConfirmation { - /** - * public key of the reserve that will receive the payback. - */ - @Checkable.String() - reserve_pub: string; - - /** - * How much will the exchange pay back (needed by wallet in - * case coin was partially spent and wallet got restored from backup) - */ - @Checkable.String() - amount: string; - - /** - * Time by which the exchange received the /payback request. - */ - @Checkable.String() - timestamp: string; - - /** - * the EdDSA signature of TALER_PaybackConfirmationPS using a current - * signing key of the exchange affirming the successful - * payback request, and that the exchange promises to transfer the funds - * by the date specified (this allows the exchange delaying the transfer - * a bit to aggregate additional payback requests into a larger one). - */ - @Checkable.String() - exchange_sig: string; - - /** - * Public EdDSA key of the exchange that was used to generate the signature. - * Should match one of the exchange's signing keys from /keys. It is given - * explicitly as the client might otherwise be confused by clock skew as to - * which signing key was used. - */ - @Checkable.String() - exchange_pub: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => PaybackConfirmation; -} - -/** - * Deposit permission for a single coin. - */ -export interface CoinPaySig { - /** - * Signature by the coin. - */ - coin_sig: string; - /** - * Public key of the coin being spend. - */ - coin_pub: string; - /** - * Signature made by the denomination public key. - */ - ub_sig: string; - /** - * The denomination public key associated with this coin. - */ - denom_pub: string; - /** - * The amount that is subtracted from this coin with this payment. - */ - contribution: string; - - /** - * URL of the exchange this coin was withdrawn from. - */ - exchange_url: string; -} - -/** - * Information about an exchange as stored inside a - * merchant's contract terms. - */ -@Checkable.Class() -export class ExchangeHandle { - /** - * Master public signing key of the exchange. - */ - @Checkable.String() - master_pub: string; - - /** - * Base URL of the exchange. - */ - @Checkable.String() - url: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => ExchangeHandle; -} - -/** - * Contract terms from a merchant. - */ -@Checkable.Class({ validate: true }) -export class ContractTerms { - static validate(x: ContractTerms) { - if (x.exchanges.length === 0) { - throw Error("no exchanges in contract terms"); - } - } - - /** - * Hash of the merchant's wire details. - */ - @Checkable.String() - H_wire: string; - - /** - * Hash of the merchant's wire details. - */ - @Checkable.Optional(Checkable.String()) - auto_refund?: string; - - /** - * Wire method the merchant wants to use. - */ - @Checkable.String() - wire_method: string; - - /** - * Human-readable short summary of the contract. - */ - @Checkable.Optional(Checkable.String()) - summary?: string; - - /** - * Nonce used to ensure freshness. - */ - @Checkable.Optional(Checkable.String()) - nonce?: string; - - /** - * Total amount payable. - */ - @Checkable.String(Amounts.check) - amount: string; - - /** - * Auditors accepted by the merchant. - */ - @Checkable.List(Checkable.AnyObject()) - auditors: any[]; - - /** - * Deadline to pay for the contract. - */ - @Checkable.Optional(Checkable.String()) - pay_deadline: string; - - /** - * Delivery locations. - */ - @Checkable.Any() - locations: any; - - /** - * Maximum deposit fee covered by the merchant. - */ - @Checkable.String(Amounts.check) - max_fee: string; - - /** - * Information about the merchant. - */ - @Checkable.Any() - merchant: any; - - /** - * Public key of the merchant. - */ - @Checkable.String() - merchant_pub: string; - - /** - * List of accepted exchanges. - */ - @Checkable.List(Checkable.Value(() => ExchangeHandle)) - exchanges: ExchangeHandle[]; - - /** - * Products that are sold in this contract. - */ - @Checkable.List(Checkable.AnyObject()) - products: any[]; - - /** - * Deadline for refunds. - */ - @Checkable.String(timestampCheck) - refund_deadline: string; - - /** - * Deadline for the wire transfer. - */ - @Checkable.String() - wire_transfer_deadline: string; - - /** - * Time when the contract was generated by the merchant. - */ - @Checkable.String(timestampCheck) - timestamp: string; - - /** - * Order id to uniquely identify the purchase within - * one merchant instance. - */ - @Checkable.String() - order_id: string; - - /** - * Base URL of the merchant's backend. - */ - @Checkable.String() - merchant_base_url: string; - - /** - * Fulfillment URL to view the product or - * delivery status. - */ - @Checkable.String() - fulfillment_url: string; - - /** - * Share of the wire fee that must be settled with one payment. - */ - @Checkable.Optional(Checkable.Number()) - wire_fee_amortization?: number; - - /** - * Maximum wire fee that the merchant agrees to pay for. - */ - @Checkable.Optional(Checkable.String()) - max_wire_fee?: string; - - /** - * Extra data, interpreted by the mechant only. - */ - @Checkable.Any() - extra: any; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => ContractTerms; -} - -/** - * Payment body sent to the merchant's /pay. - */ -export interface PayReq { - /** - * Coins with signature. - */ - coins: CoinPaySig[]; - - /** - * The merchant public key, used to uniquely - * identify the merchant instance. - */ - merchant_pub: string; - - /** - * Order ID that's being payed for. - */ - order_id: string; - - /** - * Mode for /pay. - */ - mode: "pay" | "abort-refund"; -} - -/** - * Refund permission in the format that the merchant gives it to us. - */ -@Checkable.Class() -export class MerchantRefundPermission { - /** - * Amount to be refunded. - */ - @Checkable.String(Amounts.check) - refund_amount: string; - - /** - * Fee for the refund. - */ - @Checkable.String(Amounts.check) - refund_fee: string; - - /** - * Public key of the coin being refunded. - */ - @Checkable.String() - coin_pub: string; - - /** - * Refund transaction ID between merchant and exchange. - */ - @Checkable.Number() - rtransaction_id: number; - - /** - * Signature made by the merchant over the refund permission. - */ - @Checkable.String() - merchant_sig: string; - - /** - * Create a MerchantRefundPermission from untyped JSON. - */ - static checked: (obj: any) => MerchantRefundPermission; -} - -/** - * Refund request sent to the exchange. - */ -export interface RefundRequest { - /** - * Amount to be refunded, can be a fraction of the - * coin's total deposit value (including deposit fee); - * must be larger than the refund fee. - */ - refund_amount: string; - - /** - * Refund fee associated with the given coin. - * must be smaller than the refund amount. - */ - refund_fee: string; - - /** - * SHA-512 hash of the contact of the merchant with the customer. - */ - h_contract_terms: string; - - /** - * coin's public key, both ECDHE and EdDSA. - */ - coin_pub: string; - - /** - * 64-bit transaction id of the refund transaction between merchant and customer - */ - rtransaction_id: number; - - /** - * EdDSA public key of the merchant. - */ - merchant_pub: string; - - /** - * EdDSA signature of the merchant affirming the refund. - */ - merchant_sig: string; -} - -/** - * Response for a refund pickup or a /pay in abort mode. - */ -@Checkable.Class() -export class MerchantRefundResponse { - /** - * Public key of the merchant - */ - @Checkable.String() - merchant_pub: string; - - /** - * Contract terms hash of the contract that - * is being refunded. - */ - @Checkable.String() - h_contract_terms: string; - - /** - * The signed refund permissions, to be sent to the exchange. - */ - @Checkable.List(Checkable.Value(() => MerchantRefundPermission)) - refund_permissions: MerchantRefundPermission[]; - - /** - * Create a MerchantRefundReponse from untyped JSON. - */ - static checked: (obj: any) => MerchantRefundResponse; -} - -/** - * Planchet detail sent to the merchant. - */ -export interface TipPlanchetDetail { - /** - * Hashed denomination public key. - */ - denom_pub_hash: string; - - /** - * Coin's blinded public key. - */ - coin_ev: string; -} - -/** - * Request sent to the merchant to pick up a tip. - */ -export interface TipPickupRequest { - /** - * Identifier of the tip. - */ - tip_id: string; - - /** - * List of planchets the wallet wants to use for the tip. - */ - planchets: TipPlanchetDetail[]; -} - -/** - * Reserve signature, defined as separate class to facilitate - * schema validation with "@Checkable". - */ -@Checkable.Class() -export class ReserveSigSingleton { - /** - * Reserve signature. - */ - @Checkable.String() - reserve_sig: string; - - /** - * Create a ReserveSigSingleton from untyped JSON. - */ - static checked: (obj: any) => ReserveSigSingleton; -} - -/** - * Response to /reserve/status - */ -@Checkable.Class() -export class ReserveStatus { - /** - * Reserve signature. - */ - @Checkable.String() - balance: string; - - /** - * Reserve history, currently not used by the wallet. - */ - @Checkable.Any() - history: any; - - /** - * Create a ReserveSigSingleton from untyped JSON. - */ - static checked: (obj: any) => ReserveStatus; -} - -/** - * Response of the merchant - * to the TipPickupRequest. - */ -@Checkable.Class() -export class TipResponse { - /** - * Public key of the reserve - */ - @Checkable.String() - reserve_pub: string; - - /** - * The order of the signatures matches the planchets list. - */ - @Checkable.List(Checkable.Value(() => ReserveSigSingleton)) - reserve_sigs: ReserveSigSingleton[]; - - /** - * Create a TipResponse from untyped JSON. - */ - static checked: (obj: any) => TipResponse; -} - -/** - * Element of the payback list that the - * exchange gives us in /keys. - */ -@Checkable.Class() -export class Payback { - /** - * The hash of the denomination public key for which the payback is offered. - */ - @Checkable.String() - h_denom_pub: string; -} - -/** - * Structure that the exchange gives us in /keys. - */ -@Checkable.Class({ extra: true }) -export class KeysJson { - /** - * List of offered denominations. - */ - @Checkable.List(Checkable.Value(() => Denomination)) - denoms: Denomination[]; - - /** - * The exchange's master public key. - */ - @Checkable.String() - master_public_key: string; - - /** - * The list of auditors (partially) auditing the exchange. - */ - @Checkable.List(Checkable.Value(() => Auditor)) - auditors: Auditor[]; - - /** - * Timestamp when this response was issued. - */ - @Checkable.String(timestampCheck) - list_issue_date: string; - - /** - * List of paybacks for compromised denominations. - */ - @Checkable.Optional(Checkable.List(Checkable.Value(() => Payback))) - payback?: Payback[]; - - /** - * Short-lived signing keys used to sign online - * responses. - */ - @Checkable.Any() - signkeys: any; - - /** - * Protocol version. - */ - @Checkable.Optional(Checkable.String()) - version?: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => KeysJson; -} - -/** - * Wire fees as anounced by the exchange. - */ -@Checkable.Class() -export class WireFeesJson { - /** - * Cost of a wire transfer. - */ - @Checkable.String(Amounts.check) - wire_fee: string; - - /** - * Cost of clising a reserve. - */ - @Checkable.String(Amounts.check) - closing_fee: string; - - /** - * Signature made with the exchange's master key. - */ - @Checkable.String() - sig: string; - - /** - * Date from which the fee applies. - */ - @Checkable.String(timestampCheck) - start_date: string; - - /** - * Data after which the fee doesn't apply anymore. - */ - @Checkable.String(timestampCheck) - end_date: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => WireFeesJson; -} - -@Checkable.Class({ extra: true }) -export class AccountInfo { - @Checkable.String() - url: string; - - @Checkable.String() - master_sig: string; -} - -@Checkable.Class({ extra: true }) -export class ExchangeWireJson { - @Checkable.Map( - Checkable.String(), - Checkable.List(Checkable.Value(() => WireFeesJson)), - ) - fees: { [methodName: string]: WireFeesJson[] }; - - @Checkable.List(Checkable.Value(() => AccountInfo)) - accounts: AccountInfo[]; - - static checked: (obj: any) => ExchangeWireJson; -} - -/** - * Wire detail, arbitrary object that must at least - * contain a "type" key. - */ -export type WireDetail = object & { type: string }; - -/** - * Proposal returned from the contract URL. - */ -@Checkable.Class({ extra: true }) -export class Proposal { - /** - * Contract terms for the propoal. - */ - @Checkable.Value(() => ContractTerms) - contract_terms: ContractTerms; - - /** - * Signature over contract, made by the merchant. The public key used for signing - * must be contract_terms.merchant_pub. - */ - @Checkable.String() - sig: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => Proposal; -} - -/** - * Response from the internal merchant API. - */ -@Checkable.Class({ extra: true }) -export class CheckPaymentResponse { - @Checkable.Boolean() - paid: boolean; - - @Checkable.Optional(Checkable.Boolean()) - refunded: boolean | undefined; - - @Checkable.Optional(Checkable.String()) - refunded_amount: string | undefined; - - @Checkable.Optional(Checkable.Value(() => ContractTerms)) - contract_terms: ContractTerms | undefined; - - @Checkable.Optional(Checkable.String()) - taler_pay_uri: string | undefined; - - @Checkable.Optional(Checkable.String()) - contract_url: string | undefined; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => CheckPaymentResponse; -} - -/** - * Response from the bank. - */ -@Checkable.Class({ extra: true }) -export class WithdrawOperationStatusResponse { - @Checkable.Boolean() - selection_done: boolean; - - @Checkable.Boolean() - transfer_done: boolean; - - @Checkable.String() - amount: string; - - @Checkable.Optional(Checkable.String()) - sender_wire?: string; - - @Checkable.Optional(Checkable.String()) - suggested_exchange?: string; - - @Checkable.Optional(Checkable.String()) - confirm_transfer_url?: string; - - @Checkable.List(Checkable.String()) - wire_types: string[]; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => WithdrawOperationStatusResponse; -} - -/** - * Response from the merchant. - */ -@Checkable.Class({ extra: true }) -export class TipPickupGetResponse { - @Checkable.AnyObject() - extra: any; - - @Checkable.String() - amount: string; - - @Checkable.String() - amount_left: string; - - @Checkable.String() - exchange_url: string; - - @Checkable.String() - stamp_expire: string; - - @Checkable.String() - stamp_created: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => TipPickupGetResponse; -} diff --git a/src/types-test.ts b/src/types-test.ts deleted file mode 100644 index 38cb9260a..000000000 --- a/src/types-test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - This file is part of TALER - (C) 2017 Inria and GNUnet e.V. - - 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. - - 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 - TALER; see the file COPYING. If not, see - */ - -import test from "ava"; -import * as Amounts from "./util/amounts"; -import { ContractTerms } from "./talerTypes"; - -const amt = ( - value: number, - fraction: number, - currency: string, -): Amounts.AmountJson => ({ value, fraction, currency }); - -test("amount addition (simple)", t => { - const a1 = amt(1, 0, "EUR"); - const a2 = amt(1, 0, "EUR"); - const a3 = amt(2, 0, "EUR"); - t.true(0 === Amounts.cmp(Amounts.add(a1, a2).amount, a3)); - t.pass(); -}); - -test("amount addition (saturation)", t => { - const a1 = amt(1, 0, "EUR"); - const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1); - t.true(res.saturated); - t.pass(); -}); - -test("amount subtraction (simple)", t => { - const a1 = amt(2, 5, "EUR"); - const a2 = amt(1, 0, "EUR"); - const a3 = amt(1, 5, "EUR"); - t.true(0 === Amounts.cmp(Amounts.sub(a1, a2).amount, a3)); - t.pass(); -}); - -test("amount subtraction (saturation)", t => { - const a1 = amt(0, 0, "EUR"); - const a2 = amt(1, 0, "EUR"); - let res = Amounts.sub(a1, a2); - t.true(res.saturated); - res = Amounts.sub(a1, a1); - t.true(!res.saturated); - t.pass(); -}); - -test("amount comparison", t => { - t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0); - t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1); - t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1); - t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 0, "EUR")), 1); - t.is(Amounts.cmp(amt(0, 0, "EUR"), amt(1, 0, "EUR")), -1); - t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 100000000, "EUR")), 0); - t.throws(() => Amounts.cmp(amt(1, 0, "FOO"), amt(1, 0, "BAR"))); - t.pass(); -}); - -test("amount parsing", t => { - t.is( - Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), amt(0, 0, "TESTKUDOS")), - 0, - ); - t.is( - Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), amt(10, 0, "TESTKUDOS")), - 0, - ); - t.is( - Amounts.cmp( - Amounts.parseOrThrow("TESTKUDOS:0.1"), - amt(0, 10000000, "TESTKUDOS"), - ), - 0, - ); - t.is( - Amounts.cmp( - Amounts.parseOrThrow("TESTKUDOS:0.00000001"), - amt(0, 1, "TESTKUDOS"), - ), - 0, - ); - t.is( - Amounts.cmp( - Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), - amt(4503599627370496, 99999999, "TESTKUDOS"), - ), - 0, - ); - t.throws(() => Amounts.parseOrThrow("foo:")); - t.throws(() => Amounts.parseOrThrow("1.0")); - t.throws(() => Amounts.parseOrThrow("42")); - t.throws(() => Amounts.parseOrThrow(":1.0")); - t.throws(() => Amounts.parseOrThrow(":42")); - t.throws(() => Amounts.parseOrThrow("EUR:.42")); - t.throws(() => Amounts.parseOrThrow("EUR:42.")); - t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999")); - t.is( - Amounts.cmp( - Amounts.parseOrThrow("TESTKUDOS:0.99999999"), - amt(0, 99999999, "TESTKUDOS"), - ), - 0, - ); - t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); - t.pass(); -}); - -test("amount stringification", t => { - t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); - t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); - t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); - t.is(Amounts.toString(amt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); - t.is(Amounts.toString(amt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); - // denormalized - t.is(Amounts.toString(amt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); - t.pass(); -}); - -test("contract terms validation", t => { - const c = { - H_wire: "123", - amount: "EUR:1.5", - auditors: [], - exchanges: [{ master_pub: "foo", url: "foo" }], - fulfillment_url: "foo", - max_fee: "EUR:1.5", - merchant_pub: "12345", - order_id: "test_order", - pay_deadline: "Date(12346)", - wire_transfer_deadline: "Date(12346)", - merchant_base_url: "https://example.com/pay", - products: [], - refund_deadline: "Date(12345)", - summary: "hello", - timestamp: "Date(12345)", - wire_method: "test", - }; - - ContractTerms.checked(c); - - const c1 = JSON.parse(JSON.stringify(c)); - c1.exchanges = []; - - try { - ContractTerms.checked(c1); - } catch (e) { - t.pass(); - return; - } - - t.fail(); -}); diff --git a/src/types/dbTypes.ts b/src/types/dbTypes.ts new file mode 100644 index 000000000..ce2bb4109 --- /dev/null +++ b/src/types/dbTypes.ts @@ -0,0 +1,1388 @@ +/* + This file is part of TALER + (C) 2018 GNUnet e.V. and INRIA + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + +/** + * Types for records stored in the wallet's database. + * + * Types for the objects in the database should end in "-Record". + */ + +/** + * Imports. + */ +import { AmountJson } from "../util/amounts"; +import { Checkable } from "../util/checkable"; +import { + Auditor, + CoinPaySig, + ContractTerms, + Denomination, + MerchantRefundPermission, + PayReq, + TipResponse, +} from "./talerTypes"; + +import { Index, Store } from "../util/query"; +import { + Timestamp, + OperationError, + Duration, + getTimestampNow, +} from "./walletTypes"; + +/** + * Current database version, should be incremented + * each time we do incompatible schema changes on the database. + * In the future we might consider adding migration functions for + * each version increment. + */ +export const WALLET_DB_VERSION = 28; + +export enum ReserveRecordStatus { + /** + * Waiting for manual confirmation. + */ + UNCONFIRMED = "unconfirmed", + + /** + * Reserve must be registered with the bank. + */ + REGISTERING_BANK = "registering-bank", + + /** + * We've registered reserve's information with the bank + * and are now waiting for the user to confirm the withdraw + * with the bank (typically 2nd factor auth). + */ + WAIT_CONFIRM_BANK = "wait-confirm-bank", + + /** + * Querying reserve status with the exchange. + */ + QUERYING_STATUS = "querying-status", + + /** + * Status is queried, the wallet must now select coins + * and start withdrawing. + */ + WITHDRAWING = "withdrawing", + + /** + * The corresponding withdraw record has been created. + * No further processing is done, unless explicitly requested + * by the user. + */ + DORMANT = "dormant", +} + +export interface RetryInfo { + firstTry: Timestamp; + nextRetry: Timestamp; + retryCounter: number; + active: boolean; +} + +export interface RetryPolicy { + readonly backoffDelta: Duration; + readonly backoffBase: number; +} + +const defaultRetryPolicy: RetryPolicy = { + backoffBase: 1.5, + backoffDelta: { d_ms: 200 }, +}; + +export function updateRetryInfoTimeout( + r: RetryInfo, + p: RetryPolicy = defaultRetryPolicy, +): void { + const now = getTimestampNow(); + const t = + now.t_ms + p.backoffDelta.d_ms * Math.pow(p.backoffBase, r.retryCounter); + r.nextRetry = { t_ms: t }; +} + +export function initRetryInfo( + active: boolean = true, + p: RetryPolicy = defaultRetryPolicy, +): RetryInfo { + if (!active) { + return { + active: false, + firstTry: { t_ms: Number.MAX_SAFE_INTEGER }, + nextRetry: { t_ms: Number.MAX_SAFE_INTEGER }, + retryCounter: 0, + }; + } + const info = { + firstTry: getTimestampNow(), + active: true, + nextRetry: { t_ms: 0 }, + retryCounter: 0, + }; + updateRetryInfoTimeout(info, p); + return info; +} + +/** + * A reserve record as stored in the wallet's database. + */ +export interface ReserveRecord { + /** + * The reserve public key. + */ + reservePub: string; + + /** + * The reserve private key. + */ + reservePriv: string; + + /** + * The exchange base URL. + */ + exchangeBaseUrl: string; + + /** + * Time when the reserve was created. + */ + created: Timestamp; + + /** + * Time when the information about this reserve was posted to the bank. + * + * Only applies if bankWithdrawStatusUrl is defined. + * + * Set to 0 if that hasn't happened yet. + */ + timestampReserveInfoPosted: Timestamp | undefined; + + /** + * Time when the reserve was confirmed. + * + * Set to 0 if not confirmed yet. + */ + timestampConfirmed: Timestamp | undefined; + + /** + * Amount that's still available for withdrawing + * from this reserve. + */ + withdrawRemainingAmount: AmountJson; + + /** + * Amount allocated for withdrawing. + * The corresponding withdraw operation may or may not + * have been completed yet. + */ + withdrawAllocatedAmount: AmountJson; + + withdrawCompletedAmount: AmountJson; + + /** + * Amount requested when the reserve was created. + * When a reserve is re-used (rare!) the current_amount can + * be higher than the requested_amount + */ + initiallyRequestedAmount: AmountJson; + + /** + * We got some payback to this reserve. We'll cease to automatically + * withdraw money from it. + */ + hasPayback: boolean; + + /** + * Wire information (as payto URI) for the bank account that + * transfered funds for this reserve. + */ + senderWire?: string; + + /** + * Wire information (as payto URI) for the exchange, specifically + * the account that was transferred to when creating the reserve. + */ + exchangeWire: string; + + bankWithdrawStatusUrl?: string; + + /** + * URL that the bank gave us to redirect the customer + * to in order to confirm a withdrawal. + */ + bankWithdrawConfirmUrl?: string; + + reserveStatus: ReserveRecordStatus; + + /** + * Time of the last successful status query. + */ + lastSuccessfulStatusQuery: Timestamp | undefined; + + /** + * Retry info. This field is present even if no retry is scheduled, + * because we need it to be present for the index on the object store + * to work. + */ + retryInfo: RetryInfo; + + /** + * Last error that happened in a reserve operation + * (either talking to the bank or the exchange). + */ + lastError: OperationError | undefined; +} + +/** + * Auditor record as stored with currencies in the exchange database. + */ +export interface AuditorRecord { + /** + * Base url of the auditor. + */ + baseUrl: string; + /** + * Public signing key of the auditor. + */ + auditorPub: string; + /** + * Time when the auditing expires. + */ + expirationStamp: number; +} + +/** + * Exchange for currencies as stored in the wallet's currency + * information database. + */ +export interface ExchangeForCurrencyRecord { + /** + * FIXME: unused? + */ + exchangePub: string; + /** + * Base URL of the exchange. + */ + baseUrl: string; +} + +/** + * Information about a currency as displayed in the wallet's database. + */ +export interface CurrencyRecord { + /** + * Name of the currency. + */ + name: string; + /** + * Number of fractional digits to show when rendering the currency. + */ + fractionalDigits: number; + /** + * Auditors that the wallet trusts for this currency. + */ + auditors: AuditorRecord[]; + /** + * Exchanges that the wallet trusts for this currency. + */ + exchanges: ExchangeForCurrencyRecord[]; +} + +/** + * Status of a denomination. + */ +export enum DenominationStatus { + /** + * Verification was delayed. + */ + Unverified, + /** + * Verified as valid. + */ + VerifiedGood, + /** + * Verified as invalid. + */ + VerifiedBad, +} + +/** + * Denomination record as stored in the wallet's database. + */ +@Checkable.Class() +export class DenominationRecord { + /** + * Value of one coin of the denomination. + */ + @Checkable.Value(() => AmountJson) + value: AmountJson; + + /** + * The denomination public key. + */ + @Checkable.String() + denomPub: string; + + /** + * Hash of the denomination public key. + * Stored in the database for faster lookups. + */ + @Checkable.String() + denomPubHash: string; + + /** + * Fee for withdrawing. + */ + @Checkable.Value(() => AmountJson) + feeWithdraw: AmountJson; + + /** + * Fee for depositing. + */ + @Checkable.Value(() => AmountJson) + feeDeposit: AmountJson; + + /** + * Fee for refreshing. + */ + @Checkable.Value(() => AmountJson) + feeRefresh: AmountJson; + + /** + * Fee for refunding. + */ + @Checkable.Value(() => AmountJson) + feeRefund: AmountJson; + + /** + * Validity start date of the denomination. + */ + @Checkable.Value(() => Timestamp) + stampStart: Timestamp; + + /** + * Date after which the currency can't be withdrawn anymore. + */ + @Checkable.Value(() => Timestamp) + stampExpireWithdraw: Timestamp; + + /** + * Date after the denomination officially doesn't exist anymore. + */ + @Checkable.Value(() => Timestamp) + stampExpireLegal: Timestamp; + + /** + * Data after which coins of this denomination can't be deposited anymore. + */ + @Checkable.Value(() => Timestamp) + stampExpireDeposit: Timestamp; + + /** + * Signature by the exchange's master key over the denomination + * information. + */ + @Checkable.String() + masterSig: string; + + /** + * Did we verify the signature on the denomination? + */ + @Checkable.Number() + status: DenominationStatus; + + /** + * Was this denomination still offered by the exchange the last time + * we checked? + * Only false when the exchange redacts a previously published denomination. + */ + @Checkable.Boolean() + isOffered: boolean; + + /** + * Base URL of the exchange. + */ + @Checkable.String() + exchangeBaseUrl: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => Denomination; +} + +/** + * Details about the exchange that we only know after + * querying /keys and /wire. + */ +export interface ExchangeDetails { + /** + * Master public key of the exchange. + */ + masterPublicKey: string; + /** + * Auditors (partially) auditing the exchange. + */ + auditors: Auditor[]; + + /** + * Currency that the exchange offers. + */ + currency: string; + + /** + * Last observed protocol version. + */ + protocolVersion: string; + + /** + * Timestamp for last update. + */ + lastUpdateTime: Timestamp; +} + +export const enum ExchangeUpdateStatus { + FETCH_KEYS = "fetch_keys", + FETCH_WIRE = "fetch_wire", + FETCH_TERMS = "fetch_terms", + FINISHED = "finished", +} + +export interface ExchangeBankAccount { + url: string; +} + +export interface ExchangeWireInfo { + feesForType: { [wireMethod: string]: WireFee[] }; + accounts: ExchangeBankAccount[]; +} + +/** + * Exchange record as stored in the wallet's database. + */ +export interface ExchangeRecord { + /** + * Base url of the exchange. + */ + baseUrl: string; + + /** + * Details, once known. + */ + details: ExchangeDetails | undefined; + + /** + * Mapping from wire method type to the wire fee. + */ + wireInfo: ExchangeWireInfo | undefined; + + /** + * When was the exchange added to the wallet? + */ + timestampAdded: Timestamp; + + /** + * Terms of service text or undefined if not downloaded yet. + */ + termsOfServiceText: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceLastEtag: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceAcceptedEtag: string | undefined; + + /** + * ETag for last terms of service download. + */ + termsOfServiceAcceptedTimestamp: Timestamp | undefined; + + /** + * Time when the update to the exchange has been started or + * undefined if no update is in progress. + */ + updateStarted: Timestamp | undefined; + updateStatus: ExchangeUpdateStatus; + updateReason?: "initial" | "forced"; + + lastError?: OperationError; +} + +/** + * A coin that isn't yet signed by an exchange. + */ +export interface PlanchetRecord { + /** + * Public key of the coin. + */ + coinPub: string; + coinPriv: string; + /** + * Public key of the reserve, this might be a reserve not + * known to the wallet if the planchet is from a tip. + */ + reservePub: string; + denomPubHash: string; + denomPub: string; + blindingKey: string; + withdrawSig: string; + coinEv: string; + coinValue: AmountJson; + isFromTip: boolean; +} + +/** + * Planchet for a coin during refrehs. + */ +export interface RefreshPlanchetRecord { + /** + * Public key for the coin. + */ + publicKey: string; + /** + * Private key for the coin. + */ + privateKey: string; + /** + * Blinded public key. + */ + coinEv: string; + /** + * Blinding key used. + */ + blindingKey: string; +} + +/** + * Status of a coin. + */ +export enum CoinStatus { + /** + * Withdrawn and never shown to anybody. + */ + Fresh = "fresh", + /** + * Used for a completed transaction and now dirty. + */ + Dirty = "dirty", + /** + * A coin that has been spent and refreshed. + */ + Dormant = "dormant", +} + +export enum CoinSource { + Withdraw = "withdraw", + Refresh = "refresh", + Tip = "tip", +} + +/** + * CoinRecord as stored in the "coins" data store + * of the wallet database. + */ +export interface CoinRecord { + /** + * Withdraw session ID, or "" (empty string) if withdrawn via refresh. + */ + withdrawSessionId: string; + + /** + * Index of the coin in the withdrawal session. + */ + coinIndex: number; + + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key to authorize operations on the coin. + */ + coinPriv: string; + + /** + * Key used by the exchange used to sign the coin. + */ + denomPub: string; + + /** + * Hash of the public key that signs the coin. + */ + denomPubHash: string; + + /** + * Unblinded signature by the exchange. + */ + denomSig: string; + + /** + * Amount that's left on the coin. + */ + currentAmount: AmountJson; + + /** + * Base URL that identifies the exchange from which we got the + * coin. + */ + exchangeBaseUrl: string; + + /** + * We have withdrawn the coin, but it's not accepted by the exchange anymore. + * We have to tell an auditor and wait for compensation or for the exchange + * to fix it. + */ + suspended?: boolean; + + /** + * Blinding key used when withdrawing the coin. + * Potentionally sed again during payback. + */ + blindingKey: string; + + /** + * Reserve public key for the reserve we got this coin from, + * or zero when we got the coin from refresh. + */ + reservePub: string | undefined; + + /** + * Status of the coin. + */ + status: CoinStatus; +} + +export enum ProposalStatus { + /** + * Not downloaded yet. + */ + DOWNLOADING = "downloading", + /** + * Proposal downloaded, but the user needs to accept/reject it. + */ + PROPOSED = "proposed", + /** + * The user has accepted the proposal. + */ + ACCEPTED = "accepted", + /** + * The user has rejected the proposal. + */ + REJECTED = "rejected", + /** + * Downloaded proposal was detected as a re-purchase. + */ + REPURCHASE = "repurchase", +} + +@Checkable.Class() +export class ProposalDownload { + /** + * The contract that was offered by the merchant. + */ + @Checkable.Value(() => ContractTerms) + contractTerms: ContractTerms; + + /** + * Signature by the merchant over the contract details. + */ + @Checkable.String() + merchantSig: string; + + /** + * Signature by the merchant over the contract details. + */ + @Checkable.String() + contractTermsHash: string; +} + +/** + * Record for a downloaded order, stored in the wallet's database. + */ +@Checkable.Class() +export class ProposalRecord { + @Checkable.String() + orderId: string; + + @Checkable.String() + merchantBaseUrl: string; + + /** + * Downloaded data from the merchant. + */ + download: ProposalDownload | undefined; + + /** + * Unique ID when the order is stored in the wallet DB. + */ + @Checkable.String() + proposalId: string; + + /** + * Timestamp (in ms) of when the record + * was created. + */ + @Checkable.Number() + timestamp: Timestamp; + + /** + * Private key for the nonce. + */ + @Checkable.String() + noncePriv: string; + + /** + * Public key for the nonce. + */ + @Checkable.String() + noncePub: string; + + @Checkable.String() + proposalStatus: ProposalStatus; + + @Checkable.String() + repurchaseProposalId: string | undefined; + + /** + * Session ID we got when downloading the contract. + */ + @Checkable.Optional(Checkable.String()) + downloadSessionId?: string; + + /** + * Retry info, even present when the operation isn't active to allow indexing + * on the next retry timestamp. + */ + retryInfo: RetryInfo; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => ProposalRecord; + + lastError: OperationError | undefined; +} + +/** + * Status of a tip we got from a merchant. + */ +export interface TipRecord { + lastError: OperationError | undefined; + /** + * Has the user accepted the tip? Only after the tip has been accepted coins + * withdrawn from the tip may be used. + */ + accepted: boolean; + + /** + * Have we picked up the tip record from the merchant already? + */ + pickedUp: boolean; + + /** + * The tipped amount. + */ + amount: AmountJson; + + totalFees: AmountJson; + + /** + * Timestamp, the tip can't be picked up anymore after this deadline. + */ + deadline: Timestamp; + + /** + * The exchange that will sign our coins, chosen by the merchant. + */ + exchangeUrl: string; + + /** + * Base URL of the merchant that is giving us the tip. + */ + merchantBaseUrl: string; + + /** + * Planchets, the members included in TipPlanchetDetail will be sent to the + * merchant. + */ + planchets?: TipPlanchet[]; + + /** + * Response if the merchant responded, + * undefined otherwise. + */ + response?: TipResponse[]; + + /** + * Tip ID chosen by the wallet. + */ + tipId: string; + + /** + * The merchant's identifier for this tip. + */ + merchantTipId: string; + + /** + * URL to go to once the tip has been accepted. + */ + nextUrl?: string; + + createdTimestamp: Timestamp; + + /** + * Retry info, even present when the operation isn't active to allow indexing + * on the next retry timestamp. + */ + retryInfo: RetryInfo; +} + +/** + * Ongoing refresh + */ +export interface RefreshSessionRecord { + lastError: OperationError | undefined; + + /** + * Public key that's being melted in this session. + */ + meltCoinPub: string; + + /** + * How much of the coin's value is melted away + * with this refresh session? + */ + valueWithFee: AmountJson; + + /** + * Sum of the value of denominations we want + * to withdraw in this session, without fees. + */ + valueOutput: AmountJson; + + /** + * Signature to confirm the melting. + */ + confirmSig: string; + + /** + * Hased denominations of the newly requested coins. + */ + newDenomHashes: string[]; + + /** + * Denominations of the newly requested coins. + */ + newDenoms: string[]; + + /** + * Planchets for each cut-and-choose instance. + */ + planchetsForGammas: RefreshPlanchetRecord[][]; + + /** + * The transfer keys, kappa of them. + */ + transferPubs: string[]; + + /** + * Private keys for the transfer public keys. + */ + transferPrivs: string[]; + + /** + * The no-reveal-index after we've done the melting. + */ + norevealIndex?: number; + + /** + * Hash of the session. + */ + hash: string; + + /** + * Base URL for the exchange we're doing the refresh with. + */ + exchangeBaseUrl: string; + + /** + * Timestamp when the refresh session finished. + */ + finishedTimestamp: Timestamp | undefined; + + /** + * A 32-byte base32-crockford encoded random identifier. + */ + refreshSessionId: string; + + /** + * When has this refresh session been created? + */ + created: Timestamp; + + /** + * Retry info, even present when the operation isn't active to allow indexing + * on the next retry timestamp. + */ + retryInfo: RetryInfo; +} + +/** + * Tipping planchet stored in the database. + */ +export interface TipPlanchet { + blindingKey: string; + coinEv: string; + coinPriv: string; + coinPub: string; + coinValue: AmountJson; + denomPubHash: string; + denomPub: string; +} + +/** + * Wire fee for one wire method as stored in the + * wallet's database. + */ +export interface WireFee { + /** + * Fee for wire transfers. + */ + wireFee: AmountJson; + + /** + * Fees to close and refund a reserve. + */ + closingFee: AmountJson; + + /** + * Start date of the fee. + */ + startStamp: Timestamp; + + /** + * End date of the fee. + */ + endStamp: Timestamp; + + /** + * Signature made by the exchange master key. + */ + sig: string; +} + +/** + * Record that stores status information about one purchase, starting from when + * the customer accepts a proposal. Includes refund status if applicable. + */ +export interface PurchaseRecord { + /** + * Proposal ID for this purchase. Uniquely identifies the + * purchase and the proposal. + */ + proposalId: string; + + /** + * Hash of the contract terms. + */ + contractTermsHash: string; + + /** + * Contract terms we got from the merchant. + */ + contractTerms: ContractTerms; + + /** + * The payment request, ready to be send to the merchant's + * /pay URL. + */ + payReq: PayReq; + + /** + * Signature from the merchant over the contract terms. + */ + merchantSig: string; + + firstSuccessfulPayTimestamp: Timestamp | undefined; + + /** + * Pending refunds for the purchase. + */ + refundsPending: { [refundSig: string]: MerchantRefundPermission }; + + /** + * Submitted refunds for the purchase. + */ + refundsDone: { [refundSig: string]: MerchantRefundPermission }; + + /** + * When was the purchase made? + * Refers to the time that the user accepted. + */ + acceptTimestamp: Timestamp; + + /** + * When was the last refund made? + * Set to 0 if no refund was made on the purchase. + */ + lastRefundStatusTimestamp: Timestamp | undefined; + + /** + * Last session signature that we submitted to /pay (if any). + */ + lastSessionId: string | undefined; + + /** + * Set for the first payment, or on re-plays. + */ + paymentSubmitPending: boolean; + + /** + * Do we need to query the merchant for the refund status + * of the payment? + */ + refundStatusRequested: boolean; + + /** + * An abort (with refund) was requested for this (incomplete!) purchase. + */ + abortRequested: boolean; + + /** + * The abort (with refund) was completed for this (incomplete!) purchase. + */ + abortDone: boolean; + + payRetryInfo: RetryInfo; + + lastPayError: OperationError | undefined; + + /** + * Retry information for querying the refund status with the merchant. + */ + refundStatusRetryInfo: RetryInfo; + + /** + * Last error (or undefined) for querying the refund status with the merchant. + */ + lastRefundStatusError: OperationError | undefined; + + /** + * Retry information for querying the refund status with the merchant. + */ + refundApplyRetryInfo: RetryInfo; + + /** + * Last error (or undefined) for querying the refund status with the merchant. + */ + lastRefundApplyError: OperationError | undefined; + + /** + * Continue querying the refund status until this deadline has expired. + */ + autoRefundDeadline: Timestamp | undefined; +} + +/** + * Information about wire information for bank accounts we withdrew coins from. + */ +export interface SenderWireRecord { + paytoUri: string; +} + +/** + * Configuration key/value entries to configure + * the wallet. + */ +export interface ConfigRecord { + key: string; + value: any; +} + +/** + * Coin that we're depositing ourselves. + */ +export interface DepositCoin { + coinPaySig: CoinPaySig; + + /** + * Undefined if coin not deposited, otherwise signature + * from the exchange confirming the deposit. + */ + depositedSig?: string; +} + +/** + * Record stored in the wallet's database when the user sends coins back to + * their own bank account. Stores the status of coins that are deposited to + * the wallet itself, where the wallet acts as a "merchant" for the customer. + */ +export interface CoinsReturnRecord { + /** + * Hash of the contract for sending coins to our own bank account. + */ + contractTermsHash: string; + + contractTerms: ContractTerms; + + /** + * Private key where corresponding + * public key is used in the contract terms + * as merchant pub. + */ + merchantPriv: string; + + coins: DepositCoin[]; + + /** + * Exchange base URL to deposit coins at. + */ + exchange: string; + + /** + * Our own wire information for the deposit. + */ + wire: any; +} + +export interface WithdrawalSourceTip { + type: "tip"; + tipId: string; +} + +export interface WithdrawalSourceReserve { + type: "reserve"; + reservePub: string; +} + +export type WithdrawalSource = WithdrawalSourceTip | WithdrawalSourceReserve; + +export interface WithdrawalSessionRecord { + withdrawSessionId: string; + + source: WithdrawalSource; + + exchangeBaseUrl: string; + + /** + * When was the withdrawal operation started started? + * Timestamp in milliseconds. + */ + startTimestamp: Timestamp; + + /** + * When was the withdrawal operation completed? + */ + finishTimestamp?: Timestamp; + + totalCoinValue: AmountJson; + + /** + * Amount including fees (i.e. the amount subtracted from the + * reserve to withdraw all coins in this withdrawal session). + */ + rawWithdrawalAmount: AmountJson; + + denoms: string[]; + + planchets: (undefined | PlanchetRecord)[]; + + /** + * Coins in this session that are withdrawn are set to true. + */ + withdrawn: boolean[]; + + /** + * Retry info, always present even on completed operations so that indexing works. + */ + retryInfo: RetryInfo; + + /** + * Last error per coin/planchet, or undefined if no error occured for + * the coin/planchet. + */ + lastCoinErrors: (OperationError | undefined)[]; + + lastError: OperationError | undefined; +} + +export interface BankWithdrawUriRecord { + /** + * The withdraw URI we got from the bank. + */ + talerWithdrawUri: string; + + /** + * Reserve that was created for the withdraw URI. + */ + reservePub: string; +} + +/* tslint:disable:completed-docs */ + +/** + * The stores and indices for the wallet database. + */ +export namespace Stores { + class ExchangesStore extends Store { + constructor() { + super("exchanges", { keyPath: "baseUrl" }); + } + } + + class CoinsStore extends Store { + constructor() { + super("coins", { keyPath: "coinPub" }); + } + + exchangeBaseUrlIndex = new Index( + this, + "exchangeBaseUrl", + "exchangeBaseUrl", + ); + denomPubIndex = new Index( + this, + "denomPubIndex", + "denomPub", + ); + byWithdrawalWithIdx = new Index( + this, + "planchetsByWithdrawalWithIdxIndex", + ["withdrawSessionId", "coinIndex"], + ); + } + + class ProposalsStore extends Store { + constructor() { + super("proposals", { keyPath: "proposalId" }); + } + urlAndOrderIdIndex = new Index(this, "urlIndex", [ + "merchantBaseUrl", + "orderId", + ]); + } + + class PurchasesStore extends Store { + constructor() { + super("purchases", { keyPath: "proposalId" }); + } + + fulfillmentUrlIndex = new Index( + this, + "fulfillmentUrlIndex", + "contractTerms.fulfillment_url", + ); + orderIdIndex = new Index(this, "orderIdIndex", [ + "contractTerms.merchant_base_url", + "contractTerms.order_id", + ]); + } + + class DenominationsStore extends Store { + constructor() { + // cast needed because of bug in type annotations + super("denominations", { + keyPath: (["exchangeBaseUrl", "denomPub"] as any) as IDBKeyPath, + }); + } + + denomPubHashIndex = new Index( + this, + "denomPubHashIndex", + "denomPubHash", + ); + exchangeBaseUrlIndex = new Index( + this, + "exchangeBaseUrlIndex", + "exchangeBaseUrl", + ); + denomPubIndex = new Index( + this, + "denomPubIndex", + "denomPub", + ); + } + + class CurrenciesStore extends Store { + constructor() { + super("currencies", { keyPath: "name" }); + } + } + + class ConfigStore extends Store { + constructor() { + super("config", { keyPath: "key" }); + } + } + + class ReservesStore extends Store { + constructor() { + super("reserves", { keyPath: "reservePub" }); + } + } + + class TipsStore extends Store { + constructor() { + super("tips", { keyPath: "tipId" }); + } + } + + class SenderWiresStore extends Store { + constructor() { + super("senderWires", { keyPath: "paytoUri" }); + } + } + + class WithdrawalSessionsStore extends Store { + constructor() { + super("withdrawals", { keyPath: "withdrawSessionId" }); + } + } + + class BankWithdrawUrisStore extends Store { + constructor() { + super("bankWithdrawUris", { keyPath: "talerWithdrawUri" }); + } + } + + export const coins = new CoinsStore(); + export const coinsReturns = new Store("coinsReturns", { + keyPath: "contractTermsHash", + }); + export const config = new ConfigStore(); + export const currencies = new CurrenciesStore(); + export const denominations = new DenominationsStore(); + export const exchanges = new ExchangesStore(); + export const proposals = new ProposalsStore(); + export const refresh = new Store("refresh", { + keyPath: "refreshSessionId", + }); + export const reserves = new ReservesStore(); + export const purchases = new PurchasesStore(); + export const tips = new TipsStore(); + export const senderWires = new SenderWiresStore(); + export const withdrawalSession = new WithdrawalSessionsStore(); + export const bankWithdrawUris = new BankWithdrawUrisStore(); +} + +/* tslint:enable:completed-docs */ diff --git a/src/types/history.ts b/src/types/history.ts new file mode 100644 index 000000000..e925b0ffe --- /dev/null +++ b/src/types/history.ts @@ -0,0 +1,58 @@ +import { Timestamp } from "./walletTypes"; + +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * Type and schema definitions for the wallet's history. + */ + +/** + * Activity history record. + */ +export interface HistoryEvent { + /** + * Type of the history event. + */ + type: string; + + /** + * Time when the activity was recorded. + */ + timestamp: Timestamp; + + /** + * Details used when rendering the history record. + */ + detail: any; + + /** + * Set to 'true' if the event has been explicitly created, + * and set to 'false' if the event has been derived from the + * state of the database. + */ + explicit: boolean; +} + + +export interface HistoryQuery { + /** + * Verbosity of history events. + * Level 0: Only withdraw, pay, tip and refund events. + * Level 1: All events. + */ + level: number; +} \ No newline at end of file diff --git a/src/types/notifications.ts b/src/types/notifications.ts new file mode 100644 index 000000000..c64d33bfb --- /dev/null +++ b/src/types/notifications.ts @@ -0,0 +1,213 @@ +/* + 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 + */ + +/** + * Type and schema definitions for notifications from the wallet to clients + * of the wallet. + */ + +export const enum NotificationType { + CoinWithdrawn = "coin-withdrawn", + ProposalAccepted = "proposal-accepted", + ProposalDownloaded = "proposal-downloaded", + RefundsSubmitted = "refunds-submitted", + PaybackStarted = "payback-started", + PaybackFinished = "payback-finished", + RefreshRevealed = "refresh-revealed", + RefreshMelted = "refresh-melted", + RefreshStarted = "refresh-started", + RefreshRefused = "refresh-refused", + ReserveUpdated = "reserve-updated", + ReserveConfirmed = "reserve-confirmed", + ReserveDepleted = "reserve-depleted", + ReserveCreated = "reserve-created", + WithdrawSessionCreated = "withdraw-session-created", + WithdrawSessionFinished = "withdraw-session-finished", + WaitingForRetry = "waiting-for-retry", + RefundStarted = "refund-started", + RefundQueried = "refund-queried", + RefundFinished = "refund-finished", + ExchangeOperationError = "exchange-operation-error", + RefreshOperationError = "refresh-operation-error", + RefundApplyOperationError = "refund-apply-error", + RefundStatusOperationError = "refund-status-error", + ProposalOperationError = "proposal-error", + TipOperationError = "tip-error", + PayOperationError = "pay-error", + WithdrawOperationError = "withdraw-error", + ReserveOperationError = "reserve-error", + Wildcard = "wildcard", +} + +export interface ProposalAcceptedNotification { + type: NotificationType.ProposalAccepted; + proposalId: string; +} + +export interface CoinWithdrawnNotification { + type: NotificationType.CoinWithdrawn; +} + +export interface RefundStartedNotification { + type: NotificationType.RefundStarted; +} + +export interface RefundQueriedNotification { + type: NotificationType.RefundQueried; +} + +export interface ProposalDownloadedNotification { + type: NotificationType.ProposalDownloaded; + proposalId: string; +} + +export interface RefundsSubmittedNotification { + type: NotificationType.RefundsSubmitted; + proposalId: string; +} + +export interface PaybackStartedNotification { + type: NotificationType.PaybackStarted; +} + +export interface PaybackFinishedNotification { + type: NotificationType.PaybackFinished; +} + +export interface RefreshMeltedNotification { + type: NotificationType.RefreshMelted; +} + +export interface RefreshRevealedNotification { + type: NotificationType.RefreshRevealed; +} + +export interface RefreshStartedNotification { + type: NotificationType.RefreshStarted; +} + +export interface RefreshRefusedNotification { + type: NotificationType.RefreshRefused; +} + +export interface ReserveUpdatedNotification { + type: NotificationType.ReserveUpdated; +} + +export interface ReserveConfirmedNotification { + type: NotificationType.ReserveConfirmed; +} + +export interface WithdrawSessionCreatedNotification { + type: NotificationType.WithdrawSessionCreated; + withdrawSessionId: string; +} + +export interface WithdrawSessionFinishedNotification { + type: NotificationType.WithdrawSessionFinished; + withdrawSessionId: string; +} + +export interface ReserveDepletedNotification { + type: NotificationType.ReserveDepleted; + reservePub: string; +} + +export interface WaitingForRetryNotification { + type: NotificationType.WaitingForRetry; + numPending: number; + numGivingLiveness: number; +} + +export interface RefundFinishedNotification { + type: NotificationType.RefundFinished; +} + +export interface ExchangeOperationErrorNotification { + type: NotificationType.ExchangeOperationError; +} + +export interface RefreshOperationErrorNotification { + type: NotificationType.RefreshOperationError; +} + +export interface RefundStatusOperationErrorNotification { + type: NotificationType.RefundStatusOperationError; +} + +export interface RefundApplyOperationErrorNotification { + type: NotificationType.RefundApplyOperationError; +} + +export interface PayOperationErrorNotification { + type: NotificationType.PayOperationError; +} + +export interface ProposalOperationErrorNotification { + type: NotificationType.ProposalOperationError; +} + +export interface TipOperationErrorNotification { + type: NotificationType.TipOperationError; +} + +export interface WithdrawOperationErrorNotification { + type: NotificationType.WithdrawOperationError; +} + +export interface ReserveOperationErrorNotification { + type: NotificationType.ReserveOperationError; +} + +export interface ReserveCreatedNotification { + type: NotificationType.ReserveCreated; +} + +export interface WildcardNotification { + type: NotificationType.Wildcard; +} + +export type WalletNotification = + | WithdrawOperationErrorNotification + | ReserveOperationErrorNotification + | ExchangeOperationErrorNotification + | RefreshOperationErrorNotification + | RefundStatusOperationErrorNotification + | RefundApplyOperationErrorNotification + | ProposalOperationErrorNotification + | PayOperationErrorNotification + | TipOperationErrorNotification + | ProposalAcceptedNotification + | ProposalDownloadedNotification + | RefundsSubmittedNotification + | PaybackStartedNotification + | PaybackFinishedNotification + | RefreshMeltedNotification + | RefreshRevealedNotification + | RefreshStartedNotification + | RefreshRefusedNotification + | ReserveUpdatedNotification + | ReserveCreatedNotification + | ReserveConfirmedNotification + | WithdrawSessionFinishedNotification + | ReserveDepletedNotification + | WaitingForRetryNotification + | RefundStartedNotification + | RefundFinishedNotification + | RefundQueriedNotification + | WithdrawSessionCreatedNotification + | CoinWithdrawnNotification + | WildcardNotification; diff --git a/src/types/pending.ts b/src/types/pending.ts new file mode 100644 index 000000000..5e381d09a --- /dev/null +++ b/src/types/pending.ts @@ -0,0 +1,161 @@ +/* + 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 + */ + +/** + * Type and schema definitions for pending operations in the wallet. + */ + +/** + * Imports. + */ +import { OperationError, Timestamp, Duration } from "./walletTypes"; +import { WithdrawalSource, RetryInfo } from "./dbTypes"; + +/** + * Information about a pending operation. + */ +export type PendingOperationInfo = PendingOperationInfoCommon & + ( + | PendingWithdrawOperation + | PendingReserveOperation + | PendingBugOperation + | PendingDirtyCoinOperation + | PendingExchangeUpdateOperation + | PendingRefreshOperation + | PendingTipOperation + | PendingProposalDownloadOperation + | PendingProposalChoiceOperation + | PendingPayOperation + | PendingRefundQueryOperation + | PendingRefundApplyOperation + ); + +export interface PendingExchangeUpdateOperation { + type: "exchange-update"; + stage: string; + reason: string; + exchangeBaseUrl: string; + lastError: OperationError | undefined; +} + +export interface PendingBugOperation { + type: "bug"; + message: string; + details: any; +} + +export interface PendingReserveOperation { + type: "reserve"; + retryInfo: RetryInfo | undefined; + stage: string; + timestampCreated: Timestamp; + reserveType: string; + reservePub: string; + bankWithdrawConfirmUrl?: string; +} + +export interface PendingRefreshOperation { + type: "refresh"; + lastError?: OperationError; + refreshSessionId: string; + oldCoinPub: string; + refreshStatus: string; + refreshOutputSize: number; +} + +export interface PendingDirtyCoinOperation { + type: "dirty-coin"; + coinPub: string; +} + +export interface PendingProposalDownloadOperation { + type: "proposal-download"; + merchantBaseUrl: string; + proposalTimestamp: Timestamp; + proposalId: string; + orderId: string; + lastError?: OperationError; + retryInfo: RetryInfo; +} + +/** + * User must choose whether to accept or reject the merchant's + * proposed contract terms. + */ +export interface PendingProposalChoiceOperation { + type: "proposal-choice"; + merchantBaseUrl: string; + proposalTimestamp: Timestamp; + proposalId: string; +} + +export interface PendingTipOperation { + type: "tip"; + tipId: string; + merchantBaseUrl: string; + merchantTipId: string; +} + +export interface PendingPayOperation { + type: "pay"; + proposalId: string; + isReplay: boolean; + retryInfo: RetryInfo, + lastError: OperationError | undefined; +} + +export interface PendingRefundQueryOperation { + type: "refund-query"; + proposalId: string; + retryInfo: RetryInfo, + lastError: OperationError | undefined; +} + +export interface PendingRefundApplyOperation { + type: "refund-apply"; + proposalId: string; + retryInfo: RetryInfo, + lastError: OperationError | undefined; + numRefundsPending: number; + numRefundsDone: number; +} + +export interface PendingOperationInfoCommon { + type: string; + givesLifeness: boolean; +} + + +export interface PendingWithdrawOperation { + type: "withdraw"; + source: WithdrawalSource; + withdrawSessionId: string; + numCoinsWithdrawn: number; + numCoinsTotal: number; +} + +export interface PendingRefreshOperation { + type: "refresh"; +} + +export interface PendingPayOperation { + type: "pay"; +} + +export interface PendingOperationsResponse { + pendingOperations: PendingOperationInfo[]; + nextRetryDelay: Duration; +} \ No newline at end of file diff --git a/src/types/talerTypes.ts b/src/types/talerTypes.ts new file mode 100644 index 000000000..df89b9979 --- /dev/null +++ b/src/types/talerTypes.ts @@ -0,0 +1,944 @@ +/* + 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 + */ + +/** + * Type and schema definitions and helpers for the core GNU Taler protocol. + * + * All types here should be "@Checkable". + * + * Even though the rest of the wallet uses camelCase for fields, use snake_case + * here, since that's the convention for the Taler JSON+HTTP API. + */ + +/** + * Imports. + */ +import { Checkable } from "../util/checkable"; + +import * as Amounts from "../util/amounts"; + +import { timestampCheck } from "../util/helpers"; + +/** + * Denomination as found in the /keys response from the exchange. + */ +@Checkable.Class() +export class Denomination { + /** + * Value of one coin of the denomination. + */ + @Checkable.String(Amounts.check) + value: string; + + /** + * Public signing key of the denomination. + */ + @Checkable.String() + denom_pub: string; + + /** + * Fee for withdrawing. + */ + @Checkable.String(Amounts.check) + fee_withdraw: string; + + /** + * Fee for depositing. + */ + @Checkable.String(Amounts.check) + fee_deposit: string; + + /** + * Fee for refreshing. + */ + @Checkable.String(Amounts.check) + fee_refresh: string; + + /** + * Fee for refunding. + */ + @Checkable.String(Amounts.check) + fee_refund: string; + + /** + * Start date from which withdraw is allowed. + */ + @Checkable.String(timestampCheck) + stamp_start: string; + + /** + * End date for withdrawing. + */ + @Checkable.String(timestampCheck) + stamp_expire_withdraw: string; + + /** + * Expiration date after which the exchange can forget about + * the currency. + */ + @Checkable.String(timestampCheck) + stamp_expire_legal: string; + + /** + * Date after which the coins of this denomination can't be + * deposited anymore. + */ + @Checkable.String(timestampCheck) + stamp_expire_deposit: string; + + /** + * Signature over the denomination information by the exchange's master + * signing key. + */ + @Checkable.String() + master_sig: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => Denomination; +} + +/** + * Signature by the auditor that a particular denomination key is audited. + */ +@Checkable.Class() +export class AuditorDenomSig { + /** + * Denomination public key's hash. + */ + @Checkable.String() + denom_pub_h: string; + + /** + * The signature. + */ + @Checkable.String() + auditor_sig: string; +} + +/** + * Auditor information as given by the exchange in /keys. + */ +@Checkable.Class() +export class Auditor { + /** + * Auditor's public key. + */ + @Checkable.String() + auditor_pub: string; + + /** + * Base URL of the auditor. + */ + @Checkable.String() + auditor_url: string; + + /** + * List of signatures for denominations by the auditor. + */ + @Checkable.List(Checkable.Value(() => AuditorDenomSig)) + denomination_keys: AuditorDenomSig[]; +} + +/** + * Request that we send to the exchange to get a payback. + */ +export interface PaybackRequest { + /** + * Denomination public key of the coin we want to get + * paid back. + */ + denom_pub: string; + + /** + * Signature over the coin public key by the denomination. + */ + denom_sig: string; + + /** + * Coin public key of the coin we want to refund. + */ + coin_pub: string; + + /** + * Blinding key that was used during withdraw, + * used to prove that we were actually withdrawing the coin. + */ + coin_blind_key_secret: string; + + /** + * Signature made by the coin, authorizing the payback. + */ + coin_sig: string; +} + +/** + * Response that we get from the exchange for a payback request. + */ +@Checkable.Class() +export class PaybackConfirmation { + /** + * public key of the reserve that will receive the payback. + */ + @Checkable.String() + reserve_pub: string; + + /** + * How much will the exchange pay back (needed by wallet in + * case coin was partially spent and wallet got restored from backup) + */ + @Checkable.String() + amount: string; + + /** + * Time by which the exchange received the /payback request. + */ + @Checkable.String() + timestamp: string; + + /** + * the EdDSA signature of TALER_PaybackConfirmationPS using a current + * signing key of the exchange affirming the successful + * payback request, and that the exchange promises to transfer the funds + * by the date specified (this allows the exchange delaying the transfer + * a bit to aggregate additional payback requests into a larger one). + */ + @Checkable.String() + exchange_sig: string; + + /** + * Public EdDSA key of the exchange that was used to generate the signature. + * Should match one of the exchange's signing keys from /keys. It is given + * explicitly as the client might otherwise be confused by clock skew as to + * which signing key was used. + */ + @Checkable.String() + exchange_pub: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => PaybackConfirmation; +} + +/** + * Deposit permission for a single coin. + */ +export interface CoinPaySig { + /** + * Signature by the coin. + */ + coin_sig: string; + /** + * Public key of the coin being spend. + */ + coin_pub: string; + /** + * Signature made by the denomination public key. + */ + ub_sig: string; + /** + * The denomination public key associated with this coin. + */ + denom_pub: string; + /** + * The amount that is subtracted from this coin with this payment. + */ + contribution: string; + + /** + * URL of the exchange this coin was withdrawn from. + */ + exchange_url: string; +} + +/** + * Information about an exchange as stored inside a + * merchant's contract terms. + */ +@Checkable.Class() +export class ExchangeHandle { + /** + * Master public signing key of the exchange. + */ + @Checkable.String() + master_pub: string; + + /** + * Base URL of the exchange. + */ + @Checkable.String() + url: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => ExchangeHandle; +} + +/** + * Contract terms from a merchant. + */ +@Checkable.Class({ validate: true }) +export class ContractTerms { + static validate(x: ContractTerms) { + if (x.exchanges.length === 0) { + throw Error("no exchanges in contract terms"); + } + } + + /** + * Hash of the merchant's wire details. + */ + @Checkable.String() + H_wire: string; + + /** + * Hash of the merchant's wire details. + */ + @Checkable.Optional(Checkable.String()) + auto_refund?: string; + + /** + * Wire method the merchant wants to use. + */ + @Checkable.String() + wire_method: string; + + /** + * Human-readable short summary of the contract. + */ + @Checkable.Optional(Checkable.String()) + summary?: string; + + /** + * Nonce used to ensure freshness. + */ + @Checkable.Optional(Checkable.String()) + nonce?: string; + + /** + * Total amount payable. + */ + @Checkable.String(Amounts.check) + amount: string; + + /** + * Auditors accepted by the merchant. + */ + @Checkable.List(Checkable.AnyObject()) + auditors: any[]; + + /** + * Deadline to pay for the contract. + */ + @Checkable.Optional(Checkable.String()) + pay_deadline: string; + + /** + * Delivery locations. + */ + @Checkable.Any() + locations: any; + + /** + * Maximum deposit fee covered by the merchant. + */ + @Checkable.String(Amounts.check) + max_fee: string; + + /** + * Information about the merchant. + */ + @Checkable.Any() + merchant: any; + + /** + * Public key of the merchant. + */ + @Checkable.String() + merchant_pub: string; + + /** + * List of accepted exchanges. + */ + @Checkable.List(Checkable.Value(() => ExchangeHandle)) + exchanges: ExchangeHandle[]; + + /** + * Products that are sold in this contract. + */ + @Checkable.List(Checkable.AnyObject()) + products: any[]; + + /** + * Deadline for refunds. + */ + @Checkable.String(timestampCheck) + refund_deadline: string; + + /** + * Deadline for the wire transfer. + */ + @Checkable.String() + wire_transfer_deadline: string; + + /** + * Time when the contract was generated by the merchant. + */ + @Checkable.String(timestampCheck) + timestamp: string; + + /** + * Order id to uniquely identify the purchase within + * one merchant instance. + */ + @Checkable.String() + order_id: string; + + /** + * Base URL of the merchant's backend. + */ + @Checkable.String() + merchant_base_url: string; + + /** + * Fulfillment URL to view the product or + * delivery status. + */ + @Checkable.String() + fulfillment_url: string; + + /** + * Share of the wire fee that must be settled with one payment. + */ + @Checkable.Optional(Checkable.Number()) + wire_fee_amortization?: number; + + /** + * Maximum wire fee that the merchant agrees to pay for. + */ + @Checkable.Optional(Checkable.String()) + max_wire_fee?: string; + + /** + * Extra data, interpreted by the mechant only. + */ + @Checkable.Any() + extra: any; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => ContractTerms; +} + +/** + * Payment body sent to the merchant's /pay. + */ +export interface PayReq { + /** + * Coins with signature. + */ + coins: CoinPaySig[]; + + /** + * The merchant public key, used to uniquely + * identify the merchant instance. + */ + merchant_pub: string; + + /** + * Order ID that's being payed for. + */ + order_id: string; + + /** + * Mode for /pay. + */ + mode: "pay" | "abort-refund"; +} + +/** + * Refund permission in the format that the merchant gives it to us. + */ +@Checkable.Class() +export class MerchantRefundPermission { + /** + * Amount to be refunded. + */ + @Checkable.String(Amounts.check) + refund_amount: string; + + /** + * Fee for the refund. + */ + @Checkable.String(Amounts.check) + refund_fee: string; + + /** + * Public key of the coin being refunded. + */ + @Checkable.String() + coin_pub: string; + + /** + * Refund transaction ID between merchant and exchange. + */ + @Checkable.Number() + rtransaction_id: number; + + /** + * Signature made by the merchant over the refund permission. + */ + @Checkable.String() + merchant_sig: string; + + /** + * Create a MerchantRefundPermission from untyped JSON. + */ + static checked: (obj: any) => MerchantRefundPermission; +} + +/** + * Refund request sent to the exchange. + */ +export interface RefundRequest { + /** + * Amount to be refunded, can be a fraction of the + * coin's total deposit value (including deposit fee); + * must be larger than the refund fee. + */ + refund_amount: string; + + /** + * Refund fee associated with the given coin. + * must be smaller than the refund amount. + */ + refund_fee: string; + + /** + * SHA-512 hash of the contact of the merchant with the customer. + */ + h_contract_terms: string; + + /** + * coin's public key, both ECDHE and EdDSA. + */ + coin_pub: string; + + /** + * 64-bit transaction id of the refund transaction between merchant and customer + */ + rtransaction_id: number; + + /** + * EdDSA public key of the merchant. + */ + merchant_pub: string; + + /** + * EdDSA signature of the merchant affirming the refund. + */ + merchant_sig: string; +} + +/** + * Response for a refund pickup or a /pay in abort mode. + */ +@Checkable.Class() +export class MerchantRefundResponse { + /** + * Public key of the merchant + */ + @Checkable.String() + merchant_pub: string; + + /** + * Contract terms hash of the contract that + * is being refunded. + */ + @Checkable.String() + h_contract_terms: string; + + /** + * The signed refund permissions, to be sent to the exchange. + */ + @Checkable.List(Checkable.Value(() => MerchantRefundPermission)) + refund_permissions: MerchantRefundPermission[]; + + /** + * Create a MerchantRefundReponse from untyped JSON. + */ + static checked: (obj: any) => MerchantRefundResponse; +} + +/** + * Planchet detail sent to the merchant. + */ +export interface TipPlanchetDetail { + /** + * Hashed denomination public key. + */ + denom_pub_hash: string; + + /** + * Coin's blinded public key. + */ + coin_ev: string; +} + +/** + * Request sent to the merchant to pick up a tip. + */ +export interface TipPickupRequest { + /** + * Identifier of the tip. + */ + tip_id: string; + + /** + * List of planchets the wallet wants to use for the tip. + */ + planchets: TipPlanchetDetail[]; +} + +/** + * Reserve signature, defined as separate class to facilitate + * schema validation with "@Checkable". + */ +@Checkable.Class() +export class ReserveSigSingleton { + /** + * Reserve signature. + */ + @Checkable.String() + reserve_sig: string; + + /** + * Create a ReserveSigSingleton from untyped JSON. + */ + static checked: (obj: any) => ReserveSigSingleton; +} + +/** + * Response to /reserve/status + */ +@Checkable.Class() +export class ReserveStatus { + /** + * Reserve signature. + */ + @Checkable.String() + balance: string; + + /** + * Reserve history, currently not used by the wallet. + */ + @Checkable.Any() + history: any; + + /** + * Create a ReserveSigSingleton from untyped JSON. + */ + static checked: (obj: any) => ReserveStatus; +} + +/** + * Response of the merchant + * to the TipPickupRequest. + */ +@Checkable.Class() +export class TipResponse { + /** + * Public key of the reserve + */ + @Checkable.String() + reserve_pub: string; + + /** + * The order of the signatures matches the planchets list. + */ + @Checkable.List(Checkable.Value(() => ReserveSigSingleton)) + reserve_sigs: ReserveSigSingleton[]; + + /** + * Create a TipResponse from untyped JSON. + */ + static checked: (obj: any) => TipResponse; +} + +/** + * Element of the payback list that the + * exchange gives us in /keys. + */ +@Checkable.Class() +export class Payback { + /** + * The hash of the denomination public key for which the payback is offered. + */ + @Checkable.String() + h_denom_pub: string; +} + +/** + * Structure that the exchange gives us in /keys. + */ +@Checkable.Class({ extra: true }) +export class KeysJson { + /** + * List of offered denominations. + */ + @Checkable.List(Checkable.Value(() => Denomination)) + denoms: Denomination[]; + + /** + * The exchange's master public key. + */ + @Checkable.String() + master_public_key: string; + + /** + * The list of auditors (partially) auditing the exchange. + */ + @Checkable.List(Checkable.Value(() => Auditor)) + auditors: Auditor[]; + + /** + * Timestamp when this response was issued. + */ + @Checkable.String(timestampCheck) + list_issue_date: string; + + /** + * List of paybacks for compromised denominations. + */ + @Checkable.Optional(Checkable.List(Checkable.Value(() => Payback))) + payback?: Payback[]; + + /** + * Short-lived signing keys used to sign online + * responses. + */ + @Checkable.Any() + signkeys: any; + + /** + * Protocol version. + */ + @Checkable.Optional(Checkable.String()) + version?: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => KeysJson; +} + +/** + * Wire fees as anounced by the exchange. + */ +@Checkable.Class() +export class WireFeesJson { + /** + * Cost of a wire transfer. + */ + @Checkable.String(Amounts.check) + wire_fee: string; + + /** + * Cost of clising a reserve. + */ + @Checkable.String(Amounts.check) + closing_fee: string; + + /** + * Signature made with the exchange's master key. + */ + @Checkable.String() + sig: string; + + /** + * Date from which the fee applies. + */ + @Checkable.String(timestampCheck) + start_date: string; + + /** + * Data after which the fee doesn't apply anymore. + */ + @Checkable.String(timestampCheck) + end_date: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => WireFeesJson; +} + +@Checkable.Class({ extra: true }) +export class AccountInfo { + @Checkable.String() + url: string; + + @Checkable.String() + master_sig: string; +} + +@Checkable.Class({ extra: true }) +export class ExchangeWireJson { + @Checkable.Map( + Checkable.String(), + Checkable.List(Checkable.Value(() => WireFeesJson)), + ) + fees: { [methodName: string]: WireFeesJson[] }; + + @Checkable.List(Checkable.Value(() => AccountInfo)) + accounts: AccountInfo[]; + + static checked: (obj: any) => ExchangeWireJson; +} + +/** + * Wire detail, arbitrary object that must at least + * contain a "type" key. + */ +export type WireDetail = object & { type: string }; + +/** + * Proposal returned from the contract URL. + */ +@Checkable.Class({ extra: true }) +export class Proposal { + /** + * Contract terms for the propoal. + */ + @Checkable.Value(() => ContractTerms) + contract_terms: ContractTerms; + + /** + * Signature over contract, made by the merchant. The public key used for signing + * must be contract_terms.merchant_pub. + */ + @Checkable.String() + sig: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => Proposal; +} + +/** + * Response from the internal merchant API. + */ +@Checkable.Class({ extra: true }) +export class CheckPaymentResponse { + @Checkable.Boolean() + paid: boolean; + + @Checkable.Optional(Checkable.Boolean()) + refunded: boolean | undefined; + + @Checkable.Optional(Checkable.String()) + refunded_amount: string | undefined; + + @Checkable.Optional(Checkable.Value(() => ContractTerms)) + contract_terms: ContractTerms | undefined; + + @Checkable.Optional(Checkable.String()) + taler_pay_uri: string | undefined; + + @Checkable.Optional(Checkable.String()) + contract_url: string | undefined; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => CheckPaymentResponse; +} + +/** + * Response from the bank. + */ +@Checkable.Class({ extra: true }) +export class WithdrawOperationStatusResponse { + @Checkable.Boolean() + selection_done: boolean; + + @Checkable.Boolean() + transfer_done: boolean; + + @Checkable.String() + amount: string; + + @Checkable.Optional(Checkable.String()) + sender_wire?: string; + + @Checkable.Optional(Checkable.String()) + suggested_exchange?: string; + + @Checkable.Optional(Checkable.String()) + confirm_transfer_url?: string; + + @Checkable.List(Checkable.String()) + wire_types: string[]; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => WithdrawOperationStatusResponse; +} + +/** + * Response from the merchant. + */ +@Checkable.Class({ extra: true }) +export class TipPickupGetResponse { + @Checkable.AnyObject() + extra: any; + + @Checkable.String() + amount: string; + + @Checkable.String() + amount_left: string; + + @Checkable.String() + exchange_url: string; + + @Checkable.String() + stamp_expire: string; + + @Checkable.String() + stamp_created: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => TipPickupGetResponse; +} diff --git a/src/types/types-test.ts b/src/types/types-test.ts new file mode 100644 index 000000000..a686fbe38 --- /dev/null +++ b/src/types/types-test.ts @@ -0,0 +1,164 @@ +/* + This file is part of TALER + (C) 2017 Inria and GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + +import test from "ava"; +import * as Amounts from "../util/amounts"; +import { ContractTerms } from "./talerTypes"; + +const amt = ( + value: number, + fraction: number, + currency: string, +): Amounts.AmountJson => ({ value, fraction, currency }); + +test("amount addition (simple)", t => { + const a1 = amt(1, 0, "EUR"); + const a2 = amt(1, 0, "EUR"); + const a3 = amt(2, 0, "EUR"); + t.true(0 === Amounts.cmp(Amounts.add(a1, a2).amount, a3)); + t.pass(); +}); + +test("amount addition (saturation)", t => { + const a1 = amt(1, 0, "EUR"); + const res = Amounts.add(amt(Amounts.maxAmountValue, 0, "EUR"), a1); + t.true(res.saturated); + t.pass(); +}); + +test("amount subtraction (simple)", t => { + const a1 = amt(2, 5, "EUR"); + const a2 = amt(1, 0, "EUR"); + const a3 = amt(1, 5, "EUR"); + t.true(0 === Amounts.cmp(Amounts.sub(a1, a2).amount, a3)); + t.pass(); +}); + +test("amount subtraction (saturation)", t => { + const a1 = amt(0, 0, "EUR"); + const a2 = amt(1, 0, "EUR"); + let res = Amounts.sub(a1, a2); + t.true(res.saturated); + res = Amounts.sub(a1, a1); + t.true(!res.saturated); + t.pass(); +}); + +test("amount comparison", t => { + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(1, 0, "EUR")), 0); + t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 0, "EUR")), 1); + t.is(Amounts.cmp(amt(1, 1, "EUR"), amt(1, 2, "EUR")), -1); + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 0, "EUR")), 1); + t.is(Amounts.cmp(amt(0, 0, "EUR"), amt(1, 0, "EUR")), -1); + t.is(Amounts.cmp(amt(1, 0, "EUR"), amt(0, 100000000, "EUR")), 0); + t.throws(() => Amounts.cmp(amt(1, 0, "FOO"), amt(1, 0, "BAR"))); + t.pass(); +}); + +test("amount parsing", t => { + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:0"), amt(0, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp(Amounts.parseOrThrow("TESTKUDOS:10"), amt(10, 0, "TESTKUDOS")), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.1"), + amt(0, 10000000, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.00000001"), + amt(0, 1, "TESTKUDOS"), + ), + 0, + ); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:4503599627370496.99999999"), + amt(4503599627370496, 99999999, "TESTKUDOS"), + ), + 0, + ); + t.throws(() => Amounts.parseOrThrow("foo:")); + t.throws(() => Amounts.parseOrThrow("1.0")); + t.throws(() => Amounts.parseOrThrow("42")); + t.throws(() => Amounts.parseOrThrow(":1.0")); + t.throws(() => Amounts.parseOrThrow(":42")); + t.throws(() => Amounts.parseOrThrow("EUR:.42")); + t.throws(() => Amounts.parseOrThrow("EUR:42.")); + t.throws(() => Amounts.parseOrThrow("TESTKUDOS:4503599627370497.99999999")); + t.is( + Amounts.cmp( + Amounts.parseOrThrow("TESTKUDOS:0.99999999"), + amt(0, 99999999, "TESTKUDOS"), + ), + 0, + ); + t.throws(() => Amounts.parseOrThrow("TESTKUDOS:0.999999991")); + t.pass(); +}); + +test("amount stringification", t => { + t.is(Amounts.toString(amt(0, 0, "TESTKUDOS")), "TESTKUDOS:0"); + t.is(Amounts.toString(amt(4, 94000000, "TESTKUDOS")), "TESTKUDOS:4.94"); + t.is(Amounts.toString(amt(0, 10000000, "TESTKUDOS")), "TESTKUDOS:0.1"); + t.is(Amounts.toString(amt(0, 1, "TESTKUDOS")), "TESTKUDOS:0.00000001"); + t.is(Amounts.toString(amt(5, 0, "TESTKUDOS")), "TESTKUDOS:5"); + // denormalized + t.is(Amounts.toString(amt(1, 100000000, "TESTKUDOS")), "TESTKUDOS:2"); + t.pass(); +}); + +test("contract terms validation", t => { + const c = { + H_wire: "123", + amount: "EUR:1.5", + auditors: [], + exchanges: [{ master_pub: "foo", url: "foo" }], + fulfillment_url: "foo", + max_fee: "EUR:1.5", + merchant_pub: "12345", + order_id: "test_order", + pay_deadline: "Date(12346)", + wire_transfer_deadline: "Date(12346)", + merchant_base_url: "https://example.com/pay", + products: [], + refund_deadline: "Date(12345)", + summary: "hello", + timestamp: "Date(12345)", + wire_method: "test", + }; + + ContractTerms.checked(c); + + const c1 = JSON.parse(JSON.stringify(c)); + c1.exchanges = []; + + try { + ContractTerms.checked(c1); + } catch (e) { + t.pass(); + return; + } + + t.fail(); +}); diff --git a/src/types/walletTypes.ts b/src/types/walletTypes.ts new file mode 100644 index 000000000..a9bf2061f --- /dev/null +++ b/src/types/walletTypes.ts @@ -0,0 +1,512 @@ +/* + This file is part of TALER + (C) 2015-2017 GNUnet e.V. and INRIA + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + +/** + * Types used by clients of the wallet. + * + * These types are defined in a separate file make tree shaking easier, since + * some components use these types (via RPC) but do not depend on the wallet + * code directly. + */ + +/** + * Imports. + */ +import { Checkable } from "../util/checkable"; +import * as LibtoolVersion from "../util/libtoolVersion"; + +import { AmountJson } from "../util/amounts"; + +import { + CoinRecord, + DenominationRecord, + ExchangeRecord, + ExchangeWireInfo, + WithdrawalSource, + RetryInfo, +} from "./dbTypes"; +import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; + +/** + * Response for the create reserve request to the wallet. + */ +@Checkable.Class() +export class CreateReserveResponse { + /** + * Exchange URL where the bank should create the reserve. + * The URL is canonicalized in the response. + */ + @Checkable.String() + exchange: string; + + /** + * Reserve public key of the newly created reserve. + */ + @Checkable.String() + reservePub: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => CreateReserveResponse; +} + +/** + * Information about what will happen when creating a reserve. + * + * Sent to the wallet frontend to be rendered and shown to the user. + */ +export interface ExchangeWithdrawDetails { + /** + * Exchange that the reserve will be created at. + */ + exchangeInfo: ExchangeRecord; + + /** + * Filtered wire info to send to the bank. + */ + exchangeWireAccounts: string[]; + + /** + * Selected denominations for withdraw. + */ + selectedDenoms: DenominationRecord[]; + + /** + * Fees for withdraw. + */ + withdrawFee: AmountJson; + + /** + * Remaining balance that is too small to be withdrawn. + */ + overhead: AmountJson; + + /** + * Wire fees from the exchange. + */ + wireFees: ExchangeWireInfo; + + /** + * Does the wallet know about an auditor for + * the exchange that the reserve. + */ + isAudited: boolean; + + /** + * Did the user already accept the current terms of service for the exchange? + */ + termsOfServiceAccepted: boolean; + + /** + * The exchange is trusted directly. + */ + isTrusted: boolean; + + /** + * The earliest deposit expiration of the selected coins. + */ + earliestDepositExpiration: Timestamp; + + /** + * Number of currently offered denominations. + */ + numOfferedDenoms: number; + + /** + * Public keys of trusted auditors for the currency we're withdrawing. + */ + trustedAuditorPubs: string[]; + + /** + * Result of checking the wallet's version + * against the exchange's version. + * + * Older exchanges don't return version information. + */ + versionMatch: LibtoolVersion.VersionMatchResult | undefined; + + /** + * Libtool-style version string for the exchange or "unknown" + * for older exchanges. + */ + exchangeVersion: string; + + /** + * Libtool-style version string for the wallet. + */ + walletVersion: string; +} + +export interface WithdrawDetails { + bankWithdrawDetails: BankWithdrawDetails; + exchangeWithdrawDetails: ExchangeWithdrawDetails | undefined; +} + +/** + * Mapping from currency/exchange to detailed balance + * information. + */ +export interface WalletBalance { + /** + * Mapping from currency name to detailed balance info. + */ + byExchange: { [exchangeBaseUrl: string]: WalletBalanceEntry }; + + /** + * Mapping from currency name to detailed balance info. + */ + byCurrency: { [currency: string]: WalletBalanceEntry }; +} + +/** + * Detailed wallet balance for a particular currency. + */ +export interface WalletBalanceEntry { + /** + * Directly available amount. + */ + available: AmountJson; + /** + * Amount that we're waiting for (refresh, withdrawal). + */ + pendingIncoming: AmountJson; + /** + * Amount that's marked for a pending payment. + */ + pendingPayment: AmountJson; + /** + * Amount that was paid back and we could withdraw again. + */ + paybackAmount: AmountJson; + + pendingIncomingWithdraw: AmountJson; + pendingIncomingRefresh: AmountJson; + pendingIncomingDirty: AmountJson; +} + +/** + * Coins used for a payment, with signatures authorizing the payment and the + * coins with remaining value updated to accomodate for a payment. + */ +export interface PayCoinInfo { + originalCoins: CoinRecord[]; + updatedCoins: CoinRecord[]; + sigs: CoinPaySig[]; +} + +/** + * For terseness. + */ +export function mkAmount( + value: number, + fraction: number, + currency: string, +): AmountJson { + return { value, fraction, currency }; +} + +/** + * Result for confirmPay + */ +export interface ConfirmPayResult { + nextUrl: string; +} + +/** + * Information about all sender wire details known to the wallet, + * as well as exchanges that accept these wire types. + */ +export interface SenderWireInfos { + /** + * Mapping from exchange base url to list of accepted + * wire types. + */ + exchangeWireTypes: { [exchangeBaseUrl: string]: string[] }; + + /** + * Sender wire information stored in the wallet. + */ + senderWires: string[]; +} + +/** + * Request to mark a reserve as confirmed. + */ +@Checkable.Class() +export class CreateReserveRequest { + /** + * The initial amount for the reserve. + */ + @Checkable.Value(() => AmountJson) + amount: AmountJson; + + /** + * Exchange URL where the bank should create the reserve. + */ + @Checkable.String() + exchange: string; + + /** + * Payto URI that identifies the exchange's account that the funds + * for this reserve go into. + */ + @Checkable.String() + exchangeWire: string; + + /** + * Wire details (as a payto URI) for the bank account that sent the funds to + * the exchange. + */ + @Checkable.Optional(Checkable.String()) + senderWire?: string; + + /** + * URL to fetch the withdraw status from the bank. + */ + @Checkable.Optional(Checkable.String()) + bankWithdrawStatusUrl?: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => CreateReserveRequest; +} + +/** + * Request to mark a reserve as confirmed. + */ +@Checkable.Class() +export class ConfirmReserveRequest { + /** + * Public key of then reserve that should be marked + * as confirmed. + */ + @Checkable.String() + reservePub: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => ConfirmReserveRequest; +} + +/** + * Wire coins to the user's own bank account. + */ +@Checkable.Class() +export class ReturnCoinsRequest { + /** + * The amount to wire. + */ + @Checkable.Value(() => AmountJson) + amount: AmountJson; + + /** + * The exchange to take the coins from. + */ + @Checkable.String() + exchange: string; + + /** + * Wire details for the bank account of the customer that will + * receive the funds. + */ + @Checkable.Any() + senderWire?: object; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => ReturnCoinsRequest; +} + +/** + * Result of selecting coins, contains the exchange, and selected + * coins with their denomination. + */ +export interface CoinSelectionResult { + exchangeUrl: string; + cds: CoinWithDenom[]; + totalFees: AmountJson; + /** + * Total amount, including wire fees payed by the customer. + */ + totalAmount: AmountJson; +} + +/** + * Named tuple of coin and denomination. + */ +export interface CoinWithDenom { + /** + * A coin. Must have the same denomination public key as the associated + * denomination. + */ + coin: CoinRecord; + /** + * An associated denomination. + */ + denom: DenominationRecord; +} + +/** + * Status of processing a tip. + */ +export interface TipStatus { + accepted: boolean; + amount: AmountJson; + amountLeft: AmountJson; + nextUrl: string; + exchangeUrl: string; + tipId: string; + merchantTipId: string; + merchantOrigin: string; + expirationTimestamp: number; + timestamp: number; + totalFees: AmountJson; +} + +export interface BenchmarkResult { + time: { [s: string]: number }; + repetitions: number; +} + +/** + * Cached next URL for a particular session id. + */ +export interface NextUrlResult { + nextUrl: string; + lastSessionId: string | undefined; +} + +export type PreparePayResult = + | PreparePayResultError + | PreparePayResultInsufficientBalance + | PreparePayResultPaid + | PreparePayResultPaymentPossible; + +export interface PreparePayResultPaymentPossible { + status: "payment-possible"; + proposalId: string; + contractTerms: ContractTerms; + totalFees: AmountJson; +} + +export interface PreparePayResultInsufficientBalance { + status: "insufficient-balance"; + proposalId: string; + contractTerms: ContractTerms; +} + +export interface PreparePayResultError { + status: "error"; + error: string; +} + +export interface PreparePayResultPaid { + status: "paid"; + contractTerms: ContractTerms; + nextUrl: string; +} + +export interface BankWithdrawDetails { + selectionDone: boolean; + transferDone: boolean; + amount: AmountJson; + senderWire?: string; + suggestedExchange?: string; + confirmTransferUrl?: string; + wireTypes: string[]; + extractedStatusUrl: string; +} + +export interface AcceptWithdrawalResponse { + reservePub: string; + confirmTransferUrl?: string; +} + +/** + * Details about a purchase, including refund status. + */ +export interface PurchaseDetails { + contractTerms: ContractTerms; + hasRefund: boolean; + totalRefundAmount: AmountJson; + totalRefundAndRefreshFees: AmountJson; +} + +export interface WalletDiagnostics { + walletManifestVersion: string; + walletManifestDisplayVersion: string; + errors: string[]; + firefoxIdbProblem: boolean; + dbOutdated: boolean; +} + +export interface OperationError { + type: string; + message: string; + details: any; +} + +@Checkable.Class() +export class Timestamp { + /** + * Timestamp in milliseconds. + */ + @Checkable.Number() + readonly t_ms: number; + + static checked: (obj: any) => Timestamp; +} + +export interface Duration { + /** + * Duration in milliseconds. + */ + readonly d_ms: number; +} + +export function getTimestampNow(): Timestamp { + return { + t_ms: new Date().getTime(), + }; +} + +export interface PlanchetCreationResult { + coinPub: string; + coinPriv: string; + reservePub: string; + denomPubHash: string; + denomPub: string; + blindingKey: string; + withdrawSig: string; + coinEv: string; + coinValue: AmountJson; +} + +export interface PlanchetCreationRequest { + value: AmountJson; + feeWithdraw: AmountJson; + denomPub: string; + reservePub: string; + reservePriv: string; +} diff --git a/src/util/RequestThrottler.ts b/src/util/RequestThrottler.ts index d44109bee..c144cb152 100644 --- a/src/util/RequestThrottler.ts +++ b/src/util/RequestThrottler.ts @@ -21,7 +21,7 @@ /** * Imports. */ -import { getTimestampNow, Timestamp } from "../walletTypes"; +import { getTimestampNow, Timestamp } from "../types/walletTypes"; /** * Maximum request per second, per origin. diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 3831e84af..99d046f04 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -24,7 +24,7 @@ import { AmountJson } from "./amounts"; import * as Amounts from "./amounts"; -import { Timestamp, Duration } from "../walletTypes"; +import { Timestamp, Duration } from "../types/walletTypes"; /** * Show an amount in a form suitable for the user. diff --git a/src/util/wire.ts b/src/util/wire.ts index 63b73d864..757ba9266 100644 --- a/src/util/wire.ts +++ b/src/util/wire.ts @@ -25,7 +25,7 @@ /** * Imports. */ -import * as i18n from "../i18n"; +import * as i18n from "../webex/i18n"; /** * Short summary of the wire information. diff --git a/src/wallet-impl/balance.ts b/src/wallet-impl/balance.ts deleted file mode 100644 index 8ce91a173..000000000 --- a/src/wallet-impl/balance.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { WalletBalance, WalletBalanceEntry } from "../walletTypes"; -import { runWithReadTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; -import { Logger } from "../util/logging"; - -const logger = new Logger("withdraw.ts"); - -/** - * Get detailed balance information, sliced by exchange and by currency. - */ -export async function getBalances( - ws: InternalWalletState, -): Promise { - logger.trace("starting to compute balance"); - /** - * Add amount to a balance field, both for - * the slicing by exchange and currency. - */ - function addTo( - balance: WalletBalance, - field: keyof WalletBalanceEntry, - amount: AmountJson, - exchange: string, - ): void { - const z = Amounts.getZero(amount.currency); - const balanceIdentity = { - available: z, - paybackAmount: z, - pendingIncoming: z, - pendingPayment: z, - pendingIncomingDirty: z, - pendingIncomingRefresh: z, - pendingIncomingWithdraw: z, - }; - let entryCurr = balance.byCurrency[amount.currency]; - if (!entryCurr) { - balance.byCurrency[amount.currency] = entryCurr = { - ...balanceIdentity, - }; - } - let entryEx = balance.byExchange[exchange]; - if (!entryEx) { - balance.byExchange[exchange] = entryEx = { ...balanceIdentity }; - } - entryCurr[field] = Amounts.add(entryCurr[field], amount).amount; - entryEx[field] = Amounts.add(entryEx[field], amount).amount; - } - - const balanceStore = { - byCurrency: {}, - byExchange: {}, - }; - - await runWithReadTransaction( - ws.db, - [Stores.coins, Stores.refresh, Stores.reserves, Stores.purchases, Stores.withdrawalSession], - async tx => { - await tx.iter(Stores.coins).forEach(c => { - if (c.suspended) { - return; - } - if (c.status === CoinStatus.Fresh) { - addTo(balanceStore, "available", c.currentAmount, c.exchangeBaseUrl); - } - if (c.status === CoinStatus.Dirty) { - addTo( - balanceStore, - "pendingIncoming", - c.currentAmount, - c.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingDirty", - c.currentAmount, - c.exchangeBaseUrl, - ); - } - }); - await tx.iter(Stores.refresh).forEach(r => { - // Don't count finished refreshes, since the refresh already resulted - // in coins being added to the wallet. - if (r.finishedTimestamp) { - return; - } - addTo( - balanceStore, - "pendingIncoming", - r.valueOutput, - r.exchangeBaseUrl, - ); - addTo( - balanceStore, - "pendingIncomingRefresh", - r.valueOutput, - r.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.withdrawalSession).forEach(wds => { - let w = wds.totalCoinValue; - for (let i = 0; i < wds.planchets.length; i++) { - if (wds.withdrawn[i]) { - const p = wds.planchets[i]; - if (p) { - w = Amounts.sub(w, p.coinValue).amount; - } - } - } - addTo( - balanceStore, - "pendingIncoming", - w, - wds.exchangeBaseUrl, - ); - }); - - await tx.iter(Stores.purchases).forEach(t => { - if (t.firstSuccessfulPayTimestamp) { - return; - } - for (const c of t.payReq.coins) { - addTo( - balanceStore, - "pendingPayment", - Amounts.parseOrThrow(c.contribution), - c.exchange_url, - ); - } - }); - }, - ); - - logger.trace("computed balances:", balanceStore); - return balanceStore; -} diff --git a/src/wallet-impl/errors.ts b/src/wallet-impl/errors.ts deleted file mode 100644 index 803497e66..000000000 --- a/src/wallet-impl/errors.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { OperationError } from "../walletTypes"; - -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * 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( - op: () => Promise, - onOpError: (e: OperationError) => Promise, -): Promise { - try { - return await op(); - } catch (e) { - console.log("guard: caught exception"); - if (e instanceof OperationFailedAndReportedError) { - throw e; - } - if (e instanceof OperationFailedError) { - await onOpError(e.err); - throw new OperationFailedAndReportedError(e.message); - } - if (e instanceof Error) { - console.log("guard: caught Error"); - await onOpError({ - type: "exception", - message: e.message, - details: {}, - }); - throw new OperationFailedAndReportedError(e.message); - } - console.log("guard: caught something else"); - await onOpError({ - type: "exception", - message: "non-error exception thrown", - details: { - value: e.toString(), - }, - }); - throw new OperationFailedAndReportedError(e.message); - } -} \ No newline at end of file diff --git a/src/wallet-impl/exchanges.ts b/src/wallet-impl/exchanges.ts deleted file mode 100644 index 1e5f86b4f..000000000 --- a/src/wallet-impl/exchanges.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { InternalWalletState } from "./state"; -import { WALLET_CACHE_BREAKER_CLIENT_VERSION } from "../wallet"; -import { KeysJson, Denomination, ExchangeWireJson } from "../talerTypes"; -import { getTimestampNow, OperationError } from "../walletTypes"; -import { - ExchangeRecord, - ExchangeUpdateStatus, - Stores, - DenominationRecord, - DenominationStatus, - WireFee, -} from "../dbTypes"; -import { - canonicalizeBaseUrl, - extractTalerStamp, - extractTalerStampOrThrow, -} from "../util/helpers"; -import { - oneShotGet, - oneShotPut, - runWithWriteTransaction, - oneShotMutate, -} from "../util/query"; -import * as Amounts from "../util/amounts"; -import { parsePaytoUri } from "../util/payto"; -import { - OperationFailedAndReportedError, - guardOperationException, -} from "./errors"; - -async function denominationRecordFromKeys( - ws: InternalWalletState, - exchangeBaseUrl: string, - denomIn: Denomination, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - // We do the update here, since the exchange might not even exist - // yet in our database. - const exchangeRecord = await updateExchangeFromUrl(ws, exchangeBaseUrl); - if (!exchangeRecord) { - throw Error(`Exchange '${exchangeBaseUrl}' not found.`); - } - const exchangeWireInfo = exchangeRecord.wireInfo; - if (!exchangeWireInfo) { - throw Error(`Exchange wire info for '${exchangeBaseUrl}' not found.`); - } - for (let account of exchangeWireInfo.accounts) { - const res = parsePaytoUri(account.url); - if (!res) { - continue; - } - if (supportedTargetTypes.includes(res.targetType)) { - return account.url; - } - } - throw Error("no matching exchange account found"); -} diff --git a/src/wallet-impl/history.ts b/src/wallet-impl/history.ts deleted file mode 100644 index 99e51c8de..000000000 --- a/src/wallet-impl/history.ts +++ /dev/null @@ -1,221 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { HistoryQuery, HistoryEvent } from "../walletTypes"; -import { oneShotIter, runWithReadTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; - -/** - * Retrive the full event history for this wallet. - */ -export async function getHistory( - ws: InternalWalletState, - historyQuery?: HistoryQuery, -): Promise<{ history: HistoryEvent[] }> { - const history: HistoryEvent[] = []; - - // FIXME: do pagination instead of generating the full history - // We uniquely identify history rows via their timestamp. - // This works as timestamps are guaranteed to be monotonically - // increasing even - - await runWithReadTransaction( - ws.db, - [ - Stores.currencies, - Stores.coins, - Stores.denominations, - Stores.exchanges, - Stores.proposals, - Stores.purchases, - Stores.refresh, - Stores.reserves, - Stores.tips, - Stores.withdrawalSession, - ], - async tx => { - await tx.iter(Stores.proposals).forEach(p => { - history.push({ - detail: {}, - timestamp: p.timestamp, - type: "claim-order", - explicit: false, - }); - }); - - await tx.iter(Stores.withdrawalSession).forEach(w => { - history.push({ - detail: { - withdrawalAmount: w.rawWithdrawalAmount, - }, - timestamp: w.startTimestamp, - type: "withdraw-started", - explicit: false, - }); - if (w.finishTimestamp) { - history.push({ - detail: { - withdrawalAmount: w.rawWithdrawalAmount, - }, - timestamp: w.finishTimestamp, - type: "withdraw-finished", - explicit: false, - }); - } - }); - - await tx.iter(Stores.purchases).forEach(p => { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.acceptTimestamp, - type: "pay-started", - explicit: false, - }); - if (p.firstSuccessfulPayTimestamp) { - history.push({ - detail: { - amount: p.contractTerms.amount, - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - }, - timestamp: p.firstSuccessfulPayTimestamp, - type: "pay-finished", - explicit: false, - }); - } - if (p.lastRefundStatusTimestamp) { - const contractAmount = Amounts.parseOrThrow(p.contractTerms.amount); - const amountsPending = Object.keys(p.refundsPending).map(x => - Amounts.parseOrThrow(p.refundsPending[x].refund_amount), - ); - const amountsDone = Object.keys(p.refundsDone).map(x => - Amounts.parseOrThrow(p.refundsDone[x].refund_amount), - ); - const amounts: AmountJson[] = amountsPending.concat(amountsDone); - const amount = Amounts.add( - Amounts.getZero(contractAmount.currency), - ...amounts, - ).amount; - - history.push({ - detail: { - contractTermsHash: p.contractTermsHash, - fulfillmentUrl: p.contractTerms.fulfillment_url, - merchantName: p.contractTerms.merchant.name, - refundAmount: amount, - }, - timestamp: p.lastRefundStatusTimestamp, - type: "refund", - explicit: false, - }); - } - }); - - await tx.iter(Stores.reserves).forEach(r => { - const reserveType = r.bankWithdrawStatusUrl ? "taler-bank" : "manual"; - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-created", - explicit: false, - }); - if (r.timestampConfirmed) { - history.push({ - detail: { - exchangeBaseUrl: r.exchangeBaseUrl, - requestedAmount: Amounts.toString(r.initiallyRequestedAmount), - reservePub: r.reservePub, - reserveType, - bankWithdrawStatusUrl: r.bankWithdrawStatusUrl, - }, - timestamp: r.created, - type: "reserve-confirmed", - explicit: false, - }); - } - }); - - await tx.iter(Stores.tips).forEach(tip => { - history.push({ - detail: { - accepted: tip.accepted, - amount: tip.amount, - merchantBaseUrl: tip.merchantBaseUrl, - tipId: tip.merchantTipId, - }, - timestamp: tip.createdTimestamp, - explicit: false, - type: "tip", - }); - }); - - await tx.iter(Stores.exchanges).forEach(exchange => { - history.push({ - type: "exchange-added", - explicit: false, - timestamp: exchange.timestampAdded, - detail: { - exchangeBaseUrl: exchange.baseUrl, - }, - }); - }); - - await tx.iter(Stores.refresh).forEach((r) => { - history.push({ - type: "refresh-started", - explicit: false, - timestamp: r.created, - detail: { - refreshSessionId: r.refreshSessionId, - }, - }); - if (r.finishedTimestamp) { - history.push({ - type: "refresh-finished", - explicit: false, - timestamp: r.finishedTimestamp, - detail: { - refreshSessionId: r.refreshSessionId, - }, - }); - } - - }); - }, - ); - - history.sort((h1, h2) => Math.sign(h1.timestamp.t_ms - h2.timestamp.t_ms)); - - return { history }; -} diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts deleted file mode 100644 index af9d44066..000000000 --- a/src/wallet-impl/pay.ts +++ /dev/null @@ -1,1494 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { AmountJson } from "../util/amounts"; -import { - Auditor, - ExchangeHandle, - MerchantRefundResponse, - PayReq, - Proposal, - ContractTerms, - MerchantRefundPermission, - RefundRequest, -} from "../talerTypes"; -import { - Timestamp, - CoinSelectionResult, - CoinWithDenom, - PayCoinInfo, - getTimestampNow, - PreparePayResult, - ConfirmPayResult, - OperationError, - NotificationType, -} from "../walletTypes"; -import { - oneShotIter, - oneShotIterIndex, - oneShotGet, - runWithWriteTransaction, - oneShotPut, - oneShotGetIndexed, - oneShotMutate, -} from "../util/query"; -import { - Stores, - CoinStatus, - DenominationRecord, - ProposalRecord, - PurchaseRecord, - CoinRecord, - ProposalStatus, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { - amountToPretty, - strcmp, - canonicalJson, - extractTalerStampOrThrow, - extractTalerDurationOrThrow, - extractTalerDuration, -} from "../util/helpers"; -import { Logger } from "../util/logging"; -import { InternalWalletState } from "./state"; -import { - parsePayUri, - parseRefundUri, - getOrderDownloadUrl, -} from "../util/taleruri"; -import { getTotalRefreshCost, refresh } from "./refresh"; -import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; -import { guardOperationException } from "./errors"; -import { assertUnreachable } from "../util/assertUnreachable"; - -export interface SpeculativePayData { - payCoinInfo: PayCoinInfo; - exchangeUrl: string; - orderDownloadId: string; - proposal: ProposalRecord; -} - -interface CoinsForPaymentArgs { - allowedAuditors: Auditor[]; - allowedExchanges: ExchangeHandle[]; - depositFeeLimit: AmountJson; - paymentAmount: AmountJson; - wireFeeAmortization: number; - wireFeeLimit: AmountJson; - wireFeeTime: Timestamp; - wireMethod: string; -} - -interface SelectPayCoinsResult { - cds: CoinWithDenom[]; - totalFees: AmountJson; -} - -const logger = new Logger("pay.ts"); - -/** - * Select coins for a payment under the merchant's constraints. - * - * @param denoms all available denoms, used to compute refresh fees - */ -export function selectPayCoins( - denoms: DenominationRecord[], - cds: CoinWithDenom[], - paymentAmount: AmountJson, - depositFeeLimit: AmountJson, -): SelectPayCoinsResult | undefined { - if (cds.length === 0) { - return undefined; - } - // Sort by ascending deposit fee and denomPub if deposit fee is the same - // (to guarantee deterministic results) - cds.sort( - (o1, o2) => - Amounts.cmp(o1.denom.feeDeposit, o2.denom.feeDeposit) || - strcmp(o1.denom.denomPub, o2.denom.denomPub), - ); - const currency = cds[0].denom.value.currency; - const cdsResult: CoinWithDenom[] = []; - let accDepositFee: AmountJson = Amounts.getZero(currency); - let accAmount: AmountJson = Amounts.getZero(currency); - for (const { coin, denom } of cds) { - if (coin.suspended) { - continue; - } - if (coin.status !== CoinStatus.Fresh) { - continue; - } - if (Amounts.cmp(denom.feeDeposit, coin.currentAmount) >= 0) { - continue; - } - cdsResult.push({ coin, denom }); - accDepositFee = Amounts.add(denom.feeDeposit, accDepositFee).amount; - let leftAmount = Amounts.sub( - coin.currentAmount, - Amounts.sub(paymentAmount, accAmount).amount, - ).amount; - accAmount = Amounts.add(coin.currentAmount, accAmount).amount; - const coversAmount = Amounts.cmp(accAmount, paymentAmount) >= 0; - const coversAmountWithFee = - Amounts.cmp( - accAmount, - Amounts.add(paymentAmount, denom.feeDeposit).amount, - ) >= 0; - const isBelowFee = Amounts.cmp(accDepositFee, depositFeeLimit) <= 0; - - logger.trace("candidate coin selection", { - coversAmount, - isBelowFee, - accDepositFee, - accAmount, - paymentAmount, - }); - - if ((coversAmount && isBelowFee) || coversAmountWithFee) { - const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) - .amount; - leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; - logger.trace("deposit fee to cover", amountToPretty(depositFeeToCover)); - let totalFees: AmountJson = Amounts.getZero(currency); - if (coversAmountWithFee && !isBelowFee) { - // these are the fees the customer has to pay - // because the merchant doesn't cover them - totalFees = Amounts.sub(depositFeeLimit, accDepositFee).amount; - } - totalFees = Amounts.add( - totalFees, - getTotalRefreshCost(denoms, denom, leftAmount), - ).amount; - return { cds: cdsResult, totalFees }; - } - } - return undefined; -} - -/** - * Get exchanges and associated coins that are still spendable, but only - * if the sum the coins' remaining value covers the payment amount and fees. - */ -async function getCoinsForPayment( - ws: InternalWalletState, - args: CoinsForPaymentArgs, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - if (forceNow) { - await resetPurchaseApplyRefundRetry(ws, proposalId); - } - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - console.error("not submitting refunds, payment not found:"); - return; - } - const pendingKeys = Object.keys(purchase.refundsPending); - if (pendingKeys.length === 0) { - console.log("no pending refunds"); - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URL("refund", exchangeUrl); - const resp = await ws.http.postJson(reqUrl.href, req); - console.log("sent refund permission"); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - let allRefundsProcessed = false; - - await runWithWriteTransaction( - ws.db, - [Stores.purchases, Stores.coins], - async tx => { - const p = await tx.get(Stores.purchases, proposalId); - if (!p) { - return; - } - if (p.refundsPending[pk]) { - p.refundsDone[pk] = p.refundsPending[pk]; - delete p.refundsPending[pk]; - } - if (Object.keys(p.refundsPending).length === 0) { - p.refundStatusRetryInfo = initRetryInfo(); - p.lastRefundStatusError = undefined; - allRefundsProcessed = true; - } - await tx.put(Stores.purchases, p); - const c = await tx.get(Stores.coins, perm.coin_pub); - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - await tx.put(Stores.coins, c); - }, - ); - if (allRefundsProcessed) { - ws.notify({ - type: NotificationType.RefundFinished, - }); - } - await refresh(ws, perm.coin_pub); - } - - ws.notify({ - type: NotificationType.RefundsSubmitted, - proposalId, - }); -} diff --git a/src/wallet-impl/payback.ts b/src/wallet-impl/payback.ts deleted file mode 100644 index 8cdfbf7ed..000000000 --- a/src/wallet-impl/payback.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { - oneShotIter, - runWithWriteTransaction, - oneShotGet, - oneShotPut, -} from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus } from "../dbTypes"; - -import { Logger } from "../util/logging"; -import { PaybackConfirmation } from "../talerTypes"; -import { updateExchangeFromUrl } from "./exchanges"; -import { NotificationType } from "../walletTypes"; - -const logger = new Logger("payback.ts"); - -export async function payback( - ws: InternalWalletState, - coinPub: string, -): Promise { - let coin = await oneShotGet(ws.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't request payback`); - } - const reservePub = coin.reservePub; - if (!reservePub) { - throw Error(`Can't request payback for a refreshed coin`); - } - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - throw Error(`Reserve of coin ${coinPub} not found`); - } - switch (coin.status) { - case CoinStatus.Dormant: - throw Error(`Can't do payback for coin ${coinPub} since it's dormant`); - } - coin.status = CoinStatus.Dormant; - // Even if we didn't get the payback yet, we suspend withdrawal, since - // technically we might update reserve status before we get the response - // from the reserve for the payback request. - reserve.hasPayback = true; - await runWithWriteTransaction( - ws.db, - [Stores.coins, Stores.reserves], - async tx => { - await tx.put(Stores.coins, coin!!); - await tx.put(Stores.reserves, reserve); - }, - ); - ws.notify({ - type: NotificationType.PaybackStarted, - }); - - const paybackRequest = await ws.cryptoApi.createPaybackRequest(coin); - const reqUrl = new URL("payback", coin.exchangeBaseUrl); - const resp = await ws.http.postJson(reqUrl.href, paybackRequest); - if (resp.status !== 200) { - throw Error(); - } - const paybackConfirmation = PaybackConfirmation.checked(await resp.json()); - if (paybackConfirmation.reserve_pub !== coin.reservePub) { - throw Error(`Coin's reserve doesn't match reserve on payback`); - } - coin = await oneShotGet(ws.db, Stores.coins, coinPub); - if (!coin) { - throw Error(`Coin ${coinPub} not found, can't confirm payback`); - } - coin.status = CoinStatus.Dormant; - await oneShotPut(ws.db, Stores.coins, coin); - ws.notify({ - type: NotificationType.PaybackFinished, - }); - await updateExchangeFromUrl(ws, coin.exchangeBaseUrl, true); -} diff --git a/src/wallet-impl/pending.ts b/src/wallet-impl/pending.ts deleted file mode 100644 index 7079fa5ff..000000000 --- a/src/wallet-impl/pending.ts +++ /dev/null @@ -1,452 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { - PendingOperationsResponse, - getTimestampNow, - Timestamp, - Duration, -} from "../walletTypes"; -import { runWithReadTransaction, TransactionHandle } from "../util/query"; -import { InternalWalletState } from "./state"; -import { - Stores, - ExchangeUpdateStatus, - ReserveRecordStatus, - CoinStatus, - ProposalStatus, -} from "../dbTypes"; - -function updateRetryDelay( - oldDelay: Duration, - now: Timestamp, - retryTimestamp: Timestamp, -): Duration { - if (retryTimestamp.t_ms <= now.t_ms) { - return { d_ms: 0 }; - } - return { d_ms: Math.min(oldDelay.d_ms, retryTimestamp.t_ms - now.t_ms) }; -} - -async function gatherExchangePending( - tx: TransactionHandle, - now: Timestamp, - resp: PendingOperationsResponse, - onlyDue: boolean = false, -): Promise { - 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 { - // 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 { - 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 { - // 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 { - 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 { - 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 { - 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 { - 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 { - const resp: PendingOperationsResponse = { - nextRetryDelay: { d_ms: Number.MAX_SAFE_INTEGER }, - pendingOperations: [], - }; - const now = getTimestampNow(); - await runWithReadTransaction( - ws.db, - [ - Stores.exchanges, - Stores.reserves, - Stores.refresh, - Stores.coins, - Stores.withdrawalSession, - Stores.proposals, - Stores.tips, - Stores.purchases, - ], - async tx => { - await gatherExchangePending(tx, now, resp, onlyDue); - await gatherReservePending(tx, now, resp, onlyDue); - await gatherRefreshPending(tx, now, resp, onlyDue); - await gatherCoinsPending(tx, now, resp, onlyDue); - await gatherWithdrawalPending(tx, now, resp, onlyDue); - await gatherProposalPending(tx, now, resp, onlyDue); - await gatherTipPending(tx, now, resp, onlyDue); - await gatherPurchasePending(tx, now, resp, onlyDue); - }, - ); - return resp; -} diff --git a/src/wallet-impl/refresh.ts b/src/wallet-impl/refresh.ts deleted file mode 100644 index a33511c34..000000000 --- a/src/wallet-impl/refresh.ts +++ /dev/null @@ -1,479 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { AmountJson } from "../util/amounts"; -import * as Amounts from "../util/amounts"; -import { - DenominationRecord, - Stores, - CoinStatus, - RefreshPlanchetRecord, - CoinRecord, - RefreshSessionRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import { amountToPretty } from "../util/helpers"; -import { - oneShotGet, - oneShotMutate, - runWithWriteTransaction, - TransactionAbort, - oneShotIterIndex, -} from "../util/query"; -import { InternalWalletState } from "./state"; -import { Logger } from "../util/logging"; -import { getWithdrawDenomList } from "./withdraw"; -import { updateExchangeFromUrl } from "./exchanges"; -import { - getTimestampNow, - OperationError, - NotificationType, -} from "../walletTypes"; -import { guardOperationException } from "./errors"; - -const logger = new Logger("refresh.ts"); - -/** - * Get the amount that we lose when refreshing a coin of the given denomination - * with a certain amount left. - * - * If the amount left is zero, then the refresh cost - * is also considered to be zero. If a refresh isn't possible (e.g. due to lack of - * the right denominations), then the cost is the full amount left. - * - * Considers refresh fees, withdrawal fees after refresh and amounts too small - * to refresh. - */ -export function getTotalRefreshCost( - denoms: DenominationRecord[], - refreshedDenom: DenominationRecord, - amountLeft: AmountJson, -): AmountJson { - const withdrawAmount = Amounts.sub(amountLeft, refreshedDenom.feeRefresh) - .amount; - const withdrawDenoms = getWithdrawDenomList(withdrawAmount, denoms); - const resultingAmount = Amounts.add( - Amounts.getZero(withdrawAmount.currency), - ...withdrawDenoms.map(d => d.value), - ).amount; - const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - logger.trace( - "total refresh cost for", - amountToPretty(amountLeft), - "is", - amountToPretty(totalCost), - ); - return totalCost; -} - -async function refreshMelt( - ws: InternalWalletState, - refreshSessionId: string, -): Promise { - 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 { - 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 { - 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 { - const coin = await oneShotGet(ws.db, Stores.coins, oldCoinPub); - if (!coin) { - console.warn("can't refresh, coin not in database"); - return; - } - switch (coin.status) { - case CoinStatus.Dirty: - break; - case CoinStatus.Dormant: - return; - case CoinStatus.Fresh: - if (!force) { - return; - } - break; - } - - const exchange = await updateExchangeFromUrl(ws, coin.exchangeBaseUrl); - if (!exchange) { - throw Error("db inconsistent: exchange of coin not found"); - } - - const oldDenom = await oneShotGet(ws.db, Stores.denominations, [ - exchange.baseUrl, - coin.denomPub, - ]); - - if (!oldDenom) { - throw Error("db inconsistent: denomination for coin not found"); - } - - const availableDenoms: DenominationRecord[] = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - exchange.baseUrl, - ).toArray(); - - const availableAmount = Amounts.sub(coin.currentAmount, oldDenom.feeRefresh) - .amount; - - const newCoinDenoms = getWithdrawDenomList(availableAmount, availableDenoms); - - if (newCoinDenoms.length === 0) { - logger.trace( - `not refreshing, available amount ${amountToPretty( - availableAmount, - )} too small`, - ); - await oneShotMutate(ws.db, Stores.coins, oldCoinPub, x => { - if (x.status != coin.status) { - // Concurrent modification? - return; - } - x.status = CoinStatus.Dormant; - return x; - }); - ws.notify({ type: NotificationType.RefreshRefused }); - return; - } - - const refreshSession: RefreshSessionRecord = await ws.cryptoApi.createRefreshSession( - exchange.baseUrl, - 3, - coin, - newCoinDenoms, - oldDenom.feeRefresh, - ); - - // Store refresh session and subtract refreshed amount from - // coin in the same transaction. - await runWithWriteTransaction( - ws.db, - [Stores.refresh, Stores.coins], - async tx => { - const c = await tx.get(Stores.coins, coin.coinPub); - if (!c) { - return; - } - if (c.status !== CoinStatus.Dirty) { - return; - } - const r = Amounts.sub(c.currentAmount, refreshSession.valueWithFee); - if (r.saturated) { - console.log("can't refresh coin, no amount left"); - return; - } - c.currentAmount = r.amount; - c.status = CoinStatus.Dormant; - await tx.put(Stores.refresh, refreshSession); - await tx.put(Stores.coins, c); - }, - ); - logger.info(`created refresh session ${refreshSession.refreshSessionId}`); - ws.notify({ type: NotificationType.RefreshStarted }); - - await processRefreshSession(ws, refreshSession.refreshSessionId); -} diff --git a/src/wallet-impl/reserves.ts b/src/wallet-impl/reserves.ts deleted file mode 100644 index 504cf10f0..000000000 --- a/src/wallet-impl/reserves.ts +++ /dev/null @@ -1,630 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { - CreateReserveRequest, - CreateReserveResponse, - getTimestampNow, - ConfirmReserveRequest, - OperationError, - NotificationType, -} from "../walletTypes"; -import { canonicalizeBaseUrl } from "../util/helpers"; -import { InternalWalletState } from "./state"; -import { - ReserveRecordStatus, - ReserveRecord, - CurrencyRecord, - Stores, - WithdrawalSessionRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import { - oneShotMutate, - oneShotPut, - oneShotGet, - runWithWriteTransaction, - TransactionAbort, -} from "../util/query"; -import { Logger } from "../util/logging"; -import * as Amounts from "../util/amounts"; -import { updateExchangeFromUrl, getExchangeTrust } from "./exchanges"; -import { WithdrawOperationStatusResponse, ReserveStatus } from "../talerTypes"; -import { assertUnreachable } from "../util/assertUnreachable"; -import { encodeCrock } from "../crypto/talerCrypto"; -import { randomBytes } from "../crypto/primitives/nacl-fast"; -import { - getVerifiedWithdrawDenomList, - processWithdrawSession, -} from "./withdraw"; -import { guardOperationException, OperationFailedAndReportedError } from "./errors"; - -const logger = new Logger("reserves.ts"); - -/** - * Create a reserve, but do not flag it as confirmed yet. - * - * Adds the corresponding exchange as a trusted exchange if it is neither - * audited nor trusted already. - */ -export async function createReserve( - ws: InternalWalletState, - req: CreateReserveRequest, -): Promise { - 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 { - 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 { - 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 { - const onOpError = (err: OperationError) => - incrementReserveRetry(ws, reservePub, err); - await guardOperationException( - () => processReserveBankStatusImpl(ws, reservePub), - onOpError, - ); -} - -async function processReserveBankStatusImpl( - ws: InternalWalletState, - reservePub: string, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - const reserve = await oneShotGet(ws.db, Stores.reserves, reservePub); - if (!reserve) { - return; - } - if (reserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return; - } - logger.trace(`depleting reserve ${reservePub}`); - - const withdrawAmount = reserve.withdrawRemainingAmount; - - logger.trace(`getting denom list`); - - const denomsForWithdraw = await getVerifiedWithdrawDenomList( - ws, - reserve.exchangeBaseUrl, - withdrawAmount, - ); - logger.trace(`got denom list`); - if (denomsForWithdraw.length === 0) { - const m = `Unable to withdraw from reserve, no denominations are available to withdraw.`; - await incrementReserveRetry(ws, reserve.reservePub, { - type: "internal", - message: m, - details: {}, - }); - console.log(m); - throw new OperationFailedAndReportedError(m); - } - - logger.trace("selected denominations"); - - const withdrawalSessionId = encodeCrock(randomBytes(32)); - - const totalCoinValue = Amounts.sum(denomsForWithdraw.map(x => x.value)) - .amount; - - const withdrawalRecord: WithdrawalSessionRecord = { - withdrawSessionId: withdrawalSessionId, - exchangeBaseUrl: reserve.exchangeBaseUrl, - source: { - type: "reserve", - reservePub: reserve.reservePub, - }, - rawWithdrawalAmount: withdrawAmount, - startTimestamp: getTimestampNow(), - denoms: denomsForWithdraw.map(x => x.denomPub), - withdrawn: denomsForWithdraw.map(x => false), - planchets: denomsForWithdraw.map(x => undefined), - totalCoinValue, - retryInfo: initRetryInfo(), - lastCoinErrors: denomsForWithdraw.map(x => undefined), - lastError: undefined, - }; - - const totalCoinWithdrawFee = Amounts.sum( - denomsForWithdraw.map(x => x.feeWithdraw), - ).amount; - const totalWithdrawAmount = Amounts.add(totalCoinValue, totalCoinWithdrawFee) - .amount; - - function mutateReserve(r: ReserveRecord): ReserveRecord { - const remaining = Amounts.sub( - r.withdrawRemainingAmount, - totalWithdrawAmount, - ); - if (remaining.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - const allocated = Amounts.add( - r.withdrawAllocatedAmount, - totalWithdrawAmount, - ); - if (allocated.saturated) { - console.error("can't create planchets, saturated"); - throw TransactionAbort; - } - r.withdrawRemainingAmount = remaining.amount; - r.withdrawAllocatedAmount = allocated.amount; - r.reserveStatus = ReserveRecordStatus.DORMANT; - r.retryInfo = initRetryInfo(false); - return r; - } - - const success = await runWithWriteTransaction( - ws.db, - [Stores.withdrawalSession, Stores.reserves], - async tx => { - const myReserve = await tx.get(Stores.reserves, reservePub); - if (!myReserve) { - return false; - } - if (myReserve.reserveStatus !== ReserveRecordStatus.WITHDRAWING) { - return false; - } - await tx.mutate(Stores.reserves, reserve.reservePub, mutateReserve); - await tx.put(Stores.withdrawalSession, withdrawalRecord); - return true; - }, - ); - - if (success) { - console.log("processing new withdraw session"); - ws.notify({ - type: NotificationType.WithdrawSessionCreated, - withdrawSessionId: withdrawalSessionId, - }); - await processWithdrawSession(ws, withdrawalSessionId); - } else { - console.trace("withdraw session already existed"); - } -} diff --git a/src/wallet-impl/return.ts b/src/wallet-impl/return.ts deleted file mode 100644 index 0c142f9a6..000000000 --- a/src/wallet-impl/return.ts +++ /dev/null @@ -1,271 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -/** - * Imports. - */ -import { - HistoryQuery, - HistoryEvent, - WalletBalance, - WalletBalanceEntry, - ReturnCoinsRequest, - CoinWithDenom, -} from "../walletTypes"; -import { oneShotIter, runWithWriteTransaction, oneShotGet, oneShotIterIndex, oneShotPut } from "../util/query"; -import { InternalWalletState } from "./state"; -import { Stores, TipRecord, CoinStatus, CoinsReturnRecord, CoinRecord } from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { AmountJson } from "../util/amounts"; -import { Logger } from "../util/logging"; -import { canonicalJson } from "../util/helpers"; -import { ContractTerms } from "../talerTypes"; -import { selectPayCoins } from "./pay"; - -const logger = new Logger("return.ts"); - -async function getCoinsForReturn( - ws: InternalWalletState, - exchangeBaseUrl: string, - amount: AmountJson, -): Promise { - 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 { - 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 { - for (const c of coinsReturnRecord.coins) { - if (c.depositedSig) { - continue; - } - const req = { - H_wire: coinsReturnRecord.contractTerms.H_wire, - coin_pub: c.coinPaySig.coin_pub, - coin_sig: c.coinPaySig.coin_sig, - contribution: c.coinPaySig.contribution, - denom_pub: c.coinPaySig.denom_pub, - h_contract_terms: coinsReturnRecord.contractTermsHash, - merchant_pub: coinsReturnRecord.contractTerms.merchant_pub, - pay_deadline: coinsReturnRecord.contractTerms.pay_deadline, - refund_deadline: coinsReturnRecord.contractTerms.refund_deadline, - timestamp: coinsReturnRecord.contractTerms.timestamp, - ub_sig: c.coinPaySig.ub_sig, - wire: coinsReturnRecord.wire, - wire_transfer_deadline: coinsReturnRecord.contractTerms.pay_deadline, - }; - logger.trace("req", req); - const reqUrl = new URL("deposit", coinsReturnRecord.exchange); - const resp = await ws.http.postJson(reqUrl.href, req); - if (resp.status !== 200) { - console.error("deposit failed due to status code", resp); - continue; - } - const respJson = await resp.json(); - if (respJson.status !== "DEPOSIT_OK") { - console.error("deposit failed", resp); - continue; - } - - if (!respJson.sig) { - console.error("invalid 'sig' field", resp); - continue; - } - - // FIXME: verify signature - - // For every successful deposit, we replace the old record with an updated one - const currentCrr = await oneShotGet( - ws.db, - Stores.coinsReturns, - coinsReturnRecord.contractTermsHash, - ); - if (!currentCrr) { - console.error("database inconsistent"); - continue; - } - for (const nc of currentCrr.coins) { - if (nc.coinPaySig.coin_pub === c.coinPaySig.coin_pub) { - nc.depositedSig = respJson.sig; - } - } - await oneShotPut(ws.db, Stores.coinsReturns, currentCrr); - } -} diff --git a/src/wallet-impl/state.ts b/src/wallet-impl/state.ts deleted file mode 100644 index 18df861f1..000000000 --- a/src/wallet-impl/state.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { HttpRequestLibrary } from "../util/http"; -import { - NextUrlResult, - WalletBalance, - PendingOperationsResponse, - WalletNotification, -} from "../walletTypes"; -import { SpeculativePayData } from "./pay"; -import { CryptoApi, CryptoWorkerFactory } from "../crypto/workers/cryptoApi"; -import { AsyncOpMemoMap, AsyncOpMemoSingle } from "../util/asyncMemo"; -import { Logger } from "../util/logging"; - -type NotificationListener = (n: WalletNotification) => void; - -const logger = new Logger("state.ts"); - -export class InternalWalletState { - speculativePayData: SpeculativePayData | undefined = undefined; - cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {}; - memoProcessReserve: AsyncOpMemoMap = new AsyncOpMemoMap(); - memoMakePlanchet: AsyncOpMemoMap = new AsyncOpMemoMap(); - memoGetPending: AsyncOpMemoSingle< - PendingOperationsResponse - > = new AsyncOpMemoSingle(); - memoGetBalance: AsyncOpMemoSingle = new AsyncOpMemoSingle(); - memoProcessRefresh: AsyncOpMemoMap = new AsyncOpMemoMap(); - cryptoApi: CryptoApi; - - listeners: NotificationListener[] = []; - - constructor( - public db: IDBDatabase, - public http: HttpRequestLibrary, - cryptoWorkerFactory: CryptoWorkerFactory, - ) { - this.cryptoApi = new CryptoApi(cryptoWorkerFactory); - } - - public notify(n: WalletNotification) { - logger.trace("Notification", n); - for (const l of this.listeners) { - const nc = JSON.parse(JSON.stringify(n)); - setImmediate(() => { - l(nc); - }); - } - } - - addNotificationListener(f: (n: WalletNotification) => void): void { - this.listeners.push(f); - } -} diff --git a/src/wallet-impl/tip.ts b/src/wallet-impl/tip.ts deleted file mode 100644 index 22ec37793..000000000 --- a/src/wallet-impl/tip.ts +++ /dev/null @@ -1,304 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - - -import { oneShotGet, oneShotPut, oneShotMutate, runWithWriteTransaction } from "../util/query"; -import { InternalWalletState } from "./state"; -import { parseTipUri } from "../util/taleruri"; -import { TipStatus, getTimestampNow, OperationError, NotificationType } from "../walletTypes"; -import { TipPickupGetResponse, TipPlanchetDetail, TipResponse } from "../talerTypes"; -import * as Amounts from "../util/amounts"; -import { Stores, PlanchetRecord, WithdrawalSessionRecord, initRetryInfo, updateRetryInfoTimeout } from "../dbTypes"; -import { getExchangeWithdrawalInfo, getVerifiedWithdrawDenomList, processWithdrawSession } from "./withdraw"; -import { getTalerStampSec, extractTalerStampOrThrow } from "../util/helpers"; -import { updateExchangeFromUrl } from "./exchanges"; -import { getRandomBytes, encodeCrock } from "../crypto/talerCrypto"; -import { guardOperationException } from "./errors"; - - -export async function getTipStatus( - ws: InternalWalletState, - talerTipUri: string): Promise { - 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 { - 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 { - const onOpErr = (e: OperationError) => incrementTipRetry(ws, tipId, e); - await guardOperationException(() => processTipImpl(ws, tipId, forceNow), onOpErr); -} - -async function resetTipRetry( - ws: InternalWalletState, - tipId: string, -): Promise { - 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 { - const tipRecord = await oneShotGet(ws.db, Stores.tips, tipId); - if (!tipRecord) { - console.log("tip not found"); - return; - } - - tipRecord.accepted = true; - await oneShotPut(ws.db, Stores.tips, tipRecord); - - await processTip(ws, tipId); - return; -} diff --git a/src/wallet-impl/withdraw.ts b/src/wallet-impl/withdraw.ts deleted file mode 100644 index d8b2b599c..000000000 --- a/src/wallet-impl/withdraw.ts +++ /dev/null @@ -1,699 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { AmountJson } from "../util/amounts"; -import { - DenominationRecord, - Stores, - DenominationStatus, - CoinStatus, - CoinRecord, - PlanchetRecord, - initRetryInfo, - updateRetryInfoTimeout, -} from "../dbTypes"; -import * as Amounts from "../util/amounts"; -import { - getTimestampNow, - AcceptWithdrawalResponse, - BankWithdrawDetails, - ExchangeWithdrawDetails, - WithdrawDetails, - OperationError, - NotificationType, -} from "../walletTypes"; -import { WithdrawOperationStatusResponse } from "../talerTypes"; -import { InternalWalletState } from "./state"; -import { parseWithdrawUri } from "../util/taleruri"; -import { Logger } from "../util/logging"; -import { - oneShotGet, - oneShotPut, - oneShotIterIndex, - oneShotGetIndexed, - runWithWriteTransaction, - oneShotMutate, -} from "../util/query"; -import { - updateExchangeFromUrl, - getExchangePaytoUri, - getExchangeTrust, -} from "./exchanges"; -import { createReserve, processReserveBankStatus } from "./reserves"; -import { WALLET_PROTOCOL_VERSION } from "../wallet"; - -import * as LibtoolVersion from "../util/libtoolVersion"; -import { guardOperationException } from "./errors"; - -const logger = new Logger("withdraw.ts"); - -function isWithdrawableDenom(d: DenominationRecord) { - const now = getTimestampNow(); - const started = now.t_ms >= d.stampStart.t_ms; - const stillOkay = d.stampExpireWithdraw.t_ms + 60 * 1000 > now.t_ms; - return started && stillOkay; -} - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -export function getWithdrawDenomList( - amountAvailable: AmountJson, - denoms: DenominationRecord[], -): DenominationRecord[] { - let remaining = Amounts.copy(amountAvailable); - const ds: DenominationRecord[] = []; - - denoms = denoms.filter(isWithdrawableDenom); - denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); - - // This is an arbitrary number of coins - // we can withdraw in one go. It's not clear if this limit - // is useful ... - for (let i = 0; i < 1000; i++) { - let found = false; - for (const d of denoms) { - const cost = Amounts.add(d.value, d.feeWithdraw).amount; - if (Amounts.cmp(remaining, cost) < 0) { - continue; - } - found = true; - remaining = Amounts.sub(remaining, cost).amount; - ds.push(d); - break; - } - if (!found) { - break; - } - } - return ds; -} - -/** - * Get information about a withdrawal from - * a taler://withdraw URI by asking the bank. - */ -async function getBankWithdrawalInfo( - ws: InternalWalletState, - talerWithdrawUri: string, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - }; -} diff --git a/src/wallet-test.ts b/src/wallet-test.ts index cc8532f07..c937de3f5 100644 --- a/src/wallet-test.ts +++ b/src/wallet-test.ts @@ -16,14 +16,14 @@ import test from "ava"; -import * as dbTypes from "./dbTypes"; -import * as types from "./walletTypes"; +import * as dbTypes from "./types/dbTypes"; +import * as types from "./types/walletTypes"; import * as wallet from "./wallet"; import { AmountJson } from "./util/amounts"; import * as Amounts from "./util/amounts"; -import { selectPayCoins } from "./wallet-impl/pay"; +import { selectPayCoins } from "./operations/pay"; function a(x: string): AmountJson { const amt = Amounts.parse(x); diff --git a/src/wallet.ts b/src/wallet.ts index edfc0b09d..1db458b38 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -39,7 +39,7 @@ import { acceptWithdrawal, getWithdrawDetailsForUri, getExchangeWithdrawalInfo, -} from "./wallet-impl/withdraw"; +} from "./operations/withdraw"; import { abortFailedPayment, @@ -51,7 +51,7 @@ import { processPurchasePay, processPurchaseQueryRefund, processPurchaseApplyRefund, -} from "./wallet-impl/pay"; +} from "./operations/pay"; import { CoinRecord, @@ -64,31 +64,24 @@ import { ReserveRecord, Stores, ReserveRecordStatus, -} from "./dbTypes"; -import { MerchantRefundPermission } from "./talerTypes"; +} from "./types/dbTypes"; +import { MerchantRefundPermission } from "./types/talerTypes"; import { BenchmarkResult, ConfirmPayResult, ConfirmReserveRequest, CreateReserveRequest, CreateReserveResponse, - HistoryEvent, ReturnCoinsRequest, SenderWireInfos, TipStatus, WalletBalance, PreparePayResult, - BankWithdrawDetails, WithdrawDetails, AcceptWithdrawalResponse, PurchaseDetails, - PendingOperationInfo, - PendingOperationsResponse, - HistoryQuery, - WalletNotification, - NotificationType, ExchangeWithdrawDetails, -} from "./walletTypes"; +} from "./types/walletTypes"; import { Logger } from "./util/logging"; import { assertUnreachable } from "./util/assertUnreachable"; @@ -98,22 +91,25 @@ import { getExchangeTrust, getExchangePaytoUri, acceptExchangeTermsOfService, -} from "./wallet-impl/exchanges"; -import { processReserve } from "./wallet-impl/reserves"; - -import { InternalWalletState } from "./wallet-impl/state"; -import { createReserve, confirmReserve } from "./wallet-impl/reserves"; -import { processRefreshSession, refresh } from "./wallet-impl/refresh"; -import { processWithdrawSession } from "./wallet-impl/withdraw"; -import { getHistory } from "./wallet-impl/history"; -import { getPendingOperations } from "./wallet-impl/pending"; -import { getBalances } from "./wallet-impl/balance"; -import { acceptTip, getTipStatus, processTip } from "./wallet-impl/tip"; -import { returnCoins } from "./wallet-impl/return"; -import { payback } from "./wallet-impl/payback"; +} from "./operations/exchanges"; +import { processReserve } from "./operations/reserves"; + +import { InternalWalletState } from "./operations/state"; +import { createReserve, confirmReserve } from "./operations/reserves"; +import { processRefreshSession, refresh } from "./operations/refresh"; +import { processWithdrawSession } from "./operations/withdraw"; +import { getHistory } from "./operations/history"; +import { getPendingOperations } from "./operations/pending"; +import { getBalances } from "./operations/balance"; +import { acceptTip, getTipStatus, processTip } from "./operations/tip"; +import { returnCoins } from "./operations/return"; +import { payback } from "./operations/payback"; import { TimerGroup } from "./util/timer"; import { AsyncCondition } from "./util/promiseUtils"; import { AsyncOpMemoSingle } from "./util/asyncMemo"; +import { PendingOperationInfo, PendingOperationsResponse } from "./types/pending"; +import { WalletNotification, NotificationType } from "./types/notifications"; +import { HistoryQuery, HistoryEvent } from "./types/history"; /** * Wallet protocol version spoken with the exchange diff --git a/src/walletTypes.ts b/src/walletTypes.ts deleted file mode 100644 index e136b4e01..000000000 --- a/src/walletTypes.ts +++ /dev/null @@ -1,873 +0,0 @@ -/* - This file is part of TALER - (C) 2015-2017 GNUnet e.V. and INRIA - - 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. - - 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 - TALER; see the file COPYING. If not, see - */ - -/** - * Types used by clients of the wallet. - * - * These types are defined in a separate file make tree shaking easier, since - * some components use these types (via RPC) but do not depend on the wallet - * code directly. - */ - -/** - * Imports. - */ -import { Checkable } from "./util/checkable"; -import * as LibtoolVersion from "./util/libtoolVersion"; - -import { AmountJson } from "./util/amounts"; - -import { - CoinRecord, - DenominationRecord, - ExchangeRecord, - ExchangeWireInfo, - WithdrawalSource, - RetryInfo, -} from "./dbTypes"; -import { CoinPaySig, ContractTerms, PayReq } from "./talerTypes"; - -/** - * Response for the create reserve request to the wallet. - */ -@Checkable.Class() -export class CreateReserveResponse { - /** - * Exchange URL where the bank should create the reserve. - * The URL is canonicalized in the response. - */ - @Checkable.String() - exchange: string; - - /** - * Reserve public key of the newly created reserve. - */ - @Checkable.String() - reservePub: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => CreateReserveResponse; -} - -/** - * Information about what will happen when creating a reserve. - * - * Sent to the wallet frontend to be rendered and shown to the user. - */ -export interface ExchangeWithdrawDetails { - /** - * Exchange that the reserve will be created at. - */ - exchangeInfo: ExchangeRecord; - - /** - * Filtered wire info to send to the bank. - */ - exchangeWireAccounts: string[]; - - /** - * Selected denominations for withdraw. - */ - selectedDenoms: DenominationRecord[]; - - /** - * Fees for withdraw. - */ - withdrawFee: AmountJson; - - /** - * Remaining balance that is too small to be withdrawn. - */ - overhead: AmountJson; - - /** - * Wire fees from the exchange. - */ - wireFees: ExchangeWireInfo; - - /** - * Does the wallet know about an auditor for - * the exchange that the reserve. - */ - isAudited: boolean; - - /** - * Did the user already accept the current terms of service for the exchange? - */ - termsOfServiceAccepted: boolean; - - /** - * The exchange is trusted directly. - */ - isTrusted: boolean; - - /** - * The earliest deposit expiration of the selected coins. - */ - earliestDepositExpiration: Timestamp; - - /** - * Number of currently offered denominations. - */ - numOfferedDenoms: number; - - /** - * Public keys of trusted auditors for the currency we're withdrawing. - */ - trustedAuditorPubs: string[]; - - /** - * Result of checking the wallet's version - * against the exchange's version. - * - * Older exchanges don't return version information. - */ - versionMatch: LibtoolVersion.VersionMatchResult | undefined; - - /** - * Libtool-style version string for the exchange or "unknown" - * for older exchanges. - */ - exchangeVersion: string; - - /** - * Libtool-style version string for the wallet. - */ - walletVersion: string; -} - -export interface WithdrawDetails { - bankWithdrawDetails: BankWithdrawDetails; - exchangeWithdrawDetails: ExchangeWithdrawDetails | undefined; -} - -/** - * Mapping from currency/exchange to detailed balance - * information. - */ -export interface WalletBalance { - /** - * Mapping from currency name to detailed balance info. - */ - byExchange: { [exchangeBaseUrl: string]: WalletBalanceEntry }; - - /** - * Mapping from currency name to detailed balance info. - */ - byCurrency: { [currency: string]: WalletBalanceEntry }; -} - -/** - * Detailed wallet balance for a particular currency. - */ -export interface WalletBalanceEntry { - /** - * Directly available amount. - */ - available: AmountJson; - /** - * Amount that we're waiting for (refresh, withdrawal). - */ - pendingIncoming: AmountJson; - /** - * Amount that's marked for a pending payment. - */ - pendingPayment: AmountJson; - /** - * Amount that was paid back and we could withdraw again. - */ - paybackAmount: AmountJson; - - pendingIncomingWithdraw: AmountJson; - pendingIncomingRefresh: AmountJson; - pendingIncomingDirty: AmountJson; -} - -/** - * Coins used for a payment, with signatures authorizing the payment and the - * coins with remaining value updated to accomodate for a payment. - */ -export interface PayCoinInfo { - originalCoins: CoinRecord[]; - updatedCoins: CoinRecord[]; - sigs: CoinPaySig[]; -} - -/** - * For terseness. - */ -export function mkAmount( - value: number, - fraction: number, - currency: string, -): AmountJson { - return { value, fraction, currency }; -} - -/** - * Result for confirmPay - */ -export interface ConfirmPayResult { - nextUrl: string; -} - -/** - * Activity history record. - */ -export interface HistoryEvent { - /** - * Type of the history event. - */ - type: string; - - /** - * Time when the activity was recorded. - */ - timestamp: Timestamp; - - /** - * Details used when rendering the history record. - */ - detail: any; - - /** - * Set to 'true' if the event has been explicitly created, - * and set to 'false' if the event has been derived from the - * state of the database. - */ - explicit: boolean; -} - -/** - * Information about all sender wire details known to the wallet, - * as well as exchanges that accept these wire types. - */ -export interface SenderWireInfos { - /** - * Mapping from exchange base url to list of accepted - * wire types. - */ - exchangeWireTypes: { [exchangeBaseUrl: string]: string[] }; - - /** - * Sender wire information stored in the wallet. - */ - senderWires: string[]; -} - -/** - * Request to mark a reserve as confirmed. - */ -@Checkable.Class() -export class CreateReserveRequest { - /** - * The initial amount for the reserve. - */ - @Checkable.Value(() => AmountJson) - amount: AmountJson; - - /** - * Exchange URL where the bank should create the reserve. - */ - @Checkable.String() - exchange: string; - - /** - * Payto URI that identifies the exchange's account that the funds - * for this reserve go into. - */ - @Checkable.String() - exchangeWire: string; - - /** - * Wire details (as a payto URI) for the bank account that sent the funds to - * the exchange. - */ - @Checkable.Optional(Checkable.String()) - senderWire?: string; - - /** - * URL to fetch the withdraw status from the bank. - */ - @Checkable.Optional(Checkable.String()) - bankWithdrawStatusUrl?: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => CreateReserveRequest; -} - -/** - * Request to mark a reserve as confirmed. - */ -@Checkable.Class() -export class ConfirmReserveRequest { - /** - * Public key of then reserve that should be marked - * as confirmed. - */ - @Checkable.String() - reservePub: string; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => ConfirmReserveRequest; -} - -/** - * Wire coins to the user's own bank account. - */ -@Checkable.Class() -export class ReturnCoinsRequest { - /** - * The amount to wire. - */ - @Checkable.Value(() => AmountJson) - amount: AmountJson; - - /** - * The exchange to take the coins from. - */ - @Checkable.String() - exchange: string; - - /** - * Wire details for the bank account of the customer that will - * receive the funds. - */ - @Checkable.Any() - senderWire?: object; - - /** - * Verify that a value matches the schema of this class and convert it into a - * member. - */ - static checked: (obj: any) => ReturnCoinsRequest; -} - -/** - * Result of selecting coins, contains the exchange, and selected - * coins with their denomination. - */ -export interface CoinSelectionResult { - exchangeUrl: string; - cds: CoinWithDenom[]; - totalFees: AmountJson; - /** - * Total amount, including wire fees payed by the customer. - */ - totalAmount: AmountJson; -} - -/** - * Named tuple of coin and denomination. - */ -export interface CoinWithDenom { - /** - * A coin. Must have the same denomination public key as the associated - * denomination. - */ - coin: CoinRecord; - /** - * An associated denomination. - */ - denom: DenominationRecord; -} - -/** - * Status of processing a tip. - */ -export interface TipStatus { - accepted: boolean; - amount: AmountJson; - amountLeft: AmountJson; - nextUrl: string; - exchangeUrl: string; - tipId: string; - merchantTipId: string; - merchantOrigin: string; - expirationTimestamp: number; - timestamp: number; - totalFees: AmountJson; -} - -export interface BenchmarkResult { - time: { [s: string]: number }; - repetitions: number; -} - -/** - * Cached next URL for a particular session id. - */ -export interface NextUrlResult { - nextUrl: string; - lastSessionId: string | undefined; -} - -export type PreparePayResult = - | PreparePayResultError - | PreparePayResultInsufficientBalance - | PreparePayResultPaid - | PreparePayResultPaymentPossible; - -export interface PreparePayResultPaymentPossible { - status: "payment-possible"; - proposalId: string; - contractTerms: ContractTerms; - totalFees: AmountJson; -} - -export interface PreparePayResultInsufficientBalance { - status: "insufficient-balance"; - proposalId: string; - contractTerms: ContractTerms; -} - -export interface PreparePayResultError { - status: "error"; - error: string; -} - -export interface PreparePayResultPaid { - status: "paid"; - contractTerms: ContractTerms; - nextUrl: string; -} - -export interface BankWithdrawDetails { - selectionDone: boolean; - transferDone: boolean; - amount: AmountJson; - senderWire?: string; - suggestedExchange?: string; - confirmTransferUrl?: string; - wireTypes: string[]; - extractedStatusUrl: string; -} - -export interface AcceptWithdrawalResponse { - reservePub: string; - confirmTransferUrl?: string; -} - -/** - * Details about a purchase, including refund status. - */ -export interface PurchaseDetails { - contractTerms: ContractTerms; - hasRefund: boolean; - totalRefundAmount: AmountJson; - totalRefundAndRefreshFees: AmountJson; -} - -export interface WalletDiagnostics { - walletManifestVersion: string; - walletManifestDisplayVersion: string; - errors: string[]; - firefoxIdbProblem: boolean; - dbOutdated: boolean; -} - -export interface PendingWithdrawOperation { - type: "withdraw"; - source: WithdrawalSource; - withdrawSessionId: string; - numCoinsWithdrawn: number; - numCoinsTotal: number; -} - -export interface PendingRefreshOperation { - type: "refresh"; -} - -export interface PendingPayOperation { - type: "pay"; -} - -export const enum NotificationType { - CoinWithdrawn = "coin-withdrawn", - ProposalAccepted = "proposal-accepted", - ProposalDownloaded = "proposal-downloaded", - RefundsSubmitted = "refunds-submitted", - PaybackStarted = "payback-started", - PaybackFinished = "payback-finished", - RefreshRevealed = "refresh-revealed", - RefreshMelted = "refresh-melted", - RefreshStarted = "refresh-started", - RefreshRefused = "refresh-refused", - ReserveUpdated = "reserve-updated", - ReserveConfirmed = "reserve-confirmed", - ReserveDepleted = "reserve-depleted", - ReserveCreated = "reserve-created", - WithdrawSessionCreated = "withdraw-session-created", - WithdrawSessionFinished = "withdraw-session-finished", - WaitingForRetry = "waiting-for-retry", - RefundStarted = "refund-started", - RefundQueried = "refund-queried", - RefundFinished = "refund-finished", - ExchangeOperationError = "exchange-operation-error", - RefreshOperationError = "refresh-operation-error", - RefundApplyOperationError = "refund-apply-error", - RefundStatusOperationError = "refund-status-error", - ProposalOperationError = "proposal-error", - TipOperationError = "tip-error", - PayOperationError = "pay-error", - WithdrawOperationError = "withdraw-error", - ReserveOperationError = "reserve-error", - Wildcard = "wildcard", -} - -export interface ProposalAcceptedNotification { - type: NotificationType.ProposalAccepted; - proposalId: string; -} - -export interface CoinWithdrawnNotification { - type: NotificationType.CoinWithdrawn; -} - -export interface RefundStartedNotification { - type: NotificationType.RefundStarted; -} - -export interface RefundQueriedNotification { - type: NotificationType.RefundQueried; -} - -export interface ProposalDownloadedNotification { - type: NotificationType.ProposalDownloaded; - proposalId: string; -} - -export interface RefundsSubmittedNotification { - type: NotificationType.RefundsSubmitted; - proposalId: string; -} - -export interface PaybackStartedNotification { - type: NotificationType.PaybackStarted; -} - -export interface PaybackFinishedNotification { - type: NotificationType.PaybackFinished; -} - -export interface RefreshMeltedNotification { - type: NotificationType.RefreshMelted; -} - -export interface RefreshRevealedNotification { - type: NotificationType.RefreshRevealed; -} - -export interface RefreshStartedNotification { - type: NotificationType.RefreshStarted; -} - -export interface RefreshRefusedNotification { - type: NotificationType.RefreshRefused; -} - -export interface ReserveUpdatedNotification { - type: NotificationType.ReserveUpdated; -} - -export interface ReserveConfirmedNotification { - type: NotificationType.ReserveConfirmed; -} - -export interface WithdrawSessionCreatedNotification { - type: NotificationType.WithdrawSessionCreated; - withdrawSessionId: string; -} - -export interface WithdrawSessionFinishedNotification { - type: NotificationType.WithdrawSessionFinished; - withdrawSessionId: string; -} - -export interface ReserveDepletedNotification { - type: NotificationType.ReserveDepleted; - reservePub: string; -} - -export interface WaitingForRetryNotification { - type: NotificationType.WaitingForRetry; - numPending: number; - numGivingLiveness: number; -} - -export interface RefundFinishedNotification { - type: NotificationType.RefundFinished; -} - -export interface ExchangeOperationErrorNotification { - type: NotificationType.ExchangeOperationError; -} - -export interface RefreshOperationErrorNotification { - type: NotificationType.RefreshOperationError; -} - -export interface RefundStatusOperationErrorNotification { - type: NotificationType.RefundStatusOperationError; -} - -export interface RefundApplyOperationErrorNotification { - type: NotificationType.RefundApplyOperationError; -} - -export interface PayOperationErrorNotification { - type: NotificationType.PayOperationError; -} - -export interface ProposalOperationErrorNotification { - type: NotificationType.ProposalOperationError; -} - -export interface TipOperationErrorNotification { - type: NotificationType.TipOperationError; -} - -export interface WithdrawOperationErrorNotification { - type: NotificationType.WithdrawOperationError; -} - -export interface ReserveOperationErrorNotification { - type: NotificationType.ReserveOperationError; -} - -export interface ReserveCreatedNotification { - type: NotificationType.ReserveCreated; -} - -export interface WildcardNotification { - type: NotificationType.Wildcard; -} - -export type WalletNotification = - | WithdrawOperationErrorNotification - | ReserveOperationErrorNotification - | ExchangeOperationErrorNotification - | RefreshOperationErrorNotification - | RefundStatusOperationErrorNotification - | RefundApplyOperationErrorNotification - | ProposalOperationErrorNotification - | PayOperationErrorNotification - | TipOperationErrorNotification - | ProposalAcceptedNotification - | ProposalDownloadedNotification - | RefundsSubmittedNotification - | PaybackStartedNotification - | PaybackFinishedNotification - | RefreshMeltedNotification - | RefreshRevealedNotification - | RefreshStartedNotification - | RefreshRefusedNotification - | ReserveUpdatedNotification - | ReserveCreatedNotification - | ReserveConfirmedNotification - | WithdrawSessionFinishedNotification - | ReserveDepletedNotification - | WaitingForRetryNotification - | RefundStartedNotification - | RefundFinishedNotification - | RefundQueriedNotification - | WithdrawSessionCreatedNotification - | CoinWithdrawnNotification - | WildcardNotification; - -export interface OperationError { - type: string; - message: string; - details: any; -} - -export interface PendingExchangeUpdateOperation { - type: "exchange-update"; - stage: string; - reason: string; - exchangeBaseUrl: string; - lastError: OperationError | undefined; -} - -export interface PendingBugOperation { - type: "bug"; - message: string; - details: any; -} - -export interface PendingReserveOperation { - type: "reserve"; - retryInfo: RetryInfo | undefined; - stage: string; - timestampCreated: Timestamp; - reserveType: string; - reservePub: string; - bankWithdrawConfirmUrl?: string; -} - -export interface PendingRefreshOperation { - type: "refresh"; - lastError?: OperationError; - refreshSessionId: string; - oldCoinPub: string; - refreshStatus: string; - refreshOutputSize: number; -} - -export interface PendingDirtyCoinOperation { - type: "dirty-coin"; - coinPub: string; -} - -export interface PendingProposalDownloadOperation { - type: "proposal-download"; - merchantBaseUrl: string; - proposalTimestamp: Timestamp; - proposalId: string; - orderId: string; - lastError?: OperationError; - retryInfo: RetryInfo; -} - -/** - * User must choose whether to accept or reject the merchant's - * proposed contract terms. - */ -export interface PendingProposalChoiceOperation { - type: "proposal-choice"; - merchantBaseUrl: string; - proposalTimestamp: Timestamp; - proposalId: string; -} - -export interface PendingTipOperation { - type: "tip"; - tipId: string; - merchantBaseUrl: string; - merchantTipId: string; -} - -export interface PendingPayOperation { - type: "pay"; - proposalId: string; - isReplay: boolean; - retryInfo: RetryInfo, - lastError: OperationError | undefined; -} - -export interface PendingRefundQueryOperation { - type: "refund-query"; - proposalId: string; - retryInfo: RetryInfo, - lastError: OperationError | undefined; -} - -export interface PendingRefundApplyOperation { - type: "refund-apply"; - proposalId: string; - retryInfo: RetryInfo, - lastError: OperationError | undefined; - numRefundsPending: number; - numRefundsDone: number; -} - -export interface PendingOperationInfoCommon { - type: string; - givesLifeness: boolean; -} - -export type PendingOperationInfo = PendingOperationInfoCommon & - ( - | PendingWithdrawOperation - | PendingReserveOperation - | PendingBugOperation - | PendingDirtyCoinOperation - | PendingExchangeUpdateOperation - | PendingRefreshOperation - | PendingTipOperation - | PendingProposalDownloadOperation - | PendingProposalChoiceOperation - | PendingPayOperation - | PendingRefundQueryOperation - | PendingRefundApplyOperation - ); - -export interface PendingOperationsResponse { - pendingOperations: PendingOperationInfo[]; - nextRetryDelay: Duration; -} - -export interface HistoryQuery { - /** - * Verbosity of history events. - * Level 0: Only withdraw, pay, tip and refund events. - * Level 1: All events. - */ - level: number; -} - -@Checkable.Class() -export class Timestamp { - /** - * Timestamp in milliseconds. - */ - @Checkable.Number() - readonly t_ms: number; - - static checked: (obj: any) => Timestamp; -} - -export interface Duration { - /** - * Duration in milliseconds. - */ - readonly d_ms: number; -} - -export function getTimestampNow(): Timestamp { - return { - t_ms: new Date().getTime(), - }; -} - -export interface PlanchetCreationResult { - coinPub: string; - coinPriv: string; - reservePub: string; - denomPubHash: string; - denomPub: string; - blindingKey: string; - withdrawSig: string; - coinEv: string; - coinValue: AmountJson; -} - -export interface PlanchetCreationRequest { - value: AmountJson; - feeWithdraw: AmountJson; - denomPub: string; - reservePub: string; - reservePriv: string; -} diff --git a/src/webex/i18n.tsx b/src/webex/i18n.tsx new file mode 100644 index 000000000..3923654e7 --- /dev/null +++ b/src/webex/i18n.tsx @@ -0,0 +1,267 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see + */ + +/** + * Translation helpers for React components and template literals. + */ + +/** + * Imports. + */ +import {strings} from "../i18n/strings"; + +// @ts-ignore: no type decl for this library +import * as jedLib from "jed"; + +import * as React from "react"; + + +const jed = setupJed(); + +let enableTracing = false; + + +/** + * Set up jed library for internationalization, + * based on browser language settings. + */ +function setupJed(): any { + let lang: string; + try { + lang = chrome.i18n.getUILanguage(); + // Chrome gives e.g. "en-US", but Firefox gives us "en_US" + lang = lang.replace("_", "-"); + } catch (e) { + lang = "en"; + console.warn("i18n default language not available"); + } + + if (!strings[lang]) { + lang = "en-US"; + console.log(`language ${lang} not found, defaulting to english`); + } + return new jedLib.Jed(strings[lang]); +} + + +/** + * Convert template strings to a msgid + */ +function toI18nString(stringSeq: ReadonlyArray) { + let s = ""; + for (let i = 0; i < stringSeq.length; i++) { + s += stringSeq[i]; + if (i < stringSeq.length - 1) { + s += `%${i + 1}$s`; + } + } + return s; +} + + +/** + * Internationalize a string template with arbitrary serialized values. + */ +export function str(stringSeq: TemplateStringsArray, ...values: any[]) { + const s = toI18nString(stringSeq); + const tr = jed.translate(s).ifPlural(1, s).fetch(...values); + return tr; +} + + +interface TranslateSwitchProps { + target: number; +} + + +function stringifyChildren(children: any): string { + let n = 1; + const ss = React.Children.map(children, (c) => { + if (typeof c === "string") { + return c; + } + return `%${n++}$s`; + }); + const s = ss.join("").replace(/ +/g, " ").trim(); + enableTracing && console.log("translation lookup", JSON.stringify(s)); + return s; +} + + +interface TranslateProps { + /** + * Component that the translated element should be wrapped in. + * Defaults to "div". + */ + wrap?: any; + + /** + * Props to give to the wrapped component. + */ + wrapProps?: any; +} + + +/** + * Translate text node children of this component. + * If a child component might produce a text node, it must be wrapped + * in a another non-text element. + * + * Example: + * ``` + * + * Hello. Your score is + * + * ``` + */ +export class Translate extends React.Component { + render(): JSX.Element { + const s = stringifyChildren(this.props.children); + const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); + const childArray = React.Children.toArray(this.props.children!); + for (let i = 0; i < childArray.length - 1; ++i) { + if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { + childArray[i + 1] = (childArray[i] as string).concat(childArray[i + 1] as string); + childArray.splice(i, 1); + } + } + const result = []; + while (childArray.length > 0) { + const x = childArray.shift(); + if (x === undefined) { + continue; + } + if (typeof x === "string") { + const t = tr.shift(); + result.push(t); + } else { + result.push(x); + } + } + if (!this.props.wrap) { + return
{result}
; + } + return React.createElement(this.props.wrap, this.props.wrapProps, result); + } +} + + +/** + * Switch translation based on singular or plural based on the target prop. + * Should only contain TranslateSingular and TransplatePlural as children. + * + * Example: + * ``` + * + * I have {n} apple. + * I have {n} apples. + * + * ``` + */ +export class TranslateSwitch extends React.Component { + render(): JSX.Element { + let singular: React.ReactElement | undefined; + let plural: React.ReactElement | undefined; + const children = this.props.children; + if (children) { + React.Children.forEach(children, (child: any) => { + if (child.type === TranslatePlural) { + plural = child; + } + if (child.type === TranslateSingular) { + singular = child; + } + }); + } + if ((!singular) || (!plural)) { + console.error("translation not found"); + return React.createElement("span", {}, ["translation not found"]); + } + singular.props.target = this.props.target; + plural.props.target = this.props.target; + // We're looking up the translation based on the + // singular, even if we must use the plural form. + return singular; + } +} + + +interface TranslationPluralProps { + target: number; +} + +/** + * See [[TranslateSwitch]]. + */ +export class TranslatePlural extends React.Component { + render(): JSX.Element { + const s = stringifyChildren(this.props.children); + const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); + const childArray = React.Children.toArray(this.props.children!); + for (let i = 0; i < childArray.length - 1; ++i) { + if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { + childArray[i + i] = childArray[i] as string + childArray[i + 1] as string; + childArray.splice(i, 1); + } + } + const result = []; + while (childArray.length > 0) { + const x = childArray.shift(); + if (x === undefined) { + continue; + } + if (typeof x === "string") { + const t = tr.shift(); + result.push(t); + } else { + result.push(x); + } + } + return
{result}
; + } +} + + +/** + * See [[TranslateSwitch]]. + */ +export class TranslateSingular extends React.Component { + render(): JSX.Element { + const s = stringifyChildren(this.props.children); + const tr = jed.ngettext(s, s, 1).split(/%(\d+)\$s/).filter((e: any, i: number) => i % 2 === 0); + const childArray = React.Children.toArray(this.props.children!); + for (let i = 0; i < childArray.length - 1; ++i) { + if ((typeof childArray[i]) === "string" && (typeof childArray[i + 1]) === "string") { + childArray[i + i] = childArray[i] as string + childArray[i + 1] as string; + childArray.splice(i, 1); + } + } + const result = []; + while (childArray.length > 0) { + const x = childArray.shift(); + if (x === undefined) { + continue; + } + if (typeof x === "string") { + const t = tr.shift(); + result.push(t); + } else { + result.push(x); + } + } + return
{result}
; + } +} diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 4aaf75b2b..579dd4347 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -22,11 +22,12 @@ /* tslint:disable:completed-docs */ import { AmountJson } from "../util/amounts"; -import * as dbTypes from "../dbTypes"; -import * as talerTypes from "../talerTypes"; -import * as walletTypes from "../walletTypes"; +import * as dbTypes from "../types/dbTypes"; +import * as talerTypes from "../types/talerTypes"; +import * as walletTypes from "../types/walletTypes"; import { UpgradeResponse } from "./wxApi"; +import { HistoryEvent } from "../types/history"; /** * Message type information. @@ -79,7 +80,7 @@ export interface MessageMap { }; "get-history": { request: {}; - response: walletTypes.HistoryEvent[]; + response: HistoryEvent[]; }; "get-coins": { request: { exchangeBaseUrl: string }; diff --git a/src/webex/pages/add-auditor.tsx b/src/webex/pages/add-auditor.tsx index 766db9c5d..0f681aae4 100644 --- a/src/webex/pages/add-auditor.tsx +++ b/src/webex/pages/add-auditor.tsx @@ -20,7 +20,7 @@ * @author Florian Dold */ -import { CurrencyRecord } from "../../dbTypes"; +import { CurrencyRecord } from "../../types/dbTypes"; import { getCurrencies, updateCurrency } from "../wxApi"; import React, { useState } from "react"; import { registerMountPage } from "../renderHtml"; diff --git a/src/webex/pages/auditors.tsx b/src/webex/pages/auditors.tsx index 276a7e8e1..876cf326b 100644 --- a/src/webex/pages/auditors.tsx +++ b/src/webex/pages/auditors.tsx @@ -25,7 +25,7 @@ import { AuditorRecord, CurrencyRecord, ExchangeForCurrencyRecord, -} from "../../dbTypes"; +} from "../../types/dbTypes"; import { getCurrencies, diff --git a/src/webex/pages/benchmark.tsx b/src/webex/pages/benchmark.tsx index b250bc20a..fe874f2b7 100644 --- a/src/webex/pages/benchmark.tsx +++ b/src/webex/pages/benchmark.tsx @@ -21,9 +21,9 @@ * @author Florian Dold */ -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; -import { BenchmarkResult } from "../../walletTypes"; +import { BenchmarkResult } from "../../types/walletTypes"; import * as wxApi from "../wxApi"; diff --git a/src/webex/pages/pay.tsx b/src/webex/pages/pay.tsx index cff2f9461..eca115e78 100644 --- a/src/webex/pages/pay.tsx +++ b/src/webex/pages/pay.tsx @@ -22,9 +22,9 @@ /** * Imports. */ -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; -import { PreparePayResult } from "../../walletTypes"; +import { PreparePayResult } from "../../types/walletTypes"; import { renderAmount, ProgressButton, registerMountPage } from "../renderHtml"; import * as wxApi from "../wxApi"; diff --git a/src/webex/pages/payback.tsx b/src/webex/pages/payback.tsx index 806bef17c..a25b5c6b2 100644 --- a/src/webex/pages/payback.tsx +++ b/src/webex/pages/payback.tsx @@ -23,7 +23,7 @@ /** * Imports. */ -import { ReserveRecord } from "../../dbTypes"; +import { ReserveRecord } from "../../types/dbTypes"; import { renderAmount, registerMountPage } from "../renderHtml"; import { getPaybackReserves, withdrawPaybackReserve } from "../wxApi"; import * as React from "react"; diff --git a/src/webex/pages/popup.tsx b/src/webex/pages/popup.tsx index 27d5dddba..3a2856d64 100644 --- a/src/webex/pages/popup.tsx +++ b/src/webex/pages/popup.tsx @@ -24,16 +24,15 @@ /** * Imports. */ -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; import { AmountJson } from "../../util/amounts"; import * as Amounts from "../../util/amounts"; import { - HistoryEvent, WalletBalance, WalletBalanceEntry, -} from "../../walletTypes"; +} from "../../types/walletTypes"; import { abbrev, @@ -44,6 +43,7 @@ import { import * as wxApi from "../wxApi"; import * as React from "react"; +import { HistoryEvent } from "../../types/history"; function onUpdateNotification(f: () => void): () => void { const port = chrome.runtime.connect({ name: "notifications" }); diff --git a/src/webex/pages/refund.tsx b/src/webex/pages/refund.tsx index 5196c9ea6..2a3f65d21 100644 --- a/src/webex/pages/refund.tsx +++ b/src/webex/pages/refund.tsx @@ -24,7 +24,7 @@ import React, { useEffect, useState } from "react"; import ReactDOM from "react-dom"; import * as wxApi from "../wxApi"; -import { PurchaseDetails } from "../../walletTypes"; +import { PurchaseDetails } from "../../types/walletTypes"; import { AmountView } from "../renderHtml"; function RefundStatusView(props: { talerRefundUri: string }) { diff --git a/src/webex/pages/return-coins.tsx b/src/webex/pages/return-coins.tsx index be65b4121..7c835da0a 100644 --- a/src/webex/pages/return-coins.tsx +++ b/src/webex/pages/return-coins.tsx @@ -31,9 +31,9 @@ import * as Amounts from "../../util/amounts"; import { SenderWireInfos, WalletBalance, -} from "../../walletTypes"; +} from "../../types/walletTypes"; -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; import * as wire from "../../util/wire"; diff --git a/src/webex/pages/tip.tsx b/src/webex/pages/tip.tsx index ac904cf0d..c44b343a4 100644 --- a/src/webex/pages/tip.tsx +++ b/src/webex/pages/tip.tsx @@ -24,7 +24,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; import { acceptTip, getReserveCreationInfo, getTipStatus } from "../wxApi"; @@ -32,7 +32,7 @@ import { WithdrawDetailView, renderAmount, ProgressButton } from "../renderHtml" import * as Amounts from "../../util/amounts"; import { useState, useEffect } from "react"; -import { TipStatus } from "../../walletTypes"; +import { TipStatus } from "../../types/walletTypes"; function TipDisplay(props: { talerTipUri: string }) { diff --git a/src/webex/pages/welcome.tsx b/src/webex/pages/welcome.tsx index 1026e6e6e..e8f7028ed 100644 --- a/src/webex/pages/welcome.tsx +++ b/src/webex/pages/welcome.tsx @@ -23,7 +23,7 @@ import React, { useState, useEffect } from "react"; import { getDiagnostics } from "../wxApi"; import { registerMountPage, PageLink } from "../renderHtml"; -import { WalletDiagnostics } from "../../walletTypes"; +import { WalletDiagnostics } from "../../types/walletTypes"; function Diagnostics() { const [timedOut, setTimedOut] = useState(false); diff --git a/src/webex/pages/withdraw.tsx b/src/webex/pages/withdraw.tsx index 3ee0f768a..9d84ff3a6 100644 --- a/src/webex/pages/withdraw.tsx +++ b/src/webex/pages/withdraw.tsx @@ -22,11 +22,11 @@ */ -import * as i18n from "../../i18n"; +import * as i18n from "../i18n"; import { WithdrawDetails, -} from "../../walletTypes"; +} from "../../types/walletTypes"; import { WithdrawDetailView, renderAmount } from "../renderHtml"; diff --git a/src/webex/renderHtml.tsx b/src/webex/renderHtml.tsx index bf9cdc76f..767058ebf 100644 --- a/src/webex/renderHtml.tsx +++ b/src/webex/renderHtml.tsx @@ -25,10 +25,10 @@ */ import { AmountJson } from "../util/amounts"; import * as Amounts from "../util/amounts"; -import { DenominationRecord } from "../dbTypes"; -import { ExchangeWithdrawDetails } from "../walletTypes"; +import { DenominationRecord } from "../types/dbTypes"; +import { ExchangeWithdrawDetails } from "../types/walletTypes"; import * as moment from "moment"; -import * as i18n from "../i18n"; +import * as i18n from "./i18n"; import React from "react"; import ReactDOM from "react-dom"; diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index b0af7ac29..1383ffbc3 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -30,7 +30,7 @@ import { ExchangeRecord, PlanchetRecord, ReserveRecord, -} from "../dbTypes"; +} from "../types/dbTypes"; import { BenchmarkResult, ConfirmPayResult, @@ -40,7 +40,7 @@ import { WalletBalance, PurchaseDetails, WalletDiagnostics, -} from "../walletTypes"; +} from "../types/walletTypes"; import { MessageMap, MessageType } from "./messages"; diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 27141247e..f3f4d80eb 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -30,11 +30,11 @@ import { CreateReserveRequest, ReturnCoinsRequest, WalletDiagnostics, -} from "../walletTypes"; +} from "../types/walletTypes"; import { Wallet } from "../wallet"; import { isFirefox } from "./compat"; -import { WALLET_DB_VERSION } from "../dbTypes"; -import { openTalerDb, exportDb, importDb, deleteDb } from "../db"; +import { WALLET_DB_VERSION } from "../types/dbTypes"; +import { openDatabase, exportDatabase, importDatabase, deleteDatabase } from "../db"; import { ChromeBadge } from "./chromeBadge"; import { MessageType } from "./messages"; import * as wxApi from "./wxApi"; @@ -73,11 +73,11 @@ async function handleMessage( } case "dump-db": { const db = needsWallet().db; - return exportDb(db); + return exportDatabase(db); } case "import-db": { const db = needsWallet().db; - return importDb(db, detail.dump); + return importDatabase(db, detail.dump); } case "ping": { return Promise.resolve(); @@ -91,7 +91,7 @@ async function handleMessage( tx.objectStore(db.objectStoreNames[i]).clear(); } } - deleteDb(indexedDB); + deleteDatabase(indexedDB); setBadgeText({ text: "" }); console.log("reset done"); if (!currentWallet) { @@ -423,7 +423,7 @@ async function reinitWallet() { setBadgeText({ text: "" }); const badge = new ChromeBadge(); try { - currentDatabase = await openTalerDb( + currentDatabase = await openDatabase( indexedDB, reinitWallet, handleUpgradeUnsupported, diff --git a/tsconfig.json b/tsconfig.json index 2af0ca65b..8d696591c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,18 +36,34 @@ "src/crypto/workers/nodeThreadWorker.ts", "src/crypto/workers/synchronousWorker.ts", "src/db.ts", - "src/dbTypes.ts", "src/headless/bank.ts", "src/headless/clk.ts", "src/headless/helpers.ts", "src/headless/integrationtest.ts", "src/headless/merchant.ts", "src/headless/taler-wallet-cli.ts", - "src/i18n.tsx", "src/i18n/strings.ts", "src/index.ts", - "src/talerTypes.ts", - "src/types-test.ts", + "src/operations/balance.ts", + "src/operations/errors.ts", + "src/operations/exchanges.ts", + "src/operations/history.ts", + "src/operations/pay.ts", + "src/operations/payback.ts", + "src/operations/pending.ts", + "src/operations/refresh.ts", + "src/operations/reserves.ts", + "src/operations/return.ts", + "src/operations/state.ts", + "src/operations/tip.ts", + "src/operations/withdraw.ts", + "src/types/dbTypes.ts", + "src/types/history.ts", + "src/types/notifications.ts", + "src/types/pending.ts", + "src/types/talerTypes.ts", + "src/types/types-test.ts", + "src/types/walletTypes.ts", "src/util/RequestThrottler.ts", "src/util/amounts.ts", "src/util/assertUnreachable.ts", @@ -67,25 +83,12 @@ "src/util/taleruri.ts", "src/util/timer.ts", "src/util/wire.ts", - "src/wallet-impl/balance.ts", - "src/wallet-impl/errors.ts", - "src/wallet-impl/exchanges.ts", - "src/wallet-impl/history.ts", - "src/wallet-impl/pay.ts", - "src/wallet-impl/payback.ts", - "src/wallet-impl/pending.ts", - "src/wallet-impl/refresh.ts", - "src/wallet-impl/reserves.ts", - "src/wallet-impl/return.ts", - "src/wallet-impl/state.ts", - "src/wallet-impl/tip.ts", - "src/wallet-impl/withdraw.ts", "src/wallet-test.ts", "src/wallet.ts", - "src/walletTypes.ts", "src/webex/background.ts", "src/webex/chromeBadge.ts", "src/webex/compat.ts", + "src/webex/i18n.tsx", "src/webex/messages.ts", "src/webex/notify.ts", "src/webex/pages/add-auditor.tsx", -- cgit v1.2.3