diff options
author | Florian Dold <florian.dold@gmail.com> | 2016-11-13 23:30:18 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2016-11-13 23:31:17 +0100 |
commit | f3fb8be7db6de87dae40d41bd5597a735c800ca1 (patch) | |
tree | 1a061db04de8f5bb5a6b697fa56a9948f67fac2f /lib/wallet/wallet.ts | |
parent | 200d83c3886149ebb3f018530302079e12a81f6b (diff) |
restructuring
Diffstat (limited to 'lib/wallet/wallet.ts')
-rw-r--r-- | lib/wallet/wallet.ts | 1657 |
1 files changed, 0 insertions, 1657 deletions
diff --git a/lib/wallet/wallet.ts b/lib/wallet/wallet.ts deleted file mode 100644 index 9fb6e5a27..000000000 --- a/lib/wallet/wallet.ts +++ /dev/null @@ -1,1657 +0,0 @@ -/* - This file is part of TALER - (C) 2015 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/> - */ - -/** - * High-level wallet operations that should be indepentent from the underlying - * browser extension interface. - * @module Wallet - * @author Florian Dold - */ - -import { - AmountJson, - Amounts, - CheckRepurchaseResult, - Coin, - CoinPaySig, - Contract, - CreateReserveResponse, - Denomination, - ExchangeHandle, - IExchangeInfo, - Notifier, - PayCoinInfo, - PreCoin, - RefreshSession, - ReserveCreationInfo, - ReserveRecord, - WalletBalance, - WalletBalanceEntry, - WireInfo, -} from "./types"; -import { - HttpRequestLibrary, - HttpResponse, - RequestException, -} from "./http"; -import { - AbortTransaction, - Index, - JoinResult, - QueryRoot, - Store, -} from "./query"; -import {Checkable} from "./checkable"; -import { - amountToPretty, - canonicalizeBaseUrl, - canonicalJson, - deepEquals, - flatMap, - getTalerStampSec, -} from "./helpers"; -import {CryptoApi} from "./cryptoApi"; - -"use strict"; - -export interface CoinWithDenom { - coin: Coin; - denom: Denomination; -} - - -@Checkable.Class -export class KeysJson { - @Checkable.List(Checkable.Value(Denomination)) - denoms: Denomination[]; - - @Checkable.String - master_public_key: string; - - @Checkable.Any - auditors: any[]; - - @Checkable.String - list_issue_date: string; - - @Checkable.Any - signkeys: any; - - @Checkable.String - eddsa_pub: string; - - @Checkable.String - eddsa_sig: string; - - static checked: (obj: any) => KeysJson; -} - - -@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; - - static checked: (obj: any) => CreateReserveRequest; -} - - -@Checkable.Class -export class ConfirmReserveRequest { - /** - * Public key of then reserve that should be marked - * as confirmed. - */ - @Checkable.String - reservePub: string; - - static checked: (obj: any) => ConfirmReserveRequest; -} - - -@Checkable.Class -export class Offer { - @Checkable.Value(Contract) - contract: Contract; - - @Checkable.String - merchant_sig: string; - - @Checkable.String - H_contract: string; - - @Checkable.Number - offer_time: number; - - /** - * Serial ID when the offer is stored in the wallet DB. - */ - @Checkable.Optional(Checkable.Number) - id?: number; - - static checked: (obj: any) => Offer; -} - -export interface HistoryRecord { - type: string; - timestamp: number; - subjectId?: string; - detail: any; - level: HistoryLevel; -} - - -interface ExchangeCoins { - [exchangeUrl: string]: CoinWithDenom[]; -} - -interface PayReq { - amount: AmountJson; - coins: CoinPaySig[]; - H_contract: string; - max_fee: AmountJson; - merchant_sig: string; - exchange: string; - refund_deadline: string; - timestamp: string; - transaction_id: number; - pay_deadline: string; - /** - * Merchant instance identifier that should receive the - * payment, if applicable. - */ - instance?: string; -} - -interface Transaction { - contractHash: string; - contract: Contract; - payReq: PayReq; - merchantSig: string; - - /** - * The transaction isn't active anymore, it's either successfully paid - * or refunded/aborted. - */ - finished: boolean; -} - -export enum HistoryLevel { - Trace = 1, - Developer = 2, - Expert = 3, - User = 4, -} - - -export interface Badge { - setText(s: string): void; - setColor(c: string): void; - startBusy(): void; - stopBusy(): void; -} - - -function setTimeout(f: any, t: number) { - return chrome.extension.getBackgroundPage().setTimeout(f, t); -} - - -function isWithdrawableDenom(d: Denomination) { - const now_sec = (new Date).getTime() / 1000; - const stamp_withdraw_sec = getTalerStampSec(d.stamp_expire_withdraw); - const stamp_start_sec = getTalerStampSec(d.stamp_start); - // Withdraw if still possible to withdraw within a minute - if ((stamp_withdraw_sec + 60 > now_sec) && (now_sec >= stamp_start_sec)) { - return true; - } - return false; -} - - -/** - * Result of updating exisiting information - * about an exchange with a new '/keys' response. - */ -interface KeyUpdateInfo { - updatedExchangeInfo: IExchangeInfo; - addedDenominations: Denomination[]; - removedDenominations: Denomination[]; -} - - -/** - * Get a list of denominations (with repetitions possible) - * whose total value is as close as possible to the available - * amount, but never larger. - */ -function getWithdrawDenomList(amountAvailable: AmountJson, - denoms: Denomination[]): Denomination[] { - let remaining = Amounts.copy(amountAvailable); - const ds: Denomination[] = []; - - 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 (let d of denoms) { - let cost = Amounts.add(d.value, d.fee_withdraw).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; -} - - -export namespace Stores { - class ExchangeStore extends Store<IExchangeInfo> { - constructor() { - super("exchanges", {keyPath: "baseUrl"}); - } - - pubKeyIndex = new Index<string,IExchangeInfo>(this, "pubKey", "masterPublicKey"); - } - - class CoinsStore extends Store<Coin> { - constructor() { - super("coins", {keyPath: "coinPub"}); - } - - exchangeBaseUrlIndex = new Index<string,Coin>(this, "exchangeBaseUrl", "exchangeBaseUrl"); - } - - class HistoryStore extends Store<HistoryRecord> { - constructor() { - super("history", { - keyPath: "id", - autoIncrement: true - }); - } - - timestampIndex = new Index<number,HistoryRecord>(this, "timestamp", "timestamp"); - } - - class OffersStore extends Store<Offer> { - constructor() { - super("offers", { - keyPath: "id", - autoIncrement: true - }); - } - } - - class TransactionsStore extends Store<Transaction> { - constructor() { - super("transactions", {keyPath: "contractHash"}); - } - - repurchaseIndex = new Index<[string,string],Transaction>(this, "repurchase", [ - "contract.merchant_pub", - "contract.repurchase_correlation_id" - ]); - } - - export let exchanges: ExchangeStore = new ExchangeStore(); - export let transactions: TransactionsStore = new TransactionsStore(); - export let reserves: Store<ReserveRecord> = new Store<ReserveRecord>("reserves", {keyPath: "reserve_pub"}); - export let coins: CoinsStore = new CoinsStore(); - export let refresh: Store<RefreshSession> = new Store<RefreshSession>("refresh", {keyPath: "meltCoinPub"}); - export let history: HistoryStore = new HistoryStore(); - export let offers: OffersStore = new OffersStore(); - export let precoins: Store<PreCoin> = new Store<PreCoin>("precoins", {keyPath: "coinPub"}); -} - - -export class Wallet { - private db: IDBDatabase; - private http: HttpRequestLibrary; - private badge: Badge; - private notifier: Notifier; - public cryptoApi: CryptoApi; - - /** - * Set of identifiers for running operations. - */ - private runningOperations: Set<string> = new Set(); - - q(): QueryRoot { - return new QueryRoot(this.db); - } - - constructor(db: IDBDatabase, - http: HttpRequestLibrary, - badge: Badge, - notifier: Notifier) { - this.db = db; - this.http = http; - this.badge = badge; - this.notifier = notifier; - this.cryptoApi = new CryptoApi(); - - this.resumePendingFromDb(); - } - - - private startOperation(operationId: string) { - this.runningOperations.add(operationId); - this.badge.startBusy(); - } - - private stopOperation(operationId: string) { - this.runningOperations.delete(operationId); - if (this.runningOperations.size == 0) { - this.badge.stopBusy(); - } - } - - async updateExchanges(): Promise<void> { - console.log("updating exchanges"); - - let exchangesUrls = await this.q() - .iter(Stores.exchanges) - .map((e) => e.baseUrl) - .toArray(); - - for (let url of exchangesUrls) { - this.updateExchangeFromUrl(url) - .catch((e) => { - console.error("updating exchange failed", e); - }); - } - } - - /** - * Resume various pending operations that are pending - * by looking at the database. - */ - private resumePendingFromDb(): void { - console.log("resuming pending operations from db"); - - this.q() - .iter(Stores.reserves) - .reduce((reserve) => { - console.log("resuming reserve", reserve.reserve_pub); - this.processReserve(reserve); - }); - - this.q() - .iter(Stores.precoins) - .reduce((preCoin) => { - console.log("resuming precoin"); - this.processPreCoin(preCoin); - }); - - this.q() - .iter(Stores.refresh) - .reduce((r: RefreshSession) => { - this.continueRefreshSession(r); - }); - - // FIXME: optimize via index - this.q() - .iter(Stores.coins) - .reduce((c: Coin) => { - if (c.dirty && !c.transactionPending) { - this.refresh(c.coinPub); - } - }); - } - - - /** - * Get exchanges and associated coins that are still spendable, - * but only if the sum the coins' remaining value exceeds the payment amount. - */ - private async getPossibleExchangeCoins(paymentAmount: AmountJson, - depositFeeLimit: AmountJson, - allowedExchanges: ExchangeHandle[]): Promise<ExchangeCoins> { - // Mapping from exchange base URL to list of coins together with their - // denomination - let m: ExchangeCoins = {}; - - let x: number; - - function storeExchangeCoin(mc: JoinResult<IExchangeInfo, Coin>, - url: string) { - let exchange: IExchangeInfo = mc.left; - console.log("got coin for exchange", url); - let coin: Coin = mc.right; - if (coin.suspended) { - console.log("skipping suspended coin", - coin.denomPub, - "from exchange", - exchange.baseUrl); - return; - } - let denom = exchange.active_denoms.find((e) => e.denom_pub === coin.denomPub); - if (!denom) { - console.warn("denom not found (database inconsistent)"); - return; - } - if (denom.value.currency !== paymentAmount.currency) { - console.warn("same pubkey for different currencies"); - return; - } - let cd = {coin, denom}; - let x = m[url]; - if (!x) { - m[url] = [cd]; - } else { - x.push(cd); - } - } - - // Make sure that we don't look up coins - // for the same URL twice ... - let handledExchanges = new Set(); - - let ps = flatMap(allowedExchanges, (info: ExchangeHandle) => { - if (handledExchanges.has(info.url)) { - return []; - } - handledExchanges.add(info.url); - console.log("Checking for merchant's exchange", JSON.stringify(info)); - return [ - this.q() - .iterIndex(Stores.exchanges.pubKeyIndex, info.master_pub) - .indexJoin(Stores.coins.exchangeBaseUrlIndex, - (exchange) => exchange.baseUrl) - .reduce((x) => storeExchangeCoin(x, info.url)) - ]; - }); - - await Promise.all(ps); - - let ret: ExchangeCoins = {}; - - if (Object.keys(m).length == 0) { - console.log("not suitable exchanges found"); - } - - console.log("exchange coins:"); - console.dir(m); - - // We try to find the first exchange where we have - // enough coins to cover the paymentAmount with fees - // under depositFeeLimit - - nextExchange: - for (let key in m) { - let coins = m[key]; - // Sort by ascending deposit fee - coins.sort((o1, o2) => Amounts.cmp(o1.denom.fee_deposit, - o2.denom.fee_deposit)); - let maxFee = Amounts.copy(depositFeeLimit); - let minAmount = Amounts.copy(paymentAmount); - let accFee = Amounts.copy(coins[0].denom.fee_deposit); - let accAmount = Amounts.getZero(coins[0].coin.currentAmount.currency); - let usableCoins: CoinWithDenom[] = []; - nextCoin: - for (let i = 0; i < coins.length; i++) { - let coinAmount = Amounts.copy(coins[i].coin.currentAmount); - let coinFee = coins[i].denom.fee_deposit; - if (Amounts.cmp(coinAmount, coinFee) <= 0) { - continue nextCoin; - } - accFee = Amounts.add(accFee, coinFee).amount; - accAmount = Amounts.add(accAmount, coinAmount).amount; - if (Amounts.cmp(accFee, maxFee) >= 0) { - // FIXME: if the fees are too high, we have - // to cover them ourselves .... - console.log("too much fees"); - continue nextExchange; - } - usableCoins.push(coins[i]); - if (Amounts.cmp(accAmount, minAmount) >= 0) { - ret[key] = usableCoins; - continue nextExchange; - } - } - } - return ret; - } - - - /** - * Record all information that is necessary to - * pay for a contract in the wallet's database. - */ - private async recordConfirmPay(offer: Offer, - payCoinInfo: PayCoinInfo, - chosenExchange: string): Promise<void> { - let payReq: PayReq = { - amount: offer.contract.amount, - coins: payCoinInfo.map((x) => x.sig), - H_contract: offer.H_contract, - max_fee: offer.contract.max_fee, - merchant_sig: offer.merchant_sig, - exchange: URI(chosenExchange).href(), - refund_deadline: offer.contract.refund_deadline, - pay_deadline: offer.contract.pay_deadline, - timestamp: offer.contract.timestamp, - transaction_id: offer.contract.transaction_id, - instance: offer.contract.merchant.instance - }; - let t: Transaction = { - contractHash: offer.H_contract, - contract: offer.contract, - payReq: payReq, - merchantSig: offer.merchant_sig, - finished: false, - }; - - let historyEntry: HistoryRecord = { - type: "pay", - timestamp: (new Date).getTime(), - subjectId: `contract-${offer.H_contract}`, - detail: { - merchantName: offer.contract.merchant.name, - amount: offer.contract.amount, - contractHash: offer.H_contract, - fulfillmentUrl: offer.contract.fulfillment_url, - }, - level: HistoryLevel.User - }; - - await this.q() - .put(Stores.transactions, t) - .put(Stores.history, historyEntry) - .putAll(Stores.coins, payCoinInfo.map((pci) => pci.updatedCoin)) - .finish(); - - this.notifier.notify(); - } - - - async putHistory(historyEntry: HistoryRecord): Promise<void> { - await this.q().put(Stores.history, historyEntry).finish(); - this.notifier.notify(); - } - - - async saveOffer(offer: Offer): Promise<number> { - console.log(`saving offer in wallet.ts`); - let id = await this.q().putWithResult(Stores.offers, offer); - this.notifier.notify(); - console.log(`saved offer with id ${id}`); - if (typeof id !== "number") { - throw Error("db schema wrong"); - } - return id; - } - - - /** - * Add a contract to the wallet and sign coins, - * but do not send them yet. - */ - async confirmPay(offer: Offer): Promise<any> { - console.log("executing confirmPay"); - - let transaction = await this.q().get(Stores.transactions, offer.H_contract); - - if (transaction) { - // Already payed ... - return {}; - } - - let mcs = await this.getPossibleExchangeCoins(offer.contract.amount, - offer.contract.max_fee, - offer.contract.exchanges); - - if (Object.keys(mcs).length == 0) { - console.log("not confirming payment, insufficient coins"); - return { - error: "coins-insufficient", - }; - } - let exchangeUrl = Object.keys(mcs)[0]; - - let ds = await this.cryptoApi.signDeposit(offer, mcs[exchangeUrl]); - await this.recordConfirmPay(offer, - ds, - exchangeUrl); - return {}; - } - - - /** - * Add a contract to the wallet and sign coins, - * but do not send them yet. - */ - async checkPay(offer: Offer): Promise<any> { - // First check if we already payed for it. - let transaction = await this.q().get(Stores.transactions, offer.H_contract); - if (transaction) { - return {isPayed: true}; - } - - // If not already payed, check if we could pay for it. - let mcs = await this.getPossibleExchangeCoins(offer.contract.amount, - offer.contract.max_fee, - offer.contract.exchanges); - - if (Object.keys(mcs).length == 0) { - console.log("not confirming payment, insufficient coins"); - return { - error: "coins-insufficient", - }; - } - return {isPayed: false}; - } - - - /** - * Retrieve all necessary information for looking up the contract - * with the given hash. - */ - async executePayment(H_contract: string): Promise<any> { - let t = await this.q().get<Transaction>(Stores.transactions, H_contract); - if (!t) { - return { - success: false, - contractFound: false, - } - } - let resp = { - success: true, - payReq: t.payReq, - contract: t.contract, - }; - return resp; - } - - - /** - * First fetch information requred to withdraw from the reserve, - * then deplete the reserve, withdrawing coins until it is empty. - */ - private async processReserve(reserveRecord: ReserveRecord, - retryDelayMs: number = 250): Promise<void> { - const opId = "reserve-" + reserveRecord.reserve_pub; - this.startOperation(opId); - - try { - let exchange = await this.updateExchangeFromUrl(reserveRecord.exchange_base_url); - let reserve = await this.updateReserve(reserveRecord.reserve_pub, - exchange); - let n = await this.depleteReserve(reserve, exchange); - - if (n != 0) { - let depleted: HistoryRecord = { - type: "depleted-reserve", - subjectId: `reserve-progress-${reserveRecord.reserve_pub}`, - timestamp: (new Date).getTime(), - detail: { - exchangeBaseUrl: reserveRecord.exchange_base_url, - reservePub: reserveRecord.reserve_pub, - requestedAmount: reserveRecord.requested_amount, - currentAmount: reserveRecord.current_amount, - }, - level: HistoryLevel.User - }; - await this.q().put(Stores.history, depleted).finish(); - } - } catch (e) { - // random, exponential backoff truncated at 3 minutes - let nextDelay = Math.min(2 * retryDelayMs + retryDelayMs * Math.random(), - 3000 * 60); - console.warn(`Failed to deplete reserve, trying again in ${retryDelayMs} ms`); - setTimeout(() => this.processReserve(reserveRecord, nextDelay), - retryDelayMs); - } finally { - this.stopOperation(opId); - } - } - - - private async processPreCoin(preCoin: PreCoin, - retryDelayMs = 100): Promise<void> { - - let exchange = await this.q().get(Stores.exchanges, - preCoin.exchangeBaseUrl); - if (!exchange) { - console.error("db inconsistend: exchange for precoin not found"); - return; - } - let denom = exchange.all_denoms.find((d) => d.denom_pub == preCoin.denomPub); - if (!denom) { - console.error("db inconsistent: denom for precoin not found"); - return; - } - - try { - const coin = await this.withdrawExecute(preCoin); - - const mutateReserve = (r: ReserveRecord) => { - - console.log(`before committing coin: current ${amountToPretty(r.current_amount!)}, precoin: ${amountToPretty( - r.precoin_amount)})}`); - - let x = Amounts.sub(r.precoin_amount, - preCoin.coinValue, - denom!.fee_withdraw); - if (x.saturated) { - console.error("database inconsistent"); - throw AbortTransaction; - } - r.precoin_amount = x.amount; - return r; - }; - - let historyEntry: HistoryRecord = { - type: "withdraw", - timestamp: (new Date).getTime(), - level: HistoryLevel.Expert, - detail: { - coinPub: coin.coinPub, - } - }; - - await this.q() - .mutate(Stores.reserves, preCoin.reservePub, mutateReserve) - .delete("precoins", coin.coinPub) - .add(Stores.coins, coin) - .add(Stores.history, historyEntry) - .finish(); - - this.notifier.notify(); - } catch (e) { - console.error("Failed to withdraw coin from precoin, retrying in", - retryDelayMs, - "ms", e); - // exponential backoff truncated at one minute - let nextRetryDelayMs = Math.min(retryDelayMs * 2, 1000 * 60); - setTimeout(() => this.processPreCoin(preCoin, nextRetryDelayMs), - retryDelayMs); - } - } - - - /** - * Create a reserve, but do not flag it as confirmed yet. - */ - async createReserve(req: CreateReserveRequest): Promise<CreateReserveResponse> { - let keypair = await this.cryptoApi.createEddsaKeypair(); - const now = (new Date).getTime(); - const canonExchange = canonicalizeBaseUrl(req.exchange); - - const reserveRecord: ReserveRecord = { - reserve_pub: keypair.pub, - reserve_priv: keypair.priv, - exchange_base_url: canonExchange, - created: now, - last_query: null, - current_amount: null, - requested_amount: req.amount, - confirmed: false, - precoin_amount: Amounts.getZero(req.amount.currency), - }; - - const historyEntry = { - type: "create-reserve", - level: HistoryLevel.Expert, - timestamp: now, - subjectId: `reserve-progress-${reserveRecord.reserve_pub}`, - detail: { - requestedAmount: req.amount, - reservePub: reserveRecord.reserve_pub, - } - }; - - await this.q() - .put(Stores.reserves, reserveRecord) - .put(Stores.history, historyEntry) - .finish(); - - let r: CreateReserveResponse = { - exchange: canonExchange, - reservePub: keypair.pub, - }; - return r; - } - - - /** - * Mark an existing reserve as confirmed. The wallet will start trying - * to withdraw from that reserve. This may not immediately succeed, - * since the exchange might not know about the reserve yet, even though the - * bank confirmed its creation. - * - * A confirmed reserve should be shown to the user in the UI, while - * an unconfirmed reserve should be hidden. - */ - async confirmReserve(req: ConfirmReserveRequest): Promise<void> { - const now = (new Date).getTime(); - let reserve: ReserveRecord|undefined = await ( - this.q().get<ReserveRecord>(Stores.reserves, - req.reservePub)); - if (!reserve) { - console.error("Unable to confirm reserve, not found in DB"); - return; - } - console.log("reserve confirmed"); - const historyEntry: HistoryRecord = { - type: "confirm-reserve", - timestamp: now, - subjectId: `reserve-progress-${reserve.reserve_pub}`, - detail: { - exchangeBaseUrl: reserve.exchange_base_url, - reservePub: req.reservePub, - requestedAmount: reserve.requested_amount, - }, - level: HistoryLevel.User, - }; - reserve.confirmed = true; - await this.q() - .put(Stores.reserves, reserve) - .put(Stores.history, historyEntry) - .finish(); - this.notifier.notify(); - - this.processReserve(reserve); - } - - - private async withdrawExecute(pc: PreCoin): Promise<Coin> { - let reserve = await this.q().get<ReserveRecord>(Stores.reserves, - pc.reservePub); - - if (!reserve) { - throw Error("db inconsistent"); - } - - let wd: any = {}; - wd.denom_pub = pc.denomPub; - wd.reserve_pub = pc.reservePub; - wd.reserve_sig = pc.withdrawSig; - wd.coin_ev = pc.coinEv; - let reqUrl = URI("reserve/withdraw").absoluteTo(reserve.exchange_base_url); - let resp = await this.http.postJson(reqUrl, wd); - - - if (resp.status != 200) { - throw new RequestException({ - hint: "Withdrawal failed", - status: resp.status - }); - } - let r = JSON.parse(resp.responseText); - let denomSig = await this.cryptoApi.rsaUnblind(r.ev_sig, - pc.blindingKey, - pc.denomPub); - let coin: Coin = { - coinPub: pc.coinPub, - coinPriv: pc.coinPriv, - denomPub: pc.denomPub, - denomSig: denomSig, - currentAmount: pc.coinValue, - exchangeBaseUrl: pc.exchangeBaseUrl, - dirty: false, - transactionPending: false, - }; - return coin; - } - - - /** - * Withdraw coins from a reserve until it is empty. - */ - private async depleteReserve(reserve: ReserveRecord, - exchange: IExchangeInfo): Promise<number> { - if (!reserve.current_amount) { - throw Error("can't withdraw when amount is unknown"); - } - let denomsAvailable: Denomination[] = Array.from(exchange.active_denoms); - let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount!, - denomsAvailable); - - let ps = denomsForWithdraw.map(async(denom) => { - function mutateReserve(r: ReserveRecord): ReserveRecord { - let currentAmount = r.current_amount; - if (!currentAmount) { - throw Error("can't withdraw when amount is unknown"); - } - r.precoin_amount = Amounts.add(r.precoin_amount, - denom.value, - denom.fee_withdraw).amount; - let result = Amounts.sub(currentAmount, - denom.value, - denom.fee_withdraw); - if (result.saturated) { - console.error("can't create precoin, saturated"); - throw AbortTransaction; - } - r.current_amount = result.amount; - - console.log(`after creating precoin: current ${amountToPretty(r.current_amount)}, precoin: ${amountToPretty( - r.precoin_amount)})}`); - - return r; - } - - let preCoin = await this.cryptoApi - .createPreCoin(denom, reserve); - await this.q() - .put(Stores.precoins, preCoin) - .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve); - await this.processPreCoin(preCoin); - }); - - await Promise.all(ps); - return ps.length; - } - - - /** - * Update the information about a reserve that is stored in the wallet - * by quering the reserve's exchange. - */ - private async updateReserve(reservePub: string, - exchange: IExchangeInfo): Promise<ReserveRecord> { - let reserve = await this.q() - .get<ReserveRecord>(Stores.reserves, reservePub); - if (!reserve) { - throw Error("reserve not in db"); - } - let reqUrl = URI("reserve/status").absoluteTo(exchange.baseUrl); - reqUrl.query({'reserve_pub': reservePub}); - let resp = await this.http.get(reqUrl); - if (resp.status != 200) { - throw Error(); - } - let reserveInfo = JSON.parse(resp.responseText); - if (!reserveInfo) { - throw Error(); - } - let oldAmount = reserve.current_amount; - let newAmount = reserveInfo.balance; - reserve.current_amount = reserveInfo.balance; - let historyEntry = { - type: "reserve-update", - timestamp: (new Date).getTime(), - subjectId: `reserve-progress-${reserve.reserve_pub}`, - detail: { - reservePub, - requestedAmount: reserve.requested_amount, - oldAmount, - newAmount - } - }; - await this.q() - .put(Stores.reserves, reserve) - .finish(); - this.notifier.notify(); - return reserve; - } - - - /** - * Get the wire information for the exchange with the given base URL. - */ - async getWireInfo(exchangeBaseUrl: string): Promise<WireInfo> { - exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); - let reqUrl = URI("wire").absoluteTo(exchangeBaseUrl); - let resp = await this.http.get(reqUrl); - - if (resp.status != 200) { - throw Error("/wire request failed"); - } - - let wiJson = JSON.parse(resp.responseText); - if (!wiJson) { - throw Error("/wire response malformed") - } - return wiJson; - } - - async getReserveCreationInfo(baseUrl: string, - amount: AmountJson): Promise<ReserveCreationInfo> { - let exchangeInfo = await this.updateExchangeFromUrl(baseUrl); - - let selectedDenoms = getWithdrawDenomList(amount, - exchangeInfo.active_denoms); - let acc = Amounts.getZero(amount.currency); - for (let d of selectedDenoms) { - acc = Amounts.add(acc, d.fee_withdraw).amount; - } - let actualCoinCost = selectedDenoms - .map((d: Denomination) => Amounts.add(d.value, - d.fee_withdraw).amount) - .reduce((a, b) => Amounts.add(a, b).amount); - - let wireInfo = await this.getWireInfo(baseUrl); - - let ret: ReserveCreationInfo = { - exchangeInfo, - selectedDenoms, - wireInfo, - withdrawFee: acc, - overhead: Amounts.sub(amount, actualCoinCost).amount, - }; - return ret; - } - - - /** - * Update or add exchange DB entry by fetching the /keys information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. - */ - async updateExchangeFromUrl(baseUrl: string): Promise<IExchangeInfo> { - baseUrl = canonicalizeBaseUrl(baseUrl); - let reqUrl = URI("keys").absoluteTo(baseUrl); - let resp = await this.http.get(reqUrl); - if (resp.status != 200) { - throw Error("/keys request failed"); - } - let exchangeKeysJson = KeysJson.checked(JSON.parse(resp.responseText)); - return this.updateExchangeFromJson(baseUrl, exchangeKeysJson); - } - - private async suspendCoins(exchangeInfo: IExchangeInfo): Promise<void> { - let suspendedCoins = await ( - this.q() - .iterIndex(Stores.coins.exchangeBaseUrlIndex, exchangeInfo.baseUrl) - .reduce((coin: Coin, suspendedCoins: Coin[]) => { - if (!exchangeInfo.active_denoms.find((c) => c.denom_pub == coin.denomPub)) { - return Array.prototype.concat(suspendedCoins, [coin]); - } - return Array.prototype.concat(suspendedCoins); - }, [])); - - let q = this.q(); - suspendedCoins.map((c) => { - console.log("suspending coin", c); - c.suspended = true; - q.put(Stores.coins, c); - }); - await q.finish(); - } - - - private async updateExchangeFromJson(baseUrl: string, - exchangeKeysJson: KeysJson): Promise<IExchangeInfo> { - const updateTimeSec = getTalerStampSec(exchangeKeysJson.list_issue_date); - if (updateTimeSec === null) { - throw Error("invalid update time"); - } - - let r = await this.q().get<IExchangeInfo>(Stores.exchanges, baseUrl); - - let exchangeInfo: IExchangeInfo; - - if (!r) { - exchangeInfo = { - baseUrl, - all_denoms: [], - active_denoms: [], - last_update_time: updateTimeSec, - masterPublicKey: exchangeKeysJson.master_public_key, - }; - console.log("making fresh exchange"); - } else { - if (updateTimeSec < r.last_update_time) { - console.log("outdated /keys, not updating"); - return r - } - exchangeInfo = r; - console.log("updating old exchange"); - } - - let updatedExchangeInfo = await this.updateExchangeInfo(exchangeInfo, - exchangeKeysJson); - await this.suspendCoins(updatedExchangeInfo); - - await this.q() - .put(Stores.exchanges, updatedExchangeInfo) - .finish(); - - return updatedExchangeInfo; - } - - - private async updateExchangeInfo(exchangeInfo: IExchangeInfo, - newKeys: KeysJson): Promise<IExchangeInfo> { - if (exchangeInfo.masterPublicKey != newKeys.master_public_key) { - throw Error("public keys do not match"); - } - - exchangeInfo.active_denoms = []; - - let denomsToCheck = newKeys.denoms.filter((newDenom) => { - // did we find the new denom in the list of all (old) denoms? - let found = false; - for (let oldDenom of exchangeInfo.all_denoms) { - if (oldDenom.denom_pub === newDenom.denom_pub) { - let a: any = Object.assign({}, oldDenom); - let b: any = Object.assign({}, newDenom); - // pub hash is only there for convenience in the wallet - delete a["pub_hash"]; - delete b["pub_hash"]; - if (!deepEquals(a, b)) { - console.error("denomination parameters were modified, old/new:"); - console.dir(a); - console.dir(b); - // FIXME: report to auditors - } - found = true; - break; - } - } - - if (found) { - exchangeInfo.active_denoms.push(newDenom); - // No need to check signatures - return false; - } - return true; - }); - - let ps = denomsToCheck.map(async(denom) => { - let valid = await this.cryptoApi - .isValidDenom(denom, - exchangeInfo.masterPublicKey); - if (!valid) { - console.error("invalid denomination", - denom, - "with key", - exchangeInfo.masterPublicKey); - // FIXME: report to auditors - } - exchangeInfo.active_denoms.push(denom); - exchangeInfo.all_denoms.push(denom); - }); - - await Promise.all(ps); - - return exchangeInfo; - } - - - /** - * Retrieve a mapping from currency name to the amount - * that is currenctly available for spending in the wallet. - */ - async getBalances(): Promise<WalletBalance> { - function ensureEntry(balance: WalletBalance, currency: string) { - let entry: WalletBalanceEntry|undefined = balance[currency]; - let z = Amounts.getZero(currency); - if (!entry) { - balance[currency] = entry = { - available: z, - pendingIncoming: z, - pendingPayment: z, - }; - } - return entry; - } - - function collectBalances(c: Coin, balance: WalletBalance) { - if (c.suspended) { - return balance; - } - let currency = c.currentAmount.currency; - let entry = ensureEntry(balance, currency); - entry.available = Amounts.add(entry.available, c.currentAmount).amount; - return balance; - } - - function collectPendingWithdraw(r: ReserveRecord, balance: WalletBalance) { - if (!r.confirmed) { - return balance; - } - let entry = ensureEntry(balance, r.requested_amount.currency); - let amount = r.current_amount; - if (!amount) { - amount = r.requested_amount; - } - amount = Amounts.add(amount, r.precoin_amount).amount; - if (Amounts.cmp(smallestWithdraw[r.exchange_base_url], amount) < 0) { - entry.pendingIncoming = Amounts.add(entry.pendingIncoming, - amount).amount; - } - return balance; - } - - function collectPendingRefresh(r: RefreshSession, balance: WalletBalance) { - if (!r.finished) { - return balance; - } - let entry = ensureEntry(balance, r.valueWithFee.currency); - entry.pendingIncoming = Amounts.add(entry.pendingIncoming, - r.valueOutput).amount; - - return balance; - } - - function collectPayments(t: Transaction, balance: WalletBalance) { - if (t.finished) { - return balance; - } - let entry = ensureEntry(balance, t.contract.amount.currency); - entry.pendingPayment = Amounts.add(entry.pendingPayment, - t.contract.amount).amount; - - return balance; - } - - function collectSmallestWithdraw(e: IExchangeInfo, sw: any) { - let min: AmountJson|undefined; - for (let d of e.active_denoms) { - let v = Amounts.add(d.value, d.fee_withdraw).amount; - if (!min) { - min = v; - continue; - } - if (Amounts.cmp(v, min) < 0) { - min = v; - } - } - sw[e.baseUrl] = min; - return sw; - } - - let balance = {}; - // Mapping from exchange pub to smallest - // possible amount we can withdraw - let smallestWithdraw: {[baseUrl: string]: AmountJson} = {}; - - smallestWithdraw = await (this.q() - .iter(Stores.exchanges) - .reduce(collectSmallestWithdraw, {})); - - console.log("smallest withdraws", smallestWithdraw); - - let tx = this.q(); - tx.iter(Stores.coins) - .reduce(collectBalances, balance); - tx.iter(Stores.refresh) - .reduce(collectPendingRefresh, balance); - tx.iter(Stores.reserves) - .reduce(collectPendingWithdraw, balance); - tx.iter(Stores.transactions) - .reduce(collectPayments, balance); - await tx.finish(); - return balance; - - } - - - async createRefreshSession(oldCoinPub: string): Promise<RefreshSession|undefined> { - let coin = await this.q().get<Coin>(Stores.coins, oldCoinPub); - - if (!coin) { - throw Error("coin not found"); - } - - let exchange = await this.updateExchangeFromUrl(coin.exchangeBaseUrl); - - if (!exchange) { - throw Error("db inconsistent"); - } - - let oldDenom = exchange.all_denoms.find((d) => d.denom_pub == coin!.denomPub); - - if (!oldDenom) { - throw Error("db inconsistent"); - } - - let availableDenoms: Denomination[] = exchange.active_denoms; - - let availableAmount = Amounts.sub(coin.currentAmount, - oldDenom.fee_refresh).amount; - - let newCoinDenoms = getWithdrawDenomList(availableAmount, - availableDenoms); - - console.log("refreshing into", newCoinDenoms); - - if (newCoinDenoms.length == 0) { - console.log("not refreshing, value too small"); - return undefined; - } - - - let refreshSession: RefreshSession = await ( - this.cryptoApi.createRefreshSession(exchange.baseUrl, - 3, - coin, - newCoinDenoms, - oldDenom.fee_refresh)); - - function mutateCoin(c: Coin): Coin { - let r = Amounts.sub(c.currentAmount, - refreshSession.valueWithFee); - if (r.saturated) { - // Something else must have written the coin value - throw AbortTransaction; - } - c.currentAmount = r.amount; - return c; - } - - await this.q() - .put(Stores.refresh, refreshSession) - .mutate(Stores.coins, coin.coinPub, mutateCoin) - .finish(); - - return refreshSession; - } - - - async refresh(oldCoinPub: string): Promise<void> { - let refreshSession: RefreshSession|undefined; - let oldSession = await this.q().get(Stores.refresh, oldCoinPub); - if (oldSession) { - refreshSession = oldSession; - } else { - refreshSession = await this.createRefreshSession(oldCoinPub); - } - if (!refreshSession) { - // refreshing not necessary - return; - } - this.continueRefreshSession(refreshSession); - } - - async continueRefreshSession(refreshSession: RefreshSession) { - if (refreshSession.finished) { - return; - } - if (typeof refreshSession.norevealIndex !== "number") { - let coinPub = refreshSession.meltCoinPub; - await this.refreshMelt(refreshSession); - let r = await this.q().get<RefreshSession>(Stores.refresh, coinPub); - if (!r) { - throw Error("refresh session does not exist anymore"); - } - refreshSession = r; - } - - await this.refreshReveal(refreshSession); - } - - - async refreshMelt(refreshSession: RefreshSession): Promise<void> { - if (refreshSession.norevealIndex != undefined) { - console.error("won't melt again"); - return; - } - - let coin = await this.q().get<Coin>(Stores.coins, - refreshSession.meltCoinPub); - if (!coin) { - console.error("can't melt coin, it does not exist"); - return; - } - - let reqUrl = URI("refresh/melt").absoluteTo(refreshSession.exchangeBaseUrl); - let meltCoin = { - coin_pub: coin.coinPub, - denom_pub: coin.denomPub, - denom_sig: coin.denomSig, - confirm_sig: refreshSession.confirmSig, - value_with_fee: refreshSession.valueWithFee, - }; - let coinEvs = refreshSession.preCoinsForGammas.map((x) => x.map((y) => y.coinEv)); - let req = { - "new_denoms": refreshSession.newDenoms, - "melt_coin": meltCoin, - "transfer_pubs": refreshSession.transferPubs, - "coin_evs": coinEvs, - }; - console.log("melt request:", req); - let resp = await this.http.postJson(reqUrl, req); - - console.log("melt request:", req); - console.log("melt response:", resp.responseText); - - if (resp.status != 200) { - console.error(resp.responseText); - throw Error("refresh failed"); - } - - let respJson = JSON.parse(resp.responseText); - - if (!respJson) { - throw Error("exchange responded with garbage"); - } - - let norevealIndex = respJson.noreveal_index; - - if (typeof norevealIndex != "number") { - throw Error("invalid response"); - } - - refreshSession.norevealIndex = norevealIndex; - - await this.q().put(Stores.refresh, refreshSession).finish(); - } - - - async refreshReveal(refreshSession: RefreshSession): Promise<void> { - let norevealIndex = refreshSession.norevealIndex; - if (norevealIndex == undefined) { - throw Error("can't reveal without melting first"); - } - let privs = Array.from(refreshSession.transferPrivs); - privs.splice(norevealIndex, 1); - - let req = { - "session_hash": refreshSession.hash, - "transfer_privs": privs, - }; - - let reqUrl = URI("refresh/reveal") - .absoluteTo(refreshSession.exchangeBaseUrl); - console.log("reveal request:", req); - let resp = await this.http.postJson(reqUrl, req); - - console.log("session:", refreshSession); - console.log("reveal response:", resp); - - if (resp.status != 200) { - console.log("error: /refresh/reveal returned status " + resp.status); - return; - } - - let respJson = JSON.parse(resp.responseText); - - if (!respJson.ev_sigs || !Array.isArray(respJson.ev_sigs)) { - console.log("/refresh/reveal did not contain ev_sigs"); - } - - let exchange = await this.q().get<IExchangeInfo>(Stores.exchanges, - refreshSession.exchangeBaseUrl); - if (!exchange) { - console.error(`exchange ${refreshSession.exchangeBaseUrl} not found`); - return; - } - - let coins: Coin[] = []; - - for (let i = 0; i < respJson.ev_sigs.length; i++) { - let denom = exchange.all_denoms.find((d) => d.denom_pub == refreshSession.newDenoms[i]); - if (!denom) { - console.error("denom not found"); - continue; - } - let pc = refreshSession.preCoinsForGammas[refreshSession.norevealIndex!][i]; - let denomSig = await this.cryptoApi.rsaUnblind(respJson.ev_sigs[i].ev_sig, - pc.blindingKey, - denom.denom_pub); - let coin: Coin = { - coinPub: pc.publicKey, - coinPriv: pc.privateKey, - denomPub: denom.denom_pub, - denomSig: denomSig, - currentAmount: denom.value, - exchangeBaseUrl: refreshSession.exchangeBaseUrl, - dirty: false, - transactionPending: false, - }; - - coins.push(coin); - } - - refreshSession.finished = true; - - await this.q() - .putAll(Stores.coins, coins) - .put(Stores.refresh, refreshSession) - .finish(); - } - - - /** - * Retrive the full event history for this wallet. - */ - async getHistory(): Promise<any> { - function collect(x: any, acc: any) { - acc.push(x); - return acc; - } - - let history = await ( - this.q() - .iterIndex(Stores.history.timestampIndex) - .reduce(collect, [])); - - return {history}; - } - - - async getOffer(offerId: number): Promise<any> { - let offer = await this.q() .get(Stores.offers, offerId); - return offer; - } - - async getExchanges(): Promise<IExchangeInfo[]> { - return this.q() - .iter<IExchangeInfo>(Stores.exchanges) - .flatMap((e) => [e]) - .toArray(); - } - - async getReserves(exchangeBaseUrl: string): Promise<ReserveRecord[]> { - return this.q() - .iter<ReserveRecord>(Stores.reserves) - .filter((r: ReserveRecord) => r.exchange_base_url === exchangeBaseUrl) - .toArray(); - } - - async getCoins(exchangeBaseUrl: string): Promise<Coin[]> { - return this.q() - .iter<Coin>(Stores.coins) - .filter((c: Coin) => c.exchangeBaseUrl === exchangeBaseUrl) - .toArray(); - } - - async getPreCoins(exchangeBaseUrl: string): Promise<PreCoin[]> { - return this.q() - .iter<PreCoin>(Stores.precoins) - .filter((c: PreCoin) => c.exchangeBaseUrl === exchangeBaseUrl) - .toArray(); - } - - async hashContract(contract: Contract): Promise<string> { - return this.cryptoApi.hashString(canonicalJson(contract)); - } - - /** - * Check if there's an equivalent contract we've already purchased. - */ - async checkRepurchase(contract: Contract): Promise<CheckRepurchaseResult> { - if (!contract.repurchase_correlation_id) { - console.log("no repurchase: no correlation id"); - return {isRepurchase: false}; - } - let result: Transaction|undefined = await ( - this.q() - .getIndexed(Stores.transactions.repurchaseIndex, - [ - contract.merchant_pub, - contract.repurchase_correlation_id - ])); - - if (result) { - console.assert(result.contract.repurchase_correlation_id == contract.repurchase_correlation_id); - return { - isRepurchase: true, - existingContractHash: result.contractHash, - existingFulfillmentUrl: result.contract.fulfillment_url, - }; - } else { - return {isRepurchase: false}; - } - } - - - async paymentSucceeded(contractHash: string): Promise<any> { - const doPaymentSucceeded = async() => { - let t = await this.q().get<Transaction>(Stores.transactions, - contractHash); - if (!t) { - console.error("contract not found"); - return; - } - t.finished = true; - let modifiedCoins: Coin[] = []; - for (let pc of t.payReq.coins) { - let c = await this.q().get<Coin>(Stores.coins, pc.coin_pub); - if (!c) { - console.error("coin not found"); - return; - } - c.transactionPending = false; - modifiedCoins.push(c); - } - - await this.q() - .putAll(Stores.coins, modifiedCoins) - .put(Stores.transactions, t) - .finish(); - for (let c of t.payReq.coins) { - this.refresh(c.coin_pub); - } - }; - doPaymentSucceeded(); - return; - } -} |