diff --git a/src/android/index.ts b/src/android/index.ts index fb62a5b5a..4d0136ecf 100644 --- a/src/android/index.ts +++ b/src/android/index.ts @@ -157,6 +157,7 @@ export function installAndroidWalletListener() { case "withdrawTestkudos": { const wallet = await wp.promise; await withdrawTestBalance(wallet); + result = {}; break; } case "getHistory": { @@ -164,6 +165,12 @@ export function installAndroidWalletListener() { result = await wallet.getHistory(); break; } + case "retryPendingNow": { + const wallet = await wp.promise; + await wallet.runPending(true); + result = {}; + break; + } case "preparePay": { const wallet = await wp.promise; result = await wallet.preparePay(msg.args.url); @@ -197,9 +204,6 @@ export function installAndroidWalletListener() { break; } case "reset": { - const wallet = await wp.promise; - wallet.stop(); - wp = openPromise(); const oldArgs = walletArgs; walletArgs = { ...oldArgs }; if (oldArgs && oldArgs.persistentStoragePath) { @@ -211,6 +215,9 @@ export function installAndroidWalletListener() { // Prevent further storage! walletArgs.persistentStoragePath = undefined; } + const wallet = await wp.promise; + wallet.stop(); + wp = openPromise(); maybeWallet = undefined; const w = await getDefaultNodeWallet(walletArgs); maybeWallet = w; @@ -218,6 +225,7 @@ export function installAndroidWalletListener() { console.error("Error during wallet retry loop", e); }); wp.resolve(w); + result = {}; break; } default: diff --git a/src/wallet-impl/pay.ts b/src/wallet-impl/pay.ts index 69144d2d6..9942139a6 100644 --- a/src/wallet-impl/pay.ts +++ b/src/wallet-impl/pay.ts @@ -22,6 +22,8 @@ import { PayReq, Proposal, ContractTerms, + MerchantRefundPermission, + RefundRequest, } from "../talerTypes"; import { Timestamp, @@ -39,6 +41,7 @@ import { runWithWriteTransaction, oneShotPut, oneShotGetIndexed, + oneShotMutate, } from "../util/query"; import { Stores, @@ -59,9 +62,8 @@ import { } from "../util/helpers"; import { Logger } from "../util/logging"; import { InternalWalletState } from "./state"; -import { parsePayUri } from "../util/taleruri"; +import { parsePayUri, parseRefundUri } from "../util/taleruri"; import { getTotalRefreshCost, refresh } from "./refresh"; -import { acceptRefundResponse } from "./refund"; import { encodeCrock, getRandomBytes } from "../crypto/talerCrypto"; export interface SpeculativePayData { @@ -856,3 +858,212 @@ export async function confirmPay( return submitPay(ws, proposalId, sessionId); } + + + +export async function getFullRefundFees( + ws: InternalWalletState, + refundPermissions: MerchantRefundPermission[], +): Promise { + if (refundPermissions.length === 0) { + throw Error("no refunds given"); + } + const coin0 = await oneShotGet( + ws.db, + Stores.coins, + refundPermissions[0].coin_pub, + ); + if (!coin0) { + throw Error("coin not found"); + } + let feeAcc = Amounts.getZero( + Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, + ); + + const denoms = await oneShotIterIndex( + ws.db, + Stores.denominations.exchangeBaseUrlIndex, + coin0.exchangeBaseUrl, + ).toArray(); + + for (const rp of refundPermissions) { + const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); + if (!coin) { + throw Error("coin not found"); + } + const denom = await oneShotGet(ws.db, Stores.denominations, [ + coin0.exchangeBaseUrl, + coin.denomPub, + ]); + if (!denom) { + throw Error(`denom not found (${coin.denomPub})`); + } + // FIXME: this assumes that the refund already happened. + // When it hasn't, the refresh cost is inaccurate. To fix this, + // we need introduce a flag to tell if a coin was refunded or + // refreshed normally (and what about incremental refunds?) + const refundAmount = Amounts.parseOrThrow(rp.refund_amount); + const refundFee = Amounts.parseOrThrow(rp.refund_fee); + const refreshCost = getTotalRefreshCost( + denoms, + denom, + Amounts.sub(refundAmount, refundFee).amount, + ); + feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; + } + return feeAcc; +} + +async function submitRefunds( + ws: InternalWalletState, + proposalId: string, +): Promise { + const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); + if (!purchase) { + console.error( + "not submitting refunds, payment not found:", + ); + return; + } + const pendingKeys = Object.keys(purchase.refundsPending); + if (pendingKeys.length === 0) { + return; + } + for (const pk of pendingKeys) { + const perm = purchase.refundsPending[pk]; + const req: RefundRequest = { + coin_pub: perm.coin_pub, + h_contract_terms: purchase.contractTermsHash, + merchant_pub: purchase.contractTerms.merchant_pub, + merchant_sig: perm.merchant_sig, + refund_amount: perm.refund_amount, + refund_fee: perm.refund_fee, + rtransaction_id: perm.rtransaction_id, + }; + console.log("sending refund permission", perm); + // FIXME: not correct once we support multiple exchanges per payment + const exchangeUrl = purchase.payReq.coins[0].exchange_url; + const reqUrl = new URL("refund", exchangeUrl); + const resp = await ws.http.postJson(reqUrl.href, req); + if (resp.status !== 200) { + console.error("refund failed", resp); + continue; + } + + // Transactionally mark successful refunds as done + const transformPurchase = ( + t: PurchaseRecord | undefined, + ): PurchaseRecord | undefined => { + if (!t) { + console.warn("purchase not found, not updating refund"); + return; + } + if (t.refundsPending[pk]) { + t.refundsDone[pk] = t.refundsPending[pk]; + delete t.refundsPending[pk]; + } + return t; + }; + const transformCoin = ( + c: CoinRecord | undefined, + ): CoinRecord | undefined => { + if (!c) { + console.warn("coin not found, can't apply refund"); + return; + } + const refundAmount = Amounts.parseOrThrow(perm.refund_amount); + const refundFee = Amounts.parseOrThrow(perm.refund_fee); + c.status = CoinStatus.Dirty; + c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; + c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; + + return c; + }; + + await runWithWriteTransaction( + ws.db, + [Stores.purchases, Stores.coins], + async tx => { + await tx.mutate(Stores.purchases, proposalId, transformPurchase); + await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); + }, + ); + refresh(ws, perm.coin_pub); + } + + ws.badge.showNotification(); + ws.notifier.notify(); +} + +export async function acceptRefundResponse( + ws: InternalWalletState, + refundResponse: MerchantRefundResponse, +): Promise { + const refundPermissions = refundResponse.refund_permissions; + + if (!refundPermissions.length) { + console.warn("got empty refund list"); + throw Error("empty refund"); + } + + /** + * Add refund to purchase if not already added. + */ + function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { + if (!t) { + console.error("purchase not found, not adding refunds"); + return; + } + + t.timestamp_refund = getTimestampNow(); + + for (const perm of refundPermissions) { + if ( + !t.refundsPending[perm.merchant_sig] && + !t.refundsDone[perm.merchant_sig] + ) { + t.refundsPending[perm.merchant_sig] = perm; + } + } + return t; + } + + const hc = refundResponse.h_contract_terms; + + // Add the refund permissions to the purchase within a DB transaction + await oneShotMutate(ws.db, Stores.purchases, hc, f); + ws.notifier.notify(); + + await submitRefunds(ws, hc); + + return hc; +} + +/** + * Accept a refund, return the contract hash for the contract + * that was involved in the refund. + */ +export async function applyRefund( + ws: InternalWalletState, + talerRefundUri: string, +): Promise { + const parseResult = parseRefundUri(talerRefundUri); + + if (!parseResult) { + throw Error("invalid refund URI"); + } + + const refundUrl = parseResult.refundUrl; + + logger.trace("processing refund"); + let resp; + try { + resp = await ws.http.get(refundUrl); + } catch (e) { + console.error("error downloading refund permission", e); + throw e; + } + + const refundResponse = MerchantRefundResponse.checked(resp.responseJson); + return acceptRefundResponse(ws, refundResponse); +} diff --git a/src/wallet-impl/refund.ts b/src/wallet-impl/refund.ts deleted file mode 100644 index 4cd507e40..000000000 --- a/src/wallet-impl/refund.ts +++ /dev/null @@ -1,244 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see - */ - -import { - MerchantRefundResponse, - RefundRequest, - MerchantRefundPermission, -} from "../talerTypes"; -import { PurchaseRecord, Stores, CoinRecord, CoinStatus } from "../dbTypes"; -import { getTimestampNow } from "../walletTypes"; -import { - oneShotMutate, - oneShotGet, - runWithWriteTransaction, - oneShotIterIndex, -} from "../util/query"; -import { InternalWalletState } from "./state"; -import { parseRefundUri } from "../util/taleruri"; -import { Logger } from "../util/logging"; -import { AmountJson } from "../util/amounts"; -import * as Amounts from "../util/amounts"; -import { getTotalRefreshCost, refresh } from "./refresh"; - -const logger = new Logger("refund.ts"); - -export async function getFullRefundFees( - ws: InternalWalletState, - refundPermissions: MerchantRefundPermission[], -): Promise { - if (refundPermissions.length === 0) { - throw Error("no refunds given"); - } - const coin0 = await oneShotGet( - ws.db, - Stores.coins, - refundPermissions[0].coin_pub, - ); - if (!coin0) { - throw Error("coin not found"); - } - let feeAcc = Amounts.getZero( - Amounts.parseOrThrow(refundPermissions[0].refund_amount).currency, - ); - - const denoms = await oneShotIterIndex( - ws.db, - Stores.denominations.exchangeBaseUrlIndex, - coin0.exchangeBaseUrl, - ).toArray(); - - for (const rp of refundPermissions) { - const coin = await oneShotGet(ws.db, Stores.coins, rp.coin_pub); - if (!coin) { - throw Error("coin not found"); - } - const denom = await oneShotGet(ws.db, Stores.denominations, [ - coin0.exchangeBaseUrl, - coin.denomPub, - ]); - if (!denom) { - throw Error(`denom not found (${coin.denomPub})`); - } - // FIXME: this assumes that the refund already happened. - // When it hasn't, the refresh cost is inaccurate. To fix this, - // we need introduce a flag to tell if a coin was refunded or - // refreshed normally (and what about incremental refunds?) - const refundAmount = Amounts.parseOrThrow(rp.refund_amount); - const refundFee = Amounts.parseOrThrow(rp.refund_fee); - const refreshCost = getTotalRefreshCost( - denoms, - denom, - Amounts.sub(refundAmount, refundFee).amount, - ); - feeAcc = Amounts.add(feeAcc, refreshCost, refundFee).amount; - } - return feeAcc; -} - -async function submitRefunds( - ws: InternalWalletState, - proposalId: string, -): Promise { - const purchase = await oneShotGet(ws.db, Stores.purchases, proposalId); - if (!purchase) { - console.error( - "not submitting refunds, payment not found:", - ); - return; - } - const pendingKeys = Object.keys(purchase.refundsPending); - if (pendingKeys.length === 0) { - return; - } - for (const pk of pendingKeys) { - const perm = purchase.refundsPending[pk]; - const req: RefundRequest = { - coin_pub: perm.coin_pub, - h_contract_terms: purchase.contractTermsHash, - merchant_pub: purchase.contractTerms.merchant_pub, - merchant_sig: perm.merchant_sig, - refund_amount: perm.refund_amount, - refund_fee: perm.refund_fee, - rtransaction_id: perm.rtransaction_id, - }; - console.log("sending refund permission", perm); - // FIXME: not correct once we support multiple exchanges per payment - const exchangeUrl = purchase.payReq.coins[0].exchange_url; - const reqUrl = new URL("refund", exchangeUrl); - const resp = await ws.http.postJson(reqUrl.href, req); - if (resp.status !== 200) { - console.error("refund failed", resp); - continue; - } - - // Transactionally mark successful refunds as done - const transformPurchase = ( - t: PurchaseRecord | undefined, - ): PurchaseRecord | undefined => { - if (!t) { - console.warn("purchase not found, not updating refund"); - return; - } - if (t.refundsPending[pk]) { - t.refundsDone[pk] = t.refundsPending[pk]; - delete t.refundsPending[pk]; - } - return t; - }; - const transformCoin = ( - c: CoinRecord | undefined, - ): CoinRecord | undefined => { - if (!c) { - console.warn("coin not found, can't apply refund"); - return; - } - const refundAmount = Amounts.parseOrThrow(perm.refund_amount); - const refundFee = Amounts.parseOrThrow(perm.refund_fee); - c.status = CoinStatus.Dirty; - c.currentAmount = Amounts.add(c.currentAmount, refundAmount).amount; - c.currentAmount = Amounts.sub(c.currentAmount, refundFee).amount; - - return c; - }; - - await runWithWriteTransaction( - ws.db, - [Stores.purchases, Stores.coins], - async tx => { - await tx.mutate(Stores.purchases, proposalId, transformPurchase); - await tx.mutate(Stores.coins, perm.coin_pub, transformCoin); - }, - ); - refresh(ws, perm.coin_pub); - } - - ws.badge.showNotification(); - ws.notifier.notify(); -} - -export async function acceptRefundResponse( - ws: InternalWalletState, - refundResponse: MerchantRefundResponse, -): Promise { - const refundPermissions = refundResponse.refund_permissions; - - if (!refundPermissions.length) { - console.warn("got empty refund list"); - throw Error("empty refund"); - } - - /** - * Add refund to purchase if not already added. - */ - function f(t: PurchaseRecord | undefined): PurchaseRecord | undefined { - if (!t) { - console.error("purchase not found, not adding refunds"); - return; - } - - t.timestamp_refund = getTimestampNow(); - - for (const perm of refundPermissions) { - if ( - !t.refundsPending[perm.merchant_sig] && - !t.refundsDone[perm.merchant_sig] - ) { - t.refundsPending[perm.merchant_sig] = perm; - } - } - return t; - } - - const hc = refundResponse.h_contract_terms; - - // Add the refund permissions to the purchase within a DB transaction - await oneShotMutate(ws.db, Stores.purchases, hc, f); - ws.notifier.notify(); - - await submitRefunds(ws, hc); - - return hc; -} - -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( - ws: InternalWalletState, - talerRefundUri: string, -): Promise { - const parseResult = parseRefundUri(talerRefundUri); - - if (!parseResult) { - throw Error("invalid refund URI"); - } - - const refundUrl = parseResult.refundUrl; - - logger.trace("processing refund"); - let resp; - try { - resp = await ws.http.get(refundUrl); - } catch (e) { - console.error("error downloading refund permission", e); - throw e; - } - - const refundResponse = MerchantRefundResponse.checked(resp.responseJson); - return acceptRefundResponse(ws, refundResponse); -} diff --git a/src/wallet.ts b/src/wallet.ts index 89f31f519..772bb01ac 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -47,6 +47,8 @@ import { preparePay, confirmPay, processDownloadProposal, + applyRefund, + getFullRefundFees, } from "./wallet-impl/pay"; import { @@ -88,8 +90,6 @@ import { Logger } from "./util/logging"; import { assertUnreachable } from "./util/assertUnreachable"; -import { applyRefund, getFullRefundFees } from "./wallet-impl/refund"; - import { updateExchangeFromUrl, getExchangeTrust, @@ -209,6 +209,7 @@ export class Wallet { */ async processOnePendingOperation( pending: PendingOperationInfo, + forceNow: boolean = false, ): Promise { switch (pending.type) { case "bug": @@ -247,11 +248,11 @@ export class Wallet { /** * Process pending operations. */ - public async runPending(): Promise { + public async runPending(forceNow: boolean = false): Promise { const pendingOpsResponse = await this.getPendingOperations(); for (const p of pendingOpsResponse.pendingOperations) { try { - await this.processOnePendingOperation(p); + await this.processOnePendingOperation(p, forceNow); } catch (e) { console.error(e); } diff --git a/tsconfig.json b/tsconfig.json index 75214637e..50359419b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -74,7 +74,6 @@ "src/wallet-impl/payback.ts", "src/wallet-impl/pending.ts", "src/wallet-impl/refresh.ts", - "src/wallet-impl/refund.ts", "src/wallet-impl/reserves.ts", "src/wallet-impl/return.ts", "src/wallet-impl/state.ts",