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