From 1390175a9afc53948dd1d6f8a2f88e51c1bf53cc Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 28 Aug 2019 02:49:27 +0200 Subject: rudimentary taler://withdraw support --- src/crypto/cryptoApi-test.ts | 2 + src/crypto/synchronousWorker.ts | 10 +- src/dbTypes.ts | 18 ++++ src/headless/bank.ts | 4 +- src/headless/helpers.ts | 74 ++++++-------- src/headless/taler-wallet-cli.ts | 57 +++++++++-- src/talerTypes.ts | 36 +++++++ src/taleruri-test.ts | 12 ++- src/taleruri.ts | 33 ++++++- src/wallet.ts | 209 ++++++++++++++++++++++++++++++++++----- src/walletTypes.ts | 26 ++++- 11 files changed, 399 insertions(+), 82 deletions(-) (limited to 'src') diff --git a/src/crypto/cryptoApi-test.ts b/src/crypto/cryptoApi-test.ts index 48231e5ff..39f46c5c3 100644 --- a/src/crypto/cryptoApi-test.ts +++ b/src/crypto/cryptoApi-test.ts @@ -96,6 +96,8 @@ test("precoin creation", async t => { reserve_pub: pub, timestamp_confirmed: 0, timestamp_depleted: 0, + timestamp_reserve_info_posted: 0, + exchangeWire: "payto://foo" }; const precoin = await crypto.createPreCoin(denomValid1, r); diff --git a/src/crypto/synchronousWorker.ts b/src/crypto/synchronousWorker.ts index b697c8e16..41ebee4f3 100644 --- a/src/crypto/synchronousWorker.ts +++ b/src/crypto/synchronousWorker.ts @@ -93,13 +93,19 @@ export class SynchronousCryptoWorker { return; } + let result: any; try { - const result = (impl as any)[operation](...args); - this.dispatchMessage({ result, id }); + result = (impl as any)[operation](...args); } catch (e) { console.log("error during operation", e); return; } + + try { + setImmediate(() => this.dispatchMessage({ result, id })); + } catch (e) { + console.log("got error during dispatch", e); + } } /** diff --git a/src/dbTypes.ts b/src/dbTypes.ts index 55b2ddbe3..d9fd2e9d9 100644 --- a/src/dbTypes.ts +++ b/src/dbTypes.ts @@ -81,6 +81,16 @@ 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; + /** * Time when the reserve was confirmed. * @@ -117,6 +127,14 @@ export interface ReserveRecord { * transfered funds for this reserve. */ senderWire?: string; + + /** + * Wire information (as payto URI) for the exchange, specifically + * the account that was transferred to when creating the reserve. + */ + exchangeWire: string; + + bankWithdrawStatusUrl?: string; } diff --git a/src/headless/bank.ts b/src/headless/bank.ts index 7d8db9fe5..f35021003 100644 --- a/src/headless/bank.ts +++ b/src/headless/bank.ts @@ -51,7 +51,7 @@ export class Bank { reservePub: string, exchangePaytoUri: string, ) { - const reqUrl = new URI("taler/withdraw") + const reqUrl = new URI("api/withdraw-headless") .absoluteTo(this.bankBaseUrl) .href(); @@ -80,7 +80,7 @@ export class Bank { } async registerRandomUser(): Promise { - const reqUrl = new URI("register").absoluteTo(this.bankBaseUrl).href(); + const reqUrl = new URI("api/register").absoluteTo(this.bankBaseUrl).href(); const randId = makeId(8); const bankUser: BankUser = { username: `testuser-${randId}`, diff --git a/src/headless/helpers.ts b/src/headless/helpers.ts index 9652c630f..a86b26738 100644 --- a/src/headless/helpers.ts +++ b/src/headless/helpers.ts @@ -54,17 +54,21 @@ class ConsoleBadge implements Badge { export class NodeHttpLib implements HttpRequestLibrary { async get(url: string): Promise { enableTracing && console.log("making GET request to", url); - const resp = await Axios({ - method: "get", - url: url, - responseType: "json", - }); - enableTracing && console.log("got response", resp.data); - enableTracing && console.log("resp type", typeof resp.data); - return { - responseJson: resp.data, - status: resp.status, - }; + try { + const resp = await Axios({ + method: "get", + url: url, + responseType: "json", + }); + enableTracing && console.log("got response", resp.data); + enableTracing && console.log("resp type", typeof resp.data); + return { + responseJson: resp.data, + status: resp.status, + }; + } catch (e) { + throw e; + } } async postJson( @@ -72,37 +76,22 @@ export class NodeHttpLib implements HttpRequestLibrary { body: any, ): Promise { enableTracing && console.log("making POST request to", url); - const resp = await Axios({ - method: "post", - url: url, - responseType: "json", - data: body, - }); - enableTracing && console.log("got response", resp.data); - enableTracing && console.log("resp type", typeof resp.data); - return { - responseJson: resp.data, - status: resp.status, - }; - } - - async postForm( - url: string, - form: any, - ): Promise { - enableTracing && console.log("making POST request to", url); - const resp = await Axios({ - method: "post", - url: url, - data: querystring.stringify(form), - responseType: "json", - }); - enableTracing && console.log("got response", resp.data); - enableTracing && console.log("resp type", typeof resp.data); - return { - responseJson: resp.data, - status: resp.status, - }; + try { + const resp = await Axios({ + method: "post", + url: url, + responseType: "json", + data: body, + }); + enableTracing && console.log("got response", resp.data); + enableTracing && console.log("resp type", typeof resp.data); + return { + responseJson: resp.data, + status: resp.status, + }; + } catch (e) { + throw e; + } } } @@ -221,6 +210,7 @@ export async function withdrawTestBalance( const reserveResponse = await myWallet.createReserve({ amount: amounts.parseOrThrow(amount), exchange: exchangeBaseUrl, + exchangeWire: "payto://unknown", }); const bank = new Bank(bankBaseUrl); diff --git a/src/headless/taler-wallet-cli.ts b/src/headless/taler-wallet-cli.ts index 4a1f5d91e..65b2a0297 100644 --- a/src/headless/taler-wallet-cli.ts +++ b/src/headless/taler-wallet-cli.ts @@ -103,15 +103,14 @@ program console.log("created new order with order ID", orderResp.orderId); const checkPayResp = await merchantBackend.checkPayment(orderResp.orderId); const qrcode = qrcodeGenerator(0, "M"); - const contractUrl = checkPayResp.contract_url; - if (typeof contractUrl !== "string") { - console.error("fata: no contract url received from backend"); + const talerPayUri = checkPayResp.taler_pay_uri; + if (!talerPayUri) { + console.error("fatal: no taler pay URI received from backend"); process.exit(1); return; } - const url = "talerpay:" + querystring.escape(contractUrl); - console.log("contract url:", url); - qrcode.addData(url); + console.log("taler pay URI:", talerPayUri); + qrcode.addData(talerPayUri); qrcode.make(); console.log(qrcode.createASCII()); console.log("waiting for payment ..."); @@ -127,6 +126,45 @@ program } }); +program + .command("withdraw-url ") + .action(async (withdrawUrl, cmdObj) => { + applyVerbose(program.verbose); + console.log("withdrawing", withdrawUrl); + const wallet = await getDefaultNodeWallet({ + persistentStoragePath: walletDbPath, + }); + + const withdrawInfo = await wallet.downloadWithdrawInfo(withdrawUrl); + + console.log("withdraw info", withdrawInfo); + + const selectedExchange = withdrawInfo.suggestedExchange; + if (!selectedExchange) { + console.error("no suggested exchange!"); + process.exit(1); + return; + } + + const { + reservePub, + confirmTransferUrl, + } = await wallet.createReserveFromWithdrawUrl( + withdrawUrl, + selectedExchange, + ); + + if (confirmTransferUrl) { + console.log("please confirm the transfer at", confirmTransferUrl); + } + + await wallet.processReserve(reservePub); + + console.log("finished withdrawing"); + + wallet.stop(); + }); + program .command("pay-url ") .option("-y, --yes", "automatically answer yes to prompts") @@ -153,6 +191,11 @@ program process.exit(0); return; } + if (result.status === "session-replayed") { + console.log("already paid! (replayed in different session)"); + process.exit(0); + return; + } if (result.status === "payment-possible") { console.log("paying ..."); } else { @@ -179,7 +222,7 @@ program if (pay) { const payRes = await wallet.confirmPay(result.proposalId!, undefined); - console.log("paid!"); + console.log("paid!"); } else { console.log("not paying"); } diff --git a/src/talerTypes.ts b/src/talerTypes.ts index 9176daf77..360be3338 100644 --- a/src/talerTypes.ts +++ b/src/talerTypes.ts @@ -923,6 +923,9 @@ export class CheckPaymentResponse { @Checkable.Optional(Checkable.Value(() => ContractTerms)) contract_terms: ContractTerms | undefined; + @Checkable.Optional(Checkable.String()) + taler_pay_uri: string | undefined; + @Checkable.Optional(Checkable.String()) contract_url: string | undefined; @@ -931,4 +934,37 @@ export class CheckPaymentResponse { * member. */ static checked: (obj: any) => CheckPaymentResponse; +} + +/** + * Response from the bank. + */ +@Checkable.Class({extra: true}) +export class WithdrawOperationStatusResponse { + @Checkable.Boolean() + selection_done: boolean; + + @Checkable.Boolean() + transfer_done: boolean; + + @Checkable.String() + amount: string; + + @Checkable.Optional(Checkable.String()) + sender_wire?: string; + + @Checkable.Optional(Checkable.String()) + suggested_exchange?: string; + + @Checkable.Optional(Checkable.String()) + confirm_transfer_url?: string; + + @Checkable.List(Checkable.String()) + wire_types: string[]; + + /** + * Verify that a value matches the schema of this class and convert it into a + * member. + */ + static checked: (obj: any) => WithdrawOperationStatusResponse; } \ No newline at end of file diff --git a/src/taleruri-test.ts b/src/taleruri-test.ts index a9fa0b1e3..27cd7d18b 100644 --- a/src/taleruri-test.ts +++ b/src/taleruri-test.ts @@ -15,7 +15,7 @@ */ import test from "ava"; -import { parsePayUri } from "./taleruri"; +import { parsePayUri, parseWithdrawUri } from "./taleruri"; test("taler pay url parsing: http(s)", (t) => { const url1 = "https://example.com/bar?spam=eggs"; @@ -77,3 +77,13 @@ test("taler pay url parsing: trailing parts", (t) => { t.is(r1.downloadUrl, "https://example.com/public/proposal?instance=default&order_id=myorder"); t.is(r1.sessionId, "mysession"); }); + +test("taler withdraw uri parsing", (t) => { + const url1 = "taler://withdraw/bank.example.com/-/12345"; + const r1 = parseWithdrawUri(url1); + if (!r1) { + t.fail(); + return; + } + t.is(r1.statusUrl, "https://bank.example.com/api/withdraw-operation/12345"); +}); \ No newline at end of file diff --git a/src/taleruri.ts b/src/taleruri.ts index fa3b09c31..fa305d1de 100644 --- a/src/taleruri.ts +++ b/src/taleruri.ts @@ -15,12 +15,39 @@ */ import URI = require("urijs"); +import { string } from "prop-types"; export interface PayUriResult { downloadUrl: string; sessionId?: string; } +export interface WithdrawUriResult { + statusUrl: string; +} + +export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { + const parsedUri = new URI(s); + if (parsedUri.scheme() !== "taler") { + return undefined; + } + if (parsedUri.authority() != "withdraw") { + return undefined; + } + + let [host, path, withdrawId] = parsedUri.segmentCoded(); + + if (path === "-") { + path = "/api/withdraw-operation"; + } + + return { + statusUrl: new URI({ protocol: "https", hostname: host, path: path }) + .segmentCoded(withdrawId) + .href(), + }; +} + export function parsePayUri(s: string): PayUriResult | undefined { const parsedUri = new URI(s); if (parsedUri.scheme() === "http" || parsedUri.scheme() === "https") { @@ -68,10 +95,12 @@ export function parsePayUri(s: string): PayUriResult | undefined { const downloadUrl = new URI( "https://" + host + "/" + decodeURIComponent(maybePath), - ).addQuery({ instance: maybeInstance, order_id: orderId }).href(); + ) + .addQuery({ instance: maybeInstance, order_id: orderId }) + .href(); return { downloadUrl, sessionId: maybeSessionid, - } + }; } diff --git a/src/wallet.ts b/src/wallet.ts index faced994a..b6a9361c1 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -81,6 +81,7 @@ import { TipPlanchetDetail, TipResponse, TipToken, + WithdrawOperationStatusResponse, } from "./talerTypes"; import { Badge, @@ -103,9 +104,10 @@ import { WalletBalance, WalletBalanceEntry, PreparePayResult, + DownloadedWithdrawInfo, } from "./walletTypes"; import { openPromise } from "./promiseUtils"; -import Axios from "axios"; +import { parsePayUri, parseWithdrawUri } from "./taleruri"; interface SpeculativePayData { payCoinInfo: PayCoinInfo; @@ -183,12 +185,13 @@ export function getTotalRefreshCost( ...withdrawDenoms.map(d => d.value), ).amount; const totalCost = Amounts.sub(amountLeft, resultingAmount).amount; - Wallet.enableTracing && console.log( - "total refresh cost for", - amountToPretty(amountLeft), - "is", - amountToPretty(totalCost), - ); + Wallet.enableTracing && + console.log( + "total refresh cost for", + amountToPretty(amountLeft), + "is", + amountToPretty(totalCost), + ); return totalCost; } @@ -255,7 +258,8 @@ export function selectPayCoins( const depositFeeToCover = Amounts.sub(accDepositFee, depositFeeLimit) .amount; leftAmount = Amounts.sub(leftAmount, depositFeeToCover).amount; - Wallet.enableTracing && console.log("deposit fee to cover", amountToPretty(depositFeeToCover)); + Wallet.enableTracing && + console.log("deposit fee to cover", amountToPretty(depositFeeToCover)); let totalFees: AmountJson = Amounts.getZero(currency); if (coversAmountWithFee && !isBelowFee) { @@ -714,17 +718,22 @@ export class Wallet { } async preparePay(url: string): Promise { - const talerpayPrefix = "talerpay:"; - let downloadSessionId: string | undefined; - if (url.startsWith(talerpayPrefix)) { - let [p1, p2] = url.substring(talerpayPrefix.length).split(";"); - url = decodeURIComponent(p1); - downloadSessionId = p2; + const uriResult = parsePayUri(url); + + if (!uriResult) { + return { + status: "error", + error: "URI not supported", + }; } + let proposalId: number; let checkResult: CheckPayResult; try { - proposalId = await this.downloadProposal(url, downloadSessionId); + proposalId = await this.downloadProposal( + uriResult.downloadUrl, + uriResult.sessionId, + ); checkResult = await this.checkPay(proposalId); } catch (e) { return { @@ -736,6 +745,27 @@ export class Wallet { if (!proposal) { throw Error("could not get proposal"); } + + console.log("proposal", proposal); + + if (uriResult.sessionId) { + const existingPayment = await this.q().getIndexed( + Stores.purchases.fulfillmentUrlIndex, + proposal.contractTerms.fulfillment_url, + ); + if (existingPayment) { + console.log("existing payment", existingPayment); + await this.submitPay( + existingPayment.contractTermsHash, + uriResult.sessionId, + ); + return { + status: "session-replayed", + contractTerms: existingPayment.contractTerms, + }; + } + } + if (checkResult.status === "paid") { return { status: "paid", @@ -1139,21 +1169,78 @@ export class Wallet { const op = openPromise(); const processReserveInternal = async (retryDelayMs: number = 250) => { + let isHardError = false; + // By default, do random, exponential backoff truncated at 3 minutes. + // Sometimes though, we want to try again faster. + let maxTimeout = 3000 * 60; try { - const reserve = await this.updateReserve(reservePub); - await this.depleteReserve(reserve); + const reserve = await this.q().get( + Stores.reserves, + reservePub, + ); + if (!reserve) { + isHardError = true; + throw Error("reserve not in db"); + } + + if (reserve.timestamp_confirmed === 0) { + const bankStatusUrl = reserve.bankWithdrawStatusUrl; + if (!bankStatusUrl) { + isHardError = true; + throw Error( + "reserve not confirmed yet, and no status URL available.", + ); + } + maxTimeout = 2000; + const now = new Date().getTime(); + let status; + try { + const statusResp = await this.http.get(bankStatusUrl); + status = WithdrawOperationStatusResponse.checked( + statusResp.responseJson, + ); + } catch (e) { + console.log("bank error response", e); + throw e; + } + + if (status.transfer_done) { + await this.q().mutate(Stores.reserves, reservePub, r => { + r.timestamp_confirmed = now; + return r; + }); + } else if (reserve.timestamp_reserve_info_posted === 0) { + try { + if (!status.selection_done) { + const bankResp = await this.http.postJson(bankStatusUrl, { + reserve_pub: reservePub, + selected_exchange: reserve.exchangeWire, + }); + } + } catch (e) { + console.log("bank error response", e); + throw e; + } + await this.q().mutate(Stores.reserves, reservePub, r => { + r.timestamp_reserve_info_posted = now; + return r; + }); + throw Error("waiting for reserve to be confirmed"); + } + } + + const updatedReserve = await this.updateReserve(reservePub); + await this.depleteReserve(updatedReserve); op.resolve(); } catch (e) { - // random, exponential backoff truncated at 3 minutes + if (isHardError) { + op.reject(e); + } const nextDelay = Math.min( 2 * retryDelayMs + retryDelayMs * Math.random(), - 3000 * 60, + maxTimeout, ); - Wallet.enableTracing && - console.warn( - `Failed to deplete reserve, trying again in ${retryDelayMs} ms`, - ); - Wallet.enableTracing && console.info("Cause for retry was:", e); + this.timerGroup.after(retryDelayMs, () => processReserveInternal(nextDelay), ); @@ -1346,7 +1433,10 @@ export class Wallet { reserve_pub: keypair.pub, senderWire: req.senderWire, timestamp_confirmed: 0, + timestamp_reserve_info_posted: 0, timestamp_depleted: 0, + bankWithdrawStatusUrl: req.bankWithdrawStatusUrl, + exchangeWire: req.exchangeWire, }; const senderWire = req.senderWire; @@ -1387,6 +1477,10 @@ export class Wallet { .put(Stores.reserves, reserveRecord) .finish(); + if (req.bankWithdrawStatusUrl) { + this.processReserve(keypair.pub); + } + const r: CreateReserveResponse = { exchange: canonExchange, reservePub: keypair.pub, @@ -1513,6 +1607,7 @@ export class Wallet { } const preCoin = await this.cryptoApi.createPreCoin(denom, reserve); + // This will fail and throw an exception if the remaining amount in the // reserve is too low to create a pre-coin. try { @@ -1520,6 +1615,7 @@ export class Wallet { .put(Stores.precoins, preCoin) .mutate(Stores.reserves, reserve.reserve_pub, mutateReserve) .finish(); + console.log("created precoin", preCoin.coinPub); } catch (e) { console.log("can't create pre-coin:", e.name, e.message); return; @@ -1542,6 +1638,11 @@ export class Wallet { if (!reserve) { throw Error("reserve not in db"); } + + if (reserve.timestamp_confirmed === 0) { + throw Error(""); + } + const reqUrl = new URI("reserve/status").absoluteTo( reserve.exchange_base_url, ); @@ -2462,7 +2563,14 @@ export class Wallet { refreshSession.exchangeBaseUrl, ); Wallet.enableTracing && console.log("reveal request:", req); - const resp = await this.http.postJson(reqUrl.href(), req); + + let resp; + try { + resp = await this.http.postJson(reqUrl.href(), req); + } catch (e) { + console.error("got error during /refresh/reveal request"); + return; + } Wallet.enableTracing && console.log("session:", refreshSession); Wallet.enableTracing && console.log("reveal response:", resp); @@ -3427,6 +3535,57 @@ export class Wallet { // strategy to test it. } + async downloadWithdrawInfo( + talerWithdrawUri: string, + ): Promise { + const uriResult = parseWithdrawUri(talerWithdrawUri); + if (!uriResult) { + throw Error("can't parse URL"); + } + const resp = await this.http.get(uriResult.statusUrl); + console.log("resp:", resp.responseJson); + const status = WithdrawOperationStatusResponse.checked(resp.responseJson); + return { + amount: Amounts.parseOrThrow(status.amount), + confirmTransferUrl: status.confirm_transfer_url, + extractedStatusUrl: uriResult.statusUrl, + selectionDone: status.selection_done, + senderWire: status.sender_wire, + suggestedExchange: status.suggested_exchange, + transferDone: status.transfer_done, + wireTypes: status.wire_types, + }; + } + + async createReserveFromWithdrawUrl( + talerWithdrawUri: string, + selectedExchange: string, + ): Promise<{ reservePub: string; confirmTransferUrl?: string }> { + const withdrawInfo = await this.downloadWithdrawInfo(talerWithdrawUri); + const exchangeWire = await this.getExchangePaytoUri( + selectedExchange, + withdrawInfo.wireTypes, + ); + const reserve = await this.createReserve({ + amount: withdrawInfo.amount, + bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, + exchange: selectedExchange, + senderWire: withdrawInfo.senderWire, + exchangeWire: exchangeWire, + }); + return { + reservePub: reserve.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + }; + } + + /** + * Reset the retry timeouts for ongoing operations. + */ + resetRetryTimeouts(): void { + // FIXME: implement + } + clearNotification(): void { this.badge.clearNotification(); } diff --git a/src/walletTypes.ts b/src/walletTypes.ts index a74f81136..abe9f2712 100644 --- a/src/walletTypes.ts +++ b/src/walletTypes.ts @@ -324,6 +324,13 @@ export class CreateReserveRequest { @Checkable.String() exchange: string; + /** + * Payto URI that identifies the exchange's account that the funds + * for this reserve go into. + */ + @Checkable.String() + exchangeWire: string; + /** * Wire details (as a payto URI) for the bank account that sent the funds to * the exchange. @@ -331,6 +338,12 @@ export class CreateReserveRequest { @Checkable.Optional(Checkable.String()) senderWire?: string; + /** + * URL to fetch the withdraw status from the bank. + */ + @Checkable.Optional(Checkable.String()) + bankWithdrawStatusUrl?: string; + /** * Verify that a value matches the schema of this class and convert it into a * member. @@ -474,9 +487,20 @@ export interface NextUrlResult { } export interface PreparePayResult { - status: "paid" | "insufficient-balance" | "payment-possible" | "error"; + status: "paid" | "session-replayed" | "insufficient-balance" | "payment-possible" | "error"; contractTerms?: ContractTerms; error?: string; proposalId?: number; totalFees?: AmountJson; +} + +export interface DownloadedWithdrawInfo { + selectionDone: boolean; + transferDone: boolean; + amount: AmountJson; + senderWire?: string; + suggestedExchange?: string; + confirmTransferUrl?: string; + wireTypes: string[]; + extractedStatusUrl: string; } \ No newline at end of file -- cgit v1.2.3