support for tipping protocol changes

This commit is contained in:
Florian Dold 2019-08-30 17:27:59 +02:00
parent defbf625bd
commit 5ec344290e
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 445 additions and 426 deletions

View File

@ -14,7 +14,6 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Types for records stored in the wallet's database.
*
@ -36,11 +35,7 @@ import {
TipResponse,
} from "./talerTypes";
import {
Index,
Store,
} from "./query";
import { Index, Store } from "./query";
/**
* Current database version, should be incremented
@ -50,7 +45,6 @@ import {
*/
export const WALLET_DB_VERSION = 26;
/**
* A reserve record as stored in the wallet's database.
*/
@ -81,7 +75,6 @@ export interface ReserveRecord {
*/
timestamp_depleted: number;
/**
* Time when the information about this reserve was posted to the bank.
*
@ -137,7 +130,6 @@ export interface ReserveRecord {
bankWithdrawStatusUrl?: string;
}
/**
* Auditor record as stored with currencies in the exchange database.
*/
@ -156,7 +148,6 @@ export interface AuditorRecord {
expirationStamp: number;
}
/**
* Exchange for currencies as stored in the wallet's currency
* information database.
@ -172,7 +163,6 @@ export interface ExchangeForCurrencyRecord {
baseUrl: string;
}
/**
* Information about a currency as displayed in the wallet's database.
*/
@ -195,7 +185,6 @@ export interface CurrencyRecord {
exchanges: ExchangeForCurrencyRecord[];
}
/**
* Status of a denomination.
*/
@ -214,7 +203,6 @@ export enum DenominationStatus {
VerifiedBad,
}
/**
* Denomination record as stored in the wallet's database.
*/
@ -321,7 +309,6 @@ export class DenominationRecord {
static checked: (obj: any) => Denomination;
}
/**
* Exchange record as stored in the wallet's database.
*/
@ -362,7 +349,6 @@ export interface ExchangeRecord {
protocolVersion?: string;
}
/**
* A coin that isn't yet signed by an exchange.
*/
@ -385,7 +371,6 @@ export interface PreCoinRecord {
isFromTip: boolean;
}
/**
* Planchet for a coin during refrehs.
*/
@ -408,7 +393,6 @@ export interface RefreshPreCoinRecord {
blindingKey: string;
}
/**
* Status of a coin.
*/
@ -441,13 +425,8 @@ export enum CoinStatus {
* Coin was dirty but can't be refreshed.
*/
Useless,
/**
* The coin was withdrawn for a tip that the user hasn't accepted yet.
*/
TainedByTip,
}
/**
* CoinRecord as stored in the "coins" data store
* of the wallet database.
@ -514,7 +493,6 @@ export interface CoinRecord {
status: CoinStatus;
}
/**
* Proposal record, stored in the wallet's database.
*/
@ -576,7 +554,6 @@ export class ProposalDownloadRecord {
static checked: (obj: any) => ProposalDownloadRecord;
}
/**
* Wire fees for an exchange.
*/
@ -592,7 +569,6 @@ export interface ExchangeWireFeesRecord {
feesForType: { [wireMethod: string]: WireFee[] };
}
/**
* Status of a tip we got from a merchant.
*/
@ -613,12 +589,7 @@ export interface TipRecord {
*/
amount: AmountJson;
/**
* Coin public keys from the planchets.
* This field is redundant and used for indexing the record via
* a multi-entry index to look up tip records by coin public key.
*/
coinPubs: string[];
totalFees: AmountJson;
/**
* Timestamp, the tip can't be picked up anymore after this deadline.
@ -641,7 +612,14 @@ export interface TipRecord {
* Planchets, the members included in TipPlanchetDetail will be sent to the
* merchant.
*/
planchets: TipPlanchet[];
planchets?: TipPlanchet[];
/**
* Coin public keys from the planchets.
* This field is redundant and used for indexing the record via
* a multi-entry index to look up tip records by coin public key.
*/
coinPubs: string[];
/**
* Response if the merchant responded,
@ -657,11 +635,12 @@ export interface TipRecord {
/**
* URL to go to once the tip has been accepted.
*/
nextUrl: string;
nextUrl?: string;
timestamp: number;
}
pickupUrl: string;
}
/**
* Ongoing refresh
@ -740,7 +719,6 @@ export interface RefreshSessionRecord {
id?: number;
}
/**
* Tipping planchet stored in the database.
*/
@ -754,7 +732,6 @@ export interface TipPlanchet {
denomPub: string;
}
/**
* Wire fee for one wire method as stored in the
* wallet's database.
@ -861,7 +838,6 @@ export interface PurchaseRecord {
abortDone: boolean;
}
/**
* Information about wire information for bank accounts we withdrew coins from.
*/
@ -869,7 +845,6 @@ export interface SenderWireRecord {
paytoUri: string;
}
/**
* Configuration key/value entries to configure
* the wallet.
@ -879,7 +854,6 @@ export interface ConfigRecord {
value: any;
}
/**
* Coin that we're depositing ourselves.
*/
@ -893,7 +867,6 @@ export interface DepositCoin {
depositedSig?: string;
}
/**
* Record stored in the wallet's database when the user sends coins back to
* their own bank account. Stores the status of coins that are deposited to
@ -927,7 +900,6 @@ export interface CoinsReturnRecord {
wire: any;
}
/* tslint:disable:completed-docs */
/**
@ -939,7 +911,11 @@ export namespace Stores {
super("exchanges", { keyPath: "baseUrl" });
}
pubKeyIndex = new Index<string, ExchangeRecord>(this, "pubKeyIndex", "masterPublicKey");
pubKeyIndex = new Index<string, ExchangeRecord>(
this,
"pubKeyIndex",
"masterPublicKey",
);
}
class CoinsStore extends Store<CoinRecord> {
@ -947,8 +923,16 @@ export namespace Stores {
super("coins", { keyPath: "coinPub" });
}
exchangeBaseUrlIndex = new Index<string, CoinRecord>(this, "exchangeBaseUrl", "exchangeBaseUrl");
denomPubIndex = new Index<string, CoinRecord>(this, "denomPubIndex", "denomPub");
exchangeBaseUrlIndex = new Index<string, CoinRecord>(
this,
"exchangeBaseUrl",
"exchangeBaseUrl",
);
denomPubIndex = new Index<string, CoinRecord>(
this,
"denomPubIndex",
"denomPub",
);
}
class ProposalsStore extends Store<ProposalDownloadRecord> {
@ -958,8 +942,16 @@ export namespace Stores {
keyPath: "id",
});
}
urlIndex = new Index<string, ProposalDownloadRecord>(this, "urlIndex", "url");
timestampIndex = new Index<string, ProposalDownloadRecord>(this, "timestampIndex", "timestamp");
urlIndex = new Index<string, ProposalDownloadRecord>(
this,
"urlIndex",
"url",
);
timestampIndex = new Index<string, ProposalDownloadRecord>(
this,
"timestampIndex",
"timestamp",
);
}
class PurchasesStore extends Store<PurchaseRecord> {
@ -967,23 +959,46 @@ export namespace Stores {
super("purchases", { keyPath: "contractTermsHash" });
}
fulfillmentUrlIndex = new Index<string, PurchaseRecord>(this,
fulfillmentUrlIndex = new Index<string, PurchaseRecord>(
this,
"fulfillmentUrlIndex",
"contractTerms.fulfillment_url");
orderIdIndex = new Index<string, PurchaseRecord>(this, "orderIdIndex", "contractTerms.order_id");
timestampIndex = new Index<string, PurchaseRecord>(this, "timestampIndex", "timestamp");
"contractTerms.fulfillment_url",
);
orderIdIndex = new Index<string, PurchaseRecord>(
this,
"orderIdIndex",
"contractTerms.order_id",
);
timestampIndex = new Index<string, PurchaseRecord>(
this,
"timestampIndex",
"timestamp",
);
}
class DenominationsStore extends Store<DenominationRecord> {
constructor() {
// cast needed because of bug in type annotations
super("denominations",
{keyPath: ["exchangeBaseUrl", "denomPub"] as any as IDBKeyPath});
super("denominations", {
keyPath: (["exchangeBaseUrl", "denomPub"] as any) as IDBKeyPath,
});
}
denomPubHashIndex = new Index<string, DenominationRecord>(this, "denomPubHashIndex", "denomPubHash");
exchangeBaseUrlIndex = new Index<string, DenominationRecord>(this, "exchangeBaseUrlIndex", "exchangeBaseUrl");
denomPubIndex = new Index<string, DenominationRecord>(this, "denomPubIndex", "denomPub");
denomPubHashIndex = new Index<string, DenominationRecord>(
this,
"denomPubHashIndex",
"denomPubHash",
);
exchangeBaseUrlIndex = new Index<string, DenominationRecord>(
this,
"exchangeBaseUrlIndex",
"exchangeBaseUrl",
);
denomPubIndex = new Index<string, DenominationRecord>(
this,
"denomPubIndex",
"denomPub",
);
}
class CurrenciesStore extends Store<CurrencyRecord> {
@ -1008,16 +1023,35 @@ export namespace Stores {
constructor() {
super("reserves", { keyPath: "reserve_pub" });
}
timestampCreatedIndex = new Index<string, ReserveRecord>(this, "timestampCreatedIndex", "created");
timestampConfirmedIndex = new Index<string, ReserveRecord>(this, "timestampConfirmedIndex", "timestamp_confirmed");
timestampDepletedIndex = new Index<string, ReserveRecord>(this, "timestampDepletedIndex", "timestamp_depleted");
timestampCreatedIndex = new Index<string, ReserveRecord>(
this,
"timestampCreatedIndex",
"created",
);
timestampConfirmedIndex = new Index<string, ReserveRecord>(
this,
"timestampConfirmedIndex",
"timestamp_confirmed",
);
timestampDepletedIndex = new Index<string, ReserveRecord>(
this,
"timestampDepletedIndex",
"timestamp_depleted",
);
}
class TipsStore extends Store<TipRecord> {
constructor() {
super("tips", { keyPath: ["tipId", "merchantDomain"] as any as IDBKeyPath });
super("tips", {
keyPath: (["tipId", "merchantDomain"] as any) as IDBKeyPath,
});
}
coinPubIndex = new Index<string, TipRecord>(this, "coinPubIndex", "coinPubs", { multiEntry: true });
coinPubIndex = new Index<string, TipRecord>(
this,
"coinPubIndex",
"coinPubs",
{ multiEntry: true },
);
}
class SenderWiresStore extends Store<SenderWireRecord> {
@ -1027,15 +1061,22 @@ export namespace Stores {
}
export const coins = new CoinsStore();
export const coinsReturns = new Store<CoinsReturnRecord>("coinsReturns", {keyPath: "contractTermsHash"});
export const coinsReturns = new Store<CoinsReturnRecord>("coinsReturns", {
keyPath: "contractTermsHash",
});
export const config = new ConfigStore();
export const currencies = new CurrenciesStore();
export const denominations = new DenominationsStore();
export const exchangeWireFees = new ExchangeWireFeesStore();
export const exchanges = new ExchangeStore();
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 refresh = new Store<RefreshSessionRecord>("refresh", {keyPath: "id", autoIncrement: true});
export const refresh = new Store<RefreshSessionRecord>("refresh", {
keyPath: "id",
autoIncrement: true,
});
export const reserves = new ReservesStore();
export const purchases = new PurchasesStore();
export const tips = new TipsStore();

View File

@ -127,7 +127,7 @@ program
});
program
.command("withdraw-url <withdraw-url>")
.command("withdraw-uri <withdraw-uri>")
.action(async (withdrawUrl, cmdObj) => {
applyVerbose(program.verbose);
console.log("withdrawing", withdrawUrl);
@ -166,7 +166,21 @@ program
});
program
.command("pay-url <pay-url>")
.command("tip-uri <tip-uri>")
.action(async (tipUri, cmdObj) => {
applyVerbose(program.verbose);
console.log("getting tip", tipUri);
const wallet = await getDefaultNodeWallet({
persistentStoragePath: walletDbPath,
});
const res = await wallet.getTipStatus(tipUri);
console.log("tip status", res);
await wallet.acceptTip(tipUri);
wallet.stop();
});
program
.command("pay-uri <pay-uri")
.option("-y, --yes", "automatically answer yes to prompts")
.action(async (payUrl, cmdObj) => {
applyVerbose(program.verbose);

View File

@ -32,7 +32,6 @@ import * as Amounts from "./amounts";
import { timestampCheck } from "./helpers";
/**
* Denomination as found in the /keys response from the exchange.
*/
@ -114,7 +113,6 @@ export class Denomination {
static checked: (obj: any) => Denomination;
}
/**
* Signature by the auditor that a particular denomination key is audited.
*/
@ -133,7 +131,6 @@ export class AuditorDenomSig {
auditor_sig: string;
}
/**
* Auditor information as given by the exchange in /keys.
*/
@ -158,7 +155,6 @@ export class Auditor {
denomination_keys: AuditorDenomSig[];
}
/**
* Request that we send to the exchange to get a payback.
*/
@ -191,7 +187,6 @@ export interface PaybackRequest {
coin_sig: string;
}
/**
* Response that we get from the exchange for a payback request.
*/
@ -242,7 +237,6 @@ export class PaybackConfirmation {
static checked: (obj: any) => PaybackConfirmation;
}
/**
* Deposit permission for a single coin.
*/
@ -274,7 +268,6 @@ export interface CoinPaySig {
exchange_url: string;
}
/**
* Information about an exchange as stored inside a
* merchant's contract terms.
@ -300,7 +293,6 @@ export class ExchangeHandle {
static checked: (obj: any) => ExchangeHandle;
}
/**
* Contract terms from a merchant.
*/
@ -447,7 +439,6 @@ export class ContractTerms {
static checked: (obj: any) => ContractTerms;
}
/**
* Payment body sent to the merchant's /pay.
*/
@ -474,7 +465,6 @@ export interface PayReq {
mode: "pay" | "abort-refund";
}
/**
* Refund permission in the format that the merchant gives it to us.
*/
@ -516,7 +506,6 @@ export class MerchantRefundPermission {
static checked: (obj: any) => MerchantRefundPermission;
}
/**
* Refund request sent to the exchange.
*/
@ -560,7 +549,6 @@ export interface RefundRequest {
merchant_sig: string;
}
/**
* Response for a refund pickup or a /pay in abort mode.
*/
@ -591,7 +579,6 @@ export class MerchantRefundResponse {
static checked: (obj: any) => MerchantRefundResponse;
}
/**
* Planchet detail sent to the merchant.
*/
@ -607,7 +594,6 @@ export interface TipPlanchetDetail {
coin_ev: string;
}
/**
* Request sent to the merchant to pick up a tip.
*/
@ -641,7 +627,6 @@ export class ReserveSigSingleton {
static checked: (obj: any) => ReserveSigSingleton;
}
/**
* Response to /reserve/status
*/
@ -689,56 +674,6 @@ export class TipResponse {
static checked: (obj: any) => TipResponse;
}
/**
* Token containing all the information for the wallet
* to process a tip. Given by the merchant to the wallet.
*/
@Checkable.Class()
export class TipToken {
/**
* Expiration for the tip.
*/
@Checkable.String(timestampCheck)
expiration: string;
/**
* URL of the exchange that the tip can be withdrawn from.
*/
@Checkable.String()
exchange_url: string;
/**
* Merchant's URL to pick up the tip.
*/
@Checkable.String()
pickup_url: string;
/**
* Merchant-chosen tip identifier.
*/
@Checkable.String()
tip_id: string;
/**
* Amount of tip.
*/
@Checkable.String()
amount: string;
/**
* URL to navigate after finishing tip processing.
*/
@Checkable.String()
next_url: string;
/**
* Create a TipToken from untyped JSON.
* Validates the schema and throws on error.
*/
static checked: (obj: any) => TipToken;
}
/**
* Element of the payback list that the
* exchange gives us in /keys.
@ -752,7 +687,6 @@ export class Payback {
h_denom_pub: string;
}
/**
* Structure that the exchange gives us in /keys.
*/
@ -808,7 +742,6 @@ export class KeysJson {
static checked: (obj: any) => KeysJson;
}
/**
* Wire fees as anounced by the exchange.
*/
@ -851,7 +784,6 @@ export class WireFeesJson {
static checked: (obj: any) => WireFeesJson;
}
@Checkable.Class({ extra: true })
export class AccountInfo {
@Checkable.String()
@ -861,10 +793,12 @@ export class AccountInfo {
master_sig: string;
}
@Checkable.Class({ extra: true })
export class ExchangeWireJson {
@Checkable.Map(Checkable.String(), Checkable.List(Checkable.Value(() => WireFeesJson)))
@Checkable.Map(
Checkable.String(),
Checkable.List(Checkable.Value(() => WireFeesJson)),
)
fees: { [methodName: string]: WireFeesJson[] };
@Checkable.List(Checkable.Value(() => AccountInfo))
@ -873,14 +807,12 @@ export class ExchangeWireJson {
static checked: (obj: any) => ExchangeWireJson;
}
/**
* Wire detail, arbitrary object that must at least
* contain a "type" key.
*/
export type WireDetail = object & { type: string };
/**
* Proposal returned from the contract URL.
*/
@ -968,3 +900,33 @@ export class WithdrawOperationStatusResponse {
*/
static checked: (obj: any) => WithdrawOperationStatusResponse;
}
/**
* Response from the merchant.
*/
@Checkable.Class({ extra: true })
export class TipPickupGetResponse {
@Checkable.AnyObject()
extra: any;
@Checkable.String()
amount: string;
@Checkable.String()
amount_left: string;
@Checkable.String()
exchange_url: string;
@Checkable.String()
stamp_expire: string;
@Checkable.String()
stamp_created: string;
/**
* Verify that a value matches the schema of this class and convert it into a
* member.
*/
static checked: (obj: any) => TipPickupGetResponse;
}

View File

@ -26,6 +26,13 @@ export interface WithdrawUriResult {
statusUrl: string;
}
export interface TipUriResult {
tipPickupUrl: string;
tipId: string;
merchantInstance: string;
merchantOrigin: string;
}
export function parseWithdrawUri(s: string): WithdrawUriResult | undefined {
const parsedUri = new URI(s);
if (parsedUri.scheme() !== "taler") {
@ -104,3 +111,47 @@ export function parsePayUri(s: string): PayUriResult | undefined {
sessionId: maybeSessionid,
};
}
export function parseTipUri(s: string): TipUriResult | undefined {
const parsedUri = new URI(s);
if (parsedUri.scheme() != "taler") {
return undefined;
}
if (parsedUri.authority() != "tip") {
return undefined;
}
let [_, host, maybePath, maybeInstance, tipId] = parsedUri.path().split("/");
if (!host) {
return undefined;
}
if (!maybePath) {
return undefined;
}
if (!tipId) {
return undefined;
}
if (maybePath === "-") {
maybePath = "public/tip-pickup";
} else {
maybePath = decodeURIComponent(maybePath);
}
if (maybeInstance === "-") {
maybeInstance = "default";
}
const tipPickupUrl = new URI(
"https://" + host + "/" + decodeURIComponent(maybePath),
).href();
return {
tipPickupUrl,
tipId: tipId,
merchantInstance: maybeInstance,
merchantOrigin: new URI(tipPickupUrl).origin(),
};
}

View File

@ -80,8 +80,8 @@ import {
ReserveStatus,
TipPlanchetDetail,
TipResponse,
TipToken,
WithdrawOperationStatusResponse,
TipPickupGetResponse,
} from "./talerTypes";
import {
Badge,
@ -109,7 +109,7 @@ import {
AcceptWithdrawalResponse,
} from "./walletTypes";
import { openPromise } from "./promiseUtils";
import { parsePayUri, parseWithdrawUri } from "./taleruri";
import { parsePayUri, parseWithdrawUri, parseTipUri } from "./taleruri";
interface SpeculativePayData {
payCoinInfo: PayCoinInfo;
@ -345,7 +345,7 @@ export class Wallet {
private timerGroup: TimerGroup;
private speculativePayData: SpeculativePayData | undefined;
private cachedNextUrl: { [fulfillmentUrl: string]: NextUrlResult } = {};
private activeTipOperations: { [s: string]: Promise<TipRecord> } = {};
private activeTipOperations: { [s: string]: Promise<void> } = {};
private activeProcessReserveOperations: {
[reservePub: string]: Promise<void>;
} = {};
@ -1351,33 +1351,7 @@ export class Wallet {
.add(Stores.coins, coin)
.finish();
if (coin.status === CoinStatus.TainedByTip) {
const tip = await this.q().getIndexed(
Stores.tips.coinPubIndex,
coin.coinPub,
);
if (!tip) {
throw Error(
`inconsistent DB: tip for coin pub ${coin.coinPub} not found.`,
);
}
if (tip.accepted) {
console.log("untainting already accepted tip");
// Transactionally set coin to fresh.
const mutateCoin = (c: CoinRecord) => {
if (c.status === CoinStatus.TainedByTip) {
c.status = CoinStatus.Fresh;
}
return c;
};
await this.q().mutate(Stores.coins, coin.coinPub, mutateCoin);
// Show notifications only for accepted tips
this.badge.showNotification();
}
} else {
this.badge.showNotification();
}
this.notifier.notify();
op.resolve();
@ -1566,7 +1540,7 @@ export class Wallet {
denomSig,
exchangeBaseUrl: pc.exchangeBaseUrl,
reservePub: pc.reservePub,
status: pc.isFromTip ? CoinStatus.TainedByTip : CoinStatus.Fresh,
status: CoinStatus.Fresh,
};
return coin;
}
@ -1856,14 +1830,14 @@ export class Wallet {
return { isTrusted, isAudited };
}
async getWithdrawDetails(
talerPayUri: string,
async getWithdrawDetailsForUri(
talerWithdrawUri: string,
maybeSelectedExchange?: string,
): Promise<WithdrawDetails> {
const info = await this.downloadWithdrawInfo(talerPayUri);
const info = await this.downloadWithdrawInfo(talerWithdrawUri);
let rci: ReserveCreationInfo | undefined = undefined;
if (maybeSelectedExchange) {
rci = await this.getReserveCreationInfo(
rci = await this.getWithdrawDetailsForAmount(
maybeSelectedExchange,
info.amount,
);
@ -1874,7 +1848,7 @@ export class Wallet {
};
}
async getReserveCreationInfo(
async getWithdrawDetailsForAmount(
baseUrl: string,
amount: AmountJson,
): Promise<ReserveCreationInfo> {
@ -3331,14 +3305,13 @@ export class Wallet {
return feeAcc;
}
async processTip(tipToken: TipToken): Promise<TipRecord> {
const merchantDomain = new URI(tipToken.pickup_url).origin();
const key = tipToken.tip_id + merchantDomain;
async acceptTip(talerTipUri: string): Promise<void> {
const { tipId, merchantOrigin } = await this.getTipStatus(talerTipUri);
const key = `${tipId}${merchantOrigin}`;
if (this.activeTipOperations[key]) {
return this.activeTipOperations[key];
}
const p = this.processTipImpl(tipToken);
const p = this.acceptTipImpl(tipId, merchantOrigin);
this.activeTipOperations[key] = p;
try {
return await p;
@ -3347,56 +3320,61 @@ export class Wallet {
}
}
private async processTipImpl(tipToken: TipToken): Promise<TipRecord> {
console.log("got tip token", tipToken);
const merchantDomain = new URI(tipToken.pickup_url).origin();
const deadlineSec = getTalerStampSec(tipToken.expiration);
if (!deadlineSec) {
throw Error("tipping failed (invalid expiration)");
private async acceptTipImpl(
tipId: string,
merchantOrigin: string,
): Promise<void> {
let tipRecord = await this.q().get(Stores.tips, [tipId, merchantOrigin]);
if (!tipRecord) {
throw Error("tip not in database");
}
let tipRecord = await this.q().get(Stores.tips, [
tipToken.tip_id,
merchantDomain,
]);
tipRecord.accepted = true;
if (tipRecord && tipRecord.pickedUp) {
return tipRecord;
// Create one transactional query, within this transaction
// both the tip will be marked as accepted and coins
// already withdrawn will be untainted.
await this.q()
.put(Stores.tips, tipRecord)
.finish();
if (tipRecord.pickedUp) {
console.log("tip already picked up");
return;
}
const tipAmount = Amounts.parseOrThrow(tipToken.amount);
await this.updateExchangeFromUrl(tipToken.exchange_url);
await this.updateExchangeFromUrl(tipRecord.exchangeUrl);
const denomsForWithdraw = await this.getVerifiedWithdrawDenomList(
tipToken.exchange_url,
tipAmount,
tipRecord.exchangeUrl,
tipRecord.amount,
);
if (!tipRecord.planchets) {
const planchets = await Promise.all(
denomsForWithdraw.map(d => this.cryptoApi.createTipPlanchet(d)),
);
const coinPubs: string[] = planchets.map(x => x.coinPub);
const now = new Date().getTime();
tipRecord = {
accepted: false,
amount: Amounts.parseOrThrow(tipToken.amount),
coinPubs,
deadline: deadlineSec,
exchangeUrl: tipToken.exchange_url,
merchantDomain,
nextUrl: tipToken.next_url,
pickedUp: false,
planchets,
timestamp: now,
tipId: tipToken.tip_id,
};
let merchantResp;
await this.q().mutate(Stores.tips, [tipId, merchantOrigin], r => {
if (!r.planchets) {
r.planchets = planchets;
r.coinPubs = coinPubs;
}
return r;
});
tipRecord = await this.q().putOrGetExisting(Stores.tips, tipRecord, [
tipRecord.tipId,
merchantDomain,
]);
this.notifier.notify();
}
tipRecord = await this.q().get(Stores.tips, [tipId, merchantOrigin]);
if (!tipRecord) {
throw Error("tip not in database");
}
if (!tipRecord.planchets) {
throw Error("invariant violated");
}
console.log("got planchets for tip!");
// Planchets in the form that the merchant expects
const planchetsDetail: TipPlanchetDetail[] = tipRecord.planchets.map(p => ({
@ -3404,9 +3382,12 @@ export class Wallet {
denom_pub_hash: p.denomPubHash,
}));
let merchantResp;
try {
const req = { planchets: planchetsDetail, tip_id: tipToken.tip_id };
merchantResp = await this.http.postJson(tipToken.pickup_url, req);
const req = { planchets: planchetsDetail, tip_id: tipId };
merchantResp = await this.http.postJson(tipRecord.pickupUrl, req);
console.log("got merchant resp:", merchantResp);
} catch (e) {
console.log("tipping failed", e);
throw e;
@ -3434,7 +3415,7 @@ export class Wallet {
withdrawSig: response.reserve_sigs[i].reserve_sig,
};
await this.q().put(Stores.precoins, preCoin);
this.processPreCoin(preCoin.coinPub);
await this.processPreCoin(preCoin.coinPub);
}
tipRecord.pickedUp = true;
@ -3443,61 +3424,75 @@ export class Wallet {
.put(Stores.tips, tipRecord)
.finish();
this.notifier.notify();
return tipRecord;
}
/**
* Start using the coins from a tip.
*/
async acceptTip(tipToken: TipToken): Promise<void> {
const tipId = tipToken.tip_id;
const merchantDomain = new URI(tipToken.pickup_url).origin();
const tipRecord = await this.q().get(Stores.tips, [tipId, merchantDomain]);
if (!tipRecord) {
throw Error("tip not found");
}
tipRecord.accepted = true;
// Create one transactional query, within this transaction
// both the tip will be marked as accepted and coins
// already withdrawn will be untainted.
const q = this.q();
q.put(Stores.tips, tipRecord);
const updateCoin = (c: CoinRecord) => {
if (c.status === CoinStatus.TainedByTip) {
c.status = CoinStatus.Fresh;
}
return c;
};
for (const coinPub of tipRecord.coinPubs) {
q.mutate(Stores.coins, coinPub, updateCoin);
}
await q.finish();
this.badge.showNotification();
this.notifier.notify();
return;
}
async getTipStatus(talerTipUri: string): Promise<TipStatus> {
const res = parseTipUri(talerTipUri);
if (!res) {
throw Error("invalid taler://tip URI");
}
const tipStatusUrl = new URI(res.tipPickupUrl)
.addQuery({
instance: res.merchantInstance,
tip_id: res.tipId,
})
.href();
console.log("checking tip status from", tipStatusUrl);
const merchantResp = await this.http.get(tipStatusUrl);
console.log("resp:", merchantResp.responseJson);
const tipPickupStatus = TipPickupGetResponse.checked(
merchantResp.responseJson,
);
console.log("status", tipPickupStatus);
let amount = Amounts.parseOrThrow(tipPickupStatus.amount);
let tipRecord = await this.q().get(Stores.tips, [
res.tipId,
res.merchantOrigin,
]);
if (!tipRecord) {
const withdrawDetails = await this.getWithdrawDetailsForAmount(
tipPickupStatus.exchange_url,
amount,
);
tipRecord = {
accepted: false,
amount,
coinPubs: [],
deadline: getTalerStampSec(tipPickupStatus.stamp_expire)!,
exchangeUrl: tipPickupStatus.exchange_url,
merchantDomain: res.merchantOrigin,
nextUrl: undefined,
pickedUp: false,
planchets: undefined,
response: undefined,
timestamp: new Date().getTime(),
tipId: res.tipId,
pickupUrl: res.tipPickupUrl,
totalFees: Amounts.add(withdrawDetails.overhead, withdrawDetails.withdrawFee).amount,
};
await this.q().put(Stores.tips, tipRecord);
}
async getTipStatus(tipToken: TipToken): Promise<TipStatus> {
const tipId = tipToken.tip_id;
const merchantDomain = new URI(tipToken.pickup_url).origin();
const tipRecord = await this.q().get(Stores.tips, [tipId, merchantDomain]);
const amount = Amounts.parseOrThrow(tipToken.amount);
const exchangeUrl = tipToken.exchange_url;
this.processTip(tipToken);
const nextUrl = tipToken.next_url;
const tipStatus: TipStatus = {
accepted: !!tipRecord && tipRecord.accepted,
amount,
exchangeUrl,
merchantDomain,
nextUrl,
tipRecord,
amount: Amounts.parseOrThrow(tipPickupStatus.amount),
amountLeft: Amounts.parseOrThrow(tipPickupStatus.amount_left),
exchangeUrl: tipPickupStatus.exchange_url,
nextUrl: tipPickupStatus.extra.next_url,
merchantOrigin: res.merchantOrigin,
tipId: res.tipId,
expirationTimestamp: getTalerStampSec(tipPickupStatus.stamp_expire)!,
timestamp: getTalerStampSec(tipPickupStatus.stamp_created)!,
totalFees: tipRecord.totalFees,
};
return tipStatus;
}
@ -3526,11 +3521,6 @@ export class Wallet {
const abortReq = { ...purchase.payReq, mode: "abort-refund" };
try {
const config = {
headers: { "Content-Type": "application/json;charset=UTF-8" },
timeout: 5000 /* 5 seconds */,
validateStatus: (s: number) => s === 200,
};
resp = await this.http.postJson(purchase.contractTerms.pay_url, abortReq);
} catch (e) {
// Gives the user the option to retry / abort and refresh

View File

@ -427,10 +427,14 @@ export interface CoinWithDenom {
export interface TipStatus {
accepted: boolean;
amount: AmountJson;
amountLeft: AmountJson;
nextUrl: string;
merchantDomain: string;
exchangeUrl: string;
tipRecord?: TipRecord;
tipId: string;
merchantOrigin: string;
expirationTimestamp: number;
timestamp: number;
totalFees: AmountJson;
}
/**

View File

@ -174,11 +174,11 @@ export interface MessageMap {
response: AmountJson;
};
"accept-tip": {
request: { tipToken: talerTypes.TipToken };
response: walletTypes.TipStatus;
request: { talerTipUri: string };
response: void;
};
"get-tip-status": {
request: { tipToken: talerTypes.TipToken };
request: { talerTipUri: string };
response: walletTypes.TipStatus;
};
"clear-notification": {

View File

@ -14,7 +14,6 @@
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
/**
* Page shown to the user to confirm creation
* of a reserve, usually requested by the bank.
@ -28,152 +27,114 @@ import URI = require("urijs");
import * as i18n from "../../i18n";
import {
acceptTip,
getReserveCreationInfo,
getTipStatus,
} from "../wxApi";
import { acceptTip, getReserveCreationInfo, getTipStatus } from "../wxApi";
import {
WithdrawDetailView,
renderAmount,
} from "../renderHtml";
import { WithdrawDetailView, renderAmount } from "../renderHtml";
import * as Amounts from "../../amounts";
import { TipToken } from "../../talerTypes";
import { ReserveCreationInfo, TipStatus } from "../../walletTypes";
import { useState, useEffect } from "react";
import { TipStatus } from "../../walletTypes";
interface TipDisplayProps {
tipToken: TipToken;
interface LoadingButtonProps {
loading: boolean;
}
interface TipDisplayState {
tipStatus?: TipStatus;
rci?: ReserveCreationInfo;
working: boolean;
discarded: boolean;
}
class TipDisplay extends React.Component<TipDisplayProps, TipDisplayState> {
constructor(props: TipDisplayProps) {
super(props);
this.state = { working: false, discarded: false };
}
async update() {
const tipStatus = await getTipStatus(this.props.tipToken);
this.setState({ tipStatus });
const rci = await getReserveCreationInfo(tipStatus.exchangeUrl, tipStatus.amount);
this.setState({ rci });
}
componentDidMount() {
this.update();
const port = chrome.runtime.connect();
port.onMessage.addListener((msg: any) => {
if (msg.notify) {
console.log("got notified");
this.update();
}
});
this.update();
}
renderExchangeInfo() {
const rci = this.state.rci;
if (!rci) {
return <p>Waiting for info about exchange ...</p>;
}
const totalCost = Amounts.add(rci.overhead, rci.withdrawFee).amount;
function LoadingButton(
props:
& React.PropsWithChildren<LoadingButtonProps>
& React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>,
) {
return (
<div>
<p>
The tip is handled by the exchange <strong>{rci.exchangeInfo.baseUrl}</strong>.{" "}
The exchange provider will charge
{" "}
<strong>{renderAmount(totalCost)}</strong>
{" "}.
</p>
<WithdrawDetailView rci={rci} />
</div>
);
}
accept() {
this.setState({ working: true});
acceptTip(this.props.tipToken);
}
discard() {
this.setState({ discarded: true });
}
render(): JSX.Element {
const ts = this.state.tipStatus;
if (!ts) {
return <p>Processing ...</p>;
}
const renderAccepted = () => (
<>
<p>You've accepted this tip! <a href={ts.nextUrl}>Go back to merchant</a></p>
{this.renderExchangeInfo()}
</>
);
const renderButtons = () => (
<>
<form className="pure-form">
<button
className="pure-button pure-button-primary"
type="button"
disabled={!(this.state.rci && this.state.tipStatus && this.state.tipStatus.tipRecord)}
onClick={() => this.accept()}>
{ this.state.working
? <span><object className="svg-icon svg-baseline" data="/img/spinner-bars.svg" /> </span>
: null }
Accept tip
{...props}
>
{props.loading ? <span><object className="svg-icon svg-baseline" data="/img/spinner-bars.svg" /></span> : null}
{props.children}
</button>
{" "}
<button className="pure-button" type="button" onClick={() => this.discard()}>
Discard tip
</button>
</form>
{ this.renderExchangeInfo() }
</>
);
}
const renderDiscarded = () => (
<p>You've discarded this tip. <a href={ts.nextUrl}>Go back to merchant.</a></p>
);
function TipDisplay(props: { talerTipUri: string }) {
const [tipStatus, setTipStatus] = useState<TipStatus | undefined>(undefined);
const [discarded, setDiscarded] = useState(false);
const [loading, setLoading] = useState(false);
const [finished, setFinished] = useState(false);
useEffect(() => {
const doFetch = async () => {
const ts = await getTipStatus(props.talerTipUri);
setTipStatus(ts);
};
doFetch();
}, []);
if (discarded) {
return <span>You've discarded the tip.</span>;
}
if (finished) {
return <span>Tip has been accepted!</span>;
}
if (!tipStatus) {
return <span>Loading ...</span>;
}
const discard = () => {
setDiscarded(true);
};
const accept = async () => {
setLoading(true);
await acceptTip(props.talerTipUri);
setFinished(true);
};
return (
<div>
<h2>Tip Received!</h2>
<p>You received a tip of <strong>{renderAmount(ts.amount)}</strong> from <span> </span>
<strong>{ts.merchantDomain}</strong>.</p>
{
this.state.discarded
? renderDiscarded()
: ts.accepted
? renderAccepted()
: renderButtons()
}
<p>
You received a tip of <strong>{renderAmount(tipStatus.amount)}</strong>{" "}
from <span> </span>
<strong>{tipStatus.merchantOrigin}</strong>.
</p>
<p>
The tip is handled by the exchange{" "}
<strong>{tipStatus.exchangeUrl}</strong>. This exchange will charge fees
of <strong>{renderAmount(tipStatus.totalFees)}</strong> for this
operation.
</p>
<form className="pure-form">
<LoadingButton loading={loading} onClick={() => accept()}>
AcceptTip
</LoadingButton>
{" "}
<button className="pure-button" type="button" onClick={() => discard()}>
Discard tip
</button>
</form>
</div>
);
}
}
async function main() {
try {
const url = new URI(document.location.href);
const query: any = URI.parseQuery(url.query());
const talerTipUri = query.talerTipUri;
if (typeof talerTipUri !== "string") {
throw Error("talerTipUri must be a string");
}
const tipToken = TipToken.checked(JSON.parse(query.tip_token));
ReactDOM.render(<TipDisplay tipToken={tipToken} />,
document.getElementById("container")!);
ReactDOM.render(
<TipDisplay talerTipUri={talerTipUri} />,
document.getElementById("container")!,
);
} catch (e) {
// TODO: provide more context information, maybe factor it out into a
// TODO:generic error reporting function or component.

View File

@ -45,7 +45,6 @@ import {
import {
MerchantRefundPermission,
TipToken,
} from "../talerTypes";
import { MessageMap, MessageType } from "./messages";
@ -349,15 +348,15 @@ export function getFullRefundFees(args: { refundPermissions: MerchantRefundPermi
/**
* Get the status of processing a tip.
*/
export function getTipStatus(tipToken: TipToken): Promise<TipStatus> {
return callBackend("get-tip-status", { tipToken });
export function getTipStatus(talerTipUri: string): Promise<TipStatus> {
return callBackend("get-tip-status", { talerTipUri });
}
/**
* Mark a tip as accepted by the user.
*/
export function acceptTip(tipToken: TipToken): Promise<TipStatus> {
return callBackend("accept-tip", { tipToken });
export function acceptTip(talerTipUri: string): Promise<void> {
return callBackend("accept-tip", { talerTipUri });
}

View File

@ -50,7 +50,6 @@ import * as wxApi from "./wxApi";
import URI = require("urijs");
import Port = chrome.runtime.Port;
import MessageSender = chrome.runtime.MessageSender;
import { TipToken } from "../talerTypes";
import { BrowserCryptoWorkerFactory } from "../crypto/cryptoApi";
const NeedsWallet = Symbol("NeedsWallet");
@ -182,7 +181,7 @@ function handleMessage(
return Promise.resolve({ error: "bad url" });
}
const amount = AmountJson.checked(detail.amount);
return needsWallet().getReserveCreationInfo(detail.baseUrl, amount);
return needsWallet().getWithdrawDetailsForAmount(detail.baseUrl, amount);
}
case "get-history": {
// TODO: limit history length
@ -295,12 +294,10 @@ function handleMessage(
case "accept-refund":
return needsWallet().acceptRefund(detail.refundUrl);
case "get-tip-status": {
const tipToken = TipToken.checked(detail.tipToken);
return needsWallet().getTipStatus(tipToken);
return needsWallet().getTipStatus(detail.talerTipUri);
}
case "accept-tip": {
const tipToken = TipToken.checked(detail.tipToken);
return needsWallet().acceptTip(tipToken);
return needsWallet().acceptTip(detail.talerTipUri);
}
case "clear-notification": {
return needsWallet().clearNotification();
@ -340,7 +337,7 @@ function handleMessage(
return needsWallet().benchmarkCrypto(detail.repetitions);
}
case "get-withdraw-details": {
return needsWallet().getWithdrawDetails(
return needsWallet().getWithdrawDetailsForUri(
detail.talerWithdrawUri,
detail.maybeSelectedExchange,
);