diff options
author | Florian Dold <florian.dold@gmail.com> | 2019-08-30 17:27:59 +0200 |
---|---|---|
committer | Florian Dold <florian.dold@gmail.com> | 2019-08-30 17:27:59 +0200 |
commit | 5ec344290efd937fa82c0704bc7c204a0bf14c78 (patch) | |
tree | 7d9594180bbc7b5fa2b4a8dbe24272e7a82301f3 | |
parent | defbf625bdef0f8a666b72b8ce99de5e01af6b91 (diff) |
support for tipping protocol changes
-rw-r--r-- | src/dbTypes.ts | 175 | ||||
-rw-r--r-- | src/headless/taler-wallet-cli.ts | 18 | ||||
-rw-r--r-- | src/talerTypes.ts | 122 | ||||
-rw-r--r-- | src/taleruri.ts | 51 | ||||
-rw-r--r-- | src/wallet.ts | 258 | ||||
-rw-r--r-- | src/walletTypes.ts | 8 | ||||
-rw-r--r-- | src/webex/messages.ts | 6 | ||||
-rw-r--r-- | src/webex/pages/tip.tsx | 203 | ||||
-rw-r--r-- | src/webex/wxApi.ts | 11 | ||||
-rw-r--r-- | src/webex/wxBackend.ts | 11 |
10 files changed, 441 insertions, 422 deletions
diff --git a/src/dbTypes.ts b/src/dbTypes.ts index d9fd2e9d9..17e7a89b7 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -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,12 +75,11 @@ export interface ReserveRecord { */ timestamp_depleted: number; - /** * Time when the information about this reserve was posted to the bank. - * + * * Only applies if bankWithdrawStatusUrl is defined. - * + * * Set to 0 if that hasn't happened yet. */ timestamp_reserve_info_posted: number; @@ -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. @@ -506,7 +485,7 @@ export interface CoinRecord { * Reserve public key for the reserve we got this coin from, * or zero when we got the coin from refresh. */ - reservePub: string|undefined; + reservePub: string | undefined; /** * Status of the coin. @@ -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", - "contractTerms.fulfillment_url"); - orderIdIndex = new Index<string, PurchaseRecord>(this, "orderIdIndex", "contractTerms.order_id"); - timestampIndex = new Index<string, PurchaseRecord>(this, "timestampIndex", "timestamp"); + 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", + ); } 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(); diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 659cffe67..86eaec64c 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -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); diff --git a/src/talerTypes.ts b/src/talerTypes.ts index 360be3338..73b97c93d 100644 --- a/src/talerTypes.ts +++ b/src/talerTypes.ts @@ -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,11 +293,10 @@ export class ExchangeHandle { static checked: (obj: any) => ExchangeHandle; } - /** * Contract terms from a merchant. */ -@Checkable.Class({validate: true}) +@Checkable.Class({ validate: true }) export class ContractTerms { static validate(x: ContractTerms) { if (x.exchanges.length === 0) { @@ -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 */ @@ -690,56 +675,6 @@ export class 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,11 +687,10 @@ export class Payback { h_denom_pub: string; } - /** * Structure that the exchange gives us in /keys. */ -@Checkable.Class({extra: true}) +@Checkable.Class({ extra: true }) export class KeysJson { /** * List of offered denominations. @@ -808,7 +742,6 @@ export class KeysJson { static checked: (obj: any) => KeysJson; } - /** * Wire fees as anounced by the exchange. */ @@ -851,8 +784,7 @@ export class WireFeesJson { static checked: (obj: any) => WireFeesJson; } - -@Checkable.Class({extra: true}) +@Checkable.Class({ extra: true }) export class AccountInfo { @Checkable.String() url: string; @@ -861,10 +793,12 @@ export class AccountInfo { master_sig: string; } - -@Checkable.Class({extra: true}) +@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,18 +807,16 @@ 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. */ -@Checkable.Class({extra: true}) +@Checkable.Class({ extra: true }) export class Proposal { /** * Contract terms for the propoal. @@ -909,7 +841,7 @@ export class Proposal { /** * Response from the internal merchant API. */ -@Checkable.Class({extra: true}) +@Checkable.Class({ extra: true }) export class CheckPaymentResponse { @Checkable.Boolean() paid: boolean; @@ -939,7 +871,7 @@ export class CheckPaymentResponse { /** * Response from the bank. */ -@Checkable.Class({extra: true}) +@Checkable.Class({ extra: true }) export class WithdrawOperationStatusResponse { @Checkable.Boolean() selection_done: boolean; @@ -967,4 +899,34 @@ export class WithdrawOperationStatusResponse { * member. */ static checked: (obj: any) => WithdrawOperationStatusResponse; -}
\ No newline at end of file +} + +/** + * 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; +} diff --git a/src/taleruri.ts b/src/taleruri.ts index fa305d1de..f5fc77421 100644 --- a/src/taleruri.ts +++ b/src/taleruri.ts @@ -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(), + }; +} diff --git a/src/wallet.ts b/src/wallet.ts index e476c94f6..fd1be5293 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -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.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, ); - 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; + if (!tipRecord.planchets) { + const planchets = await Promise.all( + denomsForWithdraw.map(d => this.cryptoApi.createTipPlanchet(d)), + ); + const coinPubs: string[] = planchets.map(x => x.coinPub); - tipRecord = await this.q().putOrGetExisting(Stores.tips, tipRecord, [ - tipRecord.tipId, - merchantDomain, - ]); - this.notifier.notify(); + await this.q().mutate(Stores.tips, [tipId, merchantOrigin], r => { + if (!r.planchets) { + r.planchets = planchets; + r.coinPubs = coinPubs; + } + return r; + }); + + 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; + this.badge.showNotification(); + return; } - /** - * 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"); + async getTipStatus(talerTipUri: string): Promise<TipStatus> { + const res = parseTipUri(talerTipUri); + if (!res) { + throw Error("invalid taler://tip URI"); } - 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(); + 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, + ); - q.put(Stores.tips, tipRecord); + console.log("status", tipPickupStatus); - const updateCoin = (c: CoinRecord) => { - if (c.status === CoinStatus.TainedByTip) { - c.status = CoinStatus.Fresh; - } - return c; - }; + let amount = Amounts.parseOrThrow(tipPickupStatus.amount); - for (const coinPub of tipRecord.coinPubs) { - q.mutate(Stores.coins, coinPub, updateCoin); - } + let tipRecord = await this.q().get(Stores.tips, [ + res.tipId, + res.merchantOrigin, + ]); + if (!tipRecord) { + const withdrawDetails = await this.getWithdrawDetailsForAmount( + tipPickupStatus.exchange_url, + amount, + ); - await q.finish(); - this.badge.showNotification(); - this.notifier.notify(); - } + 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 diff --git a/src/walletTypes.ts b/src/walletTypes.ts index c657ac02a..aec029371 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -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; } /** diff --git a/src/webex/messages.ts b/src/webex/messages.ts index ca0e1c7e1..f1046d5c7 100644 --- a/src/webex/messages.ts +++ b/src/webex/messages.ts @@ -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": { diff --git a/src/webex/pages/tip.tsx b/src/webex/pages/tip.tsx index c13120c43..a3f5c38c3 100644 --- a/src/webex/pages/tip.tsx +++ b/src/webex/pages/tip.tsx @@ -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; +function LoadingButton( + props: + & React.PropsWithChildren<LoadingButtonProps> + & React.DetailedHTMLProps< + React.ButtonHTMLAttributes<HTMLButtonElement>, + HTMLButtonElement + >, +) { + return ( + <button + className="pure-button pure-button-primary" + type="button" + {...props} + > + {props.loading ? <span><object className="svg-icon svg-baseline" data="/img/spinner-bars.svg" /></span> : null} + {props.children} + </button> + ); } -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; - 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> - ); +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>; } - accept() { - this.setState({ working: true}); - acceptTip(this.props.tipToken); + if (finished) { + return <span>Tip has been accepted!</span>; } - discard() { - this.setState({ discarded: true }); + if (!tipStatus) { + return <span>Loading ...</span>; } - 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 = () => ( - <> + 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(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"> - <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 - </button> + <LoadingButton loading={loading} onClick={() => accept()}> + AcceptTip + </LoadingButton> {" "} - <button className="pure-button" type="button" onClick={() => this.discard()}> + <button className="pure-button" type="button" onClick={() => 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> - ); - - 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() - } - </div> - ); - } + </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. diff --git a/src/webex/wxApi.ts b/src/webex/wxApi.ts index feabc7819..fd01aed3f 100644 --- a/src/webex/wxApi.ts +++ b/src/webex/wxApi.ts @@ -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 }); } @@ -423,4 +422,4 @@ export function preparePay(talerPayUri: string) { */ export function acceptWithdrawal(talerWithdrawUri: string, selectedExchange: string) { return callBackend("accept-withdrawal", { talerWithdrawUri, selectedExchange }); -}
\ No newline at end of file +} diff --git a/src/webex/wxBackend.ts b/src/webex/wxBackend.ts index d31ea388d..5bff4fe0a 100644 --- a/src/webex/wxBackend.ts +++ b/src/webex/wxBackend.ts @@ -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, ); |