diff --git a/extension/background/checkable.ts b/extension/background/checkable.ts index f7e99df92..7cf50318a 100644 --- a/extension/background/checkable.ts +++ b/extension/background/checkable.ts @@ -41,6 +41,13 @@ namespace Checkable { return target; } + function checkAnyObject(target, prop): any { + if (typeof target !== "object") { + throw Error("object expected for " + prop.propertyKey); + } + return target; + } + function checkValue(target, prop): any { let type = prop.type; if (!type) { @@ -84,11 +91,7 @@ namespace Checkable { export function Value(type) { function deco(target: Object, propertyKey: string | symbol): void { - let chk = target[chkSym]; - if (!chk) { - chk = {props: []}; - target[chkSym] = chk; - } + let chk = mkChk(target); chk.props.push({ propertyKey: propertyKey, checker: checkValue, @@ -108,20 +111,26 @@ namespace Checkable { } export function Number(target: Object, propertyKey: string | symbol): void { - let chk = target[chkSym]; - if (!chk) { - chk = {props: []}; - target[chkSym] = chk; - } + let chk = mkChk(target); chk.props.push({propertyKey: propertyKey, checker: checkNumber}); } + export function AnyObject(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({propertyKey: propertyKey, checker: checkAnyObject}); + } + export function String(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({propertyKey: propertyKey, checker: checkString}); + } + + function mkChk(target) { let chk = target[chkSym]; if (!chk) { chk = {props: []}; target[chkSym] = chk; } - chk.props.push({propertyKey: propertyKey, checker: checkString}); + return chk; } } diff --git a/extension/background/db.js b/extension/background/db.js index 4b81555da..8abf56b48 100644 --- a/extension/background/db.js +++ b/extension/background/db.js @@ -42,6 +42,7 @@ function openTalerDb() { coins.createIndex("mintBaseUrl", "mintBaseUrl"); db.createObjectStore("transactions", { keyPath: "contractHash" }); db.createObjectStore("precoins", { keyPath: "coinPub", autoIncrement: true }); + db.createObjectStore("history", { keyPath: "id", autoIncrement: true }); break; } }; diff --git a/extension/background/db.ts b/extension/background/db.ts index d3c6e9182..2807eb185 100644 --- a/extension/background/db.ts +++ b/extension/background/db.ts @@ -67,11 +67,8 @@ namespace Db { currentAmount: AmountJson_interface; mintBaseUrl: string; } - - } - const DB_NAME = "taler"; const DB_VERSION = 1; @@ -102,6 +99,7 @@ function openTalerDb(): Promise { db.createObjectStore("transactions", {keyPath: "contractHash"}); db.createObjectStore("precoins", {keyPath: "coinPub", autoIncrement: true}); + db.createObjectStore("history", {keyPath: "id", autoIncrement: true}); break; } }; @@ -137,4 +135,4 @@ function exportDb(db): Promise { }); } }); -} \ No newline at end of file +} diff --git a/extension/background/messaging.ts b/extension/background/messaging.ts index 6d444f95d..8cde06262 100644 --- a/extension/background/messaging.ts +++ b/extension/background/messaging.ts @@ -25,45 +25,82 @@ "use strict"; -// FIXME: none of these handlers should pass on the sendResponse. - -let handlers = { - ["balances"]: function(db, detail, sendResponse) { - getBalances(db).then(sendResponse); - return true; - }, - ["dump-db"]: function(db, detail, sendResponse) { - exportDb(db).then(sendResponse); - return true; - }, - ["reset"]: function(db, detail, sendResponse) { - let tx = db.transaction(db.objectStoreNames, 'readwrite'); - for (let i = 0; i < db.objectStoreNames.length; i++) { - tx.objectStore(db.objectStoreNames[i]).clear(); +function makeHandlers(wallet) { + return { + ["balances"]: function(db, detail, sendResponse) { + wallet.getBalances().then(sendResponse); + return true; + }, + ["dump-db"]: function(db, detail, sendResponse) { + exportDb(db).then(sendResponse); + return true; + }, + ["reset"]: function(db, detail, sendResponse) { + let tx = db.transaction(db.objectStoreNames, 'readwrite'); + for (let i = 0; i < db.objectStoreNames.length; i++) { + tx.objectStore(db.objectStoreNames[i]).clear(); + } + indexedDB.deleteDatabase(DB_NAME); + chrome.browserAction.setBadgeText({text: ""}); + console.log("reset done"); + // Response is synchronous + return false; + }, + ["confirm-reserve"]: function(db, detail, sendResponse) { + // TODO: make it a checkable + let req: ConfirmReserveRequest = { + field_amount: detail.field_amount, + field_mint: detail.field_mint, + field_reserve_pub: detail.field_reserve_pub, + post_url: detail.post_url, + mint: detail.mint, + amount_str: detail.amount_str + }; + wallet.confirmReserve(req) + .then((resp) => { + if (resp.success) { + resp.backlink = chrome.extension.getURL("pages/reserve-success.html"); + } + sendResponse(resp); + }); + return true; + }, + ["confirm-pay"]: function(db, detail, sendResponse) { + wallet.confirmPay(detail.offer, detail.merchantPageUrl) + .then(() => { + sendResponse({success: true}) + }) + .catch((e) => { + sendResponse({error: e.message}); + }); + return true; + }, + ["execute-payment"]: function(db, detail, sendResponse) { + wallet.doPayment(detail.H_contract) + .then((r) => { + sendResponse({ + success: true, + payUrl: r.payUrl, + payReq: r.payReq + }); + }) + .catch((e) => { + sendResponse({success: false, error: e.message}); + }); + // async sendResponse + return true; } - indexedDB.deleteDatabase(DB_NAME); - chrome.browserAction.setBadgeText({text: ""}); - console.log("reset done"); - // Response is synchronous - return false; - }, - ["confirm-reserve"]: function(db, detail, sendResponse) { - return confirmReserveHandler(db, detail, sendResponse); - }, - ["confirm-pay"]: function(db, detail, sendResponse) { - return confirmPayHandler(db, detail, sendResponse); - }, - ["execute-payment"]: function(db, detail, sendResponse) { - return doPaymentHandler(db, detail, sendResponse); - } -}; + }; +} function wxMain() { chrome.browserAction.setBadgeText({text: ""}); openTalerDb().then((db) => { - updateBadge(db); + let wallet = new Wallet(db, undefined, undefined); + let handlers = makeHandlers(wallet); + wallet.updateBadge(); chrome.runtime.onMessage.addListener( function(req, sender, onresponse) { if (req.type in handlers) { diff --git a/extension/background/wallet.js b/extension/background/wallet.js index 0962961d6..f0337818c 100644 --- a/extension/background/wallet.js +++ b/extension/background/wallet.js @@ -75,235 +75,6 @@ function canonicalizeBaseUrl(url) { x.query(); return x.href(); } -function signDeposit(db, offer, cds) { - let ret = []; - let amountSpent = Amount.getZero(cds[0].coin.currentAmount.currency); - let amountRemaining = new Amount(offer.contract.amount); - cds = copy(cds); - for (let cd of cds) { - let coinSpend; - if (amountRemaining.value == 0 && amountRemaining.fraction == 0) { - break; - } - if (amountRemaining.cmp(new Amount(cd.coin.currentAmount)) < 0) { - coinSpend = new Amount(amountRemaining.toJson()); - } - else { - coinSpend = new Amount(cd.coin.currentAmount); - } - amountSpent.add(coinSpend); - amountRemaining.sub(coinSpend); - let newAmount = new Amount(cd.coin.currentAmount); - newAmount.sub(coinSpend); - cd.coin.currentAmount = newAmount.toJson(); - let args = { - h_contract: HashCode.fromCrock(offer.H_contract), - h_wire: HashCode.fromCrock(offer.contract.H_wire), - amount_with_fee: coinSpend.toNbo(), - coin_pub: EddsaPublicKey.fromCrock(cd.coin.coinPub), - deposit_fee: new Amount(cd.denom.fee_deposit).toNbo(), - merchant: EddsaPublicKey.fromCrock(offer.contract.merchant_pub), - refund_deadline: AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline), - timestamp: AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp), - transaction_id: UInt64.fromNumber(offer.contract.transaction_id), - }; - let d = new DepositRequestPS(args); - let coinSig = eddsaSign(d.toPurpose(), EddsaPrivateKey.fromCrock(cd.coin.coinPriv)) - .toCrock(); - let s = { - coin_sig: coinSig, - coin_pub: cd.coin.coinPub, - ub_sig: cd.coin.denomSig, - denom_pub: cd.coin.denomPub, - f: coinSpend.toJson(), - }; - ret.push({ sig: s, updatedCoin: cd.coin }); - } - return ret; -} -/** - * Get mints and associated coins that are still spendable, - * but only if the sum the coins' remaining value exceeds the payment amount. - * @param db - * @param paymentAmount - * @param depositFeeLimit - * @param allowedMints - */ -function getPossibleMintCoins(db, paymentAmount, depositFeeLimit, allowedMints) { - let m = {}; - function storeMintCoin(mc) { - let mint = mc[0]; - let coin = mc[1]; - let cd = { - coin: coin, - denom: mint.keys.denoms.find((e) => e.denom_pub === coin.denomPub) - }; - if (!cd.denom) { - throw Error("denom not found (database inconsistent)"); - } - let x = m[mint.baseUrl]; - if (!x) { - m[mint.baseUrl] = [cd]; - } - else { - x.push(cd); - } - } - let ps = allowedMints.map((info) => { - return Query(db) - .iterIndex("mints", "pubKey", info.master_pub) - .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) - .reduce(storeMintCoin); - }); - return Promise.all(ps).then(() => { - let ret = {}; - nextMint: for (let key in m) { - let coins = m[key].map((x) => ({ - a: new Amount(x.denom.fee_deposit), - c: x - })); - // Sort by ascending deposit fee - coins.sort((o1, o2) => o1.a.cmp(o2.a)); - let maxFee = new Amount(depositFeeLimit); - let minAmount = new Amount(paymentAmount); - let accFee = new Amount(coins[0].c.denom.fee_deposit); - let accAmount = Amount.getZero(coins[0].c.coin.currentAmount.currency); - let usableCoins = []; - nextCoin: for (let i = 0; i < coins.length; i++) { - let coinAmount = new Amount(coins[i].c.coin.currentAmount); - let coinFee = coins[i].a; - if (coinAmount.cmp(coinFee) <= 0) { - continue nextCoin; - } - accFee.add(coinFee); - accAmount.add(coinAmount); - if (accFee.cmp(maxFee) >= 0) { - console.log("too much fees"); - continue nextMint; - } - usableCoins.push(coins[i].c); - if (accAmount.cmp(minAmount) >= 0) { - ret[key] = usableCoins; - continue nextMint; - } - } - } - return ret; - }); -} -function executePay(db, offer, payCoinInfo, merchantBaseUrl, chosenMint) { - let payReq = {}; - payReq["H_wire"] = offer.contract.H_wire; - payReq["H_contract"] = offer.H_contract; - payReq["transaction_id"] = offer.contract.transaction_id; - payReq["refund_deadline"] = offer.contract.refund_deadline; - payReq["mint"] = URI(chosenMint).href(); - payReq["coins"] = payCoinInfo.map((x) => x.sig); - payReq["timestamp"] = offer.contract.timestamp; - let payUrl = URI(offer.pay_url).absoluteTo(merchantBaseUrl); - let t = { - contractHash: offer.H_contract, - contract: offer.contract, - payUrl: payUrl.href(), - payReq: payReq - }; - return Query(db) - .put("transactions", t) - .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) - .finish(); -} -function confirmPayHandler(db, detail, sendResponse) { - let offer = detail.offer; - getPossibleMintCoins(db, offer.contract.amount, offer.contract.max_fee, offer.contract.mints) - .then((mcs) => { - if (Object.keys(mcs).length == 0) { - sendResponse({ error: "Not enough coins." }); - return; - } - let mintUrl = Object.keys(mcs)[0]; - let ds = signDeposit(db, offer, mcs[mintUrl]); - return executePay(db, offer, ds, detail.merchantPageUrl, mintUrl) - .then(() => { - sendResponse({ - success: true, - }); - }); - }); - return true; -} -function doPaymentHandler(db, detail, sendResponse) { - let H_contract = detail.H_contract; - Query(db) - .get("transactions", H_contract) - .then((r) => { - if (!r) { - sendResponse({ success: false, error: "contract not found" }); - return; - } - sendResponse({ - success: true, - payUrl: r.payUrl, - payReq: r.payReq - }); - }); - // async sendResponse - return true; -} -function confirmReserveHandler(db, detail, sendResponse) { - let reservePriv = EddsaPrivateKey.create(); - let reservePub = reservePriv.getPublicKey(); - let form = new FormData(); - let now = (new Date()).toString(); - form.append(detail.field_amount, detail.amount_str); - form.append(detail.field_reserve_pub, reservePub.toCrock()); - form.append(detail.field_mint, detail.mint); - // XXX: set bank-specified fields. - let mintBaseUrl = canonicalizeBaseUrl(detail.mint); - httpPostForm(detail.post_url, form) - .then((hresp) => { - // TODO: extract as interface - let resp = { - status: hresp.status, - text: hresp.responseText, - success: undefined, - backlink: undefined - }; - let reserveRecord = { - reserve_pub: reservePub.toCrock(), - reserve_priv: reservePriv.toCrock(), - mint_base_url: mintBaseUrl, - created: now, - last_query: null, - current_amount: null, - // XXX: set to actual amount - initial_amount: null - }; - if (hresp.status != 200) { - resp.success = false; - return resp; - } - resp.success = true; - // We can't show the page directly, so - // we show some generic page from the wallet. - // TODO: this should not be webextensions-specific - resp.backlink = chrome.extension.getURL("pages/reserve-success.html"); - return Query(db) - .put("reserves", reserveRecord) - .finish() - .then(() => { - // Do this in the background - updateMintFromUrl(db, reserveRecord.mint_base_url) - .then((mint) => updateReserve(db, reservePub, mint) - .then((reserve) => depleteReserve(db, reserve, mint))); - return resp; - }); - }) - .then((resp) => { - sendResponse(resp); - }); - // Allow async response - return true; -} function copy(o) { return JSON.parse(JSON.stringify(o)); } @@ -313,200 +84,420 @@ function rankDenom(denom1, denom2) { let v2 = new Amount(denom2.value); return (-1) * v1.cmp(v2); } -function withdrawPrepare(db, denom, reserve) { - let reservePriv = new EddsaPrivateKey(); - reservePriv.loadCrock(reserve.reserve_priv); - let reservePub = new EddsaPublicKey(); - reservePub.loadCrock(reserve.reserve_pub); - let denomPub = RsaPublicKey.fromCrock(denom.denom_pub); - let coinPriv = EddsaPrivateKey.create(); - let coinPub = coinPriv.getPublicKey(); - let blindingFactor = RsaBlindingKey.create(1024); - let pubHash = coinPub.hash(); - let ev = rsaBlind(pubHash, blindingFactor, denomPub); - if (!denom.fee_withdraw) { - throw Error("Field fee_withdraw missing"); +class Wallet { + constructor(db, http, badge) { + this.db = db; + this.http = http; + this.badge = badge; } - let amountWithFee = new Amount(denom.value); - amountWithFee.add(new Amount(denom.fee_withdraw)); - let withdrawFee = new Amount(denom.fee_withdraw); - // Signature - let withdrawRequest = new WithdrawRequestPS({ - reserve_pub: reservePub, - amount_with_fee: amountWithFee.toNbo(), - withdraw_fee: withdrawFee.toNbo(), - h_denomination_pub: denomPub.encode().hash(), - h_coin_envelope: ev.hash() - }); - var sig = eddsaSign(withdrawRequest.toPurpose(), reservePriv); - let preCoin = { - reservePub: reservePub.toCrock(), - blindingKey: blindingFactor.toCrock(), - coinPub: coinPub.toCrock(), - coinPriv: coinPriv.toCrock(), - denomPub: denomPub.encode().toCrock(), - mintBaseUrl: reserve.mint_base_url, - withdrawSig: sig.toCrock(), - coinEv: ev.toCrock(), - coinValue: denom.value - }; - return Query(db).put("precoins", preCoin).finish().then(() => preCoin); -} -function withdrawExecute(db, pc) { - return Query(db) - .get("reserves", pc.reservePub) - .then((r) => { - let wd = {}; - 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 httpPostJson(reqUrl, wd); - }) - .then(resp => { - if (resp.status != 200) { - throw new RequestException({ - hint: "Withdrawal failed", - status: resp.status - }); - } - let r = JSON.parse(resp.responseText); - let denomSig = rsaUnblind(RsaSignature.fromCrock(r.ev_sig), RsaBlindingKey.fromCrock(pc.blindingKey), RsaPublicKey.fromCrock(pc.denomPub)); - let coin = { - coinPub: pc.coinPub, - coinPriv: pc.coinPriv, - denomPub: pc.denomPub, - denomSig: denomSig.encode().toCrock(), - currentAmount: pc.coinValue, - mintBaseUrl: pc.mintBaseUrl, - }; - return coin; - }); -} -function updateBadge(db) { - function countNonEmpty(c, n) { - if (c.currentAmount.fraction != 0 || c.currentAmount.value != 0) { - return n + 1; - } - return n; - } - function doBadge(n) { - chrome.browserAction.setBadgeText({ text: "" + n }); - chrome.browserAction.setBadgeBackgroundColor({ color: "#0F0" }); - } - Query(db) - .iter("coins") - .reduce(countNonEmpty, 0) - .then(doBadge); -} -function storeCoin(db, coin) { - Query(db) - .delete("precoins", coin.coinPub) - .add("coins", coin) - .finish() - .then(() => { - updateBadge(db); - }); -} -function withdraw(db, denom, reserve) { - return withdrawPrepare(db, denom, reserve) - .then((pc) => withdrawExecute(db, pc)) - .then((c) => storeCoin(db, c)); -} -/** - * Withdraw coins from a reserve until it is empty. - */ -function depleteReserve(db, reserve, mint) { - let denoms = copy(mint.keys.denoms); - let remaining = new Amount(reserve.current_amount); - denoms.sort(rankDenom); - let workList = []; - for (let i = 0; i < 1000; i++) { - let found = false; - for (let d of denoms) { - let cost = new Amount(d.value); - cost.add(new Amount(d.fee_withdraw)); - if (remaining.cmp(cost) < 0) { - continue; + static signDeposit(offer, cds) { + let ret = []; + let amountSpent = Amount.getZero(cds[0].coin.currentAmount.currency); + let amountRemaining = new Amount(offer.contract.amount); + cds = copy(cds); + for (let cd of cds) { + let coinSpend; + if (amountRemaining.value == 0 && amountRemaining.fraction == 0) { + break; } - found = true; - remaining.sub(cost); - workList.push(d); - } - if (!found) { - console.log("did not find coins for remaining ", remaining.toJson()); - break; - } - } - // Do the request one by one. - function next() { - if (workList.length == 0) { - return; - } - let d = workList.pop(); - withdraw(db, d, reserve) - .then(() => next()); - } - next(); -} -function updateReserve(db, reservePub, mint) { - let reservePubStr = reservePub.toCrock(); - return Query(db) - .get("reserves", reservePubStr) - .then((reserve) => { - let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); - reqUrl.query({ 'reserve_pub': reservePubStr }); - return httpGet(reqUrl).then(resp => { - if (resp.status != 200) { - throw Error(); + if (amountRemaining.cmp(new Amount(cd.coin.currentAmount)) < 0) { + coinSpend = new Amount(amountRemaining.toJson()); } - let reserveInfo = JSON.parse(resp.responseText); - if (!reserveInfo) { - throw Error(); + else { + coinSpend = new Amount(cd.coin.currentAmount); } - reserve.current_amount = reserveInfo.balance; - return Query(db) - .put("reserves", reserve) - .finish() - .then(() => reserve); + amountSpent.add(coinSpend); + amountRemaining.sub(coinSpend); + let newAmount = new Amount(cd.coin.currentAmount); + newAmount.sub(coinSpend); + cd.coin.currentAmount = newAmount.toJson(); + let args = { + h_contract: HashCode.fromCrock(offer.H_contract), + h_wire: HashCode.fromCrock(offer.contract.H_wire), + amount_with_fee: coinSpend.toNbo(), + coin_pub: EddsaPublicKey.fromCrock(cd.coin.coinPub), + deposit_fee: new Amount(cd.denom.fee_deposit).toNbo(), + merchant: EddsaPublicKey.fromCrock(offer.contract.merchant_pub), + refund_deadline: AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline), + timestamp: AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp), + transaction_id: UInt64.fromNumber(offer.contract.transaction_id), + }; + let d = new DepositRequestPS(args); + let coinSig = eddsaSign(d.toPurpose(), EddsaPrivateKey.fromCrock(cd.coin.coinPriv)) + .toCrock(); + let s = { + coin_sig: coinSig, + coin_pub: cd.coin.coinPub, + ub_sig: cd.coin.denomSig, + denom_pub: cd.coin.denomPub, + f: coinSpend.toJson(), + }; + ret.push({ sig: s, updatedCoin: cd.coin }); + } + return ret; + } + /** + * Get mints and associated coins that are still spendable, + * but only if the sum the coins' remaining value exceeds the payment amount. + * @param paymentAmount + * @param depositFeeLimit + * @param allowedMints + */ + getPossibleMintCoins(paymentAmount, depositFeeLimit, allowedMints) { + let m = {}; + function storeMintCoin(mc) { + let mint = mc[0]; + let coin = mc[1]; + let cd = { + coin: coin, + denom: mint.keys.denoms.find((e) => e.denom_pub === coin.denomPub) + }; + if (!cd.denom) { + throw Error("denom not found (database inconsistent)"); + } + let x = m[mint.baseUrl]; + if (!x) { + m[mint.baseUrl] = [cd]; + } + else { + x.push(cd); + } + } + let ps = allowedMints.map((info) => { + return Query(this.db) + .iterIndex("mints", "pubKey", info.master_pub) + .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) + .reduce(storeMintCoin); + }); + return Promise.all(ps).then(() => { + let ret = {}; + nextMint: for (let key in m) { + let coins = m[key].map((x) => ({ + a: new Amount(x.denom.fee_deposit), + c: x + })); + // Sort by ascending deposit fee + coins.sort((o1, o2) => o1.a.cmp(o2.a)); + let maxFee = new Amount(depositFeeLimit); + let minAmount = new Amount(paymentAmount); + let accFee = new Amount(coins[0].c.denom.fee_deposit); + let accAmount = Amount.getZero(coins[0].c.coin.currentAmount.currency); + let usableCoins = []; + nextCoin: for (let i = 0; i < coins.length; i++) { + let coinAmount = new Amount(coins[i].c.coin.currentAmount); + let coinFee = coins[i].a; + if (coinAmount.cmp(coinFee) <= 0) { + continue nextCoin; + } + accFee.add(coinFee); + accAmount.add(coinAmount); + if (accFee.cmp(maxFee) >= 0) { + console.log("too much fees"); + continue nextMint; + } + usableCoins.push(coins[i].c); + if (accAmount.cmp(minAmount) >= 0) { + ret[key] = usableCoins; + continue nextMint; + } + } + } + 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. - */ -function updateMintFromUrl(db, baseUrl) { - let reqUrl = URI("keys").absoluteTo(baseUrl); - return httpGet(reqUrl).then((resp) => { - if (resp.status != 200) { - throw Error("/keys request failed"); - } - let mintKeysJson = JSON.parse(resp.responseText); - if (!mintKeysJson) { - throw new RequestException({ url: reqUrl, hint: "keys invalid" }); - } - let mint = { - baseUrl: baseUrl, - keys: mintKeysJson - }; - return Query(db).put("mints", mint).finish().then(() => mint); - }); -} -function getBalances(db) { - function collectBalances(c, byCurrency) { - let acc = byCurrency[c.currentAmount.currency]; - if (!acc) { - acc = Amount.getZero(c.currentAmount.currency).toJson(); - } - let am = new Amount(c.currentAmount); - am.add(new Amount(acc)); - byCurrency[c.currentAmount.currency] = am.toJson(); - return byCurrency; } - return Query(db) - .iter("coins") - .reduce(collectBalances, {}); + executePay(offer, payCoinInfo, merchantBaseUrl, chosenMint) { + let payReq = {}; + payReq["H_wire"] = offer.contract.H_wire; + payReq["H_contract"] = offer.H_contract; + payReq["transaction_id"] = offer.contract.transaction_id; + payReq["refund_deadline"] = offer.contract.refund_deadline; + payReq["mint"] = URI(chosenMint).href(); + payReq["coins"] = payCoinInfo.map((x) => x.sig); + payReq["timestamp"] = offer.contract.timestamp; + let payUrl = URI(offer.pay_url).absoluteTo(merchantBaseUrl); + let t = { + contractHash: offer.H_contract, + contract: offer.contract, + payUrl: payUrl.href(), + payReq: payReq + }; + return Query(this.db) + .put("transactions", t) + .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) + .finish(); + } + confirmPay(offer, merchantPageUrl) { + 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) { + throw Error("Not enough coins."); + } + let mintUrl = Object.keys(mcs)[0]; + let ds = Wallet.signDeposit(offer, mcs[mintUrl]); + return this.executePay(offer, ds, merchantPageUrl, mintUrl); + }); + } + doPayment(H_contract) { + return Promise.resolve().then(() => { + return Query(this.db) + .get("transactions", H_contract) + .then((t) => { + if (!t) { + throw Error("contract not found"); + } + let resp = { + payUrl: t.payUrl, + payReq: t.payReq + }; + return resp; + }); + }); + } + confirmReserve(req) { + let reservePriv = EddsaPrivateKey.create(); + let reservePub = reservePriv.getPublicKey(); + let form = new FormData(); + let now = (new Date()).toString(); + form.append(req.field_amount, req.amount_str); + form.append(req.field_reserve_pub, reservePub.toCrock()); + form.append(req.field_mint, req.mint); + // TODO: set bank-specified fields. + let mintBaseUrl = canonicalizeBaseUrl(req.mint); + return httpPostForm(req.post_url, form) + .then((hresp) => { + let resp = { + status: hresp.status, + text: hresp.responseText, + success: undefined, + backlink: undefined + }; + let reserveRecord = { + reserve_pub: reservePub.toCrock(), + reserve_priv: reservePriv.toCrock(), + mint_base_url: mintBaseUrl, + created: now, + last_query: null, + current_amount: null, + // XXX: set to actual amount + initial_amount: null + }; + if (hresp.status != 200) { + resp.success = false; + return resp; + } + resp.success = true; + // We can't show the page directly, so + // we show some generic page from the wallet. + resp.backlink = null; + return Query(this.db) + .put("reserves", reserveRecord) + .finish() + .then(() => { + // Do this in the background + this.updateMintFromUrl(reserveRecord.mint_base_url) + .then((mint) => this.updateReserve(reservePub, mint) + .then((reserve) => this.depleteReserve(reserve, mint))); + return resp; + }); + }); + } + withdrawPrepare(denom, reserve) { + let reservePriv = new EddsaPrivateKey(); + reservePriv.loadCrock(reserve.reserve_priv); + let reservePub = new EddsaPublicKey(); + reservePub.loadCrock(reserve.reserve_pub); + let denomPub = RsaPublicKey.fromCrock(denom.denom_pub); + let coinPriv = EddsaPrivateKey.create(); + let coinPub = coinPriv.getPublicKey(); + let blindingFactor = RsaBlindingKey.create(1024); + let pubHash = coinPub.hash(); + let ev = rsaBlind(pubHash, blindingFactor, denomPub); + if (!denom.fee_withdraw) { + throw Error("Field fee_withdraw missing"); + } + let amountWithFee = new Amount(denom.value); + amountWithFee.add(new Amount(denom.fee_withdraw)); + let withdrawFee = new Amount(denom.fee_withdraw); + // Signature + let withdrawRequest = new WithdrawRequestPS({ + reserve_pub: reservePub, + amount_with_fee: amountWithFee.toNbo(), + withdraw_fee: withdrawFee.toNbo(), + h_denomination_pub: denomPub.encode().hash(), + h_coin_envelope: ev.hash() + }); + var sig = eddsaSign(withdrawRequest.toPurpose(), reservePriv); + let preCoin = { + reservePub: reservePub.toCrock(), + blindingKey: blindingFactor.toCrock(), + coinPub: coinPub.toCrock(), + coinPriv: coinPriv.toCrock(), + denomPub: denomPub.encode().toCrock(), + mintBaseUrl: reserve.mint_base_url, + withdrawSig: sig.toCrock(), + coinEv: ev.toCrock(), + coinValue: denom.value + }; + return Query(this.db).put("precoins", preCoin).finish().then(() => preCoin); + } + withdrawExecute(pc) { + return Query(this.db) + .get("reserves", pc.reservePub) + .then((r) => { + let wd = {}; + 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 httpPostJson(reqUrl, wd); + }) + .then(resp => { + if (resp.status != 200) { + throw new RequestException({ + hint: "Withdrawal failed", + status: resp.status + }); + } + let r = JSON.parse(resp.responseText); + let denomSig = rsaUnblind(RsaSignature.fromCrock(r.ev_sig), RsaBlindingKey.fromCrock(pc.blindingKey), RsaPublicKey.fromCrock(pc.denomPub)); + let coin = { + coinPub: pc.coinPub, + coinPriv: pc.coinPriv, + denomPub: pc.denomPub, + denomSig: denomSig.encode().toCrock(), + currentAmount: pc.coinValue, + mintBaseUrl: pc.mintBaseUrl, + }; + return coin; + }); + } + updateBadge() { + function countNonEmpty(c, n) { + if (c.currentAmount.fraction != 0 || c.currentAmount.value != 0) { + return n + 1; + } + return n; + } + function doBadge(n) { + chrome.browserAction.setBadgeText({ text: "" + n }); + chrome.browserAction.setBadgeBackgroundColor({ color: "#0F0" }); + } + Query(this.db) + .iter("coins") + .reduce(countNonEmpty, 0) + .then(doBadge); + } + storeCoin(coin) { + Query(this.db) + .delete("precoins", coin.coinPub) + .add("coins", coin) + .finish() + .then(() => { + this.updateBadge(); + }); + } + withdraw(denom, reserve) { + return this.withdrawPrepare(denom, reserve) + .then((pc) => this.withdrawExecute(pc)) + .then((c) => this.storeCoin(c)); + } + /** + * Withdraw coins from a reserve until it is empty. + */ + depleteReserve(reserve, mint) { + let denoms = copy(mint.keys.denoms); + let remaining = new Amount(reserve.current_amount); + denoms.sort(rankDenom); + let workList = []; + for (let i = 0; i < 1000; i++) { + let found = false; + for (let d of denoms) { + let cost = new Amount(d.value); + cost.add(new Amount(d.fee_withdraw)); + if (remaining.cmp(cost) < 0) { + continue; + } + found = true; + remaining.sub(cost); + workList.push(d); + } + if (!found) { + console.log("did not find coins for remaining ", remaining.toJson()); + break; + } + } + // Do the request one by one. + let next = () => { + if (workList.length == 0) { + return; + } + let d = workList.pop(); + this.withdraw(d, reserve) + .then(() => next()); + }; + // Asynchronous recursion + next(); + } + updateReserve(reservePub, mint) { + let reservePubStr = reservePub.toCrock(); + return Query(this.db) + .get("reserves", reservePubStr) + .then((reserve) => { + let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); + reqUrl.query({ 'reserve_pub': reservePubStr }); + return httpGet(reqUrl).then(resp => { + if (resp.status != 200) { + throw Error(); + } + let reserveInfo = JSON.parse(resp.responseText); + if (!reserveInfo) { + throw Error(); + } + reserve.current_amount = reserveInfo.balance; + return Query(this.db) + .put("reserves", reserve) + .finish() + .then(() => reserve); + }); + }); + } + /** + * 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) { + let reqUrl = URI("keys").absoluteTo(baseUrl); + return httpGet(reqUrl).then((resp) => { + if (resp.status != 200) { + throw Error("/keys request failed"); + } + let mintKeysJson = JSON.parse(resp.responseText); + if (!mintKeysJson) { + throw new RequestException({ url: reqUrl, hint: "keys invalid" }); + } + let mint = { + baseUrl: baseUrl, + keys: mintKeysJson + }; + return Query(this.db).put("mints", mint).finish().then(() => mint); + }); + } + getBalances() { + function collectBalances(c, byCurrency) { + let acc = byCurrency[c.currentAmount.currency]; + if (!acc) { + acc = Amount.getZero(c.currentAmount.currency).toJson(); + } + let am = new Amount(c.currentAmount); + am.add(new Amount(acc)); + byCurrency[c.currentAmount.currency] = am.toJson(); + return byCurrency; + } + return Query(this.db) + .iter("coins") + .reduce(collectBalances, {}); + } } diff --git a/extension/background/wallet.ts b/extension/background/wallet.ts index d9187f14a..6479b961a 100644 --- a/extension/background/wallet.ts +++ b/extension/background/wallet.ts @@ -131,6 +131,54 @@ interface Reserve { } +interface PaymentResponse { + payUrl: string; + payReq: any; +} + + +interface ConfirmReserveRequest { + /** + * Name of the form field for the amount. + */ + field_amount; + + /** + * Name of the form field for the reserve public key. + */ + field_reserve_pub; + + /** + * Name of the form field for the reserve public key. + */ + field_mint; + + /** + * The actual amount in string form. + * TODO: where is this format specified? + */ + amount_str; + + /** + * Target URL for the reserve creation request. + */ + post_url; + + /** + * Mint URL where the bank should create the reserve. + */ + mint; +} + + +interface ConfirmReserveResponse { + backlink: string; + success: boolean; + status: number; + text: string; +} + + type PayCoinInfo = Array<{ updatedCoin: Db.Coin, sig: CoinPaySig_interface }>; @@ -148,278 +196,12 @@ function canonicalizeBaseUrl(url) { return x.href() } +interface HttpRequestLibrary { -function signDeposit(db: IDBDatabase, - offer: Offer, - cds: Db.CoinWithDenom[]): PayCoinInfo { - let ret = []; - let amountSpent = Amount.getZero(cds[0].coin.currentAmount.currency); - let amountRemaining = new Amount(offer.contract.amount); - cds = copy(cds); - for (let cd of cds) { - let coinSpend; - - if (amountRemaining.value == 0 && amountRemaining.fraction == 0) { - break; - } - - if (amountRemaining.cmp(new Amount(cd.coin.currentAmount)) < 0) { - coinSpend = new Amount(amountRemaining.toJson()); - } else { - coinSpend = new Amount(cd.coin.currentAmount); - } - - amountSpent.add(coinSpend); - amountRemaining.sub(coinSpend); - - let newAmount = new Amount(cd.coin.currentAmount); - newAmount.sub(coinSpend); - cd.coin.currentAmount = newAmount.toJson(); - - let args: DepositRequestPS_Args = { - h_contract: HashCode.fromCrock(offer.H_contract), - h_wire: HashCode.fromCrock(offer.contract.H_wire), - amount_with_fee: coinSpend.toNbo(), - coin_pub: EddsaPublicKey.fromCrock(cd.coin.coinPub), - deposit_fee: new Amount(cd.denom.fee_deposit).toNbo(), - merchant: EddsaPublicKey.fromCrock(offer.contract.merchant_pub), - refund_deadline: AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline), - timestamp: AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp), - transaction_id: UInt64.fromNumber(offer.contract.transaction_id), - }; - - let d = new DepositRequestPS(args); - - let coinSig = eddsaSign(d.toPurpose(), - EddsaPrivateKey.fromCrock(cd.coin.coinPriv)) - .toCrock(); - - let s: CoinPaySig_interface = { - coin_sig: coinSig, - coin_pub: cd.coin.coinPub, - ub_sig: cd.coin.denomSig, - denom_pub: cd.coin.denomPub, - f: coinSpend.toJson(), - }; - ret.push({sig: s, updatedCoin: cd.coin}); - } - return ret; } +interface Badge { -/** - * Get mints and associated coins that are still spendable, - * but only if the sum the coins' remaining value exceeds the payment amount. - * @param db - * @param paymentAmount - * @param depositFeeLimit - * @param allowedMints - */ -function getPossibleMintCoins(db: IDBDatabase, - paymentAmount: AmountJson_interface, - depositFeeLimit: AmountJson_interface, - allowedMints: MintInfo[]): Promise { - - - let m: MintCoins = {}; - - function storeMintCoin(mc) { - let mint = mc[0]; - let coin = mc[1]; - let cd = { - coin: coin, - denom: mint.keys.denoms.find((e) => e.denom_pub === coin.denomPub) - }; - if (!cd.denom) { - throw Error("denom not found (database inconsistent)"); - } - let x = m[mint.baseUrl]; - if (!x) { - m[mint.baseUrl] = [cd]; - } else { - x.push(cd); - } - } - - let ps = allowedMints.map((info) => { - return Query(db) - .iterIndex("mints", "pubKey", info.master_pub) - .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) - .reduce(storeMintCoin); - }); - - return Promise.all(ps).then(() => { - let ret: MintCoins = {}; - - nextMint: - for (let key in m) { - let coins = m[key].map((x) => ({ - a: new Amount(x.denom.fee_deposit), - c: x - })); - // Sort by ascending deposit fee - coins.sort((o1, o2) => o1.a.cmp(o2.a)); - let maxFee = new Amount(depositFeeLimit); - let minAmount = new Amount(paymentAmount); - let accFee = new Amount(coins[0].c.denom.fee_deposit); - let accAmount = Amount.getZero(coins[0].c.coin.currentAmount.currency); - let usableCoins: Db.CoinWithDenom[] = []; - nextCoin: - for (let i = 0; i < coins.length; i++) { - let coinAmount = new Amount(coins[i].c.coin.currentAmount); - let coinFee = coins[i].a; - if (coinAmount.cmp(coinFee) <= 0) { - continue nextCoin; - } - accFee.add(coinFee); - accAmount.add(coinAmount); - if (accFee.cmp(maxFee) >= 0) { - console.log("too much fees"); - continue nextMint; - } - usableCoins.push(coins[i].c); - if (accAmount.cmp(minAmount) >= 0) { - ret[key] = usableCoins; - continue nextMint; - } - } - } - return ret; - }); -} - - -function executePay(db, - offer: Offer, - payCoinInfo: PayCoinInfo, - merchantBaseUrl: string, - chosenMint: string): Promise { - let payReq = {}; - payReq["H_wire"] = offer.contract.H_wire; - payReq["H_contract"] = offer.H_contract; - payReq["transaction_id"] = offer.contract.transaction_id; - payReq["refund_deadline"] = offer.contract.refund_deadline; - payReq["mint"] = URI(chosenMint).href(); - payReq["coins"] = payCoinInfo.map((x) => x.sig); - payReq["timestamp"] = offer.contract.timestamp; - let payUrl = URI(offer.pay_url).absoluteTo(merchantBaseUrl); - let t: Transaction = { - contractHash: offer.H_contract, - contract: offer.contract, - payUrl: payUrl.href(), - payReq: payReq - }; - - return Query(db) - .put("transactions", t) - .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) - .finish(); -} - - -function confirmPayHandler(db, detail: ConfirmPayRequest, sendResponse) { - let offer: Offer = detail.offer; - getPossibleMintCoins(db, - offer.contract.amount, - offer.contract.max_fee, - offer.contract.mints) - .then((mcs) => { - if (Object.keys(mcs).length == 0) { - sendResponse({error: "Not enough coins."}); - return; - } - let mintUrl = Object.keys(mcs)[0]; - let ds = signDeposit(db, offer, mcs[mintUrl]); - return executePay(db, offer, ds, detail.merchantPageUrl, mintUrl) - .then(() => { - sendResponse({ - success: true, - }); - }); - }); - return true; -} - - -function doPaymentHandler(db, detail, sendResponse) { - let H_contract = detail.H_contract; - Query(db) - .get("transactions", H_contract) - .then((r) => { - if (!r) { - sendResponse({success: false, error: "contract not found"}); - return; - } - sendResponse({ - success: true, - payUrl: r.payUrl, - payReq: r.payReq - }); - }); - // async sendResponse - return true; -} - - -function confirmReserveHandler(db, detail, sendResponse) { - let reservePriv = EddsaPrivateKey.create(); - let reservePub = reservePriv.getPublicKey(); - let form = new FormData(); - let now = (new Date()).toString(); - form.append(detail.field_amount, detail.amount_str); - form.append(detail.field_reserve_pub, reservePub.toCrock()); - form.append(detail.field_mint, detail.mint); - // XXX: set bank-specified fields. - let mintBaseUrl = canonicalizeBaseUrl(detail.mint); - httpPostForm(detail.post_url, form) - .then((hresp) => { - // TODO: extract as interface - let resp = { - status: hresp.status, - text: hresp.responseText, - success: undefined, - backlink: undefined - }; - let reserveRecord = { - reserve_pub: reservePub.toCrock(), - reserve_priv: reservePriv.toCrock(), - mint_base_url: mintBaseUrl, - created: now, - last_query: null, - current_amount: null, - // XXX: set to actual amount - initial_amount: null - }; - - if (hresp.status != 200) { - resp.success = false; - return resp; - } - - resp.success = true; - // We can't show the page directly, so - // we show some generic page from the wallet. - // TODO: this should not be webextensions-specific - resp.backlink = chrome.extension.getURL("pages/reserve-success.html"); - return Query(db) - .put("reserves", reserveRecord) - .finish() - .then(() => { - // Do this in the background - updateMintFromUrl(db, reserveRecord.mint_base_url) - .then((mint) => - updateReserve(db, reservePub, mint) - .then((reserve) => depleteReserve(db, reserve, mint)) - ); - return resp; - }); - }) - .then((resp) => { - sendResponse(resp); - }); - - // Allow async response - return true; } @@ -436,233 +218,490 @@ function rankDenom(denom1: any, denom2: any) { } -function withdrawPrepare(db: IDBDatabase, - denom: Db.Denomination, - reserve: Reserve): Promise { - let reservePriv = new EddsaPrivateKey(); - reservePriv.loadCrock(reserve.reserve_priv); - let reservePub = new EddsaPublicKey(); - reservePub.loadCrock(reserve.reserve_pub); - let denomPub = RsaPublicKey.fromCrock(denom.denom_pub); - let coinPriv = EddsaPrivateKey.create(); - let coinPub = coinPriv.getPublicKey(); - let blindingFactor = RsaBlindingKey.create(1024); - let pubHash: HashCode = coinPub.hash(); - let ev: ByteArray = rsaBlind(pubHash, blindingFactor, denomPub); +class Wallet { + private db: IDBDatabase; + private http: HttpRequestLibrary; + private badge: Badge; - if (!denom.fee_withdraw) { - throw Error("Field fee_withdraw missing"); + constructor(db: IDBDatabase, http: HttpRequestLibrary, badge: Badge) { + this.db = db; + this.http = http; + this.badge = badge; } - let amountWithFee = new Amount(denom.value); - amountWithFee.add(new Amount(denom.fee_withdraw)); - let withdrawFee = new Amount(denom.fee_withdraw); + static signDeposit(offer: Offer, + cds: Db.CoinWithDenom[]): PayCoinInfo { + let ret = []; + let amountSpent = Amount.getZero(cds[0].coin.currentAmount.currency); + let amountRemaining = new Amount(offer.contract.amount); + cds = copy(cds); + for (let cd of cds) { + let coinSpend; - // Signature - let withdrawRequest = new WithdrawRequestPS({ - reserve_pub: reservePub, - amount_with_fee: amountWithFee.toNbo(), - withdraw_fee: withdrawFee.toNbo(), - h_denomination_pub: denomPub.encode().hash(), - h_coin_envelope: ev.hash() - }); - - var sig = eddsaSign(withdrawRequest.toPurpose(), reservePriv); - - let preCoin: Db.PreCoin = { - reservePub: reservePub.toCrock(), - blindingKey: blindingFactor.toCrock(), - coinPub: coinPub.toCrock(), - coinPriv: coinPriv.toCrock(), - denomPub: denomPub.encode().toCrock(), - mintBaseUrl: reserve.mint_base_url, - withdrawSig: sig.toCrock(), - coinEv: ev.toCrock(), - coinValue: denom.value - }; - - return Query(db).put("precoins", preCoin).finish().then(() => preCoin); -} - - -function withdrawExecute(db, pc: Db.PreCoin): Promise { - return Query(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 httpPostJson(reqUrl, wd); - }) - .then(resp => { - if (resp.status != 200) { - throw new RequestException({ - hint: "Withdrawal failed", - status: resp.status - }); + if (amountRemaining.value == 0 && amountRemaining.fraction == 0) { + break; } - let r = JSON.parse(resp.responseText); - let denomSig = rsaUnblind(RsaSignature.fromCrock(r.ev_sig), - RsaBlindingKey.fromCrock(pc.blindingKey), - RsaPublicKey.fromCrock(pc.denomPub)); - let coin: Db.Coin = { - coinPub: pc.coinPub, - coinPriv: pc.coinPriv, - denomPub: pc.denomPub, - denomSig: denomSig.encode().toCrock(), - currentAmount: pc.coinValue, - mintBaseUrl: pc.mintBaseUrl, + + if (amountRemaining.cmp(new Amount(cd.coin.currentAmount)) < 0) { + coinSpend = new Amount(amountRemaining.toJson()); + } else { + coinSpend = new Amount(cd.coin.currentAmount); + } + + amountSpent.add(coinSpend); + amountRemaining.sub(coinSpend); + + let newAmount = new Amount(cd.coin.currentAmount); + newAmount.sub(coinSpend); + cd.coin.currentAmount = newAmount.toJson(); + + let args: DepositRequestPS_Args = { + h_contract: HashCode.fromCrock(offer.H_contract), + h_wire: HashCode.fromCrock(offer.contract.H_wire), + amount_with_fee: coinSpend.toNbo(), + coin_pub: EddsaPublicKey.fromCrock(cd.coin.coinPub), + deposit_fee: new Amount(cd.denom.fee_deposit).toNbo(), + merchant: EddsaPublicKey.fromCrock(offer.contract.merchant_pub), + refund_deadline: AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline), + timestamp: AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp), + transaction_id: UInt64.fromNumber(offer.contract.transaction_id), }; - return coin; - }); -} + let d = new DepositRequestPS(args); -function updateBadge(db) { - function countNonEmpty(c, n) { - if (c.currentAmount.fraction != 0 || c.currentAmount.value != 0) { - return n + 1; + let coinSig = eddsaSign(d.toPurpose(), + EddsaPrivateKey.fromCrock(cd.coin.coinPriv)) + .toCrock(); + + let s: CoinPaySig_interface = { + coin_sig: coinSig, + coin_pub: cd.coin.coinPub, + ub_sig: cd.coin.denomSig, + denom_pub: cd.coin.denomPub, + f: coinSpend.toJson(), + }; + ret.push({sig: s, updatedCoin: cd.coin}); } - return n; + return ret; } - function doBadge(n) { - chrome.browserAction.setBadgeText({text: "" + n}); - chrome.browserAction.setBadgeBackgroundColor({color: "#0F0"}); - } - Query(db) - .iter("coins") - .reduce(countNonEmpty, 0) - .then(doBadge); -} + /** + * Get mints and associated coins that are still spendable, + * but only if the sum the coins' remaining value exceeds the payment amount. + * @param paymentAmount + * @param depositFeeLimit + * @param allowedMints + */ + getPossibleMintCoins(paymentAmount: AmountJson_interface, + depositFeeLimit: AmountJson_interface, + allowedMints: MintInfo[]): Promise { -function storeCoin(db, coin: Db.Coin) { - Query(db) - .delete("precoins", coin.coinPub) - .add("coins", coin) - .finish() - .then(() => { - updateBadge(db); - }); -} + let m: MintCoins = {}; - -function withdraw(db, denom, reserve): Promise { - return withdrawPrepare(db, denom, reserve) - .then((pc) => withdrawExecute(db, pc)) - .then((c) => storeCoin(db, c)); -} - - -/** - * Withdraw coins from a reserve until it is empty. - */ -function depleteReserve(db, reserve, mint): void { - let denoms = copy(mint.keys.denoms); - let remaining = new Amount(reserve.current_amount); - denoms.sort(rankDenom); - let workList = []; - for (let i = 0; i < 1000; i++) { - let found = false; - for (let d of denoms) { - let cost = new Amount(d.value); - cost.add(new Amount(d.fee_withdraw)); - if (remaining.cmp(cost) < 0) { - continue; + function storeMintCoin(mc) { + let mint = mc[0]; + let coin = mc[1]; + let cd = { + coin: coin, + denom: mint.keys.denoms.find((e) => e.denom_pub === coin.denomPub) + }; + if (!cd.denom) { + throw Error("denom not found (database inconsistent)"); + } + let x = m[mint.baseUrl]; + if (!x) { + m[mint.baseUrl] = [cd]; + } else { + x.push(cd); } - found = true; - remaining.sub(cost); - workList.push(d); } - if (!found) { - console.log("did not find coins for remaining ", remaining.toJson()); - break; - } - } - // Do the request one by one. - function next(): void { - if (workList.length == 0) { - return; - } - let d = workList.pop(); - withdraw(db, d, reserve) - .then(() => next()); - } - - next(); -} - - -function updateReserve(db: IDBDatabase, - reservePub: EddsaPublicKey, - mint): Promise { - let reservePubStr = reservePub.toCrock(); - return Query(db) - .get("reserves", reservePubStr) - .then((reserve) => { - let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); - reqUrl.query({'reserve_pub': reservePubStr}); - return httpGet(reqUrl).then(resp => { - if (resp.status != 200) { - throw Error(); - } - let reserveInfo = JSON.parse(resp.responseText); - if (!reserveInfo) { - throw Error(); - } - reserve.current_amount = reserveInfo.balance; - return Query(db) - .put("reserves", reserve) - .finish() - .then(() => reserve); - }); + let ps = allowedMints.map((info) => { + return Query(this.db) + .iterIndex("mints", "pubKey", info.master_pub) + .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) + .reduce(storeMintCoin); }); -} + return Promise.all(ps).then(() => { + let ret: MintCoins = {}; -/** - * 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. - */ -function updateMintFromUrl(db, baseUrl) { - let reqUrl = URI("keys").absoluteTo(baseUrl); - return httpGet(reqUrl).then((resp) => { - if (resp.status != 200) { - throw Error("/keys request failed"); - } - let mintKeysJson = JSON.parse(resp.responseText); - if (!mintKeysJson) { - throw new RequestException({url: reqUrl, hint: "keys invalid"}); - } - let mint: Db.Mint = { - baseUrl: baseUrl, - keys: mintKeysJson - }; - return Query(db).put("mints", mint).finish().then(() => mint); - }); -} - - -function getBalances(db): Promise { - function collectBalances(c: Db.Coin, byCurrency) { - let acc: AmountJson_interface = byCurrency[c.currentAmount.currency]; - if (!acc) { - acc = Amount.getZero(c.currentAmount.currency).toJson(); - } - let am = new Amount(c.currentAmount); - am.add(new Amount(acc)); - byCurrency[c.currentAmount.currency] = am.toJson(); - return byCurrency; + nextMint: + for (let key in m) { + let coins = m[key].map((x) => ({ + a: new Amount(x.denom.fee_deposit), + c: x + })); + // Sort by ascending deposit fee + coins.sort((o1, o2) => o1.a.cmp(o2.a)); + let maxFee = new Amount(depositFeeLimit); + let minAmount = new Amount(paymentAmount); + let accFee = new Amount(coins[0].c.denom.fee_deposit); + let accAmount = Amount.getZero(coins[0].c.coin.currentAmount.currency); + let usableCoins: Db.CoinWithDenom[] = []; + nextCoin: + for (let i = 0; i < coins.length; i++) { + let coinAmount = new Amount(coins[i].c.coin.currentAmount); + let coinFee = coins[i].a; + if (coinAmount.cmp(coinFee) <= 0) { + continue nextCoin; + } + accFee.add(coinFee); + accAmount.add(coinAmount); + if (accFee.cmp(maxFee) >= 0) { + console.log("too much fees"); + continue nextMint; + } + usableCoins.push(coins[i].c); + if (accAmount.cmp(minAmount) >= 0) { + ret[key] = usableCoins; + continue nextMint; + } + } + } + return ret; + }); } - return Query(db) - .iter("coins") - .reduce(collectBalances, {}); + + executePay(offer: Offer, + payCoinInfo: PayCoinInfo, + merchantBaseUrl: string, + chosenMint: string): Promise { + let payReq = {}; + payReq["H_wire"] = offer.contract.H_wire; + payReq["H_contract"] = offer.H_contract; + payReq["transaction_id"] = offer.contract.transaction_id; + payReq["refund_deadline"] = offer.contract.refund_deadline; + payReq["mint"] = URI(chosenMint).href(); + payReq["coins"] = payCoinInfo.map((x) => x.sig); + payReq["timestamp"] = offer.contract.timestamp; + let payUrl = URI(offer.pay_url).absoluteTo(merchantBaseUrl); + let t: Transaction = { + contractHash: offer.H_contract, + contract: offer.contract, + payUrl: payUrl.href(), + payReq: payReq + }; + + return Query(this.db) + .put("transactions", t) + .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) + .finish(); + } + + confirmPay(offer: Offer, merchantPageUrl: string): Promise { + 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) { + throw Error("Not enough coins."); + } + let mintUrl = Object.keys(mcs)[0]; + let ds = Wallet.signDeposit(offer, mcs[mintUrl]); + return this.executePay(offer, ds, merchantPageUrl, mintUrl); + }); + } + + doPayment(H_contract): Promise { + return Promise.resolve().then(() => { + return Query(this.db) + .get("transactions", H_contract) + .then((t) => { + if (!t) { + throw Error("contract not found"); + } + let resp: PaymentResponse = { + payUrl: t.payUrl, + payReq: t.payReq + }; + return resp; + }); + }); + } + + confirmReserve(req: ConfirmReserveRequest): Promise { + let reservePriv = EddsaPrivateKey.create(); + let reservePub = reservePriv.getPublicKey(); + let form = new FormData(); + let now = (new Date()).toString(); + form.append(req.field_amount, req.amount_str); + form.append(req.field_reserve_pub, reservePub.toCrock()); + form.append(req.field_mint, req.mint); + // TODO: set bank-specified fields. + let mintBaseUrl = canonicalizeBaseUrl(req.mint); + + return httpPostForm(req.post_url, form) + .then((hresp) => { + let resp: ConfirmReserveResponse = { + status: hresp.status, + text: hresp.responseText, + success: undefined, + backlink: undefined + }; + let reserveRecord = { + reserve_pub: reservePub.toCrock(), + reserve_priv: reservePriv.toCrock(), + mint_base_url: mintBaseUrl, + created: now, + last_query: null, + current_amount: null, + // XXX: set to actual amount + initial_amount: null + }; + + if (hresp.status != 200) { + resp.success = false; + return resp; + } + + resp.success = true; + // We can't show the page directly, so + // we show some generic page from the wallet. + resp.backlink = null; + return Query(this.db) + .put("reserves", reserveRecord) + .finish() + .then(() => { + // Do this in the background + this.updateMintFromUrl(reserveRecord.mint_base_url) + .then((mint) => + this.updateReserve(reservePub, mint) + .then((reserve) => this.depleteReserve(reserve, + mint)) + ); + return resp; + }); + }); + } + + withdrawPrepare(denom: Db.Denomination, + reserve: Reserve): Promise { + let reservePriv = new EddsaPrivateKey(); + reservePriv.loadCrock(reserve.reserve_priv); + let reservePub = new EddsaPublicKey(); + reservePub.loadCrock(reserve.reserve_pub); + let denomPub = RsaPublicKey.fromCrock(denom.denom_pub); + let coinPriv = EddsaPrivateKey.create(); + let coinPub = coinPriv.getPublicKey(); + let blindingFactor = RsaBlindingKey.create(1024); + let pubHash: HashCode = coinPub.hash(); + let ev: ByteArray = rsaBlind(pubHash, blindingFactor, denomPub); + + if (!denom.fee_withdraw) { + throw Error("Field fee_withdraw missing"); + } + + let amountWithFee = new Amount(denom.value); + amountWithFee.add(new Amount(denom.fee_withdraw)); + let withdrawFee = new Amount(denom.fee_withdraw); + + // Signature + let withdrawRequest = new WithdrawRequestPS({ + reserve_pub: reservePub, + amount_with_fee: amountWithFee.toNbo(), + withdraw_fee: withdrawFee.toNbo(), + h_denomination_pub: denomPub.encode().hash(), + h_coin_envelope: ev.hash() + }); + + var sig = eddsaSign(withdrawRequest.toPurpose(), reservePriv); + + let preCoin: Db.PreCoin = { + reservePub: reservePub.toCrock(), + blindingKey: blindingFactor.toCrock(), + coinPub: coinPub.toCrock(), + coinPriv: coinPriv.toCrock(), + denomPub: denomPub.encode().toCrock(), + mintBaseUrl: reserve.mint_base_url, + withdrawSig: sig.toCrock(), + coinEv: ev.toCrock(), + coinValue: denom.value + }; + + return Query(this.db).put("precoins", preCoin).finish().then(() => preCoin); + } + + + withdrawExecute(pc: Db.PreCoin): Promise { + 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 httpPostJson(reqUrl, wd); + }) + .then(resp => { + if (resp.status != 200) { + throw new RequestException({ + hint: "Withdrawal failed", + status: resp.status + }); + } + let r = JSON.parse(resp.responseText); + let denomSig = rsaUnblind(RsaSignature.fromCrock(r.ev_sig), + RsaBlindingKey.fromCrock(pc.blindingKey), + RsaPublicKey.fromCrock(pc.denomPub)); + let coin: Db.Coin = { + coinPub: pc.coinPub, + coinPriv: pc.coinPriv, + denomPub: pc.denomPub, + denomSig: denomSig.encode().toCrock(), + currentAmount: pc.coinValue, + mintBaseUrl: pc.mintBaseUrl, + }; + return coin; + }); + } + + + updateBadge() { + function countNonEmpty(c, n) { + if (c.currentAmount.fraction != 0 || c.currentAmount.value != 0) { + return n + 1; + } + return n; + } + + function doBadge(n) { + chrome.browserAction.setBadgeText({text: "" + n}); + chrome.browserAction.setBadgeBackgroundColor({color: "#0F0"}); + } + + Query(this.db) + .iter("coins") + .reduce(countNonEmpty, 0) + .then(doBadge); + } + + storeCoin(coin: Db.Coin) { + Query(this.db) + .delete("precoins", coin.coinPub) + .add("coins", coin) + .finish() + .then(() => { + this.updateBadge(); + }); + } + + withdraw(denom, reserve): Promise { + return this.withdrawPrepare(denom, reserve) + .then((pc) => this.withdrawExecute(pc)) + .then((c) => this.storeCoin(c)); + } + + + /** + * Withdraw coins from a reserve until it is empty. + */ + depleteReserve(reserve, mint): void { + let denoms = copy(mint.keys.denoms); + let remaining = new Amount(reserve.current_amount); + denoms.sort(rankDenom); + let workList = []; + for (let i = 0; i < 1000; i++) { + let found = false; + for (let d of denoms) { + let cost = new Amount(d.value); + cost.add(new Amount(d.fee_withdraw)); + if (remaining.cmp(cost) < 0) { + continue; + } + found = true; + remaining.sub(cost); + workList.push(d); + } + if (!found) { + console.log("did not find coins for remaining ", remaining.toJson()); + break; + } + } + + // Do the request one by one. + let next = () => { + if (workList.length == 0) { + return; + } + let d = workList.pop(); + this.withdraw(d, reserve) + .then(() => next()); + }; + + // Asynchronous recursion + next(); + } + + updateReserve(reservePub: EddsaPublicKey, + mint): Promise { + let reservePubStr = reservePub.toCrock(); + return Query(this.db) + .get("reserves", reservePubStr) + .then((reserve) => { + let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); + reqUrl.query({'reserve_pub': reservePubStr}); + return httpGet(reqUrl).then(resp => { + if (resp.status != 200) { + throw Error(); + } + let reserveInfo = JSON.parse(resp.responseText); + if (!reserveInfo) { + throw Error(); + } + reserve.current_amount = reserveInfo.balance; + return Query(this.db) + .put("reserves", reserve) + .finish() + .then(() => reserve); + }); + }); + } + + /** + * 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) { + let reqUrl = URI("keys").absoluteTo(baseUrl); + return httpGet(reqUrl).then((resp) => { + if (resp.status != 200) { + throw Error("/keys request failed"); + } + let mintKeysJson = JSON.parse(resp.responseText); + if (!mintKeysJson) { + throw new RequestException({url: reqUrl, hint: "keys invalid"}); + } + let mint: Db.Mint = { + baseUrl: baseUrl, + keys: mintKeysJson + }; + return Query(this.db).put("mints", mint).finish().then(() => mint); + }); + } + + + getBalances(): Promise { + function collectBalances(c: Db.Coin, byCurrency) { + let acc: AmountJson_interface = byCurrency[c.currentAmount.currency]; + if (!acc) { + acc = Amount.getZero(c.currentAmount.currency).toJson(); + } + let am = new Amount(c.currentAmount); + am.add(new Amount(acc)); + byCurrency[c.currentAmount.currency] = am.toJson(); + return byCurrency; + } + + return Query(this.db) + .iter("coins") + .reduce(collectBalances, {}); + } } diff --git a/extension/content_scripts/notify.js b/extension/content_scripts/notify.js index 57d32135d..47c839799 100644 --- a/extension/content_scripts/notify.js +++ b/extension/content_scripts/notify.js @@ -50,7 +50,7 @@ document.addEventListener("taler-create-reserve", function (e) { let uri = URI(chrome.extension.getURL("pages/confirm-create-reserve.html")); document.location.href = uri.query(params).href(); }); -document.addEventListener('taler-contract', function (e) { +document.addEventListener("taler-contract", function (e) { // XXX: the merchant should just give us the parsed data ... let offer = JSON.parse(e.detail); let uri = URI(chrome.extension.getURL("pages/confirm-contract.html")); diff --git a/extension/content_scripts/notify.ts b/extension/content_scripts/notify.ts index a75ab09cf..e2ffaefa9 100644 --- a/extension/content_scripts/notify.ts +++ b/extension/content_scripts/notify.ts @@ -57,7 +57,7 @@ document.addEventListener("taler-create-reserve", function(e: CustomEvent) { document.location.href = uri.query(params).href(); }); -document.addEventListener('taler-contract', function(e: CustomEvent) { +document.addEventListener("taler-contract", function(e: CustomEvent) { // XXX: the merchant should just give us the parsed data ... let offer = JSON.parse(e.detail); let uri = URI(chrome.extension.getURL("pages/confirm-contract.html")); diff --git a/extension/manifest.json b/extension/manifest.json index 7c0f295b4..c913eafed 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -43,12 +43,12 @@ "scripts": [ "lib/util.js", "lib/URI.js", + "background/checkable.js", "background/libwrapper.js", "background/emscriptif.js", "background/db.js", "background/query.js", "background/messaging.js", - "background/checkable.js", "background/http.js", "background/wallet.js" ] diff --git a/extension/popup/balance-overview.html b/extension/popup/balance-overview.html index 1bc80d97e..f6adc521d 100644 --- a/extension/popup/balance-overview.html +++ b/extension/popup/balance-overview.html @@ -22,7 +22,7 @@