diff --git a/src/checkable.ts b/src/checkable.ts index 124eb6587..159e5a85e 100644 --- a/src/checkable.ts +++ b/src/checkable.ts @@ -15,8 +15,6 @@ */ -"use strict"; - /** * Decorators for validating JSON objects and converting them to a typed * object. @@ -55,6 +53,7 @@ export namespace Checkable { propertyKey: any; checker: any; type?: any; + typeThunk?: () => any; elementChecker?: any; elementProp?: any; keyProp?: any; @@ -167,11 +166,18 @@ export namespace Checkable { function checkValue(target: any, prop: Prop, path: Path): any { - const type = prop.type; - const typeName = type.name || "??"; - if (!type) { - throw Error(`assertion failed (prop is ${JSON.stringify(prop)})`); + let type; + if (prop.type) { + type = prop.type; + } else if (prop.typeThunk) { + type = prop.typeThunk(); + if (!type) { + throw Error(`assertion failed: typeThunk returned null (prop is ${JSON.stringify(prop)})`); + } + } else { + throw Error(`assertion failed: type/typeThunk missing (prop is ${JSON.stringify(prop)})`); } + const typeName = type.name || "??"; const v = target; if (!v || typeof v !== "object") { throw new SchemaError( @@ -236,16 +242,13 @@ export namespace Checkable { /** * Target property must be a Checkable object of the given type. */ - export function Value(type: any) { - if (!type) { - throw Error("Type does not exist yet (wrong order of definitions?)"); - } + export function Value(typeThunk: () => any) { function deco(target: object, propertyKey: string | symbol): void { const chk = getCheckableInfo(target); chk.props.push({ checker: checkValue, propertyKey, - type, + typeThunk, }); } diff --git a/src/dbTypes.ts b/src/dbTypes.ts index b5040bee4..86f3e0a1e 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -49,7 +49,7 @@ import { * In the future we might consider adding migration functions for * each version increment. */ -export const WALLET_DB_VERSION = 24; +export const WALLET_DB_VERSION = 25; /** @@ -206,7 +206,7 @@ export class DenominationRecord { /** * Value of one coin of the denomination. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) value: AmountJson; /** @@ -225,25 +225,25 @@ export class DenominationRecord { /** * Fee for withdrawing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) feeWithdraw: AmountJson; /** * Fee for depositing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) feeDeposit: AmountJson; /** * Fee for refreshing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) feeRefresh: AmountJson; /** * Fee for refunding. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) feeRefund: AmountJson; /** @@ -491,15 +491,22 @@ export interface CoinRecord { status: CoinStatus; } + /** * Proposal record, stored in the wallet's database. */ @Checkable.Class() -export class ProposalRecord { +export class ProposalDownloadRecord { + /** + * URL where the proposal was downloaded. + */ + @Checkable.String + url: string; + /** * The contract that was offered by the merchant. */ - @Checkable.Value(ContractTerms) + @Checkable.Value(() => ContractTerms) contractTerms: ContractTerms; /** @@ -527,11 +534,17 @@ export class ProposalRecord { @Checkable.Number timestamp: number; + /** + * Private key for the nonce. + */ + @Checkable.String + noncePriv: string; + /** * Verify that a value matches the schema of this class and convert it into a * member. */ - static checked: (obj: any) => ProposalRecord; + static checked: (obj: any) => ProposalDownloadRecord; } @@ -788,15 +801,6 @@ export interface SenderWireRecord { } -/** - * Nonce record as stored in the wallet's database. - */ -export interface NonceRecord { - priv: string; - pub: string; -} - - /** * Configuration key/value entries to configure * the wallet. @@ -869,12 +873,6 @@ export namespace Stores { pubKeyIndex = new Index(this, "pubKeyIndex", "masterPublicKey"); } - class NonceStore extends Store { - constructor() { - super("nonces", { keyPath: "pub" }); - } - } - class CoinsStore extends Store { constructor() { super("coins", { keyPath: "coinPub" }); @@ -884,14 +882,14 @@ export namespace Stores { denomPubIndex = new Index(this, "denomPubIndex", "denomPub"); } - class ProposalsStore extends Store { + class ProposalsStore extends Store { constructor() { super("proposals", { autoIncrement: true, keyPath: "id", }); } - timestampIndex = new Index(this, "timestampIndex", "timestamp"); + timestampIndex = new Index(this, "timestampIndex", "timestamp"); } class PurchasesStore extends Store { @@ -965,7 +963,6 @@ export namespace Stores { export const denominations = new DenominationsStore(); export const exchangeWireFees = new ExchangeWireFeesStore(); export const exchanges = new ExchangeStore(); - export const nonces = new NonceStore(); export const precoins = new Store("precoins", {keyPath: "coinPub"}); export const proposals = new ProposalsStore(); export const refresh = new Store("refresh", {keyPath: "id", autoIncrement: true}); diff --git a/src/i18n/de.po b/src/i18n/de.po index 21ff8dfe0..5f163a0d3 100644 --- a/src/i18n/de.po +++ b/src/i18n/de.po @@ -42,13 +42,13 @@ msgstr "" msgid "Exchanges in the wallet:" msgstr "" -#: src/webex/pages/confirm-contract.tsx:159 +#: src/webex/pages/confirm-contract.tsx:175 #, c-format msgid "You have insufficient funds of the requested currency in your wallet." msgstr "" #. tslint:disable-next-line:max-line-length -#: src/webex/pages/confirm-contract.tsx:161 +#: src/webex/pages/confirm-contract.tsx:177 #, c-format msgid "" "You do not have any funds from an exchange that is accepted by this " @@ -56,12 +56,12 @@ msgid "" "wallet." msgstr "" -#: src/webex/pages/confirm-contract.tsx:217 +#: src/webex/pages/confirm-contract.tsx:236 #, c-format msgid "The merchant%1$s offers you to purchase:\n" msgstr "" -#: src/webex/pages/confirm-contract.tsx:238 +#: src/webex/pages/confirm-contract.tsx:257 #, fuzzy, c-format msgid "Confirm payment" msgstr "Bezahlung bestätigen" diff --git a/src/i18n/en-US.po b/src/i18n/en-US.po index 3307229b1..0dfa852ca 100644 --- a/src/i18n/en-US.po +++ b/src/i18n/en-US.po @@ -42,13 +42,13 @@ msgstr "" msgid "Exchanges in the wallet:" msgstr "" -#: src/webex/pages/confirm-contract.tsx:159 +#: src/webex/pages/confirm-contract.tsx:175 #, c-format msgid "You have insufficient funds of the requested currency in your wallet." msgstr "" #. tslint:disable-next-line:max-line-length -#: src/webex/pages/confirm-contract.tsx:161 +#: src/webex/pages/confirm-contract.tsx:177 #, c-format msgid "" "You do not have any funds from an exchange that is accepted by this " @@ -56,12 +56,12 @@ msgid "" "wallet." msgstr "" -#: src/webex/pages/confirm-contract.tsx:217 +#: src/webex/pages/confirm-contract.tsx:236 #, c-format msgid "The merchant%1$s offers you to purchase:\n" msgstr "" -#: src/webex/pages/confirm-contract.tsx:238 +#: src/webex/pages/confirm-contract.tsx:257 #, c-format msgid "Confirm payment" msgstr "" diff --git a/src/i18n/fr.po b/src/i18n/fr.po index b955dc6a1..55677763b 100644 --- a/src/i18n/fr.po +++ b/src/i18n/fr.po @@ -42,13 +42,13 @@ msgstr "" msgid "Exchanges in the wallet:" msgstr "" -#: src/webex/pages/confirm-contract.tsx:159 +#: src/webex/pages/confirm-contract.tsx:175 #, c-format msgid "You have insufficient funds of the requested currency in your wallet." msgstr "" #. tslint:disable-next-line:max-line-length -#: src/webex/pages/confirm-contract.tsx:161 +#: src/webex/pages/confirm-contract.tsx:177 #, c-format msgid "" "You do not have any funds from an exchange that is accepted by this " @@ -56,12 +56,12 @@ msgid "" "wallet." msgstr "" -#: src/webex/pages/confirm-contract.tsx:217 +#: src/webex/pages/confirm-contract.tsx:236 #, c-format msgid "The merchant%1$s offers you to purchase:\n" msgstr "" -#: src/webex/pages/confirm-contract.tsx:238 +#: src/webex/pages/confirm-contract.tsx:257 #, c-format msgid "Confirm payment" msgstr "" diff --git a/src/i18n/it.po b/src/i18n/it.po index b955dc6a1..55677763b 100644 --- a/src/i18n/it.po +++ b/src/i18n/it.po @@ -42,13 +42,13 @@ msgstr "" msgid "Exchanges in the wallet:" msgstr "" -#: src/webex/pages/confirm-contract.tsx:159 +#: src/webex/pages/confirm-contract.tsx:175 #, c-format msgid "You have insufficient funds of the requested currency in your wallet." msgstr "" #. tslint:disable-next-line:max-line-length -#: src/webex/pages/confirm-contract.tsx:161 +#: src/webex/pages/confirm-contract.tsx:177 #, c-format msgid "" "You do not have any funds from an exchange that is accepted by this " @@ -56,12 +56,12 @@ msgid "" "wallet." msgstr "" -#: src/webex/pages/confirm-contract.tsx:217 +#: src/webex/pages/confirm-contract.tsx:236 #, c-format msgid "The merchant%1$s offers you to purchase:\n" msgstr "" -#: src/webex/pages/confirm-contract.tsx:238 +#: src/webex/pages/confirm-contract.tsx:257 #, c-format msgid "Confirm payment" msgstr "" diff --git a/src/i18n/taler-wallet-webex.pot b/src/i18n/taler-wallet-webex.pot index b955dc6a1..55677763b 100644 --- a/src/i18n/taler-wallet-webex.pot +++ b/src/i18n/taler-wallet-webex.pot @@ -42,13 +42,13 @@ msgstr "" msgid "Exchanges in the wallet:" msgstr "" -#: src/webex/pages/confirm-contract.tsx:159 +#: src/webex/pages/confirm-contract.tsx:175 #, c-format msgid "You have insufficient funds of the requested currency in your wallet." msgstr "" #. tslint:disable-next-line:max-line-length -#: src/webex/pages/confirm-contract.tsx:161 +#: src/webex/pages/confirm-contract.tsx:177 #, c-format msgid "" "You do not have any funds from an exchange that is accepted by this " @@ -56,12 +56,12 @@ msgid "" "wallet." msgstr "" -#: src/webex/pages/confirm-contract.tsx:217 +#: src/webex/pages/confirm-contract.tsx:236 #, c-format msgid "The merchant%1$s offers you to purchase:\n" msgstr "" -#: src/webex/pages/confirm-contract.tsx:238 +#: src/webex/pages/confirm-contract.tsx:257 #, c-format msgid "Confirm payment" msgstr "" diff --git a/src/talerTypes.ts b/src/talerTypes.ts index 27bf7b43b..d593c3d34 100644 --- a/src/talerTypes.ts +++ b/src/talerTypes.ts @@ -38,7 +38,7 @@ export class Denomination { /** * Value of one coin of the denomination. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) value: AmountJson; /** @@ -50,25 +50,25 @@ export class Denomination { /** * Fee for withdrawing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) fee_withdraw: AmountJson; /** * Fee for depositing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) fee_deposit: AmountJson; /** * Fee for refreshing. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) fee_refresh: AmountJson; /** * Fee for refunding. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) fee_refund: AmountJson; /** @@ -151,7 +151,7 @@ export class Auditor { /** * List of signatures for denominations by the auditor. */ - @Checkable.List(Checkable.Value(AuditorDenomSig)) + @Checkable.List(Checkable.Value(() => AuditorDenomSig)) denomination_keys: AuditorDenomSig[]; } @@ -204,7 +204,7 @@ export class PaybackConfirmation { * How much will the exchange pay back (needed by wallet in * case coin was partially spent and wallet got restored from backup) */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) amount: AmountJson; /** @@ -336,7 +336,7 @@ export class ContractTerms { /** * Total amount payable. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) amount: AmountJson; /** @@ -360,7 +360,7 @@ export class ContractTerms { /** * Maximum deposit fee covered by the merchant. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) max_fee: AmountJson; /** @@ -378,7 +378,7 @@ export class ContractTerms { /** * List of accepted exchanges. */ - @Checkable.List(Checkable.Value(ExchangeHandle)) + @Checkable.List(Checkable.Value(() => ExchangeHandle)) exchanges: ExchangeHandle[]; /** @@ -428,7 +428,7 @@ export class ContractTerms { /** * Maximum wire fee that the merchant agrees to pay for. */ - @Checkable.Optional(Checkable.Value(AmountJson)) + @Checkable.Optional(Checkable.Value(() => AmountJson)) max_wire_fee?: AmountJson; /** @@ -578,7 +578,7 @@ export class TipResponse { /** * The order of the signatures matches the planchets list. */ - @Checkable.List(Checkable.Value(ReserveSigSingleton)) + @Checkable.List(Checkable.Value(() => ReserveSigSingleton)) reserve_sigs: ReserveSigSingleton[]; /** @@ -620,7 +620,7 @@ export class TipToken { /** * Amount of tip. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) amount: AmountJson; /** @@ -659,7 +659,7 @@ export class KeysJson { /** * List of offered denominations. */ - @Checkable.List(Checkable.Value(Denomination)) + @Checkable.List(Checkable.Value(() => Denomination)) denoms: Denomination[]; /** @@ -671,7 +671,7 @@ export class KeysJson { /** * The list of auditors (partially) auditing the exchange. */ - @Checkable.List(Checkable.Value(Auditor)) + @Checkable.List(Checkable.Value(() => Auditor)) auditors: Auditor[]; /** @@ -683,7 +683,7 @@ export class KeysJson { /** * List of paybacks for compromised denominations. */ - @Checkable.Optional(Checkable.List(Checkable.Value(Payback))) + @Checkable.Optional(Checkable.List(Checkable.Value(() => Payback))) payback?: Payback[]; /** @@ -715,13 +715,13 @@ export class WireFeesJson { /** * Cost of a wire transfer. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) wire_fee: AmountJson; /** * Cost of clising a reserve. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) closing_fee: AmountJson; /** @@ -765,7 +765,7 @@ export class WireDetailJson { /** * Fees associated with the wire transfer method. */ - @Checkable.List(Checkable.Value(WireFeesJson)) + @Checkable.List(Checkable.Value(() => WireFeesJson)) fees: WireFeesJson[]; /** @@ -788,3 +788,21 @@ export type WireDetail = object & { type: string }; export function isWireDetail(x: any): x is WireDetail { return x && typeof x === "object" && typeof x.type === "string"; } + +/** + * Proposal returned from the contract URL. + */ +@Checkable.Class({extra: true}) +export class Proposal { + @Checkable.Value(() => ContractTerms) + contract_terms: ContractTerms; + + @Checkable.String + sig: string; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => Proposal; +} diff --git a/src/wallet.ts b/src/wallet.ts index 8a63e45e2..24fab9f86 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -49,6 +49,8 @@ import * as Amounts from "./amounts"; import URI = require("urijs"); +import axios from "axios"; + import { CoinRecord, CoinStatus, @@ -59,7 +61,7 @@ import { ExchangeRecord, ExchangeWireFeesRecord, PreCoinRecord, - ProposalRecord, + ProposalDownloadRecord, PurchaseRecord, RefreshPreCoinRecord, RefreshSessionRecord, @@ -76,9 +78,11 @@ import { KeysJson, PayReq, PaybackConfirmation, + Proposal, RefundPermission, TipPlanchetDetail, TipResponse, + TipToken, WireDetailJson, isWireDetail, } from "./talerTypes"; @@ -109,7 +113,7 @@ interface SpeculativePayData { payCoinInfo: PayCoinInfo; exchangeUrl: string; proposalId: number; - proposal: ProposalRecord; + proposal: ProposalDownloadRecord; } @@ -624,9 +628,9 @@ export class Wallet { * Record all information that is necessary to * pay for a proposal in the wallet's database. */ - private async recordConfirmPay(proposal: ProposalRecord, + private async recordConfirmPay(proposal: ProposalDownloadRecord, payCoinInfo: PayCoinInfo, - chosenExchange: string): Promise { + chosenExchange: string): Promise { const payReq: PayReq = { coins: payCoinInfo.sigs, merchant_pub: proposal.contractTerms.merchant_pub, @@ -651,15 +655,42 @@ export class Wallet { .finish(); this.badge.showNotification(); this.notifier.notify(); + return t; } /** - * Save a proposal in the database and return an id for it to - * retrieve it later. + * Download a proposal and store it in the database. + * Returns an id for it to retrieve it later. */ - async saveProposal(proposal: ProposalRecord): Promise { - const id = await this.q().putWithResult(Stores.proposals, proposal); + async downloadProposal(url: string): Promise { + const { priv, pub } = await this.cryptoApi.createEddsaKeypair(); + const parsed_url = new URI(url); + url = parsed_url.setQuery({ nonce: pub }).href(); + console.log("downloading contract from '" + url + "'"); + let resp; + try { + resp = await axios.get(url, { validateStatus: (s) => s === 200 }); + } catch (e) { + console.log("contract download failed", e); + throw e; + } + console.log("got response", resp); + + const proposal = Proposal.checked(resp.data); + + const contractTermsHash = await this.hashContract(proposal.contract_terms); + + const proposalRecord: ProposalDownloadRecord = { + contractTerms: proposal.contract_terms, + contractTermsHash, + merchantSig: proposal.sig, + noncePriv: priv, + timestamp: (new Date()).getTime(), + url, + }; + + const id = await this.q().putWithResult(Stores.proposals, proposalRecord); this.notifier.notify(); if (typeof id !== "number") { throw Error("db schema wrong"); @@ -667,24 +698,50 @@ export class Wallet { return id; } + async submitPay(purchase: PurchaseRecord, sessionId: string | undefined): Promise { + let resp; + const payReq = { ...purchase.payReq, session_id: sessionId }; + try { + const config = { + headers: { "Content-Type": "application/json;charset=UTF-8" }, + timeout: 5000, /* 5 seconds */ + validateStatus: (s: number) => s === 200, + }; + resp = await axios.post(purchase.contractTerms.pay_url, payReq, config); + } catch (e) { + // Gives the user the option to retry / abort and refresh + console.log("payment failed", e); + throw e; + } + const merchantResp = resp.data; + console.log("got success from pay_url"); + await this.paymentSucceeded(purchase.contractTermsHash, merchantResp.sig); + const fu = new URI(purchase.contractTerms.fulfillment_url); + fu.addSearch("order_id", purchase.contractTerms.order_id); + if (merchantResp.session_sig) { + fu.addSearch("session_sig", merchantResp.session_sig); + } + const nextUrl = fu.href(); + return { nextUrl }; + } + /** * Add a contract to the wallet and sign coins, * but do not send them yet. */ - async confirmPay(proposalId: number): Promise { - console.log("executing confirmPay"); - const proposal: ProposalRecord|undefined = await this.q().get(Stores.proposals, proposalId); + async confirmPay(proposalId: number, sessionId: string | undefined): Promise { + console.log(`executing confirmPay with proposalId ${proposalId} and sessionId ${sessionId}`); + const proposal: ProposalDownloadRecord|undefined = await this.q().get(Stores.proposals, proposalId); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } - const purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash); + let purchase = await this.q().get(Stores.purchases, proposal.contractTermsHash); if (purchase) { - // Already payed ... - return "paid"; + return this.submitPay(purchase, sessionId); } const res = await this.getCoinsForPayment({ @@ -702,22 +759,24 @@ export class Wallet { console.log("coin selection result", res); if (!res) { + // Should not happen, since checkPay should be called first console.log("not confirming payment, insufficient coins"); - return "insufficient-balance"; + throw Error("insufficient balance"); } const sd = await this.getSpeculativePayData(proposalId); if (!sd) { const { exchangeUrl, cds } = res; const payCoinInfo = await this.cryptoApi.signDeposit(proposal.contractTerms, cds); - await this.recordConfirmPay(proposal, payCoinInfo, exchangeUrl); + purchase = await this.recordConfirmPay(proposal, payCoinInfo, exchangeUrl); } else { - await this.recordConfirmPay(sd.proposal, sd.payCoinInfo, sd.exchangeUrl); + purchase = await this.recordConfirmPay(sd.proposal, sd.payCoinInfo, sd.exchangeUrl); } - return "paid"; + return this.submitPay(purchase, sessionId); } + /** * Get the speculative pay data, but only if coins have not changed in between. */ @@ -803,7 +862,7 @@ export class Wallet { * Retrieve information required to pay for a contract, where the * contract is identified via the fulfillment url. */ - async queryPayment(url: string): Promise { + async queryPaymentByFulfillmentUrl(url: string): Promise { console.log("query for payment", url); const t = await this.q().getIndexed(Stores.purchases.fulfillmentUrlIndex, url); @@ -823,6 +882,30 @@ export class Wallet { }; } + /** + * Retrieve information required to pay for a contract, where the + * contract is identified via the contract terms hash. + */ + async queryPaymentByContractTermsHash(contractTermsHash: string): Promise { + console.log("query for payment", contractTermsHash); + + const t = await this.q().get(Stores.purchases, contractTermsHash); + + if (!t) { + console.log("query for payment failed"); + return { + found: false, + }; + } + console.log("query for payment succeeded:", t); + return { + contractTerms: t.contractTerms, + contractTermsHash: t.contractTermsHash, + found: true, + payReq: t.payReq, + }; + } + /** * First fetch information requred to withdraw from the reserve, @@ -2020,7 +2103,7 @@ export class Wallet { // FIXME: do pagination instead of generating the full history - const proposals = await this.q().iter(Stores.proposals).toArray(); + const proposals = await this.q().iter(Stores.proposals).toArray(); for (const p of proposals) { history.push({ detail: { @@ -2111,7 +2194,7 @@ export class Wallet { return denoms; } - async getProposal(proposalId: number): Promise { + async getProposal(proposalId: number): Promise { const proposal = await this.q().get(Stores.proposals, proposalId); return proposal; } @@ -2162,18 +2245,6 @@ export class Wallet { } - /** - * Generate a nonce in form of an EdDSA public key. - * Store the private key in our DB, so we can prove ownership. - */ - async generateNonce(): Promise { - const {priv, pub} = await this.cryptoApi.createEddsaKeypair(); - await this.q() - .put(Stores.nonces, {priv, pub}) - .finish(); - return pub; - } - async getCurrencyRecord(currency: string): Promise { return this.q().get(Stores.currencies, currency); } @@ -2466,10 +2537,25 @@ export class Wallet { } } - async acceptRefund(refundPermissions: RefundPermission[]): Promise { + async acceptRefund(refundUrl: string): Promise { + console.log("processing refund"); + let resp; + try { + const config = { + validateStatus: (s: number) => s === 200, + }; + resp = await axios.get(refundUrl, config); + } catch (e) { + console.log("error downloading refund permission", e); + throw e; + } + + // FIXME: validate schema + const refundPermissions = resp.data; + if (!refundPermissions.length) { console.warn("got empty refund list"); - return; + throw Error("empty refund"); } const hc = refundPermissions[0].h_contract_terms; if (!hc) { @@ -2513,6 +2599,8 @@ export class Wallet { // Start submitting it but don't wait for it here. this.submitRefunds(hc); + + return refundPermissions[0].h_contract_terms; } async submitRefunds(contractTermsHash: string): Promise { @@ -2646,6 +2734,54 @@ export class Wallet { return planchetDetail; } + + async processTip(tipToken: TipToken): Promise { + console.log("got tip token", tipToken); + + const deadlineSec = getTalerStampSec(tipToken.expiration); + if (!deadlineSec) { + throw Error("tipping failed (invalid expiration)"); + } + + const merchantDomain = new URI(document.location.href).origin(); + let walletResp; + walletResp = await this.getTipPlanchets(merchantDomain, + tipToken.tip_id, + tipToken.amount, + deadlineSec, + tipToken.exchange_url, + tipToken.next_url); + + const planchets = walletResp; + + if (!planchets) { + console.log("failed tip", walletResp); + throw Error("processing tip failed"); + } + + let merchantResp; + + try { + const config = { + validateStatus: (s: number) => s === 200, + }; + const req = { planchets, tip_id: tipToken.tip_id }; + merchantResp = await axios.post(tipToken.pickup_url, req, config); + } catch (e) { + console.log("tipping failed", e); + throw e; + } + + try { + this.processTipResponse(merchantDomain, tipToken.tip_id, merchantResp.data); + } catch (e) { + console.log("processTipResponse failed", e); + throw e; + } + + return; + } + /** * Accept a merchant's response to a tip pickup and start withdrawing the coins. * These coins will not appear in the wallet yet. @@ -2725,6 +2861,11 @@ export class Wallet { return tipStatus; } + + getNextUrlFromResourceUrl(resourceUrl: string): string | undefined { + return; + } + /** * Remove unreferenced / expired data from the wallet's database * based on the current system time. @@ -2745,7 +2886,7 @@ export class Wallet { }; await this.q().deleteIf(Stores.reserves, gcReserve).finish(); - const gcProposal = (d: ProposalRecord, n: number) => { + const gcProposal = (d: ProposalDownloadRecord, n: number) => { // Delete proposal after 60 minutes or 5 minutes before pay deadline, // whatever comes first. const deadlinePayMilli = getTalerStampSec(d.contractTerms.pay_deadline)! * 1000; diff --git a/src/walletTypes.ts b/src/walletTypes.ts index 3c7bff1eb..d1a4f8746 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -246,9 +246,11 @@ export interface CheckPayResult { /** - * Possible results for confirmPay. + * Result for confirmPay */ -export type ConfirmPayResult = "paid" | "insufficient-balance"; +export interface ConfirmPayResult { + nextUrl: string; +} /** @@ -299,6 +301,7 @@ export interface QueryPaymentFound { found: true; contractTermsHash: string; contractTerms: ContractTerms; + lastSessionSig?: string; payReq: PayReq; } @@ -329,7 +332,7 @@ export class CreateReserveRequest { /** * The initial amount for the reserve. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) amount: AmountJson; /** @@ -380,7 +383,7 @@ export class ReturnCoinsRequest { /** * The amount to wire. */ - @Checkable.Value(AmountJson) + @Checkable.Value(() => AmountJson) amount: AmountJson; /** @@ -511,7 +514,7 @@ export class ProcessTipResponseRequest { /** * Tip response from the merchant. */ - @Checkable.Value(TipResponse) + @Checkable.Value(() => TipResponse) tipResponse: TipResponse; /** @@ -543,7 +546,7 @@ export class GetTipPlanchetsRequest { /** * Amount of the tip. */ - @Checkable.Optional(Checkable.Value(AmountJson)) + @Checkable.Optional(Checkable.Value(() => AmountJson)) amount: AmountJson; /** diff --git a/src/webex/messages.ts b/src/webex/messages.ts index 0d0329808..0fcd6047e 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -44,10 +44,6 @@ export interface MessageMap { }; response: void; }; - "get-tab-cookie": { - request: { } - response: any; - }; "ping": { request: { }; response: void; @@ -67,12 +63,8 @@ export interface MessageMap { request: { reservePub: string }; response: void; }; - "generate-nonce": { - request: { } - response: string; - }; "confirm-pay": { - request: { proposalId: number; }; + request: { proposalId: number; sessionId?: string }; response: walletTypes.ConfirmPayResult; }; "check-pay": { @@ -95,10 +87,6 @@ export interface MessageMap { request: { contract: object }; response: string; }; - "save-proposal": { - request: { proposal: dbTypes.ProposalRecord }; - response: void; - }; "reserve-creation-info": { request: { baseUrl: string, amount: AmountJson }; response: walletTypes.ReserveCreationInfo; @@ -109,7 +97,7 @@ export interface MessageMap { }; "get-proposal": { request: { proposalId: number }; - response: dbTypes.ProposalRecord | undefined; + response: dbTypes.ProposalDownloadRecord | undefined; }; "get-coins": { request: { exchangeBaseUrl: string }; @@ -155,14 +143,6 @@ export interface MessageMap { request: { coinPub: string }; response: void; }; - "payment-failed": { - request: { contractTermsHash: string }; - response: void; - }; - "payment-succeeded": { - request: { contractTermsHash: string; merchantSig: string }; - response: void; - }; "check-upgrade": { request: { }; response: void; @@ -183,10 +163,6 @@ export interface MessageMap { request: { reportUid: string }; response: void; }; - "accept-refund": { - request: any; - response: void; - }; "get-purchase": { request: any; response: void; @@ -215,6 +191,14 @@ export interface MessageMap { request: { }; response: void; }; + "taler-pay": { + request: any; + response: void; + }; + "download-proposal": { + request: any; + response: void; + }; } /** diff --git a/src/webex/notify.ts b/src/webex/notify.ts index a7d393a65..e163a6272 100644 --- a/src/webex/notify.ts +++ b/src/webex/notify.ts @@ -28,13 +28,6 @@ import URI = require("urijs"); import wxApi = require("./wxApi"); -import { getTalerStampSec } from "../helpers"; -import { TipToken } from "../talerTypes"; -import { QueryPaymentResult } from "../walletTypes"; - - -import axios from "axios"; - declare var cloneInto: any; let logVerbose: boolean = false; @@ -103,42 +96,6 @@ function setStyles(installed: boolean) { } -async function handlePaymentResponse(maybeFoundResponse: QueryPaymentResult) { - if (!maybeFoundResponse.found) { - console.log("pay-failed", {hint: "payment not found in the wallet"}); - return; - } - const walletResp = maybeFoundResponse; - - logVerbose && console.log("handling taler-notify-payment: ", walletResp); - let resp; - try { - const config = { - headers: { "Content-Type": "application/json;charset=UTF-8" }, - timeout: 5000, /* 5 seconds */ - validateStatus: (s: number) => s === 200, - }; - resp = await axios.post(walletResp.contractTerms.pay_url, walletResp.payReq, config); - } catch (e) { - // Gives the user the option to retry / abort and refresh - wxApi.logAndDisplayError({ - contractTerms: walletResp.contractTerms, - message: e.message, - name: "pay-post-failed", - response: e.response, - }); - throw e; - } - const merchantResp = resp.data; - logVerbose && console.log("got success from pay_url"); - await wxApi.paymentSucceeded(walletResp.contractTermsHash, merchantResp.sig); - const nextUrl = walletResp.contractTerms.fulfillment_url; - logVerbose && console.log("taler-payment-succeeded done, going to", nextUrl); - window.location.href = nextUrl; - window.location.reload(true); -} - - function onceOnComplete(cb: () => void) { if (document.readyState === "complete") { cb(); @@ -153,234 +110,29 @@ function onceOnComplete(cb: () => void) { function init() { - // Only place where we don't use the nicer RPC wrapper, since the wallet - // backend might not be ready (during install, upgrade, etc.) - chrome.runtime.sendMessage({type: "get-tab-cookie"}, (resp) => { - logVerbose && console.log("got response for get-tab-cookie"); - if (chrome.runtime.lastError) { - logVerbose && console.log("extension not yet ready"); - window.setTimeout(init, 200); - return; + onceOnComplete(() => { + if (document.documentElement.getAttribute("data-taler-nojs")) { + initStyle(); + setStyles(true); } - onceOnComplete(() => { - if (document.documentElement.getAttribute("data-taler-nojs")) { - initStyle(); - setStyles(true); - } - }); - registerHandlers(); - // Hack to know when the extension is unloaded - const port = chrome.runtime.connect(); + }); + registerHandlers(); + // Hack to know when the extension is unloaded + const port = chrome.runtime.connect(); - port.onDisconnect.addListener(() => { - logVerbose && console.log("chrome runtime disconnected, removing handlers"); - if (document.documentElement.getAttribute("data-taler-nojs")) { - setStyles(false); - } - for (const handler of handlers) { - document.removeEventListener(handler.type, handler.listener); - } - }); - - if (resp && resp.type === "pay") { - logVerbose && console.log("doing taler.pay with", resp.payDetail); - talerPay(resp.payDetail).then(handlePaymentResponse); + port.onDisconnect.addListener(() => { + logVerbose && console.log("chrome runtime disconnected, removing handlers"); + if (document.documentElement.getAttribute("data-taler-nojs")) { + setStyles(false); + } + for (const handler of handlers) { + document.removeEventListener(handler.type, handler.listener); } }); } type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void; -async function downloadContract(url: string, nonce: string): Promise { - const parsed_url = new URI(url); - url = parsed_url.setQuery({nonce}).href(); - console.log("downloading contract from '" + url + "'"); - let resp; - try { - resp = await axios.get(url, { validateStatus: (s) => s === 200 }); - } catch (e) { - wxApi.logAndDisplayError({ - message: e.message, - name: "contract-download-failed", - response: e.response, - sameTab: true, - }); - throw e; - } - console.log("got response", resp); - return resp.data; -} - -async function processProposal(proposal: any) { - - if (!proposal.contract_terms) { - console.error("field proposal.contract_terms field missing"); - return; - } - - const contractHash = await wxApi.hashContract(proposal.contract_terms); - - const proposalId = await wxApi.saveProposal({ - contractTerms: proposal.contract_terms, - contractTermsHash: contractHash, - merchantSig: proposal.sig, - timestamp: (new Date()).getTime(), - }); - - const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-contract.html")); - const params = { - proposalId: proposalId.toString(), - }; - const target = uri.query(params).href(); - document.location.replace(target); -} - - -/** - * Handle a payment request (coming either from an HTTP 402 or - * the JS wallet API). - */ -function talerPay(msg: any): Promise { - // Use a promise directly instead of of an async - // function since some paths never resolve the promise. - return new Promise(async(resolve, reject) => { - if (msg.tip) { - const tipToken = TipToken.checked(JSON.parse(msg.tip)); - - console.log("got tip token", tipToken); - - const deadlineSec = getTalerStampSec(tipToken.expiration); - if (!deadlineSec) { - wxApi.logAndDisplayError({ - message: "invalid expiration", - name: "tipping-failed", - sameTab: true, - }); - return; - } - - const merchantDomain = new URI(document.location.href).origin(); - let walletResp; - try { - walletResp = await wxApi.getTipPlanchets(merchantDomain, - tipToken.tip_id, - tipToken.amount, - deadlineSec, - tipToken.exchange_url, - tipToken.next_url); - } catch (e) { - wxApi.logAndDisplayError({ - message: e.message, - name: "tipping-failed", - response: e.response, - sameTab: true, - }); - throw e; - } - - const planchets = walletResp; - - if (!planchets) { - wxApi.logAndDisplayError({ - detail: walletResp, - message: "processing tip failed", - name: "tipping-failed", - sameTab: true, - }); - return; - } - - let merchantResp; - - try { - const config = { - validateStatus: (s: number) => s === 200, - }; - const req = { planchets, tip_id: tipToken.tip_id }; - merchantResp = await axios.post(tipToken.pickup_url, req, config); - } catch (e) { - wxApi.logAndDisplayError({ - message: e.message, - name: "tipping-failed", - response: e.response, - sameTab: true, - }); - throw e; - } - - try { - wxApi.processTipResponse(merchantDomain, tipToken.tip_id, merchantResp.data); - } catch (e) { - wxApi.logAndDisplayError({ - message: e.message, - name: "tipping-failed", - response: e.response, - sameTab: true, - }); - throw e; - } - - // Go to tip dialog page, where the user can confirm the tip or - // decline if they are not happy with the exchange. - const uri = new URI(chrome.extension.getURL("/src/webex/pages/tip.html")); - const params = { tip_id: tipToken.tip_id, merchant_domain: merchantDomain }; - const redirectUrl = uri.query(params).href(); - window.location.href = redirectUrl; - - return; - } - - if (msg.refund_url) { - console.log("processing refund"); - let resp; - try { - const config = { - validateStatus: (s: number) => s === 200, - }; - resp = await axios.get(msg.refund_url, config); - } catch (e) { - wxApi.logAndDisplayError({ - message: e.message, - name: "refund-download-failed", - response: e.response, - sameTab: true, - }); - throw e; - } - await wxApi.acceptRefund(resp.data); - const hc = resp.data.refund_permissions[0].h_contract_terms; - document.location.href = chrome.extension.getURL(`/src/webex/pages/refund.html?contractTermsHash=${hc}`); - return; - } - - // current URL without fragment - const url = new URI(document.location.href).fragment("").href(); - const res = await wxApi.queryPayment(url); - logVerbose && console.log("taler-pay: got response", res); - if (res && res.found && res.payReq) { - resolve(res); - return; - } - if (msg.contract_url) { - const nonce = await wxApi.generateNonce(); - const proposal = await downloadContract(msg.contract_url, nonce); - if (proposal.contract_terms.nonce !== nonce) { - console.error("stale contract"); - return; - } - await processProposal(proposal); - return; - } - - if (msg.offer_url) { - document.location.href = msg.offer_url; - return; - } - - console.log("can't proceed with payment, no way to get contract specified"); - }); -} - function registerHandlers() { /** @@ -457,7 +209,7 @@ function registerHandlers() { }); addHandler("taler-pay", async(msg: any, sendResponse: any) => { - const resp = await talerPay(msg); + const resp = await wxApi.talerPay(msg); sendResponse(resp); }); } diff --git a/src/webex/pages/confirm-contract.tsx b/src/webex/pages/confirm-contract.tsx index 83de738b9..090737475 100644 --- a/src/webex/pages/confirm-contract.tsx +++ b/src/webex/pages/confirm-contract.tsx @@ -27,7 +27,7 @@ import * as i18n from "../../i18n"; import { ExchangeRecord, - ProposalRecord, + ProposalDownloadRecord, } from "../../dbTypes"; import { ContractTerms } from "../../talerTypes"; import { @@ -102,12 +102,15 @@ class Details extends React.Component { } interface ContractPromptProps { - proposalId: number; + proposalId?: number; + contractUrl?: string; + sessionId?: string; } interface ContractPromptState { - proposal: ProposalRecord|null; - error: string|null; + proposalId: number | undefined; + proposal: ProposalDownloadRecord | null; + error: string | null; payDisabled: boolean; alreadyPaid: boolean; exchanges: null|ExchangeRecord[]; @@ -130,6 +133,7 @@ class ContractPrompt extends React.Component e.master_pub); const ex = this.state.exchanges.find((e) => acceptedExchangePubs.indexOf(e.masterPublicKey) >= 0); if (ex) { - this.setState({error: msgInsufficient}); + this.setState({ error: msgInsufficient }); } else { - this.setState({error: msgNoMatch}); + this.setState({ error: msgNoMatch }); } } else { - this.setState({error: msgInsufficient}); + this.setState({ error: msgInsufficient }); } - this.setState({payDisabled: true}); + this.setState({ payDisabled: true }); } else if (payStatus.status === "paid") { - this.setState({alreadyPaid: true, payDisabled: false, error: null, payStatus}); + this.setState({ alreadyPaid: true, payDisabled: false, error: null, payStatus }); } else { - this.setState({payDisabled: false, error: null, payStatus}); + this.setState({ payDisabled: false, error: null, payStatus }); } } @@ -184,21 +200,24 @@ class ContractPrompt extends React.ComponentError: either contractUrl or proposalId must be given; + } + if (this.state.proposalId === undefined) { + return Downloading contract terms; + } if (!this.state.proposal) { return ...; } @@ -255,8 +274,18 @@ class ContractPrompt extends React.Component { const url = new URI(document.location.href); const query: any = URI.parseQuery(url.query()); - const proposalId = JSON.parse(query.proposalId); - ReactDOM.render(, document.getElementById( - "contract")!); + let proposalId; + try { + proposalId = JSON.parse(query.proposalId); + } catch { + // ignore error + } + + const sessionId = query.sessionId; + const contractUrl = query.contractUrl; + + ReactDOM.render( + , + document.getElementById("contract")!); }); diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index 2f7a13c48..efebf21d1 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -217,8 +217,8 @@ export function checkPay(proposalId: number): Promise { /** * Pay for a proposal. */ -export function confirmPay(proposalId: number): Promise { - return callBackend("confirm-pay", { proposalId }); +export function confirmPay(proposalId: number, sessionId: string | undefined): Promise { + return callBackend("confirm-pay", { proposalId, sessionId }); } /** @@ -228,15 +228,6 @@ export function hashContract(contract: object): Promise { return callBackend("hash-contract", { contract }); } - -/** - * Save a proposal in the wallet. Returns the proposal id that - * the proposal is stored under. - */ -export function saveProposal(proposal: any): Promise { - return callBackend("save-proposal", { proposal }); -} - /** * Mark a reserve as confirmed. */ @@ -251,36 +242,6 @@ export function queryPayment(url: string): Promise { return callBackend("query-payment", { url }); } -/** - * Mark a payment as succeeded. - */ -export function paymentSucceeded(contractTermsHash: string, merchantSig: string): Promise { - return callBackend("payment-succeeded", { contractTermsHash, merchantSig }); -} - -/** - * Mark a payment as succeeded. - */ -export function paymentFailed(contractTermsHash: string): Promise { - return callBackend("payment-failed", { contractTermsHash }); -} - -/** - * Get the payment cookie for the current tab, or undefined if no payment - * cookie was set. - */ -export function getTabCookie(): Promise { - return callBackend("get-tab-cookie", { }); -} - -/** - * Generate a contract nonce (EdDSA key pair), store it in the wallet's - * database and return the public key. - */ -export function generateNonce(): Promise { - return callBackend("generate-nonce", { }); -} - /** * Check upgrade information */ @@ -344,12 +305,6 @@ export function getReport(reportUid: string): Promise { return callBackend("get-report", { reportUid }); } -/** - * Apply a refund that we got from the merchant. - */ -export function acceptRefund(refundData: any): Promise { - return callBackend("accept-refund", refundData); -} /** * Look up a purchase in the wallet database from @@ -407,3 +362,17 @@ export function processTipResponse(merchantDomain: string, tipId: string, tipRes export function clearNotification(): Promise { return callBackend("clear-notification", { }); } + +/** + * Trigger taler payment processing (for payment, tipping and refunds). + */ +export function talerPay(msg: any): Promise { + return callBackend("taler-pay", msg); +} + +/** + * Download a contract. + */ +export function downloadProposal(url: string): Promise { + return callBackend("download-proposal", { url }); +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index 02a1543e5..c0b42a768 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -33,7 +33,6 @@ import { import { AmountJson } from "../amounts"; -import { ProposalRecord } from "../dbTypes"; import { AcceptTipRequest, ConfirmReserveRequest, @@ -41,6 +40,7 @@ import { GetTipPlanchetsRequest, Notifier, ProcessTipResponseRequest, + QueryPaymentFound, ReturnCoinsRequest, TipStatusRequest, } from "../walletTypes"; @@ -62,6 +62,7 @@ import * as wxApi from "./wxApi"; import URI = require("urijs"); import Port = chrome.runtime.Port; import MessageSender = chrome.runtime.MessageSender; +import { TipToken } from "../talerTypes"; const DB_NAME = "taler"; @@ -93,15 +94,6 @@ function handleMessage(sender: MessageSender, const db = needsWallet().db; return importDb(db, detail.dump); } - case "get-tab-cookie": { - if (!sender || !sender.tab || !sender.tab.id) { - return Promise.resolve(); - } - const id: number = sender.tab.id; - const info: any = paymentRequestCookies[id] as any; - delete paymentRequestCookies[id]; - return Promise.resolve(info); - } case "ping": { return Promise.resolve(); } @@ -138,14 +130,11 @@ function handleMessage(sender: MessageSender, const req = ConfirmReserveRequest.checked(d); return needsWallet().confirmReserve(req); } - case "generate-nonce": { - return needsWallet().generateNonce(); - } case "confirm-pay": { if (typeof detail.proposalId !== "number") { throw Error("proposalId must be number"); } - return needsWallet().confirmPay(detail.proposalId); + return needsWallet().confirmPay(detail.proposalId, detail.sessionId); } case "check-pay": { if (typeof detail.proposalId !== "number") { @@ -166,7 +155,7 @@ function handleMessage(sender: MessageSender, return Promise.resolve(msg); } } - return needsWallet().queryPayment(detail.url); + return needsWallet().queryPaymentByFulfillmentUrl(detail.url); } case "exchange-info": { if (!detail.baseUrl) { @@ -188,11 +177,6 @@ function handleMessage(sender: MessageSender, return hash; }); } - case "save-proposal": { - console.log("handling save-proposal", detail); - const checkedRecord = ProposalRecord.checked(detail.proposal); - return needsWallet().saveProposal(checkedRecord); - } case "reserve-creation-info": { if (!detail.baseUrl || typeof detail.baseUrl !== "string") { return Promise.resolve({ error: "bad url" }); @@ -261,25 +245,6 @@ function handleMessage(sender: MessageSender, } return needsWallet().payback(detail.coinPub); } - case "payment-failed": { - // For now we just update exchanges (maybe the exchange did something - // wrong and the keys were messed up). - // FIXME: in the future we should look at what actually went wrong. - console.error("payment reported as failed"); - needsWallet().updateExchanges(); - return Promise.resolve(); - } - case "payment-succeeded": { - const contractTermsHash = detail.contractTermsHash; - const merchantSig = detail.merchantSig; - if (!contractTermsHash) { - return Promise.reject(Error("contractHash missing")); - } - if (!merchantSig) { - return Promise.reject(Error("merchantSig missing")); - } - return needsWallet().paymentSucceeded(contractTermsHash, merchantSig); - } case "get-sender-wire-infos": { return needsWallet().getSenderWireInfos(); } @@ -316,8 +281,6 @@ function handleMessage(sender: MessageSender, return; case "get-report": return logging.getReport(detail.reportUid); - case "accept-refund": - return needsWallet().acceptRefund(detail.refund_permissions); case "get-purchase": { const contractTermsHash = detail.contractTermsHash; if (!contractTermsHash) { @@ -351,6 +314,28 @@ function handleMessage(sender: MessageSender, case "clear-notification": { return needsWallet().clearNotification(); } + case "download-proposal": { + return needsWallet().downloadProposal(detail.url); + } + case "taler-pay": { + const senderUrl = sender.url; + if (!senderUrl) { + console.log("can't trigger payment, no sender URL"); + return; + } + const tab = sender.tab; + if (!tab) { + console.log("can't trigger payment, no sender tab"); + return; + } + const tabId = tab.id; + if (typeof tabId !== "string") { + console.log("can't trigger payment, no sender tab id"); + return; + } + talerPay(detail, senderUrl, tabId); + return; + } default: // Exhaustiveness check. // See https://www.typescriptlang.org/docs/handbook/advanced-types.html @@ -417,13 +402,67 @@ class ChromeNotifier implements Notifier { } -/** - * Mapping from tab ID to payment information (if any). - * - * Used to pass information from an intercepted HTTP header to the content - * script on the page. - */ -const paymentRequestCookies: { [n: number]: any } = {}; +async function talerPay(fields: any, url: string, tabId: number): Promise { + if (!currentWallet) { + console.log("can't handle payment, no wallet"); + return undefined; + } + + const w = currentWallet; + + const goToPayment = (p: QueryPaymentFound): string => { + const nextUrl = new URI(p.contractTerms.fulfillment_url); + nextUrl.addSearch("order_id", p.contractTerms.order_id); + if (p.lastSessionSig) { + nextUrl.addSearch("session_sig", p.lastSessionSig); + } + return url; + }; + + if (fields.resource_url) { + const p = await w.queryPaymentByFulfillmentUrl(fields.resource_url); + if (p.found) { + return goToPayment(p); + } + } + if (fields.contract_hash) { + const p = await w.queryPaymentByContractTermsHash(fields.contract_hash); + if (p.found) { + goToPayment(p); + return goToPayment(p); + } + } + if (fields.contract_url) { + const proposalId = await w.downloadProposal(fields.contract_url); + const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-contract.html")); + if (fields.session_id) { + uri.addSearch("sessionId", fields.session_id); + } + uri.addSearch("proposalId", proposalId); + const redirectUrl = uri.href(); + return redirectUrl; + } + if (fields.offer_url) { + return fields.offer_url; + } + if (fields.refund_url) { + console.log("processing refund"); + const hc = await w.acceptRefund(fields.refund_url); + return chrome.extension.getURL(`/src/webex/pages/refund.html?contractTermsHash=${hc}`); + } + if (fields.tip) { + const tipToken = TipToken.checked(fields.tip); + w.processTip(tipToken); + // Go to tip dialog page, where the user can confirm the tip or + // decline if they are not happy with the exchange. + const merchantDomain = new URI(url).origin(); + const uri = new URI(chrome.extension.getURL("/src/webex/pages/tip.html")); + const params = { tip_id: tipToken.tip_id, merchant_domain: merchantDomain }; + const redirectUrl = uri.query(params).href(); + return redirectUrl; + } + return undefined; +} /** @@ -433,6 +472,11 @@ const paymentRequestCookies: { [n: number]: any } = {}; * in this tab. */ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: string, tabId: number): any { + if (!currentWallet) { + console.log("can't handle payment, no wallet"); + return; + } + const headers: { [s: string]: string } = {}; for (const kv of headerList) { if (kv.value) { @@ -441,9 +485,12 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri } const fields = { + contract_hash: headers["x-taler-contract-hash"], contract_url: headers["x-taler-contract-url"], offer_url: headers["x-taler-offer-url"], refund_url: headers["x-taler-refund-url"], + resource_url: headers["x-taler-resource-url"], + session_id: headers["x-taler-session-id"], tip: headers["x-taler-tip"], }; @@ -456,21 +503,33 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri return; } - const payDetail = { - contract_url: fields.contract_url, - offer_url: fields.offer_url, - refund_url: fields.refund_url, - tip: fields.tip, - }; + console.log("got pay detail", fields); - console.log("got pay detail", payDetail); + // Fast path for existing payment + if (fields.resource_url) { + const nextUrl = currentWallet.getNextUrlFromResourceUrl(fields.resource_url); + if (nextUrl) { + return { redirectUrl: nextUrl }; + } + } + // Fast path for new contract + if (!fields.contract_hash && fields.contract_url) { + const uri = new URI(chrome.extension.getURL("/src/webex/pages/confirm-contract.html")); + uri.addSearch("contractUrl", fields.contract_url); + if (fields.session_id) { + uri.addSearch("sessionId", fields.session_id); + } + return { redirectUrl: uri.href() }; + } - // This cookie will be read by the injected content script - // in the tab that displays the page. - paymentRequestCookies[tabId] = { - payDetail, - type: "pay", - }; + // We need to do some asynchronous operation, we can't directly redirect + talerPay(fields, url, tabId).then((nextUrl) => { + if (nextUrl) { + chrome.tabs.update(tabId, { url: nextUrl }); + } + }); + + return; } @@ -541,7 +600,7 @@ function handleBankRequest(wallet: Wallet, headerList: chrome.webRequest.HttpHea const redirectUrl = uri.query(params).href(); console.log("redirecting to", redirectUrl); // FIXME: use direct redirect when https://bugzilla.mozilla.org/show_bug.cgi?id=707624 is fixed - chrome.tabs.update(tabId, {url: redirectUrl}); + chrome.tabs.update(tabId, { url: redirectUrl }); return; }