diff options
author | Florian Dold <florian.dold@gmail.com> | 2016-02-29 18:03:02 +0100 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2016-02-29 18:03:02 +0100 |
commit | c962e9402123900c53967c14cf809ea10576cdb8 (patch) | |
tree | e7df9cfdd6fceae30fb99c8ec6be5e07c8b153a8 /lib/wallet/wallet.ts | |
parent | 30ee3320c788129b258ed8b42f4fc63d28431e2f (diff) |
restructure
Diffstat (limited to 'lib/wallet/wallet.ts')
-rw-r--r-- | lib/wallet/wallet.ts | 957 |
1 files changed, 957 insertions, 0 deletions
diff --git a/lib/wallet/wallet.ts b/lib/wallet/wallet.ts new file mode 100644 index 000000000..92fb92a4a --- /dev/null +++ b/lib/wallet/wallet.ts @@ -0,0 +1,957 @@ +/* + 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, 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, CreateReserveResponse, IMintInfo, Denomination, Notifier} from "./types"; +import {HttpResponse, RequestException} from "./http"; +import {Query} from "./query"; +import {Checkable} from "./checkable"; +import {canonicalizeBaseUrl} from "./helpers"; +import {ReserveCreationInfo, Amounts} from "./types"; +import {PreCoin} from "./types"; +import {Reserve} from "./types"; +import {CryptoApi} from "./cryptoApi"; +import {Coin} from "./types"; +import {PayCoinInfo} from "./types"; +import {CheckRepurchaseResult} from "./types"; + +"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; +} + + +class MintInfo implements IMintInfo { + baseUrl: string; + masterPublicKey: string; + denoms: Denomination[]; + + constructor(obj: {baseUrl: string} & any) { + this.baseUrl = obj.baseUrl; + + if (obj.denoms) { + this.denoms = Array.from(<Denomination[]>obj.denoms); + } else { + this.denoms = []; + } + + if (typeof obj.masterPublicKey === "string") { + this.masterPublicKey = obj.masterPublicKey; + } + } + + static fresh(baseUrl: string): MintInfo { + return new MintInfo({baseUrl}); + } + + /** + * Merge new key information into the mint info. + * If the new key information is invalid (missing fields, + * invalid signatures), an exception is thrown, but the + * mint info is updated with the new information up until + * the first error. + */ + mergeKeys(newKeys: KeysJson, cryptoApi: CryptoApi): Promise<void> { + if (!this.masterPublicKey) { + this.masterPublicKey = newKeys.master_public_key; + } + + if (this.masterPublicKey != newKeys.master_public_key) { + throw Error("public keys do not match"); + } + + let ps = newKeys.denoms.map((newDenom) => { + let found = false; + for (let oldDenom of this.denoms) { + if (oldDenom.denom_pub === newDenom.denom_pub) { + let a = Object.assign({}, oldDenom); + let b = 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.log("old/new:"); + console.dir(a); + console.dir(b); + throw Error("denomination modified"); + } + found = true; + break; + } + } + + if (found) { + return Promise.resolve(); + } + + return cryptoApi + .isValidDenom(newDenom, this.masterPublicKey) + .then((valid) => { + if (!valid) { + throw Error("signature on denomination invalid"); + } + return cryptoApi.hashRsaPub(newDenom.denom_pub); + }) + .then((h) => { + this.denoms.push(Object.assign({}, newDenom, {pub_hash: h})); + }); + }); + + return Promise.all(ps).then(() => void 0); + } +} + + +@Checkable.Class +export class CreateReserveRequest { + /** + * The initial amount for the reserve. + */ + @Checkable.Value(AmountJson) + amount: AmountJson; + + /** + * Mint URL where the bank should create the reserve. + */ + @Checkable.String + mint: 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 MintHandle { + @Checkable.String + master_pub: string; + + @Checkable.String + url: string; + + static checked: (obj: any) => MintHandle; +} + + +@Checkable.Class +export class Contract { + @Checkable.String + H_wire: string; + + @Checkable.Value(AmountJson) + amount: AmountJson; + + @Checkable.List(Checkable.AnyObject) + auditors: any[]; + + @Checkable.String + expiry: string; + + @Checkable.Any + locations: any; + + @Checkable.Value(AmountJson) + max_fee: AmountJson; + + @Checkable.Any + merchant: any; + + @Checkable.String + merchant_pub: string; + + @Checkable.List(Checkable.Value(MintHandle)) + mints: MintHandle[]; + + @Checkable.List(Checkable.AnyObject) + products: any[]; + + @Checkable.String + refund_deadline: string; + + @Checkable.String + timestamp: string; + + @Checkable.Number + transaction_id: number; + + @Checkable.String + fulfillment_url: string; + + @Checkable.Optional(Checkable.String) + repurchase_correlation_id: string; + + static checked: (obj: any) => Contract; +} + + +@Checkable.Class +export class Offer { + @Checkable.Value(Contract) + contract: Contract; + + @Checkable.String + merchant_sig: string; + + @Checkable.String + H_contract: string; + + static checked: (obj: any) => Offer; +} + + +interface ConfirmPayRequest { + offer: Offer; +} + +interface MintCoins { + [mintUrl: string]: CoinWithDenom[]; +} + + +interface CoinPaySig { + coin_sig: string; + coin_pub: string; + ub_sig: string; + denom_pub: string; + f: AmountJson; +} + + +interface Transaction { + contractHash: string; + contract: Contract; + payReq: any; + merchantSig: string; +} + + +export interface Badge { + setText(s: string): void; + setColor(c: string): void; +} + + +function deepEquals(x, y) { + if (x === y) { + return true; + } + + if (Array.isArray(x) && x.length !== y.length) { + return false; + } + + var p = Object.keys(x); + return Object.keys(y).every((i) => p.indexOf(i) !== -1) && + p.every((i) => deepEquals(x[i], y[i])); +} + + +function getTalerStampSec(stamp: string) { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m) { + return null; + } + return parseInt(m[1]); +} + + +function isWithdrawableDenom(d: Denomination) { + const now_sec = (new Date).getTime() / 1000; + const stamp_withdraw_sec = getTalerStampSec(d.stamp_expire_withdraw); + // Withdraw if still possible to withdraw within a minute + if (stamp_withdraw_sec + 60 > now_sec) { + return true; + } + return false; +} + + +interface HttpRequestLibrary { + req(method: string, + url: string|uri.URI, + options?: any): Promise<HttpResponse>; + + get(url: string|uri.URI): Promise<HttpResponse>; + + postJson(url: string|uri.URI, body): Promise<HttpResponse>; + + postForm(url: string|uri.URI, form): Promise<HttpResponse>; +} + + +function copy(o) { + return JSON.parse(JSON.stringify(o)); +} + + +/** + * 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); + let 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 class Wallet { + private db: IDBDatabase; + private http: HttpRequestLibrary; + private badge: Badge; + private notifier: Notifier; + public cryptoApi: CryptoApi; + + + 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(); + } + + + /** + * Get mints and associated coins that are still spendable, + * but only if the sum the coins' remaining value exceeds the payment amount. + */ + private getPossibleMintCoins(paymentAmount: AmountJson, + depositFeeLimit: AmountJson, + allowedMints: MintHandle[]): Promise<MintCoins> { + // Mapping from mint base URL to list of coins together with their + // denomination + let m: MintCoins = {}; + + function storeMintCoin(mc) { + let mint: IMintInfo = mc[0]; + let coin: Coin = mc[1]; + let cd = { + coin: coin, + denom: mint.denoms.find((e) => e.denom_pub === coin.denomPub) + }; + if (!cd.denom) { + throw Error("denom not found (database inconsistent)"); + } + if (cd.denom.value.currency !== paymentAmount.currency) { + console.warn("same pubkey for different currencies"); + return; + } + let x = m[mint.baseUrl]; + if (!x) { + m[mint.baseUrl] = [cd]; + } else { + x.push(cd); + } + } + + let ps = allowedMints.map((info) => { + console.log("Checking for merchant's mint", JSON.stringify(info)); + return Query(this.db) + .iter("mints", {indexName: "pubKey", only: info.master_pub}) + .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) + .reduce(storeMintCoin); + }); + + return Promise.all(ps).then(() => { + let ret: MintCoins = {}; + + if (Object.keys(m).length == 0) { + console.log("not suitable mints found"); + } + + console.dir(m); + + // We try to find the first mint where we have + // enough coins to cover the paymentAmount with fees + // under depositFeeLimit + + nextMint: + 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 nextMint; + } + usableCoins.push(coins[i]); + if (Amounts.cmp(accAmount, minAmount) >= 0) { + ret[key] = usableCoins; + continue nextMint; + } + } + } + return ret; + }); + } + + + /** + * Record all information that is necessary to + * pay for a contract in the wallet's database. + */ + private recordConfirmPay(offer: Offer, + payCoinInfo: PayCoinInfo, + chosenMint: string): Promise<void> { + let payReq = {}; + payReq["amount"] = offer.contract.amount; + payReq["coins"] = payCoinInfo.map((x) => x.sig); + payReq["H_contract"] = offer.H_contract; + payReq["max_fee"] = offer.contract.max_fee; + payReq["merchant_sig"] = offer.merchant_sig; + payReq["mint"] = URI(chosenMint).href(); + payReq["refund_deadline"] = offer.contract.refund_deadline; + payReq["timestamp"] = offer.contract.timestamp; + payReq["transaction_id"] = offer.contract.transaction_id; + let t: Transaction = { + contractHash: offer.H_contract, + contract: offer.contract, + payReq: payReq, + merchantSig: offer.merchant_sig, + }; + + console.log("pay request"); + console.dir(payReq); + + let historyEntry = { + type: "pay", + timestamp: (new Date).getTime(), + detail: { + merchantName: offer.contract.merchant.name, + amount: offer.contract.amount, + contractHash: offer.H_contract, + fulfillmentUrl: offer.contract.fulfillment_url + } + }; + + return Query(this.db) + .put("transactions", t) + .put("history", historyEntry) + .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) + .finish() + .then(() => { + this.notifier.notify(); + }); + } + + + /** + * Add a contract to the wallet and sign coins, + * but do not send them yet. + */ + confirmPay(offer: Offer): Promise<any> { + console.log("executing confirmPay"); + return Promise.resolve().then(() => { + return this.getPossibleMintCoins(offer.contract.amount, + offer.contract.max_fee, + offer.contract.mints) + }).then((mcs) => { + if (Object.keys(mcs).length == 0) { + console.log("not confirming payment, insufficient coins"); + return { + error: "coins-insufficient", + }; + } + let mintUrl = Object.keys(mcs)[0]; + + return this.cryptoApi.signDeposit(offer, mcs[mintUrl]) + .then((ds) => this.recordConfirmPay(offer, ds, mintUrl)) + .then(() => ({})); + }); + } + + + /** + * Retrieve all necessary information for looking up the contract + * with the given hash. + */ + executePayment(H_contract): Promise<any> { + return Promise.resolve().then(() => { + return Query(this.db) + .get("transactions", H_contract) + .then((t) => { + 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 initReserve(reserveRecord) { + this.updateMintFromUrl(reserveRecord.mint_base_url) + .then((mint) => + this.updateReserve(reserveRecord.reserve_pub, mint) + .then((reserve) => this.depleteReserve(reserve, + mint))) + .then(() => { + let depleted = { + type: "depleted-reserve", + timestamp: (new Date).getTime(), + detail: { + reservePub: reserveRecord.reserve_pub, + } + }; + return Query(this.db).put("history", depleted).finish(); + }) + .catch((e) => { + console.error("Failed to deplete reserve"); + console.error(e); + }); + } + + + /** + * Create a reserve, but do not flag it as confirmed yet. + */ + createReserve(req: CreateReserveRequest): Promise<CreateReserveResponse> { + return this.cryptoApi.createEddsaKeypair().then((keypair) => { + const now = (new Date).getTime(); + const canonMint = canonicalizeBaseUrl(req.mint); + + const reserveRecord = { + reserve_pub: keypair.pub, + reserve_priv: keypair.priv, + mint_base_url: canonMint, + created: now, + last_query: null, + current_amount: null, + requested_amount: req.amount, + confirmed: false, + }; + + + const historyEntry = { + type: "create-reserve", + timestamp: now, + detail: { + requestedAmount: req.amount, + reservePub: reserveRecord.reserve_pub, + } + }; + + return Query(this.db) + .put("reserves", reserveRecord) + .put("history", historyEntry) + .finish() + .then(() => { + let r: CreateReserveResponse = { + mint: canonMint, + 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 mint 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. + */ + confirmReserve(req: ConfirmReserveRequest): Promise<void> { + const now = (new Date).getTime(); + const historyEntry = { + type: "confirm-reserve", + timestamp: now, + detail: { + reservePub: req.reservePub, + } + }; + return Query(this.db) + .get("reserves", req.reservePub) + .then((r) => { + r.confirmed = true; + return Query(this.db) + .put("reserves", r) + .put("history", historyEntry) + .finish() + .then(() => { + // Do this in the background + this.initReserve(r); + }); + }); + } + + + private withdrawExecute(pc: PreCoin): Promise<Coin> { + return Query(this.db) + .get("reserves", pc.reservePub) + .then((r) => { + 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(r.mint_base_url); + return this.http.postJson(reqUrl, wd); + }) + .then(resp => { + if (resp.status != 200) { + throw new RequestException({ + hint: "Withdrawal failed", + status: resp.status + }); + } + let r = JSON.parse(resp.responseText); + return this.cryptoApi.rsaUnblind(r.ev_sig, pc.blindingKey, pc.denomPub) + .then((denomSig) => { + let coin: Coin = { + coinPub: pc.coinPub, + coinPriv: pc.coinPriv, + denomPub: pc.denomPub, + denomSig: denomSig, + currentAmount: pc.coinValue, + mintBaseUrl: pc.mintBaseUrl, + }; + return coin; + + }); + }); + } + + storeCoin(coin: Coin): Promise<void> { + let historyEntry = { + type: "withdraw", + timestamp: (new Date).getTime(), + detail: { + coinPub: coin.coinPub, + } + }; + return Query(this.db) + .delete("precoins", coin.coinPub) + .add("coins", coin) + .add("history", historyEntry) + .finish() + .then(() => { + this.notifier.notify(); + }); + } + + + /** + * Withdraw one coins of the given denomination from the given reserve. + */ + private withdraw(denom: Denomination, reserve: Reserve): Promise<void> { + console.log("creating pre coin at", new Date()); + return this.cryptoApi + .createPreCoin(denom, reserve) + .then((preCoin) => { + return Query(this.db) + .put("precoins", preCoin) + .finish() + .then(() => this.withdrawExecute(preCoin)) + .then((c) => this.storeCoin(c)); + }); + + } + + + /** + * Withdraw coins from a reserve until it is empty. + */ + private depleteReserve(reserve, mint: MintInfo): Promise<void> { + let denomsAvailable: Denomination[] = copy(mint.denoms); + let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount, + denomsAvailable); + + let ps = denomsForWithdraw.map((denom) => { + console.log("withdrawing", JSON.stringify(denom)); + // Do the withdraw asynchronously, so crypto is interleaved + // with requests + return this.withdraw(denom, reserve); + }); + + return Promise.all(ps).then(() => void 0); + } + + + /** + * Update the information about a reserve that is stored in the wallet + * by quering the reserve's mint. + */ + private updateReserve(reservePub: string, mint: MintInfo): Promise<Reserve> { + return Query(this.db) + .get("reserves", reservePub) + .then((reserve) => { + let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); + reqUrl.query({'reserve_pub': reservePub}); + return this.http.get(reqUrl).then(resp => { + 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(), + detail: { + reservePub, + oldAmount, + newAmount + } + }; + return Query(this.db) + .put("reserves", reserve) + .finish() + .then(() => reserve); + }); + }); + } + + + getReserveCreationInfo(baseUrl: string, + amount: AmountJson): Promise<ReserveCreationInfo> { + return this.updateMintFromUrl(baseUrl) + .then((mintInfo: IMintInfo) => { + let selectedDenoms = getWithdrawDenomList(amount, + mintInfo.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 ret: ReserveCreationInfo = { + mintInfo, + selectedDenoms, + withdrawFee: acc, + overhead: Amounts.sub(amount, actualCoinCost).amount, + }; + return ret; + }); + } + + + /** + * Update or add mint DB entry by fetching the /keys information. + * Optionally link the reserve entry to the new or existing + * mint entry in then DB. + */ + updateMintFromUrl(baseUrl): Promise<MintInfo> { + baseUrl = canonicalizeBaseUrl(baseUrl); + let reqUrl = URI("keys").absoluteTo(baseUrl); + return this.http.get(reqUrl).then((resp) => { + if (resp.status != 200) { + throw Error("/keys request failed"); + } + let mintKeysJson = KeysJson.checked(JSON.parse(resp.responseText)); + + return Query(this.db).get("mints", baseUrl).then((r) => { + let mintInfo; + console.dir(r); + + if (!r) { + mintInfo = MintInfo.fresh(baseUrl); + console.log("making fresh mint"); + } else { + mintInfo = new MintInfo(r); + console.log("using old mint"); + } + + return mintInfo.mergeKeys(mintKeysJson, this.cryptoApi) + .then(() => { + return Query(this.db) + .put("mints", mintInfo) + .finish() + .then(() => mintInfo); + }); + + }); + }); + } + + + /** + * Retrieve a mapping from currency name to the amount + * that is currenctly available for spending in the wallet. + */ + getBalances(): Promise<any> { + function collectBalances(c: Coin, byCurrency) { + let acc: AmountJson = byCurrency[c.currentAmount.currency]; + if (!acc) { + acc = Amounts.getZero(c.currentAmount.currency); + } + byCurrency[c.currentAmount.currency] = Amounts.add(c.currentAmount, + acc).amount; + return byCurrency; + } + + return Query(this.db) + .iter("coins") + .reduce(collectBalances, {}); + } + + + /** + * Retrive the full event history for this wallet. + */ + getHistory(): Promise<any[]> { + function collect(x, acc) { + acc.push(x); + return acc; + } + + return Query(this.db) + .iter("history", {indexName: "timestamp"}) + .reduce(collect, []) + } + + checkRepurchase(contract: Contract): Promise<CheckRepurchaseResult> { + if (!contract.repurchase_correlation_id) { + console.log("no repurchase: no correlation id"); + return Promise.resolve({isRepurchase: false}); + } + return Query(this.db) + .getIndexed("transactions", + "repurchase", + [contract.merchant_pub, contract.repurchase_correlation_id]) + .then((result: Transaction) => { + console.log("db result", result); + let isRepurchase; + 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}; + } + }); + } +}
\ No newline at end of file |