implement new mobile-compatible payment logic

This commit is contained in:
Florian Dold 2018-01-17 03:49:54 +01:00
parent 894a09a51c
commit c62ba4986f
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
15 changed files with 500 additions and 545 deletions

View File

@ -15,8 +15,6 @@
*/ */
"use strict";
/** /**
* Decorators for validating JSON objects and converting them to a typed * Decorators for validating JSON objects and converting them to a typed
* object. * object.
@ -55,6 +53,7 @@ export namespace Checkable {
propertyKey: any; propertyKey: any;
checker: any; checker: any;
type?: any; type?: any;
typeThunk?: () => any;
elementChecker?: any; elementChecker?: any;
elementProp?: any; elementProp?: any;
keyProp?: any; keyProp?: any;
@ -167,11 +166,18 @@ export namespace Checkable {
function checkValue(target: any, prop: Prop, path: Path): any { function checkValue(target: any, prop: Prop, path: Path): any {
const type = prop.type; let type;
const typeName = type.name || "??"; if (prop.type) {
type = prop.type;
} else if (prop.typeThunk) {
type = prop.typeThunk();
if (!type) { if (!type) {
throw Error(`assertion failed (prop is ${JSON.stringify(prop)})`); 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; const v = target;
if (!v || typeof v !== "object") { if (!v || typeof v !== "object") {
throw new SchemaError( throw new SchemaError(
@ -236,16 +242,13 @@ export namespace Checkable {
/** /**
* Target property must be a Checkable object of the given type. * Target property must be a Checkable object of the given type.
*/ */
export function Value(type: any) { export function Value(typeThunk: () => any) {
if (!type) {
throw Error("Type does not exist yet (wrong order of definitions?)");
}
function deco(target: object, propertyKey: string | symbol): void { function deco(target: object, propertyKey: string | symbol): void {
const chk = getCheckableInfo(target); const chk = getCheckableInfo(target);
chk.props.push({ chk.props.push({
checker: checkValue, checker: checkValue,
propertyKey, propertyKey,
type, typeThunk,
}); });
} }

View File

@ -49,7 +49,7 @@ import {
* In the future we might consider adding migration functions for * In the future we might consider adding migration functions for
* each version increment. * 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. * Value of one coin of the denomination.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
value: AmountJson; value: AmountJson;
/** /**
@ -225,25 +225,25 @@ export class DenominationRecord {
/** /**
* Fee for withdrawing. * Fee for withdrawing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
feeWithdraw: AmountJson; feeWithdraw: AmountJson;
/** /**
* Fee for depositing. * Fee for depositing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
feeDeposit: AmountJson; feeDeposit: AmountJson;
/** /**
* Fee for refreshing. * Fee for refreshing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
feeRefresh: AmountJson; feeRefresh: AmountJson;
/** /**
* Fee for refunding. * Fee for refunding.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
feeRefund: AmountJson; feeRefund: AmountJson;
/** /**
@ -491,15 +491,22 @@ export interface CoinRecord {
status: CoinStatus; status: CoinStatus;
} }
/** /**
* Proposal record, stored in the wallet's database. * Proposal record, stored in the wallet's database.
*/ */
@Checkable.Class() @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. * The contract that was offered by the merchant.
*/ */
@Checkable.Value(ContractTerms) @Checkable.Value(() => ContractTerms)
contractTerms: ContractTerms; contractTerms: ContractTerms;
/** /**
@ -527,11 +534,17 @@ export class ProposalRecord {
@Checkable.Number @Checkable.Number
timestamp: 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 * Verify that a value matches the schema of this class and convert it into a
* member. * 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 * Configuration key/value entries to configure
* the wallet. * the wallet.
@ -869,12 +873,6 @@ export namespace Stores {
pubKeyIndex = new Index<string, ExchangeRecord>(this, "pubKeyIndex", "masterPublicKey"); pubKeyIndex = new Index<string, ExchangeRecord>(this, "pubKeyIndex", "masterPublicKey");
} }
class NonceStore extends Store<NonceRecord> {
constructor() {
super("nonces", { keyPath: "pub" });
}
}
class CoinsStore extends Store<CoinRecord> { class CoinsStore extends Store<CoinRecord> {
constructor() { constructor() {
super("coins", { keyPath: "coinPub" }); super("coins", { keyPath: "coinPub" });
@ -884,14 +882,14 @@ export namespace Stores {
denomPubIndex = new Index<string, CoinRecord>(this, "denomPubIndex", "denomPub"); denomPubIndex = new Index<string, CoinRecord>(this, "denomPubIndex", "denomPub");
} }
class ProposalsStore extends Store<ProposalRecord> { class ProposalsStore extends Store<ProposalDownloadRecord> {
constructor() { constructor() {
super("proposals", { super("proposals", {
autoIncrement: true, autoIncrement: true,
keyPath: "id", keyPath: "id",
}); });
} }
timestampIndex = new Index<string, ProposalRecord>(this, "timestampIndex", "timestamp"); timestampIndex = new Index<string, ProposalDownloadRecord>(this, "timestampIndex", "timestamp");
} }
class PurchasesStore extends Store<PurchaseRecord> { class PurchasesStore extends Store<PurchaseRecord> {
@ -965,7 +963,6 @@ export namespace Stores {
export const denominations = new DenominationsStore(); export const denominations = new DenominationsStore();
export const exchangeWireFees = new ExchangeWireFeesStore(); export const exchangeWireFees = new ExchangeWireFeesStore();
export const exchanges = new ExchangeStore(); export const exchanges = new ExchangeStore();
export const nonces = new NonceStore();
export const precoins = new Store<PreCoinRecord>("precoins", {keyPath: "coinPub"}); export const precoins = new Store<PreCoinRecord>("precoins", {keyPath: "coinPub"});
export const proposals = new ProposalsStore(); export const proposals = new ProposalsStore();
export const refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: "id", autoIncrement: true}); export const refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: "id", autoIncrement: true});

View File

@ -42,13 +42,13 @@ msgstr ""
msgid "Exchanges in the wallet:" msgid "Exchanges in the wallet:"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:159 #: src/webex/pages/confirm-contract.tsx:175
#, c-format #, c-format
msgid "You have insufficient funds of the requested currency in your wallet." msgid "You have insufficient funds of the requested currency in your wallet."
msgstr "" msgstr ""
#. tslint:disable-next-line:max-line-length #. tslint:disable-next-line:max-line-length
#: src/webex/pages/confirm-contract.tsx:161 #: src/webex/pages/confirm-contract.tsx:177
#, c-format #, c-format
msgid "" msgid ""
"You do not have any funds from an exchange that is accepted by this " "You do not have any funds from an exchange that is accepted by this "
@ -56,12 +56,12 @@ msgid ""
"wallet." "wallet."
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:217 #: src/webex/pages/confirm-contract.tsx:236
#, c-format #, c-format
msgid "The merchant%1$s offers you to purchase:\n" msgid "The merchant%1$s offers you to purchase:\n"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:238 #: src/webex/pages/confirm-contract.tsx:257
#, fuzzy, c-format #, fuzzy, c-format
msgid "Confirm payment" msgid "Confirm payment"
msgstr "Bezahlung bestätigen" msgstr "Bezahlung bestätigen"

View File

@ -42,13 +42,13 @@ msgstr ""
msgid "Exchanges in the wallet:" msgid "Exchanges in the wallet:"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:159 #: src/webex/pages/confirm-contract.tsx:175
#, c-format #, c-format
msgid "You have insufficient funds of the requested currency in your wallet." msgid "You have insufficient funds of the requested currency in your wallet."
msgstr "" msgstr ""
#. tslint:disable-next-line:max-line-length #. tslint:disable-next-line:max-line-length
#: src/webex/pages/confirm-contract.tsx:161 #: src/webex/pages/confirm-contract.tsx:177
#, c-format #, c-format
msgid "" msgid ""
"You do not have any funds from an exchange that is accepted by this " "You do not have any funds from an exchange that is accepted by this "
@ -56,12 +56,12 @@ msgid ""
"wallet." "wallet."
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:217 #: src/webex/pages/confirm-contract.tsx:236
#, c-format #, c-format
msgid "The merchant%1$s offers you to purchase:\n" msgid "The merchant%1$s offers you to purchase:\n"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:238 #: src/webex/pages/confirm-contract.tsx:257
#, c-format #, c-format
msgid "Confirm payment" msgid "Confirm payment"
msgstr "" msgstr ""

View File

@ -42,13 +42,13 @@ msgstr ""
msgid "Exchanges in the wallet:" msgid "Exchanges in the wallet:"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:159 #: src/webex/pages/confirm-contract.tsx:175
#, c-format #, c-format
msgid "You have insufficient funds of the requested currency in your wallet." msgid "You have insufficient funds of the requested currency in your wallet."
msgstr "" msgstr ""
#. tslint:disable-next-line:max-line-length #. tslint:disable-next-line:max-line-length
#: src/webex/pages/confirm-contract.tsx:161 #: src/webex/pages/confirm-contract.tsx:177
#, c-format #, c-format
msgid "" msgid ""
"You do not have any funds from an exchange that is accepted by this " "You do not have any funds from an exchange that is accepted by this "
@ -56,12 +56,12 @@ msgid ""
"wallet." "wallet."
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:217 #: src/webex/pages/confirm-contract.tsx:236
#, c-format #, c-format
msgid "The merchant%1$s offers you to purchase:\n" msgid "The merchant%1$s offers you to purchase:\n"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:238 #: src/webex/pages/confirm-contract.tsx:257
#, c-format #, c-format
msgid "Confirm payment" msgid "Confirm payment"
msgstr "" msgstr ""

View File

@ -42,13 +42,13 @@ msgstr ""
msgid "Exchanges in the wallet:" msgid "Exchanges in the wallet:"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:159 #: src/webex/pages/confirm-contract.tsx:175
#, c-format #, c-format
msgid "You have insufficient funds of the requested currency in your wallet." msgid "You have insufficient funds of the requested currency in your wallet."
msgstr "" msgstr ""
#. tslint:disable-next-line:max-line-length #. tslint:disable-next-line:max-line-length
#: src/webex/pages/confirm-contract.tsx:161 #: src/webex/pages/confirm-contract.tsx:177
#, c-format #, c-format
msgid "" msgid ""
"You do not have any funds from an exchange that is accepted by this " "You do not have any funds from an exchange that is accepted by this "
@ -56,12 +56,12 @@ msgid ""
"wallet." "wallet."
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:217 #: src/webex/pages/confirm-contract.tsx:236
#, c-format #, c-format
msgid "The merchant%1$s offers you to purchase:\n" msgid "The merchant%1$s offers you to purchase:\n"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:238 #: src/webex/pages/confirm-contract.tsx:257
#, c-format #, c-format
msgid "Confirm payment" msgid "Confirm payment"
msgstr "" msgstr ""

View File

@ -42,13 +42,13 @@ msgstr ""
msgid "Exchanges in the wallet:" msgid "Exchanges in the wallet:"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:159 #: src/webex/pages/confirm-contract.tsx:175
#, c-format #, c-format
msgid "You have insufficient funds of the requested currency in your wallet." msgid "You have insufficient funds of the requested currency in your wallet."
msgstr "" msgstr ""
#. tslint:disable-next-line:max-line-length #. tslint:disable-next-line:max-line-length
#: src/webex/pages/confirm-contract.tsx:161 #: src/webex/pages/confirm-contract.tsx:177
#, c-format #, c-format
msgid "" msgid ""
"You do not have any funds from an exchange that is accepted by this " "You do not have any funds from an exchange that is accepted by this "
@ -56,12 +56,12 @@ msgid ""
"wallet." "wallet."
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:217 #: src/webex/pages/confirm-contract.tsx:236
#, c-format #, c-format
msgid "The merchant%1$s offers you to purchase:\n" msgid "The merchant%1$s offers you to purchase:\n"
msgstr "" msgstr ""
#: src/webex/pages/confirm-contract.tsx:238 #: src/webex/pages/confirm-contract.tsx:257
#, c-format #, c-format
msgid "Confirm payment" msgid "Confirm payment"
msgstr "" msgstr ""

View File

@ -38,7 +38,7 @@ export class Denomination {
/** /**
* Value of one coin of the denomination. * Value of one coin of the denomination.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
value: AmountJson; value: AmountJson;
/** /**
@ -50,25 +50,25 @@ export class Denomination {
/** /**
* Fee for withdrawing. * Fee for withdrawing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
fee_withdraw: AmountJson; fee_withdraw: AmountJson;
/** /**
* Fee for depositing. * Fee for depositing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
fee_deposit: AmountJson; fee_deposit: AmountJson;
/** /**
* Fee for refreshing. * Fee for refreshing.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
fee_refresh: AmountJson; fee_refresh: AmountJson;
/** /**
* Fee for refunding. * Fee for refunding.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
fee_refund: AmountJson; fee_refund: AmountJson;
/** /**
@ -151,7 +151,7 @@ export class Auditor {
/** /**
* List of signatures for denominations by the auditor. * List of signatures for denominations by the auditor.
*/ */
@Checkable.List(Checkable.Value(AuditorDenomSig)) @Checkable.List(Checkable.Value(() => AuditorDenomSig))
denomination_keys: AuditorDenomSig[]; denomination_keys: AuditorDenomSig[];
} }
@ -204,7 +204,7 @@ export class PaybackConfirmation {
* How much will the exchange pay back (needed by wallet in * How much will the exchange pay back (needed by wallet in
* case coin was partially spent and wallet got restored from backup) * case coin was partially spent and wallet got restored from backup)
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
amount: AmountJson; amount: AmountJson;
/** /**
@ -336,7 +336,7 @@ export class ContractTerms {
/** /**
* Total amount payable. * Total amount payable.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
amount: AmountJson; amount: AmountJson;
/** /**
@ -360,7 +360,7 @@ export class ContractTerms {
/** /**
* Maximum deposit fee covered by the merchant. * Maximum deposit fee covered by the merchant.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
max_fee: AmountJson; max_fee: AmountJson;
/** /**
@ -378,7 +378,7 @@ export class ContractTerms {
/** /**
* List of accepted exchanges. * List of accepted exchanges.
*/ */
@Checkable.List(Checkable.Value(ExchangeHandle)) @Checkable.List(Checkable.Value(() => ExchangeHandle))
exchanges: ExchangeHandle[]; exchanges: ExchangeHandle[];
/** /**
@ -428,7 +428,7 @@ export class ContractTerms {
/** /**
* Maximum wire fee that the merchant agrees to pay for. * Maximum wire fee that the merchant agrees to pay for.
*/ */
@Checkable.Optional(Checkable.Value(AmountJson)) @Checkable.Optional(Checkable.Value(() => AmountJson))
max_wire_fee?: AmountJson; max_wire_fee?: AmountJson;
/** /**
@ -578,7 +578,7 @@ export class TipResponse {
/** /**
* The order of the signatures matches the planchets list. * The order of the signatures matches the planchets list.
*/ */
@Checkable.List(Checkable.Value(ReserveSigSingleton)) @Checkable.List(Checkable.Value(() => ReserveSigSingleton))
reserve_sigs: ReserveSigSingleton[]; reserve_sigs: ReserveSigSingleton[];
/** /**
@ -620,7 +620,7 @@ export class TipToken {
/** /**
* Amount of tip. * Amount of tip.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
amount: AmountJson; amount: AmountJson;
/** /**
@ -659,7 +659,7 @@ export class KeysJson {
/** /**
* List of offered denominations. * List of offered denominations.
*/ */
@Checkable.List(Checkable.Value(Denomination)) @Checkable.List(Checkable.Value(() => Denomination))
denoms: Denomination[]; denoms: Denomination[];
/** /**
@ -671,7 +671,7 @@ export class KeysJson {
/** /**
* The list of auditors (partially) auditing the exchange. * The list of auditors (partially) auditing the exchange.
*/ */
@Checkable.List(Checkable.Value(Auditor)) @Checkable.List(Checkable.Value(() => Auditor))
auditors: Auditor[]; auditors: Auditor[];
/** /**
@ -683,7 +683,7 @@ export class KeysJson {
/** /**
* List of paybacks for compromised denominations. * List of paybacks for compromised denominations.
*/ */
@Checkable.Optional(Checkable.List(Checkable.Value(Payback))) @Checkable.Optional(Checkable.List(Checkable.Value(() => Payback)))
payback?: Payback[]; payback?: Payback[];
/** /**
@ -715,13 +715,13 @@ export class WireFeesJson {
/** /**
* Cost of a wire transfer. * Cost of a wire transfer.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
wire_fee: AmountJson; wire_fee: AmountJson;
/** /**
* Cost of clising a reserve. * Cost of clising a reserve.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
closing_fee: AmountJson; closing_fee: AmountJson;
/** /**
@ -765,7 +765,7 @@ export class WireDetailJson {
/** /**
* Fees associated with the wire transfer method. * Fees associated with the wire transfer method.
*/ */
@Checkable.List(Checkable.Value(WireFeesJson)) @Checkable.List(Checkable.Value(() => WireFeesJson))
fees: WireFeesJson[]; fees: WireFeesJson[];
/** /**
@ -788,3 +788,21 @@ export type WireDetail = object & { type: string };
export function isWireDetail(x: any): x is WireDetail { export function isWireDetail(x: any): x is WireDetail {
return x && typeof x === "object" && typeof x.type === "string"; 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;
}

View File

@ -49,6 +49,8 @@ import * as Amounts from "./amounts";
import URI = require("urijs"); import URI = require("urijs");
import axios from "axios";
import { import {
CoinRecord, CoinRecord,
CoinStatus, CoinStatus,
@ -59,7 +61,7 @@ import {
ExchangeRecord, ExchangeRecord,
ExchangeWireFeesRecord, ExchangeWireFeesRecord,
PreCoinRecord, PreCoinRecord,
ProposalRecord, ProposalDownloadRecord,
PurchaseRecord, PurchaseRecord,
RefreshPreCoinRecord, RefreshPreCoinRecord,
RefreshSessionRecord, RefreshSessionRecord,
@ -76,9 +78,11 @@ import {
KeysJson, KeysJson,
PayReq, PayReq,
PaybackConfirmation, PaybackConfirmation,
Proposal,
RefundPermission, RefundPermission,
TipPlanchetDetail, TipPlanchetDetail,
TipResponse, TipResponse,
TipToken,
WireDetailJson, WireDetailJson,
isWireDetail, isWireDetail,
} from "./talerTypes"; } from "./talerTypes";
@ -109,7 +113,7 @@ interface SpeculativePayData {
payCoinInfo: PayCoinInfo; payCoinInfo: PayCoinInfo;
exchangeUrl: string; exchangeUrl: string;
proposalId: number; proposalId: number;
proposal: ProposalRecord; proposal: ProposalDownloadRecord;
} }
@ -624,9 +628,9 @@ export class Wallet {
* Record all information that is necessary to * Record all information that is necessary to
* pay for a proposal in the wallet's database. * pay for a proposal in the wallet's database.
*/ */
private async recordConfirmPay(proposal: ProposalRecord, private async recordConfirmPay(proposal: ProposalDownloadRecord,
payCoinInfo: PayCoinInfo, payCoinInfo: PayCoinInfo,
chosenExchange: string): Promise<void> { chosenExchange: string): Promise<PurchaseRecord> {
const payReq: PayReq = { const payReq: PayReq = {
coins: payCoinInfo.sigs, coins: payCoinInfo.sigs,
merchant_pub: proposal.contractTerms.merchant_pub, merchant_pub: proposal.contractTerms.merchant_pub,
@ -651,15 +655,42 @@ export class Wallet {
.finish(); .finish();
this.badge.showNotification(); this.badge.showNotification();
this.notifier.notify(); this.notifier.notify();
return t;
} }
/** /**
* Save a proposal in the database and return an id for it to * Download a proposal and store it in the database.
* retrieve it later. * Returns an id for it to retrieve it later.
*/ */
async saveProposal(proposal: ProposalRecord): Promise<number> { async downloadProposal(url: string): Promise<number> {
const id = await this.q().putWithResult(Stores.proposals, proposal); 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(); this.notifier.notify();
if (typeof id !== "number") { if (typeof id !== "number") {
throw Error("db schema wrong"); throw Error("db schema wrong");
@ -667,24 +698,50 @@ export class Wallet {
return id; return id;
} }
async submitPay(purchase: PurchaseRecord, sessionId: string | undefined): Promise<ConfirmPayResult> {
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, * Add a contract to the wallet and sign coins,
* but do not send them yet. * but do not send them yet.
*/ */
async confirmPay(proposalId: number): Promise<ConfirmPayResult> { async confirmPay(proposalId: number, sessionId: string | undefined): Promise<ConfirmPayResult> {
console.log("executing confirmPay"); console.log(`executing confirmPay with proposalId ${proposalId} and sessionId ${sessionId}`);
const proposal: ProposalRecord|undefined = await this.q().get(Stores.proposals, proposalId); const proposal: ProposalDownloadRecord|undefined = await this.q().get(Stores.proposals, proposalId);
if (!proposal) { if (!proposal) {
throw Error(`proposal with id ${proposalId} not found`); 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) { if (purchase) {
// Already payed ... return this.submitPay(purchase, sessionId);
return "paid";
} }
const res = await this.getCoinsForPayment({ const res = await this.getCoinsForPayment({
@ -702,22 +759,24 @@ export class Wallet {
console.log("coin selection result", res); console.log("coin selection result", res);
if (!res) { if (!res) {
// Should not happen, since checkPay should be called first
console.log("not confirming payment, insufficient coins"); console.log("not confirming payment, insufficient coins");
return "insufficient-balance"; throw Error("insufficient balance");
} }
const sd = await this.getSpeculativePayData(proposalId); const sd = await this.getSpeculativePayData(proposalId);
if (!sd) { if (!sd) {
const { exchangeUrl, cds } = res; const { exchangeUrl, cds } = res;
const payCoinInfo = await this.cryptoApi.signDeposit(proposal.contractTerms, cds); const payCoinInfo = await this.cryptoApi.signDeposit(proposal.contractTerms, cds);
await this.recordConfirmPay(proposal, payCoinInfo, exchangeUrl); purchase = await this.recordConfirmPay(proposal, payCoinInfo, exchangeUrl);
} else { } 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. * 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 * Retrieve information required to pay for a contract, where the
* contract is identified via the fulfillment url. * contract is identified via the fulfillment url.
*/ */
async queryPayment(url: string): Promise<QueryPaymentResult> { async queryPaymentByFulfillmentUrl(url: string): Promise<QueryPaymentResult> {
console.log("query for payment", url); console.log("query for payment", url);
const t = await this.q().getIndexed(Stores.purchases.fulfillmentUrlIndex, 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<QueryPaymentResult> {
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, * 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 // FIXME: do pagination instead of generating the full history
const proposals = await this.q().iter<ProposalRecord>(Stores.proposals).toArray(); const proposals = await this.q().iter<ProposalDownloadRecord>(Stores.proposals).toArray();
for (const p of proposals) { for (const p of proposals) {
history.push({ history.push({
detail: { detail: {
@ -2111,7 +2194,7 @@ export class Wallet {
return denoms; return denoms;
} }
async getProposal(proposalId: number): Promise<ProposalRecord|undefined> { async getProposal(proposalId: number): Promise<ProposalDownloadRecord|undefined> {
const proposal = await this.q().get(Stores.proposals, proposalId); const proposal = await this.q().get(Stores.proposals, proposalId);
return proposal; 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<string> {
const {priv, pub} = await this.cryptoApi.createEddsaKeypair();
await this.q()
.put(Stores.nonces, {priv, pub})
.finish();
return pub;
}
async getCurrencyRecord(currency: string): Promise<CurrencyRecord|undefined> { async getCurrencyRecord(currency: string): Promise<CurrencyRecord|undefined> {
return this.q().get(Stores.currencies, currency); return this.q().get(Stores.currencies, currency);
} }
@ -2466,10 +2537,25 @@ export class Wallet {
} }
} }
async acceptRefund(refundPermissions: RefundPermission[]): Promise<void> { async acceptRefund(refundUrl: string): Promise<string> {
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) { if (!refundPermissions.length) {
console.warn("got empty refund list"); console.warn("got empty refund list");
return; throw Error("empty refund");
} }
const hc = refundPermissions[0].h_contract_terms; const hc = refundPermissions[0].h_contract_terms;
if (!hc) { if (!hc) {
@ -2513,6 +2599,8 @@ export class Wallet {
// Start submitting it but don't wait for it here. // Start submitting it but don't wait for it here.
this.submitRefunds(hc); this.submitRefunds(hc);
return refundPermissions[0].h_contract_terms;
} }
async submitRefunds(contractTermsHash: string): Promise<void> { async submitRefunds(contractTermsHash: string): Promise<void> {
@ -2646,6 +2734,54 @@ export class Wallet {
return planchetDetail; return planchetDetail;
} }
async processTip(tipToken: TipToken): Promise<void> {
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. * Accept a merchant's response to a tip pickup and start withdrawing the coins.
* These coins will not appear in the wallet yet. * These coins will not appear in the wallet yet.
@ -2725,6 +2861,11 @@ export class Wallet {
return tipStatus; return tipStatus;
} }
getNextUrlFromResourceUrl(resourceUrl: string): string | undefined {
return;
}
/** /**
* Remove unreferenced / expired data from the wallet's database * Remove unreferenced / expired data from the wallet's database
* based on the current system time. * based on the current system time.
@ -2745,7 +2886,7 @@ export class Wallet {
}; };
await this.q().deleteIf(Stores.reserves, gcReserve).finish(); 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, // Delete proposal after 60 minutes or 5 minutes before pay deadline,
// whatever comes first. // whatever comes first.
const deadlinePayMilli = getTalerStampSec(d.contractTerms.pay_deadline)! * 1000; const deadlinePayMilli = getTalerStampSec(d.contractTerms.pay_deadline)! * 1000;

View File

@ -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; found: true;
contractTermsHash: string; contractTermsHash: string;
contractTerms: ContractTerms; contractTerms: ContractTerms;
lastSessionSig?: string;
payReq: PayReq; payReq: PayReq;
} }
@ -329,7 +332,7 @@ export class CreateReserveRequest {
/** /**
* The initial amount for the reserve. * The initial amount for the reserve.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
amount: AmountJson; amount: AmountJson;
/** /**
@ -380,7 +383,7 @@ export class ReturnCoinsRequest {
/** /**
* The amount to wire. * The amount to wire.
*/ */
@Checkable.Value(AmountJson) @Checkable.Value(() => AmountJson)
amount: AmountJson; amount: AmountJson;
/** /**
@ -511,7 +514,7 @@ export class ProcessTipResponseRequest {
/** /**
* Tip response from the merchant. * Tip response from the merchant.
*/ */
@Checkable.Value(TipResponse) @Checkable.Value(() => TipResponse)
tipResponse: TipResponse; tipResponse: TipResponse;
/** /**
@ -543,7 +546,7 @@ export class GetTipPlanchetsRequest {
/** /**
* Amount of the tip. * Amount of the tip.
*/ */
@Checkable.Optional(Checkable.Value(AmountJson)) @Checkable.Optional(Checkable.Value(() => AmountJson))
amount: AmountJson; amount: AmountJson;
/** /**

View File

@ -44,10 +44,6 @@ export interface MessageMap {
}; };
response: void; response: void;
}; };
"get-tab-cookie": {
request: { }
response: any;
};
"ping": { "ping": {
request: { }; request: { };
response: void; response: void;
@ -67,12 +63,8 @@ export interface MessageMap {
request: { reservePub: string }; request: { reservePub: string };
response: void; response: void;
}; };
"generate-nonce": {
request: { }
response: string;
};
"confirm-pay": { "confirm-pay": {
request: { proposalId: number; }; request: { proposalId: number; sessionId?: string };
response: walletTypes.ConfirmPayResult; response: walletTypes.ConfirmPayResult;
}; };
"check-pay": { "check-pay": {
@ -95,10 +87,6 @@ export interface MessageMap {
request: { contract: object }; request: { contract: object };
response: string; response: string;
}; };
"save-proposal": {
request: { proposal: dbTypes.ProposalRecord };
response: void;
};
"reserve-creation-info": { "reserve-creation-info": {
request: { baseUrl: string, amount: AmountJson }; request: { baseUrl: string, amount: AmountJson };
response: walletTypes.ReserveCreationInfo; response: walletTypes.ReserveCreationInfo;
@ -109,7 +97,7 @@ export interface MessageMap {
}; };
"get-proposal": { "get-proposal": {
request: { proposalId: number }; request: { proposalId: number };
response: dbTypes.ProposalRecord | undefined; response: dbTypes.ProposalDownloadRecord | undefined;
}; };
"get-coins": { "get-coins": {
request: { exchangeBaseUrl: string }; request: { exchangeBaseUrl: string };
@ -155,14 +143,6 @@ export interface MessageMap {
request: { coinPub: string }; request: { coinPub: string };
response: void; response: void;
}; };
"payment-failed": {
request: { contractTermsHash: string };
response: void;
};
"payment-succeeded": {
request: { contractTermsHash: string; merchantSig: string };
response: void;
};
"check-upgrade": { "check-upgrade": {
request: { }; request: { };
response: void; response: void;
@ -183,10 +163,6 @@ export interface MessageMap {
request: { reportUid: string }; request: { reportUid: string };
response: void; response: void;
}; };
"accept-refund": {
request: any;
response: void;
};
"get-purchase": { "get-purchase": {
request: any; request: any;
response: void; response: void;
@ -215,6 +191,14 @@ export interface MessageMap {
request: { }; request: { };
response: void; response: void;
}; };
"taler-pay": {
request: any;
response: void;
};
"download-proposal": {
request: any;
response: void;
};
} }
/** /**

View File

@ -28,13 +28,6 @@ import URI = require("urijs");
import wxApi = require("./wxApi"); import wxApi = require("./wxApi");
import { getTalerStampSec } from "../helpers";
import { TipToken } from "../talerTypes";
import { QueryPaymentResult } from "../walletTypes";
import axios from "axios";
declare var cloneInto: any; declare var cloneInto: any;
let logVerbose: boolean = false; 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) { function onceOnComplete(cb: () => void) {
if (document.readyState === "complete") { if (document.readyState === "complete") {
cb(); cb();
@ -153,15 +110,6 @@ function onceOnComplete(cb: () => void) {
function init() { 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(() => { onceOnComplete(() => {
if (document.documentElement.getAttribute("data-taler-nojs")) { if (document.documentElement.getAttribute("data-taler-nojs")) {
initStyle(); initStyle();
@ -181,206 +129,10 @@ function init() {
document.removeEventListener(handler.type, handler.listener); 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);
}
});
} }
type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void; type HandlerFn = (detail: any, sendResponse: (msg: any) => void) => void;
async function downloadContract(url: string, nonce: string): Promise<any> {
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<any> {
// 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() { function registerHandlers() {
/** /**
@ -457,7 +209,7 @@ function registerHandlers() {
}); });
addHandler("taler-pay", async(msg: any, sendResponse: any) => { addHandler("taler-pay", async(msg: any, sendResponse: any) => {
const resp = await talerPay(msg); const resp = await wxApi.talerPay(msg);
sendResponse(resp); sendResponse(resp);
}); });
} }

View File

@ -27,7 +27,7 @@ import * as i18n from "../../i18n";
import { import {
ExchangeRecord, ExchangeRecord,
ProposalRecord, ProposalDownloadRecord,
} from "../../dbTypes"; } from "../../dbTypes";
import { ContractTerms } from "../../talerTypes"; import { ContractTerms } from "../../talerTypes";
import { import {
@ -102,11 +102,14 @@ class Details extends React.Component<DetailProps, DetailState> {
} }
interface ContractPromptProps { interface ContractPromptProps {
proposalId: number; proposalId?: number;
contractUrl?: string;
sessionId?: string;
} }
interface ContractPromptState { interface ContractPromptState {
proposal: ProposalRecord|null; proposalId: number | undefined;
proposal: ProposalDownloadRecord | null;
error: string | null; error: string | null;
payDisabled: boolean; payDisabled: boolean;
alreadyPaid: boolean; alreadyPaid: boolean;
@ -130,6 +133,7 @@ class ContractPrompt extends React.Component<ContractPromptProps, ContractPrompt
holdCheck: false, holdCheck: false,
payDisabled: true, payDisabled: true,
proposal: null, proposal: null,
proposalId: props.proposalId,
}; };
} }
@ -142,11 +146,19 @@ class ContractPrompt extends React.Component<ContractPromptProps, ContractPrompt
} }
async update() { async update() {
const proposal = await wxApi.getProposal(this.props.proposalId); let proposalId = this.props.proposalId;
this.setState({proposal} as any); if (proposalId === undefined) {
if (this.props.contractUrl === undefined) {
// Nothing we can do ...
return;
}
proposalId = await wxApi.downloadProposal(this.props.contractUrl);
}
const proposal = await wxApi.getProposal(proposalId);
this.setState({ proposal, proposalId });
this.checkPayment(); this.checkPayment();
const exchanges = await wxApi.getExchanges(); const exchanges = await wxApi.getExchanges();
this.setState({exchanges} as any); this.setState({ exchanges });
} }
async checkPayment() { async checkPayment() {
@ -154,7 +166,11 @@ class ContractPrompt extends React.Component<ContractPromptProps, ContractPrompt
if (this.state.holdCheck) { if (this.state.holdCheck) {
return; return;
} }
const payStatus = await wxApi.checkPay(this.props.proposalId); const proposalId = this.state.proposalId;
if (proposalId === undefined) {
return;
}
const payStatus = await wxApi.checkPay(proposalId);
if (payStatus.status === "insufficient-balance") { if (payStatus.status === "insufficient-balance") {
const msgInsufficient = i18n.str`You have insufficient funds of the requested currency in your wallet.`; const msgInsufficient = i18n.str`You have insufficient funds of the requested currency in your wallet.`;
// tslint:disable-next-line:max-line-length // tslint:disable-next-line:max-line-length
@ -184,21 +200,24 @@ class ContractPrompt extends React.Component<ContractPromptProps, ContractPrompt
if (!proposal) { if (!proposal) {
return; return;
} }
const payStatus = await wxApi.confirmPay(this.props.proposalId); const proposalId = proposal.id;
switch (payStatus) { if (proposalId === undefined) {
case "insufficient-balance": console.error("proposal has no id");
this.checkPayment();
return; return;
case "paid":
console.log("contract", proposal.contractTerms);
document.location.href = proposal.contractTerms.fulfillment_url;
break;
} }
const payResult = await wxApi.confirmPay(proposalId, this.props.sessionId);
document.location.href = payResult.nextUrl;
this.setState({ holdCheck: true }); this.setState({ holdCheck: true });
} }
render() { render() {
if (this.props.contractUrl === undefined && this.props.proposalId === undefined) {
return <span>Error: either contractUrl or proposalId must be given</span>;
}
if (this.state.proposalId === undefined) {
return <span>Downloading contract terms</span>;
}
if (!this.state.proposal) { if (!this.state.proposal) {
return <span>...</span>; return <span>...</span>;
} }
@ -255,8 +274,18 @@ class ContractPrompt extends React.Component<ContractPromptProps, ContractPrompt
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const url = new URI(document.location.href); const url = new URI(document.location.href);
const query: any = URI.parseQuery(url.query()); const query: any = URI.parseQuery(url.query());
const proposalId = JSON.parse(query.proposalId);
ReactDOM.render(<ContractPrompt proposalId={proposalId}/>, document.getElementById( let proposalId;
"contract")!); try {
proposalId = JSON.parse(query.proposalId);
} catch {
// ignore error
}
const sessionId = query.sessionId;
const contractUrl = query.contractUrl;
ReactDOM.render(
<ContractPrompt {...{ proposalId, contractUrl, sessionId }}/>,
document.getElementById("contract")!);
}); });

View File

@ -217,8 +217,8 @@ export function checkPay(proposalId: number): Promise<CheckPayResult> {
/** /**
* Pay for a proposal. * Pay for a proposal.
*/ */
export function confirmPay(proposalId: number): Promise<ConfirmPayResult> { export function confirmPay(proposalId: number, sessionId: string | undefined): Promise<ConfirmPayResult> {
return callBackend("confirm-pay", { proposalId }); return callBackend("confirm-pay", { proposalId, sessionId });
} }
/** /**
@ -228,15 +228,6 @@ export function hashContract(contract: object): Promise<string> {
return callBackend("hash-contract", { contract }); 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<number> {
return callBackend("save-proposal", { proposal });
}
/** /**
* Mark a reserve as confirmed. * Mark a reserve as confirmed.
*/ */
@ -251,36 +242,6 @@ export function queryPayment(url: string): Promise<QueryPaymentResult> {
return callBackend("query-payment", { url }); return callBackend("query-payment", { url });
} }
/**
* Mark a payment as succeeded.
*/
export function paymentSucceeded(contractTermsHash: string, merchantSig: string): Promise<void> {
return callBackend("payment-succeeded", { contractTermsHash, merchantSig });
}
/**
* Mark a payment as succeeded.
*/
export function paymentFailed(contractTermsHash: string): Promise<void> {
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<any> {
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<string> {
return callBackend("generate-nonce", { });
}
/** /**
* Check upgrade information * Check upgrade information
*/ */
@ -344,12 +305,6 @@ export function getReport(reportUid: string): Promise<any> {
return callBackend("get-report", { reportUid }); return callBackend("get-report", { reportUid });
} }
/**
* Apply a refund that we got from the merchant.
*/
export function acceptRefund(refundData: any): Promise<number> {
return callBackend("accept-refund", refundData);
}
/** /**
* Look up a purchase in the wallet database from * 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<void> { export function clearNotification(): Promise<void> {
return callBackend("clear-notification", { }); return callBackend("clear-notification", { });
} }
/**
* Trigger taler payment processing (for payment, tipping and refunds).
*/
export function talerPay(msg: any): Promise<void> {
return callBackend("taler-pay", msg);
}
/**
* Download a contract.
*/
export function downloadProposal(url: string): Promise<number> {
return callBackend("download-proposal", { url });
}

View File

@ -33,7 +33,6 @@ import {
import { AmountJson } from "../amounts"; import { AmountJson } from "../amounts";
import { ProposalRecord } from "../dbTypes";
import { import {
AcceptTipRequest, AcceptTipRequest,
ConfirmReserveRequest, ConfirmReserveRequest,
@ -41,6 +40,7 @@ import {
GetTipPlanchetsRequest, GetTipPlanchetsRequest,
Notifier, Notifier,
ProcessTipResponseRequest, ProcessTipResponseRequest,
QueryPaymentFound,
ReturnCoinsRequest, ReturnCoinsRequest,
TipStatusRequest, TipStatusRequest,
} from "../walletTypes"; } from "../walletTypes";
@ -62,6 +62,7 @@ import * as wxApi from "./wxApi";
import URI = require("urijs"); import URI = require("urijs");
import Port = chrome.runtime.Port; import Port = chrome.runtime.Port;
import MessageSender = chrome.runtime.MessageSender; import MessageSender = chrome.runtime.MessageSender;
import { TipToken } from "../talerTypes";
const DB_NAME = "taler"; const DB_NAME = "taler";
@ -93,15 +94,6 @@ function handleMessage(sender: MessageSender,
const db = needsWallet().db; const db = needsWallet().db;
return importDb(db, detail.dump); 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": { case "ping": {
return Promise.resolve(); return Promise.resolve();
} }
@ -138,14 +130,11 @@ function handleMessage(sender: MessageSender,
const req = ConfirmReserveRequest.checked(d); const req = ConfirmReserveRequest.checked(d);
return needsWallet().confirmReserve(req); return needsWallet().confirmReserve(req);
} }
case "generate-nonce": {
return needsWallet().generateNonce();
}
case "confirm-pay": { case "confirm-pay": {
if (typeof detail.proposalId !== "number") { if (typeof detail.proposalId !== "number") {
throw Error("proposalId must be number"); throw Error("proposalId must be number");
} }
return needsWallet().confirmPay(detail.proposalId); return needsWallet().confirmPay(detail.proposalId, detail.sessionId);
} }
case "check-pay": { case "check-pay": {
if (typeof detail.proposalId !== "number") { if (typeof detail.proposalId !== "number") {
@ -166,7 +155,7 @@ function handleMessage(sender: MessageSender,
return Promise.resolve(msg); return Promise.resolve(msg);
} }
} }
return needsWallet().queryPayment(detail.url); return needsWallet().queryPaymentByFulfillmentUrl(detail.url);
} }
case "exchange-info": { case "exchange-info": {
if (!detail.baseUrl) { if (!detail.baseUrl) {
@ -188,11 +177,6 @@ function handleMessage(sender: MessageSender,
return hash; 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": { case "reserve-creation-info": {
if (!detail.baseUrl || typeof detail.baseUrl !== "string") { if (!detail.baseUrl || typeof detail.baseUrl !== "string") {
return Promise.resolve({ error: "bad url" }); return Promise.resolve({ error: "bad url" });
@ -261,25 +245,6 @@ function handleMessage(sender: MessageSender,
} }
return needsWallet().payback(detail.coinPub); 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": { case "get-sender-wire-infos": {
return needsWallet().getSenderWireInfos(); return needsWallet().getSenderWireInfos();
} }
@ -316,8 +281,6 @@ function handleMessage(sender: MessageSender,
return; return;
case "get-report": case "get-report":
return logging.getReport(detail.reportUid); return logging.getReport(detail.reportUid);
case "accept-refund":
return needsWallet().acceptRefund(detail.refund_permissions);
case "get-purchase": { case "get-purchase": {
const contractTermsHash = detail.contractTermsHash; const contractTermsHash = detail.contractTermsHash;
if (!contractTermsHash) { if (!contractTermsHash) {
@ -351,6 +314,28 @@ function handleMessage(sender: MessageSender,
case "clear-notification": { case "clear-notification": {
return needsWallet().clearNotification(); 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: default:
// Exhaustiveness check. // Exhaustiveness check.
// See https://www.typescriptlang.org/docs/handbook/advanced-types.html // See https://www.typescriptlang.org/docs/handbook/advanced-types.html
@ -417,13 +402,67 @@ class ChromeNotifier implements Notifier {
} }
/** async function talerPay(fields: any, url: string, tabId: number): Promise<string | undefined> {
* Mapping from tab ID to payment information (if any). if (!currentWallet) {
* console.log("can't handle payment, no wallet");
* Used to pass information from an intercepted HTTP header to the content return undefined;
* script on the page. }
*/
const paymentRequestCookies: { [n: number]: any } = {}; 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. * in this tab.
*/ */
function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: string, tabId: number): any { 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 } = {}; const headers: { [s: string]: string } = {};
for (const kv of headerList) { for (const kv of headerList) {
if (kv.value) { if (kv.value) {
@ -441,9 +485,12 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri
} }
const fields = { const fields = {
contract_hash: headers["x-taler-contract-hash"],
contract_url: headers["x-taler-contract-url"], contract_url: headers["x-taler-contract-url"],
offer_url: headers["x-taler-offer-url"], offer_url: headers["x-taler-offer-url"],
refund_url: headers["x-taler-refund-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"], tip: headers["x-taler-tip"],
}; };
@ -456,21 +503,33 @@ function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], url: stri
return; return;
} }
const payDetail = { console.log("got pay detail", fields);
contract_url: fields.contract_url,
offer_url: fields.offer_url,
refund_url: fields.refund_url,
tip: fields.tip,
};
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 // We need to do some asynchronous operation, we can't directly redirect
// in the tab that displays the page. talerPay(fields, url, tabId).then((nextUrl) => {
paymentRequestCookies[tabId] = { if (nextUrl) {
payDetail, chrome.tabs.update(tabId, { url: nextUrl });
type: "pay", }
}; });
return;
} }